1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-28 03:50:10 +00:00

Compare commits

...

22 Commits

Author SHA1 Message Date
62e27c394d
build: include latest banner automatically
now copied from images/ into static/ on build.
2024-08-04 21:04:20 +01:00
8f3c723b07
systemd: get executable path properly
was just evaluating os.Args[0], which incorrectly points to your current
directory if jfa-go was in your PATH (i.e. you ran `jfa-go` not
`/usr/bin/jfa-go`). Uses Go's os.Executable() now. Fixes #352.
2024-08-04 20:53:09 +01:00
3c28537498
lang: rename ckb.json
hopefully correct.
2024-08-04 19:07:39 +01:00
b0e94a4ef6
merge lang 2024-08-04 19:03:53 +01:00
baeb89b694
setup: add jellyseer, reference wiki for PWR
add jellyseerr section along with ombi, and add a warning about ombi.
A link to the PWR wiki page is given to explain the different methods.
2024-08-04 19:03:00 +01:00
016263894f
lang: report section error comes from
doesn't directly report which file caused the error, but this is close
enough.
2024-08-04 18:09:25 +01:00
Muhammad Bayiz
5fe532fb78 translation from Weblate (Kurdish (Central))
Currently translated at 27.9% (19 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ckb/
2024-08-04 18:23:27 +02:00
Muhammad Bayiz
598859ae31 add translation from Weblate (Kurdish (Central)) 2024-08-04 18:23:27 +02:00
FiSTWHO
d8dcb84870 translation from Weblate (German)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2024-08-04 18:23:27 +02:00
28ca02272c
discord: also check disabled users in housekeeping daemon
externally disabled users will also be checked and de-roled by the discord daemon.
2024-08-04 15:48:01 +01:00
448955c915
discord: ensure discord housekeeping enable for previous feature
was only enabled when "require unique" was set, now also is if the new
"disable_enable_role" is set.
2024-08-04 15:40:50 +01:00
ffd46ff190
discord: option to add/remove role on enable/disable/deletion
in Settings > Discord, shown when a role is selected in "Apply Role".
The discord housekeeping daemon should pick up users deleted outide of
jfa-go too, so users who delete their own accounts should have their
roles removed (periodically).
2024-08-04 15:37:01 +01:00
44311162a6
users: consolidate methods for disable/enable and deleting users
now both in users.go and shared between the admin UI and daemon. Will be
used for discord role auto-add/deletion.
2024-08-04 15:14:51 +01:00
f289680d98
users: remove notes 2024-08-04 13:57:42 +01:00
280c6e4f16
users: attach Jellyfin ID to contactMethodUser 2024-08-04 13:40:16 +01:00
54e4a51a7f
users: huge cleanup/dedupe, interface-based third-party services
shared "newUser" method is now "NewUserPostVerification", and is shared
between all routes which create a jellyfin account. The new
"NewUserFromInvite", "NewUserFromAdmin" and "NewUserFromConfirmationKey"
are smaller as a result. Discord, Telegram, and Matrix now implement the
"ContactMethodLinker" and "ContactMethodUser" interfaces, meaning code
is shared a lot between them in the NewUser methods, and the specifics
are now in their own files. Ombi/Jellyseerr similarly implement a
simpler interface "ThirdPartyService", which simply has ImportUser and
AddContactMethod routes. Note these new interface methods are only used
for user creation as of yet, but could likely be used in other places.
2024-08-03 21:27:46 +01:00
711394232b
logmessages: all log strings in one file
EXCEPT: migrations.go, log strings there aren't gonna be repeated
anywhere else, are very specific, and will probably change a lot.
2024-08-01 20:17:05 +01:00
15a317f84f
discord: remove old message-based commands
removed !-prefixed, non-native commands, since "slash" commands have
been available for a long while now

separating this from the rest of the logmessages-ing because its a
significant change.
2024-08-01 20:15:54 +01:00
f348262f88
logmessages: finish up to api-users (alphabetically), refactor
.go files done in alphabetical order. Some refactoring done to
checkInvite(s) so they share most code. Also removed some useless debug
lines.
2024-08-01 13:59:24 +01:00
e9b8d970d1
logging: start consolidating log lines
log messages are very fragmented and are often repeated many times throughout the software with small differences.

Messages will be listed in logmessages/, which are simply strings with
formatting directives if necessary. So far, only main.go has been
completed.
2024-07-31 17:45:05 +01:00
fb5d3c4165
remove drone.yml 2024-07-31 16:46:02 +01:00
c442ff5f98
rename daemon files 2024-07-31 16:44:59 +01:00
52 changed files with 2178 additions and 1919 deletions

View File

@ -1,172 +0,0 @@
---
name: jfa-go
kind: pipeline
type: docker
steps:
- name: fetch
image: docker:git
commands:
- git fetch --tags
- name: release
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
GITHUB_TOKEN:
from_secret: github_token
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sL https://git.io/goreleaser > ../goreleaser
- chmod +x ../goreleaser
- ./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 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- 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
---
name: docker-buildx
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
event:
- tag
---
name: jfa-go-git
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
- name: ssh_key2
path: /id_rsa2
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- wget https://builds.hrfee.pw/upload.py
- pip3 install requests
- bash -c 'sftp -i /id_rsa2 -o StrictHostKeyChecking=no root@161.97.102.153:/mnt/redoc <<< $"put docs/swagger.json jfa-go.json"'
- 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 "reprepro -Vb /repo remove trusty-unstable jfa-go"'
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go-tray"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- 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
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
JFA_GO_SNAPSHOT: y
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
- name: ssh_key2
host:
path: /root/.ssh/docker-build
trigger:
branch:
- main
- go1.16
event:
exclude:
- pull_request
---
name: docker-buildx-unstable
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
branch:
- main
event:
exclude:
- pull_request
---
name: jfa-go-pr
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
trigger:
event:
include:
- pull_request

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ scripts/langmover/lang
scripts/langmover/lang2 scripts/langmover/lang2
scripts/langmover/out scripts/langmover/out
tinyproxy.conf tinyproxy.conf
static/banner.svg

View File

@ -11,6 +11,7 @@ before:
- go mod download - go mod download
- rm -rf data/web - rm -rf data/web
- mkdir -p data/web/css - mkdir -p data/web/css
- cp images/banner.svg static/banner.svg
- bash -c 'cp -r static/* data/web/' - bash -c 'cp -r static/* data/web/'
- npm install - npm install
- npm install esbuild - npm install esbuild

View File

@ -167,6 +167,7 @@ copy:
mv $(DATA)/crash.html $(DATA)/html/ mv $(DATA)/crash.html $(DATA)/html/
$(info copying static data) $(info copying static data)
mkdir -p $(DATA)/web mkdir -p $(DATA)/web
cp images/banner.svg static/banner.svg
cp -r static/* $(DATA)/web/ cp -r static/* $(DATA)/web/
$(info copying systemd service) $(info copying systemd service)
cp jfa-go.service $(DATA)/ cp jfa-go.service $(DATA)/

View File

@ -2,6 +2,7 @@ package main
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -120,7 +121,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
err := app.storage.db.Find(&results, query) err := app.storage.db.Find(&results, query)
if err != nil { if err != nil {
app.err.Printf("Failed to read activities from DB: %v\n", err) app.err.Printf(lm.FailedDBReadActivities, err)
} }
resp := GetActivitiesRespDTO{ resp := GetActivitiesRespDTO{

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
) )
// @Summary Creates a backup of the database. // @Summary Creates a backup of the database.
@ -35,7 +36,7 @@ func (app *appContext) GetBackup(gc *gin.Context) {
ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX) ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX)) t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() { if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err) app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -83,7 +84,7 @@ func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX) ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX)) t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() { if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err) app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -103,15 +104,15 @@ func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
func (app *appContext) RestoreBackup(gc *gin.Context) { func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file") file, err := gc.FormFile("backups-file")
if err != nil { if err != nil {
app.err.Printf("Failed to get file from form data: %v\n", err) app.err.Printf(lm.FailedGetUpload, err)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
app.debug.Printf("Got uploaded file \"%s\"\n", file.Filename) app.debug.Printf(lm.GetUpload, file.Filename)
path := app.config.Section("backups").Key("path").String() path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX) fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
gc.SaveUploadedFile(file, fullpath) gc.SaveUploadedFile(file, fullpath)
app.debug.Printf("Saved to \"%s\"\n", fullpath) app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath LOADBAK = fullpath
app.restart(gc) app.restart(gc)
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
@ -29,6 +30,7 @@ func GenerateInviteCode() string {
return inviteCode return inviteCode
} }
// checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data.
func (app *appContext) checkInvites() { func (app *appContext) checkInvites() {
currentTime := time.Now() currentTime := time.Now()
for _, data := range app.storage.GetInvites() { for _, data := range app.storage.GetInvites() {
@ -52,60 +54,11 @@ func (app *appContext) checkInvites() {
if !currentTime.After(expiry) { if !currentTime.After(expiry) {
continue continue
} }
app.deleteExpiredInvite(data)
app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
} }
} }
// checkInvite checks the validity of a specific invite, optionally removing it if invalid(ated).
func (app *appContext) checkInvite(code string, used bool, username string) bool { func (app *appContext) checkInvite(code string, used bool, username string) bool {
currentTime := time.Now() currentTime := time.Now()
inv, match := app.storage.GetInvitesKey(code) inv, match := app.storage.GetInvitesKey(code)
@ -114,54 +67,8 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
} }
expiry := inv.ValidTill expiry := inv.ValidTill
if currentTime.After(expiry) { if currentTime.After(expiry) {
app.debug.Printf("Housekeeping: Deleting old invite %s", code) app.deleteExpiredInvite(inv)
notify := inv.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(code, inv, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
}
}
match = false match = false
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if used { } else if used {
del := false del := false
newInv := inv newInv := inv
@ -187,6 +94,67 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
return match return match
} }
func (app *appContext) deleteExpiredInvite(data Invite) {
app.debug.Printf(lm.DeleteOldInvite, data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
wait := app.sendAdminExpiryNotification(data)
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
if wait != nil {
wait.Wait()
}
}
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 {
return nil
}
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
// Check whether notify "address" is an email address or Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err)
} else {
app.info.Printf(lm.SentExpiryAdmin, data.Code, addr)
}
}
}(address)
}
return &wait
}
// @Summary Create a new invite. // @Summary Create a new invite.
// @Produce json // @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object" // @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@ -196,7 +164,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
// @tags Invites // @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) { func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO var req generateInviteDTO
app.debug.Println("Generating new invite") app.debug.Println(lm.GenerateInvite)
gc.BindJSON(&req) gc.BindJSON(&req)
currentTime := time.Now() currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days) validTill := currentTime.AddDate(0, req.Months, req.Days)
@ -230,13 +198,12 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false addressValid := false
discord := "" discord := ""
app.debug.Printf("%s: Sending invite message", invite.Code)
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) { if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo) users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 { if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo) invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 { } else if len(users) > 1 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo) invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else { } else {
invite.SendTo = req.SendTo invite.SendTo = req.SendTo
addressValid = true addressValid = true
@ -249,8 +216,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if addressValid { if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false) msg, err := app.email.constructInvite(invite.Code, invite, app, false)
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo) // Slight misuse of the template
app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else { } else {
var err error var err error
if discord != "" { if discord != "" {
@ -259,10 +228,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
err = app.email.send(msg, req.SendTo) err = app.email.send(msg, req.SendTo)
} }
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo) invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err) app.err.Println(invite.SendTo)
} else { } else {
app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo) app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
} }
} }
} }
@ -297,7 +266,6 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) { func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
currentTime := time.Now() currentTime := time.Now()
app.checkInvites() app.checkInvites()
var invites []inviteDTO var invites []inviteDTO
@ -332,7 +300,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if err != nil { if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern) date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
if err != nil { if err != nil {
app.err.Printf("Failed to parse usedBy time: %v", err) app.err.Printf(lm.FailedParseTime, err)
} }
unix = date.Unix() unix = date.Unix()
} }
@ -347,7 +315,6 @@ func (app *appContext) GetInvites(gc *gin.Context) {
invite.SendTo = inv.SendTo invite.SendTo = inv.SendTo
} }
if len(inv.Notify) != 0 { if len(inv.Notify) != 0 {
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
var addressOrID string var addressOrID string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId") addressOrID = gc.GetString("jfId")
@ -397,10 +364,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
func (app *appContext) SetProfile(gc *gin.Context) { func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO var req inviteProfileDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
// "" means "Don't apply profile" // "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" { if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile) app.err.Printf(lm.FailedGetProfile, req.Profile)
respond(500, "Profile not found", gc) respond(500, "Profile not found", gc)
return return
} }
@ -424,11 +390,11 @@ func (app *appContext) SetNotify(gc *gin.Context) {
gc.BindJSON(&req) gc.BindJSON(&req)
changed := false changed := false
for code, settings := range req { for code, settings := range req {
app.debug.Printf("%s: Notification settings change requested", code)
invite, ok := app.storage.GetInvitesKey(code) invite, ok := app.storage.GetInvitesKey(code)
if !ok { if !ok {
app.err.Printf("%s Notification setting change failed: Invalid code", code) msg := fmt.Sprintf(lm.InvalidInviteCode, code)
respond(400, "Invalid invite code", gc) app.err.Println(msg)
respond(400, msg, gc)
return return
} }
var address string var address string
@ -436,9 +402,8 @@ func (app *appContext) SetNotify(gc *gin.Context) {
if jellyfinLogin { if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != "" var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable { if !addressAvailable {
app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code) app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId")) respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
respond(500, "Missing user contact method", gc)
return return
} }
address = gc.GetString("jfId") address = gc.GetString("jfId")
@ -453,15 +418,12 @@ func (app *appContext) SetNotify(gc *gin.Context) {
} /*else { } /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok { if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/ */
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] { for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"] if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address) invite.Notify[address][notifyType] = settings[notifyType]
changed = true app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
} changed = true
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] { }
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
changed = true
} }
if changed { if changed {
app.storage.SetInvitesKey(code, invite) app.storage.SetInvitesKey(code, invite)
@ -480,7 +442,6 @@ func (app *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) { func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO var req deleteInviteDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code) inv, ok := app.storage.GetInvitesKey(req.Code)
if ok { if ok {
app.storage.DeleteInvitesKey(req.Code) app.storage.DeleteInvitesKey(req.Code)
@ -495,10 +456,10 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
Time: time.Now(), Time: time.Now(),
}, gc, false) }, gc, false)
app.info.Printf("%s: Invite deleted", req.Code) app.info.Printf(lm.DeleteInvite, req.Code)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
app.err.Printf("%s: Deletion failed: Invalid code", req.Code) app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code")
respond(400, "Code doesn't exist", gc) respond(400, "Code doesn't exist", gc)
} }

View File

@ -1,10 +1,13 @@
package main package main
import ( import (
"fmt"
"net/url" "net/url"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
) )
// @Summary Get a list of Jellyseerr users. // @Summary Get a list of Jellyseerr users.
@ -15,14 +18,12 @@ import (
// @Security Bearer // @Security Bearer
// @tags Jellyseerr // @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) { func (app *appContext) JellyseerrUsers(gc *gin.Context) {
app.debug.Println("Jellyseerr users requested")
users, err := app.js.GetUsers() users, err := app.js.GetUsers()
if err != nil { if err != nil {
app.err.Printf("Failed to get users from Jellyseerr: %v", err) app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
userlist := make([]ombiUser, len(users)) userlist := make([]ombiUser, len(users))
i := 0 i := 0
for _, u := range users { for _, u := range users {
@ -60,14 +61,14 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
} }
u, err := app.js.UserByID(jellyseerrID) u, err := app.js.UserByID(jellyseerrID)
if err != nil { if err != nil {
app.err.Printf("Couldn't get user from Jellyseerr: %v", err) app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
profile.Jellyseerr.User = u.UserTemplate profile.Jellyseerr.User = u.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID) n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
if err != nil { if err != nil {
app.err.Printf("Couldn't get user's notification prefs from Jellyseerr: %v", err) app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, gc.Param("id"), err)
respond(500, "Couldn't get user notification prefs", gc) respond(500, "Couldn't get user notification prefs", gc)
return return
} }
@ -98,3 +99,67 @@ func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile) app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc) respondBool(204, true, gc)
} }
type JellyseerrWrapper struct {
*jellyseerr.Jellyseerr
}
func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
// Gets existing user (not possible) or imports the given user.
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
ok = true
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
return
}
err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err)
return
}
return
}
func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
}
}
if discordEnabled && discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
if err != nil {
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
return
}
}
return
}
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
@ -134,7 +135,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id) customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok && id != "Announcement" { if !ok && id != "Announcement" {
app.err.Printf("Failed to get custom message with ID \"%s\"", id) app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -328,7 +329,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID, jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact, jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
@ -361,11 +362,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
tgUser.Contact = req.Telegram tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser) app.storage.SetTelegramKey(req.ID, tgUser)
if change { if change {
msg := "" app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
} }
} }
@ -374,11 +371,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
dcUser.Contact = req.Discord dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser) app.storage.SetDiscordKey(req.ID, dcUser)
if change { if change {
msg := "" app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
} }
} }
@ -387,11 +380,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
mxUser.Contact = req.Matrix mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser) app.storage.SetMatrixKey(req.ID, mxUser)
if change { if change {
msg := "" app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
} }
} }
if email, ok := app.storage.GetEmailsKey(req.ID); ok { if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@ -399,18 +388,14 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
email.Contact = req.Email email.Contact = req.Email
app.storage.SetEmailsKey(req.ID, email) app.storage.SetEmailsKey(req.ID, email)
if change { if change {
msg := "" app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
} }
} }
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs) err := app.js.ModifyNotifications(req.ID, jsPrefs)
if err != nil { if err != nil {
app.err.Printf("Failed to sync contact prefs with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
} }
respondBool(200, true, gc) respondBool(200, true, gc)
@ -446,7 +431,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin) token, ok := app.telegram.TokenVerified(pin)
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) { if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
app.discord.DeleteVerifiedUser(pin) app.discord.DeleteVerifiedToken(pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -469,7 +454,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
} }
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.discord.UserVerified(pin) user, ok := app.discord.UserVerified(pin)
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) { if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) {
delete(app.discord.verifiedTokens, pin) delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -487,7 +472,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get] // @Router /invite/{invCode}/discord/invite [get]
// @tags Other // @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) { func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" { if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -555,7 +540,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
func (app *appContext) MatrixCheckPIN(gc *gin.Context) { func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Println("Matrix: Invite code was invalid") app.debug.Printf(lm.InvalidInviteCode, code)
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
@ -563,12 +548,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin] user, ok := app.matrix.tokens[pin]
if !ok { if !ok {
app.debug.Println("Matrix: PIN not found") app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
if user.User.UserID != userID { if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match") app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
@ -596,7 +581,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
} }
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password) token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil { if err != nil {
app.err.Printf("Matrix: Failed to generate token: %v", err) app.err.Printf(lm.FailedGenerateToken, err)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -607,7 +592,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
matrix.Key("token").SetValue(token) matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username) matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil { if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err) app.err.Printf(lm.FailedWriting, app.configPath, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -631,7 +616,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
} }
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID) roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
if err != nil { if err != nil {
app.err.Printf("Matrix: Failed to create room: %v", err) app.err.Printf(lm.FailedCreateRoom, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -701,7 +686,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
jellyseerr.FieldDiscord: req.DiscordID, jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true, jellyseerr.FieldDiscordEnabled: true,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -739,7 +724,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier, jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false, jellyseerr.FieldDiscordEnabled: false,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -775,7 +760,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier, jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false, jellyseerr.FieldTelegramEnabled: false,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{

View File

@ -1,18 +1,18 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
) )
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) { func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false) jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 { if err != nil || code != 200 {
return nil, code, err return nil, code, err
@ -22,6 +22,14 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
if e, ok := app.storage.GetEmailsKey(jfID); ok { if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr email = e.Addr
} }
return app.ombi.getUser(username, email)
}
func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, int, error) {
ombiUsers, code, err := ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
for _, ombiUser := range ombiUsers { for _, ombiUser := range ombiUsers {
ombiAddr := "" ombiAddr := ""
if a, ok := ombiUser["emailAddress"]; ok && a != nil { if a, ok := ombiUser["emailAddress"]; ok && a != nil {
@ -31,13 +39,13 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
return ombiUser, code, err return ombiUser, code, err
} }
} }
return nil, 400, fmt.Errorf("couldn't find user") return nil, 400, errors.New(lm.NotFound)
} }
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi // Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) { func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, int, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin // Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, code, err := app.ombi.GetUsers() ombiUsers, code, err := ombi.GetUsers()
if err != nil || code != 200 { if err != nil || code != 200 {
return nil, code, err return nil, code, err
} }
@ -66,10 +74,9 @@ func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{},
// @Security Bearer // @Security Bearer
// @tags Ombi // @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) { func (app *appContext) OmbiUsers(gc *gin.Context) {
app.debug.Println("Ombi users requested")
users, status, err := app.ombi.GetUsers() users, status, err := app.ombi.GetUsers()
if err != nil || status != 200 { if err != nil || status != 200 {
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err) app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
@ -105,7 +112,7 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
} }
template, code, err := app.ombi.TemplateByID(req.ID) template, code, err := app.ombi.TemplateByID(req.ID)
if err != nil || code != 200 || len(template) == 0 { if err != nil || code != 200 || len(template) == 0 {
app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err) app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
@ -136,7 +143,11 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
respondBool(204, true, gc) respondBool(204, true, gc)
} }
func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) { type OmbiWrapper struct {
*ombi.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) {
for k, v := range profile { for k, v := range profile {
switch v.(type) { switch v.(type) {
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
@ -147,6 +158,66 @@ func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map
} }
} }
} }
status, err = app.ombi.ModifyUser(user) status, err = ombi.ModifyUser(user)
return return
} }
func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
errors, code, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
var ombiUser map[string]interface{}
var status int
if err != nil || code != 200 {
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, status, err = ombi.getImportedUser(req.Username)
if status == 200 && err == nil {
// app.info.Println(lm.Ombi + " " + lm.UserExists)
profile.Ombi["password"] = req.Password
status, err = ombi.applyProfile(ombiUser, profile.Ombi)
if status != 200 || err != nil {
err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
}
} else {
if len(errors) != 0 {
err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", "))
}
return
}
}
ok = true
return
}
func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
var ombiUser map[string]interface{}
var status int
ombiUser, status, err = ombi.getUser(req.Username, req.Email)
if status != 200 || err != nil {
return
}
if discordEnabled || telegramEnabled {
dID := ""
tUser := ""
if discord != nil {
dID = discord.ID
}
if telegram != nil {
tUser = telegram.Username
}
var resp string
var status int
resp, status, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
}
return
}
}
return
}
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
}

View File

@ -1,9 +1,11 @@
package main package main
import ( import (
"fmt"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -14,7 +16,6 @@ import (
// @Security Bearer // @Security Bearer
// @tags Profiles & Settings // @tags Profiles & Settings
func (app *appContext) GetProfiles(gc *gin.Context) { func (app *appContext) GetProfiles(gc *gin.Context) {
app.debug.Println("Profiles requested")
out := getProfilesDTO{ out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name, DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{}, Profiles: map[string]profileDTO{},
@ -52,10 +53,11 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
func (app *appContext) SetDefaultProfile(gc *gin.Context) { func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{} req := profileChangeDTO{}
gc.BindJSON(&req) gc.BindJSON(&req)
app.info.Printf("Setting default profile to \"%s\"", req.Name) app.info.Printf(lm.SetDefaultProfile, req.Name)
if _, ok := app.storage.GetProfileKey(req.Name); !ok { if _, ok := app.storage.GetProfileKey(req.Name); !ok {
app.err.Printf("Profile not found: \"%s\"", req.Name) msg := fmt.Sprintf(lm.FailedGetProfile, req.Name)
respond(500, "Profile not found", gc) app.err.Println(msg)
respond(500, msg, gc)
return return
} }
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error { app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
@ -79,13 +81,12 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Profiles & Settings // @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) { func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested")
var req newProfileDTO var req newProfileDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
user, status, err := app.jf.UserByID(req.ID, false) user, status, err := app.jf.UserByID(req.ID, false)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
@ -94,12 +95,12 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
Policy: user.Policy, Policy: user.Policy,
Homescreen: req.Homescreen, Homescreen: req.Homescreen,
} }
app.debug.Printf("Creating profile from user \"%s\"", user.Name) app.debug.Printf(lm.CreateProfileFromUser, user.Name)
if req.Homescreen { if req.Homescreen {
profile.Configuration = user.Configuration profile.Configuration = user.Configuration
profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err) app.err.Printf(lm.FailedGetJellyfinDisplayPrefs, req.ID, err)
respond(500, "Couldn't get displayprefs", gc) respond(500, "Couldn't get displayprefs", gc)
return return
} }
@ -145,13 +146,13 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
inv, ok := app.storage.GetInvitesKey(invCode) inv, ok := app.storage.GetInvitesKey(invCode)
if !ok { if !ok {
respond(400, "Invalid invite code", gc) respond(400, "Invalid invite code", gc)
app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName) app.err.Printf(lm.InvalidInviteCode, invCode)
return return
} }
profile, ok := app.storage.GetProfileKey(profileName) profile, ok := app.storage.GetProfileKey(profileName)
if !ok { if !ok {
respond(400, "Invalid profile", gc) respond(400, "Invalid profile", gc)
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName) app.err.Printf(lm.FailedGetProfile, profileName)
return return
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -29,7 +31,7 @@ func (app *appContext) MyDetails(gc *gin.Context) {
user, status, err := app.jf.UserByID(resp.Id, false) user, status, err := app.jf.UserByID(resp.Id, false)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Failed to get user", gc) respond(500, "Failed to get user", gc)
return return
} }
@ -133,8 +135,9 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
func (app *appContext) LogoutUser(gc *gin.Context) { func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh") cookie, err := gc.Cookie("user-refresh")
if err != nil { if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err) msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
respond(500, "Couldn't fetch cookies", gc) app.debug.Println(msg)
respond(500, msg, gc)
return return
} }
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
@ -174,21 +177,21 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
} }
token, err := jwt.Parse(key, checkToken) token, err := jwt.Parse(key, checkToken)
if err != nil { if err != nil {
app.err.Printf("Failed to parse key: %s", err) app.err.Printf(lm.FailedParseJWT, err)
fail() fail()
// respond(500, "unknownError", gc) // respond(500, "unknownError", gc)
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
app.err.Printf("Failed to parse key: %s", err) app.err.Println(lm.FailedCastJWT)
fail() fail()
// respond(500, "unknownError", gc) // respond(500, "unknownError", gc)
return return
} }
expiry := time.Unix(int64(claims["exp"].(float64)), 0) expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Printf("Invalid key") app.err.Println(lm.InvalidJWT)
fail() fail()
// respond(400, "invalidKey", gc) // respond(400, "invalidKey", gc)
return return
@ -212,7 +215,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Time: time.Now(), Time: time.Now(),
}, gc, true) }, gc, true)
app.info.Println("Email list modified") app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, "/my/account") gc.Redirect(http.StatusSeeOther, "/my/account")
return return
} }
@ -231,7 +234,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
func (app *appContext) ModifyMyEmail(gc *gin.Context) { func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO var req ModifyMyEmailDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') { if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc) respond(400, "Invalid Email Address", gc)
return return
@ -251,7 +253,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil { if err != nil {
app.err.Printf("Failed to generate confirmation token: %v", err) app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc) respond(500, "errorUnknown", gc)
return return
} }
@ -262,15 +264,15 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
if status == 200 && err == nil { if status == 200 && err == nil {
name = user.Name name = user.Name
} }
app.debug.Printf("%s: Email confirmation required", id) app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false) msg, err := app.email.constructConfirmation("", name, key, app, false)
if err != nil { if err != nil {
app.err.Printf("%s: Failed to construct confirmation email: %v", name, err) app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err) app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else { } else {
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email) app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
} }
return return
} }
@ -290,7 +292,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags User Page // @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" { if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -338,7 +340,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin) app.discord.DeleteVerifiedToken(pin)
if !ok { if !ok {
respondBool(200, false, gc) respondBool(200, false, gc)
return return
@ -358,7 +360,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldDiscord: dcUser.ID, jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact, jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -413,7 +415,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID, jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact, jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -477,12 +479,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin] user, ok := app.matrix.tokens[pin]
if !ok { if !ok {
app.debug.Println("Matrix: PIN not found") app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
if user.User.UserID != userID { if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match") app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc) respondBool(200, false, gc)
return return
} }
@ -523,7 +525,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier, jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false, jellyseerr.FieldDiscordEnabled: false,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -551,7 +553,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier, jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false, jellyseerr.FieldTelegramEnabled: false,
}); err != nil { }); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@ -606,7 +608,6 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true) contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address") address := gc.Param("address")
if address == "" { if address == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop() cancel.Stop()
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -616,7 +617,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed) jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok { if !ok {
app.debug.Printf("Ignoring PWR request: User not found") app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
@ -626,7 +627,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
} }
pwr, err = app.GenInternalReset(jfUser.ID) pwr, err = app.GenInternalReset(jfUser.ID)
if err != nil { if err != nil {
app.err.Printf("Failed to get user from Jellyfin: %v", err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
return return
@ -647,16 +648,16 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}, app, false, }, app, false,
) )
if err != nil { if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err) app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
return return
} }
return return
} else if err := app.sendByID(msg, jfUser.ID); err != nil { } else if err := app.sendByID(msg, jfUser.ID); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err) app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
} else { } else {
app.info.Printf("Sent password reset message to \"%s\"", address) app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
} }
for range timerWait { for range timerWait {
respondBool(204, true, gc) respondBool(204, true, gc)
@ -683,14 +684,13 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
validation := app.validator.validate(req.New) validation := app.validator.validate(req.New)
for _, val := range validation { for _, val := range validation {
if !val { if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation) gc.JSON(400, validation)
return return
} }
} }
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false) user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err) app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -718,16 +718,16 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
func() { func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId")) ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err) app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return return
} }
ombiUser["password"] = req.New ombiUser["password"] = req.New
status, err = app.ombi.ModifyUser(ombiUser) status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
return return
} }
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
}() }()
} }
cookie, err := gc.Cookie("user-refresh") cookie, err := gc.Cookie("user-refresh")
@ -735,7 +735,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true) gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else { } else {
app.debug.Printf("Couldn't get cookies: %s", err) app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
} }
respondBool(204, true, gc) respondBool(204, true, gc)
} }
@ -761,7 +761,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv) err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil || user.ReferralTemplateKey == "" { if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf("Ignoring referral request, couldn't find template.") app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -782,6 +782,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// If UseReferralExpiry is enabled, we delete it and return nothing. // If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code) app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry { if inv.UseReferralExpiry {
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok { if ok {
user.ReferralTemplateKey = "" user.ReferralTemplateKey = ""
@ -791,6 +792,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
app.debug.Printf(lm.RenewOldReferral, inv.Code)
inv.Code = GenerateInviteCode() inv.Code = GenerateInviteCode()
inv.Created = time.Now() inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)

File diff suppressed because it is too large Load Diff

63
api.go
View File

@ -1,10 +1,12 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
@ -122,14 +124,14 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
} }
if !valid || req.PIN == "" { if !valid || req.PIN == "" {
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN) app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.InvalidPassword)
gc.JSON(400, validation) gc.JSON(400, validation)
return return
} }
isInternal := false isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) { if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN) app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
respond(400, "errorCaptcha", gc) respond(400, "errorCaptcha", gc)
return return
} }
@ -138,7 +140,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
if reset, ok := app.internalPWRs[req.PIN]; ok { if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true isInternal = true
if time.Now().After(reset.Expiry) { if time.Now().After(reset.Expiry) {
app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN) app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, reset.PIN))
respondBool(401, false, gc) respondBool(401, false, gc)
delete(app.internalPWRs, req.PIN) delete(app.internalPWRs, req.PIN)
return return
@ -148,7 +150,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
status, err := app.jf.ResetPasswordAdmin(userID) status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err) app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
respondBool(status, false, gc) respondBool(status, false, gc)
return return
} }
@ -156,7 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} else { } else {
resp, status, err := app.jf.ResetPassword(req.PIN) resp, status, err := app.jf.ResetPassword(req.PIN)
if status != 200 || err != nil || !resp.Success { if status != 200 || err != nil || !resp.Success {
app.err.Printf("Password Reset failed (%d): %v", status, err) app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
respondBool(status, false, gc) respondBool(status, false, gc)
return return
} }
@ -176,7 +178,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
user, status, err = app.jf.UserByName(username, false) user, status, err = app.jf.UserByName(username, false)
} }
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err) app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -195,31 +197,33 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password) status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err) app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, user.ID, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
// Silently fail for changing ombi passwords // This makes no sense so has been commented out.
// It probably did at some point in the past.
/* Silently fail for changing ombi passwords
if (status != 200 && status != 204) || err != nil { if (status != 200 && status != 204) || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err) app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} } */
ombiUser, status, err := app.getOmbiUser(user.ID) ombiUser, status, err := app.getOmbiUser(user.ID)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err) app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
ombiUser["password"] = req.Password ombiUser["password"] = req.Password
status, err = app.ombi.ModifyUser(ombiUser) status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) app.err.Printf(lm.FailedChangePassword, lm.Ombi, user.ID, err)
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} }
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) app.debug.Printf(lm.ChangePassword, lm.Ombi, user.ID)
} }
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -231,7 +235,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) { func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := app.configBase resp := app.configBase
// Load language options // Load language options
formOptions := app.storage.lang.User.getOptions() formOptions := app.storage.lang.User.getOptions()
@ -341,7 +344,6 @@ func (app *appContext) GetConfig(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) ModifyConfig(gc *gin.Context) { func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req configDTO var req configDTO
gc.BindJSON(&req) gc.BindJSON(&req)
// Load a new config, as we set various default values in app.config that shouldn't be stored. // Load a new config, as we set various default values in app.config that shouldn't be stored.
@ -366,26 +368,18 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
} }
tempConfig.Section("").Key("first_run").SetValue("false") tempConfig.Section("").Key("first_run").SetValue("false")
if err := tempConfig.SaveTo(app.configPath); err != nil { if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err) app.err.Printf(lm.FailedWriting, app.configPath, err)
respond(500, err.Error(), gc) respond(500, err.Error(), gc)
return return
} }
app.debug.Println("Config saved") app.info.Printf(lm.ModifyConfig, app.configPath)
gc.JSON(200, map[string]bool{"success": true}) gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) { if req["restart-program"] != nil && req["restart-program"].(bool) {
app.info.Println("Restarting...") app.Restart()
if TRAY {
TRAYRESTART <- true
} else {
RESTART <- true
}
// Safety Sleep (Ensure shutdown tasks get done)
time.Sleep(time.Second)
} }
app.loadConfig() app.loadConfig()
// Reinitialize password validator on config change, as opposed to every applicable request like in python. // Reinitialize password validator on config change, as opposed to every applicable request like in python.
if _, ok := req["password_validation"]; ok { if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{ validatorConf := ValidatorConf{
"length": app.config.Section("password_validation").Key("min_length").MustInt(0), "length": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0), "uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
@ -425,12 +419,13 @@ func (app *appContext) CheckUpdate(gc *gin.Context) {
// @tags Configuration // @tags Configuration
func (app *appContext) ApplyUpdate(gc *gin.Context) { func (app *appContext) ApplyUpdate(gc *gin.Context) {
if !app.update.CanUpdate { if !app.update.CanUpdate {
respond(400, "Update is manual", gc) app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual)
respond(400, lm.UpdateManual, gc)
return return
} }
err := app.update.update() err := app.update.update()
if err != nil { if err != nil {
app.err.Printf("Failed to apply update: %v", err) app.err.Printf(lm.FailedApplyUpdate, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -452,8 +447,9 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
func (app *appContext) Logout(gc *gin.Context) { func (app *appContext) Logout(gc *gin.Context) {
cookie, err := gc.Cookie("refresh") cookie, err := gc.Cookie("refresh")
if err != nil { if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err) msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err)
respond(500, "Couldn't fetch cookies", gc) app.debug.Println(msg)
respond(500, msg, gc)
return return
} }
app.invalidTokens = append(app.invalidTokens, cookie) app.invalidTokens = append(app.invalidTokens, cookie)
@ -526,11 +522,7 @@ func (app *appContext) ServeLang(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Other // @tags Other
func (app *appContext) restart(gc *gin.Context) { func (app *appContext) restart(gc *gin.Context) {
app.info.Println("Restarting...") app.Restart()
err := app.Restart()
if err != nil {
app.err.Printf("Couldn't restart, try restarting manually: %v", err)
}
} }
// @Summary Returns the last 100 lines of the log. // @Summary Returns the last 100 lines of the log.
@ -544,6 +536,7 @@ func (app *appContext) GetLog(gc *gin.Context) {
// no need to syscall.exec anymore! // no need to syscall.exec anymore!
func (app *appContext) Restart() error { func (app *appContext) Restart() error {
app.info.Println(lm.Restarting)
if TRAY { if TRAY {
TRAYRESTART <- true TRAYRESTART <- true
} else { } else {

65
auth.go
View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
@ -41,6 +42,8 @@ func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate return app.authenticate
} }
func (app *appContext) authLog(v any) { app.debug.Printf(lm.FailedAuthRequest, v) }
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens. // CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string, admin bool) (string, string, error) { func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string var token, refresh string
@ -72,32 +75,26 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
ok = false ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" { if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header") app.authLog(lm.InvalidAuthHeader)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
token, err := jwt.Parse(string(header[1]), checkToken) token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil { if err != nil {
app.debug.Printf("Auth denied: %s", err) app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
claims, ok = token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
if !ok { if !ok {
app.debug.Println("Invalid JWT") app.authLog(lm.FailedCastJWT)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.debug.Printf("Auth denied: Invalid token") app.authLog(lm.InvalidJWT)
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string)) // app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
ok = false ok = false
@ -115,7 +112,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
} }
isAdminToken := claims["admin"].(bool) isAdminToken := claims["admin"].(bool)
if !isAdminToken { if !isAdminToken {
app.debug.Printf("Auth denied: Token was not for admin access") app.authLog(lm.NonAdminToken)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -130,14 +127,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
} }
} }
if !match { if !match {
app.debug.Printf("Couldn't find user ID \"%s\"", userID) app.authLog(fmt.Sprintf(lm.NonAdminUser, userID))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
gc.Set("jfId", jfID) gc.Set("jfId", jfID)
gc.Set("userId", userID) gc.Set("userId", userID)
gc.Set("userMode", false) gc.Set("userMode", false)
app.debug.Println("Auth succeeded")
gc.Next() gc.Next()
} }
@ -160,7 +156,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1] password = creds[1]
ok = false ok = false
if username == "" || password == "" { if username == "" || password == "" {
app.logIpDebug(gc, userpage, "Auth denied: blank username/password") app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -173,16 +169,16 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
user, status, err := app.authJf.Authenticate(username, password) user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil { if status != 200 || err != nil {
if status == 401 || status == 400 { if status == 401 || status == 400 {
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)") app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
if status == 403 { if status == 403 {
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled") app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
respond(403, "yourAccountWasDisabled", gc) respond(403, "yourAccountWasDisabled", gc)
return return
} }
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err) app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, status, err))
respond(500, "Jellyfin error", gc) respond(500, "Jellyfin error", gc)
return return
} }
@ -199,7 +195,7 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth // @tags Auth
// @Security getTokenAuth // @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) { func (app *appContext) getTokenLogin(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (login attempt)") app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, false) username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok { if !ok {
return return
@ -209,13 +205,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
for _, user := range app.adminUsers { for _, user := range app.adminUsers {
if user.Username == username && user.Password == password { if user.Username == username && user.Password == password {
match = true match = true
app.debug.Println("Found existing user")
userID = user.UserID userID = user.UserID
break break
} }
} }
if !app.jellyfinLogin && !match { if !app.jellyfinLogin && !match {
app.logIpInfo(gc, false, "Auth denied: Invalid username/password") app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -233,7 +228,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
} }
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin { if !accountsAdmin {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username) app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -243,12 +238,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
newUser := User{ newUser := User{
UserID: userID, UserID: userID,
} }
app.debug.Printf("Token generated for user \"%s\"", username) app.debug.Printf(lm.GenerateToken, username)
app.adminUsers = append(app.adminUsers, newUser) app.adminUsers = append(app.adminUsers, newUser)
} }
token, refresh, err := CreateToken(userID, jfID, true) token, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf("getToken failed: Couldn't generate token (%s)", err) app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
@ -261,35 +256,29 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
ok = false ok = false
cookie, err := gc.Cookie(cookieName) cookie, err := gc.Cookie(cookieName)
if err != nil || cookie == "" { if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err) app.authLog(fmt.Sprintf(lm.FailedGetCookies, cookieName, err))
respond(400, "Couldn't get token", gc) respond(400, "Couldn't get token", gc)
return return
} }
for _, token := range app.invalidTokens { for _, token := range app.invalidTokens {
if cookie == token { if cookie == token {
app.debug.Println("getTokenRefresh: Invalid token") app.authLog(lm.LocallyInvalidatedJWT)
respond(401, "Invalid token", gc) respond(401, lm.InvalidJWT, gc)
return return
} }
} }
token, err := jwt.Parse(cookie, checkToken) token, err := jwt.Parse(cookie, checkToken)
if err != nil { if err != nil {
app.debug.Println("getTokenRefresh: Invalid token") app.authLog(lm.FailedParseJWT)
respond(400, "Invalid token", gc) respond(400, lm.InvalidJWT, gc)
return return
} }
claims, ok = token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err) app.authLog(lm.InvalidJWT)
respond(401, "Invalid token", gc) respond(401, lm.InvalidJWT, gc)
ok = false ok = false
return return
} }
@ -304,7 +293,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get] // @Router /token/refresh [get]
// @tags Auth // @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) { func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (refresh token)") app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh") claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok { if !ok {
return return
@ -313,7 +302,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
jfID := claims["jfid"].(string) jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID, true) jwt, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err) app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }

View File

@ -7,6 +7,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
lm "github.com/hrfee/jfa-go/logmessages"
) )
const ( const (
@ -60,12 +62,12 @@ func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String() path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755) err := os.MkdirAll(path, 0755)
if err != nil { if err != nil {
app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err) app.err.Printf(lm.FailedCreateDir, path, err)
return nil return nil
} }
items, err := os.ReadDir(path) items, err := os.ReadDir(path)
if err != nil { if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err) app.err.Printf(lm.FailedReading, path, err)
return nil return nil
} }
backups := &BackupList{} backups := &BackupList{}
@ -78,7 +80,7 @@ func (app *appContext) getBackups() *BackupList {
} }
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX)) t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if err != nil { if err != nil {
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err) app.debug.Printf(lm.FailedParseTime, err)
continue continue
} }
backups.dates[i] = t backups.dates[i] = t
@ -101,36 +103,36 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
sort.Sort(backups) sort.Sort(backups)
for _, item := range backups.files[:toDelete] { for _, item := range backups.files[:toDelete] {
fullpath := filepath.Join(path, item.Name()) fullpath := filepath.Join(path, item.Name())
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
err := os.Remove(fullpath) err := os.Remove(fullpath)
if err != nil { if err != nil {
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err) app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
return return
} }
app.debug.Printf(lm.DeleteOldBackup, fullpath)
} }
} }
fullpath := filepath.Join(path, fname) fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath) f, err := os.Create(fullpath)
if err != nil { if err != nil {
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err) app.err.Printf(lm.FailedOpen, fullpath, err)
return return
} }
defer f.Close() defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0) _, err = app.storage.db.Badger().Backup(f, 0)
if err != nil { if err != nil {
app.err.Printf("Failed to create backup: %v\n", err) app.err.Printf(lm.FailedCreateBackup, err)
return return
} }
fstat, err := f.Stat() fstat, err := f.Stat()
if err != nil { if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err) app.err.Printf(lm.FailedStat, fullpath, err)
return return
} }
fileDetails.Size = fileSize(fstat.Size()) fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname fileDetails.Name = fname
fileDetails.Path = fullpath fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails) app.debug.Printf(lm.CreateBackup, fileDetails)
return return
} }
@ -139,25 +141,25 @@ func (app *appContext) loadPendingBackup() {
return return
} }
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK)) oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
err := os.Rename(app.storage.db_path, oldPath) err := os.Rename(app.storage.db_path, oldPath)
if err != nil { if err != nil {
app.err.Fatalf("Failed to move existing database: %v\n", err) app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
} }
app.info.Printf(lm.MoveOldDB, oldPath)
app.ConnectDB() app.ConnectDB()
defer app.storage.db.Close() defer app.storage.db.Close()
f, err := os.Open(LOADBAK) f, err := os.Open(LOADBAK)
if err != nil { if err != nil {
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err) app.err.Fatalf(lm.FailedOpen, LOADBAK, err)
} }
err = app.storage.db.Badger().Load(f, 256) err = app.storage.db.Badger().Load(f, 256)
f.Close() f.Close()
if err != nil { if err != nil {
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err) app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
} }
app.info.Printf("Restored backup \"%s\".", LOADBAK) app.info.Printf(lm.RestoreDB, LOADBAK)
LOADBAK = "" LOADBAK = ""
} }
@ -165,7 +167,6 @@ func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app, d := NewGenericDaemon(interval, app,
func(app *appContext) { func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup() app.makeBackup()
}, },
) )

View File

@ -7,8 +7,10 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
@ -140,7 +142,7 @@ func (app *appContext) loadConfig() error {
} }
} }
if allDisabled { if allDisabled {
fmt.Println("SETALLTRUE") app.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods { for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true") app.config.Section("user_page").Key(v).SetValue("true")
} }
@ -175,9 +177,15 @@ func (app *appContext) loadConfig() error {
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("") app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig) app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
if err != nil { if err != nil {
app.err.Printf("Failed to initialize Proxy: %v\n", err) app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
app.proxyEnabled = false
} else {
app.proxyEnabled = true
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
} }
app.proxyEnabled = true
} }
app.MustSetValue("updates", "enabled", "true") app.MustSetValue("updates", "enabled", "true")

View File

@ -1120,6 +1120,15 @@
"value": "", "value": "",
"description": "Add the selected role to a user when they sign up." "description": "Add the selected role to a user when they sign up."
}, },
"disable_enable_role": {
"name": "Remove/add role on user enable/disable/deletion",
"required": false,
"requires_restart": true,
"depends_true": "apply_role",
"type": "bool",
"value": false,
"description": "When a user is disabled or deleted, remove the Discord role, and when re-enabled, add it back."
},
"language": { "language": {
"name": "Language", "name": "Language",
"required": false, "required": false,

View File

@ -6,25 +6,26 @@ import (
"time" "time"
dg "github.com/bwmarrin/discordgo" dg "github.com/bwmarrin/discordgo"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
type DiscordDaemon struct { type DiscordDaemon struct {
Stopped bool Stopped bool
ShutdownChannel chan string ShutdownChannel chan string
bot *dg.Session bot *dg.Session
username string username string
tokens map[string]VerifToken // Map of pins to tokens. tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users. verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string Channel, InviteChannel struct{ ID, Name string }
guildID string guildID string
serverChannelName, serverName string serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string roleID string
app *appContext app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string commandIDs []string
commandDescriptions []*dg.ApplicationCommand commandDescriptions []*dg.ApplicationCommand
} }
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@ -92,13 +93,11 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
} }
func (d *DiscordDaemon) run() { func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.messageHandler)
d.bot.AddHandler(d.commandHandler) d.bot.AddHandler(d.commandHandler)
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
if err := d.bot.Open(); err != nil { if err := d.bot.Open(); err != nil {
d.app.err.Printf("Discord: Failed to start daemon: %v", err) d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
return return
} }
// Wait for everything to populate, it's slow sometimes. // Wait for everything to populate, it's slow sometimes.
@ -116,17 +115,17 @@ func (d *DiscordDaemon) run() {
d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID
guild, err := d.bot.Guild(d.guildID) guild, err := d.bot.Guild(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err) d.app.err.Printf(lm.FailedGetDiscordGuild, err)
} }
d.serverChannelName = guild.Name d.serverChannelName = guild.Name
d.serverName = guild.Name d.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" { if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.channelName = channel d.Channel.Name = channel
d.serverChannelName += "/" + channel d.serverChannelName += "/" + channel
} }
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) { if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" { if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
d.inviteChannelName = invChannel d.InviteChannel.Name = invChannel
} }
} }
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
@ -145,7 +144,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
var r []*dg.Role var r []*dg.Role
r, err = d.bot.GuildRoles(d.guildID) r, err = d.bot.GuildRoles(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get roles: %v", err) d.app.err.Printf(lm.FailedGetDiscordRoles, err)
return return
} }
for _, role := range r { for _, role := range r {
@ -168,44 +167,62 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID) return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID)
} }
// RemoveRole removes the member role to the given user if set.
func (d *DiscordDaemon) RemoveRole(userID string) error {
if d.roleID == "" {
return nil
}
return d.bot.GuildMemberRoleRemove(d.guildID, userID, d.roleID)
}
// SetRoleDisabled removes the role if "disabled", and applies if "!disabled".
func (d *DiscordDaemon) SetRoleDisabled(userID string, disabled bool) (err error) {
if disabled {
err = d.RemoveRole(userID)
} else {
err = d.ApplyRole(userID)
}
return
}
// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon. // NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon.
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) { func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite var inv *dg.Invite
var err error var err error
if d.inviteChannelName == "" { if d.InviteChannel.Name == "" {
d.app.err.Println("Discord: Cannot create invite without channel specified in settings.") d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return return
} }
if d.inviteChannelID == "" { if d.InviteChannel.ID == "" {
channels, err := d.bot.GuildChannels(d.guildID) channels, err := d.bot.GuildChannels(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Couldn't get channel list: %v", err) d.app.err.Printf(lm.FailedGetDiscordChannels, err)
return return
} }
found := false found := false
for _, channel := range channels { for _, channel := range channels {
// channel, err := d.bot.Channel(ch.ID) // channel, err := d.bot.Channel(ch.ID)
// if err != nil { // if err != nil {
// d.app.err.Printf("Discord: Couldn't get channel: %v", err) // d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
// return // return
// } // }
if channel.Name == d.inviteChannelName { if channel.Name == d.InviteChannel.Name {
d.inviteChannelID = channel.ID d.InviteChannel.ID = channel.ID
found = true found = true
break break
} }
} }
if !found { if !found {
d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName) d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound)
return return
} }
} }
// channel, err := d.bot.Channel(d.inviteChannelID) // channel, err := d.bot.Channel(d.inviteChannelID)
// if err != nil { // if err != nil {
// d.app.err.Printf("Discord: Couldn't get invite channel: %v", err) // d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err)
// return // return
// } // }
inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{ inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1], // Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
// Channel: channel, // Channel: channel,
// Inviter: d.bot.State.User, // Inviter: d.bot.State.User,
@ -214,13 +231,13 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
Temporary: false, Temporary: false,
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to create invite: %v", err) d.app.err.Printf(lm.FailedGenerateDiscordInvite, err)
return return
} }
inviteURL = "https://discord.gg/" + inv.Code inviteURL = "https://discord.gg/" + inv.Code
guild, err := d.bot.Guild(d.guildID) guild, err := d.bot.Guild(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err) d.app.err.Printf(lm.FailedGetDiscordGuild, err)
return return
} }
iconURL = guild.IconURL("256") iconURL = guild.IconURL("256")
@ -255,7 +272,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
1000, 1000,
) )
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get members: %v", err) d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err)
return nil return nil
} }
hasDiscriminator := strings.Contains(username, "#") hasDiscriminator := strings.Contains(username, "#")
@ -285,7 +302,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) { func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
u, err := d.bot.User(ID) u, err := d.bot.User(ID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get user: %v", err) d.app.err.Printf(lm.FailedGetUser, ID, lm.Discord, err)
return return
} }
user.ID = ID user.ID = ID
@ -294,7 +311,7 @@ func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
user.Discriminator = u.Discriminator user.Discriminator = u.Discriminator
channel, err := d.bot.UserChannelCreate(ID) channel, err := d.bot.UserChannelCreate(ID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to create DM channel: %v", err) d.app.err.Printf(lm.FailedCreateDiscordDMChannel, ID, err)
return return
} }
user.ChannelID = channel.ID user.ChannelID = channel.ID
@ -381,7 +398,7 @@ func (d *DiscordDaemon) registerCommands() {
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram)) d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
i := 0 i := 0
for code := range d.app.storage.lang.Telegram { for code := range d.app.storage.lang.Telegram {
d.app.debug.Printf("Discord: registering lang choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code) d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Lang, d.app.storage.lang.Telegram[code].Meta.Name+":"+code)
d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{ d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: d.app.storage.lang.Telegram[code].Meta.Name, Name: d.app.storage.lang.Telegram[code].Meta.Name,
Value: code, Value: code,
@ -392,7 +409,7 @@ func (d *DiscordDaemon) registerCommands() {
profiles := d.app.storage.GetProfiles() profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles)) d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles { for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name) d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{ d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name, Name: profile.Name,
Value: profile.Name, Value: profile.Name,
@ -409,9 +426,9 @@ func (d *DiscordDaemon) registerCommands() {
for i, cmd := range d.commandDescriptions { for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd) command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err) d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
} else { } else {
d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name) d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID d.commandIDs[i] = command.ID
} }
} }
@ -420,12 +437,12 @@ func (d *DiscordDaemon) registerCommands() {
func (d *DiscordDaemon) deregisterCommands() { func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID) existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to get commands: %v", err) d.app.err.Printf(lm.FailedGetDiscordCommands, err)
return return
} }
for _, cmd := range existingCommands { for _, cmd := range existingCommands {
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil { if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil {
d.app.err.Printf("Discord: Failed to deregister command: %v", err) d.app.err.Printf(lm.FailedDeregDiscordCommand, cmd.Name, err)
} }
} }
} }
@ -436,7 +453,7 @@ func (d *DiscordDaemon) UpdateCommands() {
profiles := d.app.storage.GetProfiles() profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles)) d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles { for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name) d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{ d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name, Name: profile.Name,
Value: profile.Name, Value: profile.Name,
@ -444,7 +461,7 @@ func (d *DiscordDaemon) UpdateCommands() {
} }
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3]) cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to update profile list: %v\n", err) d.app.err.Printf(lm.FailedRegisterDiscordChoices, lm.Profile, err)
} else { } else {
d.commandIDs[3] = cmd.ID d.commandIDs[3] = cmd.ID
} }
@ -452,19 +469,20 @@ func (d *DiscordDaemon) UpdateCommands() {
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) { func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok { if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.channelName != "" { if i.GuildID != "" && d.Channel.Name != "" {
if d.channelID == "" { if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID) channel, err := s.Channel(i.ChannelID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err) d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.channelName = "" d.app.err.Println(lm.MonitorAllDiscordChannels)
d.Channel.Name = ""
} }
if channel.Name == d.channelName { if channel.Name == d.Channel.Name {
d.channelID = channel.ID d.Channel.ID = channel.ID
} }
} }
if d.channelID != i.ChannelID { if d.Channel.ID != i.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel") d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
return return
} }
} }
@ -486,7 +504,7 @@ func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID) channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return return
} }
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username) user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
@ -503,7 +521,7 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err) d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return return
} }
} }
@ -521,7 +539,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
} }
delete(d.tokens, pin) delete(d.tokens, pin)
return return
@ -535,7 +553,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
} }
dcUser := d.users[i.Interaction.Member.User.ID] dcUser := d.users[i.Interaction.Member.User.ID]
dcUser.JellyfinID = user.JellyfinID dcUser.JellyfinID = user.JellyfinID
@ -566,7 +584,7 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err) d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return return
} }
} }
@ -575,7 +593,7 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID) channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return return
} }
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username) requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
@ -590,12 +608,9 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
//} //}
// Check whether requestor is linked to the admin account // Check whether requestor is linked to the admin account
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID) requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
if !ok { if !(ok && requesterEmail.Admin) {
d.app.err.Printf("Failed to verify admin") d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
} // FIXME: add response message
if !requesterEmail.Admin {
d.app.err.Printf("User is not admin")
//add response message
return return
} }
@ -629,7 +644,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
ValidTill: validTill, ValidTill: validTill,
UserLabel: userLabel, UserLabel: userLabel,
Profile: "Default", Profile: "Default",
Label: fmt.Sprintf("Discord: %s", RenderDiscordUsername(recipient)), Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
} }
if profileName != "" { if profileName != "" {
if _, ok := d.app.storage.GetProfileKey(profileName); ok { if _, ok := d.app.storage.GetProfileKey(profileName); ok {
@ -638,13 +653,12 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
} }
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) { if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
d.app.debug.Printf("%s: Sending invite message", invite.Code)
invname, err := d.bot.GuildMember(d.guildID, recipient.ID) invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false) msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient)) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err) d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{ Data: &dg.InteractionResponseData{
@ -653,14 +667,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err) d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
} }
} else { } else {
var err error var err error
err = d.app.discord.SendDM(msg, recipient.ID) err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient)) invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err) d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{ Data: &dg.InteractionResponseData{
@ -669,10 +683,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err) d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
} }
} else { } else {
d.app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, RenderDiscordUsername(recipient)) d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{ Data: &dg.InteractionResponseData{
@ -681,7 +695,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
}, },
}) })
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err) d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
} }
} }
} }
@ -690,140 +704,6 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
d.app.storage.SetInvitesKey(invite.Code, invite) d.app.storage.SetInvitesKey(invite.Code, invite)
} }
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
if m.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
}
}
if d.channelID != m.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
return
}
}
if m.Author.ID == s.State.User.ID {
return
}
sects := strings.Split(m.Content, " ")
if len(sects) == 0 {
return
}
lang := d.app.storage.lang.chosenTelegramLang
if user, ok := d.users[m.Author.ID]; ok {
if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok {
lang = user.Lang
}
}
switch msg := sects[0]; msg {
case "!" + d.app.config.Section("discord").Key("start_command").MustString("start"):
d.msgStart(s, m, lang)
case "!lang":
d.msgLang(s, m, sects, lang)
default:
d.msgPIN(s, m, sects, lang)
}
}
func (d *DiscordDaemon) msgStart(s *dg.Session, m *dg.MessageCreate, lang string) {
channel, err := s.UserChannelCreate(m.Author.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err)
return
}
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
d.users[m.Author.ID] = user
_, err = d.bot.ChannelMessageSendReply(m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("discordDMs"), m.Reference())
if err != nil {
d.app.err.Printf("Discord: Failed to send reply to \"%s\": %v", m.Author.Username, err)
return
}
content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"})
_, err = s.ChannelMessageSend(channel.ID, content)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
return
}
}
func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if len(sects) == 1 {
list := "!lang <lang>\n"
for code := range d.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name)
}
_, err := s.ChannelMessageSendReply(
m.ChannelID,
list,
m.Reference(),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
return
}
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
var user DiscordUser
for _, u := range d.app.storage.GetDiscord() {
if u.ID == m.Author.ID {
u.Lang = sects[1]
d.app.storage.SetDiscordKey(u.JellyfinID, u)
user = u
break
}
}
d.users[m.Author.ID] = user
}
}
func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if _, ok := d.users[m.Author.ID]; ok {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Failed to get channel: %v", err)
return
}
if channel.Type != dg.ChannelTypeDM {
d.app.debug.Println("Discord: Ignoring message as not a DM")
return
}
} else {
d.app.debug.Println("Discord: Ignoring message as user was not found")
return
}
user, ok := d.tokens[sects[0]]
if !ok || time.Now().After(user.Expiry) {
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
delete(d.tokens, sects[0])
return
}
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
dcUser := d.users[m.Author.ID]
dcUser.JellyfinID = user.JellyfinID
d.verifiedTokens[sects[0]] = dcUser
delete(d.tokens, sects[0])
}
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
channels := make([]string, len(userID)) channels := make([]string, len(userID))
for i, id := range userID { for i, id := range userID {
@ -877,10 +757,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
} }
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself. // UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) { func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
user, ok = d.verifiedTokens[pin] u, ok := d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin) // delete(d.verifiedTokens, pin)
return return &u, ok
} }
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself. // AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
@ -900,7 +780,44 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// DeleteVerifiedUser removes the token with the given PIN. // Exists returns whether or not the given user exists.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) { func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
delete(d.verifiedTokens, pin) return d.UserExists(user.MethodID().(string))
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
delete(d.verifiedTokens, PIN)
}
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
func (d *DiscordDaemon) Name() string { return lm.Discord }
func (d *DiscordDaemon) Required() bool {
return d.app.config.Section("discord").Key("required").MustBool(false)
}
func (d *DiscordDaemon) UniqueRequired() bool {
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
}
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
err := d.ApplyRole(u.MethodID().(string))
if err != nil {
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
}
return err
}
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
func (d *DiscordUser) MethodID() any { return d.ID }
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
func (d *DiscordUser) AllowContact() bool { return d.Contact }
func (d *DiscordUser) Store(st *Storage) {
st.SetDiscordKey(d.Jellyfin(), *d)
} }

View File

@ -20,6 +20,7 @@ import (
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go" "github.com/itchyny/timefmt-go"
"github.com/mailgun/mailgun-go/v4" "github.com/mailgun/mailgun-go/v4"
@ -95,7 +96,7 @@ func NewEmailer(app *appContext) *Emailer {
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4)) authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf) err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil { if err != nil {
app.err.Printf("Error while initiating SMTP mailer: %v", err) app.err.Printf(lm.FailedInitSMTP, err)
} }
} else if method == "mailgun" { } else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String()) emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
@ -580,7 +581,7 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
// Only used in html email. // Only used in html email.
template["pin_code"] = pwr.Pin template["pin_code"] = pwr.Pin
} else { } else {
app.info.Println("Couldn't generate PWR link: %v", err) app.info.Println(lm.FailedGeneratePWRLink, err)
template["pin"] = pwr.Pin template["pin"] = pwr.Pin
} }
} else { } else {

View File

@ -1,6 +1,10 @@
package main package main
import "time" import (
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
@ -36,7 +40,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
func (d *GenericDaemon) Name(name string) { d.name = name } func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() { func (d *GenericDaemon) run() {
d.app.info.Printf("%s started", d.name) d.app.info.Printf(lm.StartDaemon, d.name)
for { for {
select { select {
case <-d.ShutdownChannel: case <-d.ShutdownChannel:

7
go.mod
View File

@ -1,6 +1,6 @@
module github.com/hrfee/jfa-go module github.com/hrfee/jfa-go
go 1.20 go 1.22.4
replace github.com/hrfee/jfa-go/docs => ./docs replace github.com/hrfee/jfa-go/docs => ./docs
@ -10,6 +10,8 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi
replace github.com/hrfee/jfa-go/logger => ./logger replace github.com/hrfee/jfa-go/logger => ./logger
replace github.com/hrfee/jfa-go/logmessages => ./logmessages
replace github.com/hrfee/jfa-go/linecache => ./linecache replace github.com/hrfee/jfa-go/linecache => ./linecache
replace github.com/hrfee/jfa-go/api => ./api replace github.com/hrfee/jfa-go/api => ./api
@ -35,7 +37,7 @@ require (
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3 github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000 github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3 github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3 github.com/hrfee/jfa-go/logger v0.0.0-20240731152135-2d066ea7cd32
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3 github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/mediabrowser v0.3.13 github.com/hrfee/mediabrowser v0.3.13
github.com/itchyny/timefmt-go v0.1.5 github.com/itchyny/timefmt-go v0.1.5
@ -91,6 +93,7 @@ require (
github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
github.com/hrfee/jfa-go/logmessages v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.6 // indirect github.com/klauspost/compress v1.16.6 // indirect

View File

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/dgraph-io/badger/v3" "github.com/dgraph-io/badger/v3"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -12,7 +13,7 @@ import (
// meant to be called with other such housekeeping functions, so assumes // meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh. // the user cache is fresh.
func (app *appContext) clearEmails() { func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses") app.debug.Println(lm.HousekeepingEmail)
emails := app.storage.GetEmails() emails := app.storage.GetEmails()
for _, email := range emails { for _, email := range emails {
_, _, err := app.jf.UserByID(email.JellyfinID, false) _, _, err := app.jf.UserByID(email.JellyfinID, false)
@ -28,15 +29,20 @@ func (app *appContext) clearEmails() {
// clearDiscord does the same as clearEmails, but for Discord Users. // clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() { func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs") app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord() discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers { for _, discordUser := range discordUsers {
_, _, err := app.jf.UserByID(discordUser.JellyfinID, false) user, _, err := app.jf.UserByID(discordUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured // Make sure the user doesn't exist, and no other error has occured
switch err.(type) { switch err.(type) {
case mediabrowser.ErrUserNotFound: case mediabrowser.ErrUserNotFound:
// Remove role in case their account was deleted oustide of jfa-go
app.discord.RemoveRole(discordUser.MethodID().(string))
app.storage.DeleteDiscordKey(discordUser.JellyfinID) app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default: default:
if user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue continue
} }
} }
@ -44,7 +50,7 @@ func (app *appContext) clearDiscord() {
// clearMatrix does the same as clearEmails, but for Matrix Users. // clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() { func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs") app.debug.Println(lm.HousekeepingMatrix)
matrixUsers := app.storage.GetMatrix() matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers { for _, matrixUser := range matrixUsers {
_, _, err := app.jf.UserByID(matrixUser.JellyfinID, false) _, _, err := app.jf.UserByID(matrixUser.JellyfinID, false)
@ -60,7 +66,7 @@ func (app *appContext) clearMatrix() {
// clearTelegram does the same as clearEmails, but for Telegram Users. // clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() { func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs") app.debug.Println(lm.HousekeepingTelegram)
telegramUsers := app.storage.GetTelegram() telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers { for _, telegramUser := range telegramUsers {
_, _, err := app.jf.UserByID(telegramUser.JellyfinID, false) _, _, err := app.jf.UserByID(telegramUser.JellyfinID, false)
@ -75,7 +81,7 @@ func (app *appContext) clearTelegram() {
} }
func (app *appContext) clearPWRCaptchas() { func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas") app.debug.Println(lm.HousekeepingCaptcha)
captchas := map[string]Captcha{} captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas { for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) { if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
@ -86,7 +92,7 @@ func (app *appContext) clearPWRCaptchas() {
} }
func (app *appContext) clearActivities() { func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...") app.debug.Println(lm.HousekeepingActivity)
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90) maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays) minAge := time.Now().AddDate(0, 0, -maxAgeDays)
@ -103,7 +109,7 @@ func (app *appContext) clearActivities() {
} }
} }
if err == badger.ErrTxnTooBig { if err == badger.ErrTxnTooBig {
app.debug.Printf("Activities: Delete txn was too big, doing it manually.") app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{} list := []Activity{}
if errorSource == 0 { if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge)) app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
@ -119,7 +125,7 @@ func (app *appContext) clearActivities() {
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon { func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app, d := NewGenericDaemon(interval, app,
func(app *appContext) { func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites") app.debug.Println(lm.HousekeepingInvites)
app.checkInvites() app.checkInvites()
}, },
func(app *appContext) { app.clearActivities() }, func(app *appContext) { app.clearActivities() },
@ -128,7 +134,7 @@ func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaem
d.Name("Housekeeping daemon") d.Name("Housekeeping daemon")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) || app.config.Section("discord").Key("disable_enable_role").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false) clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false) clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false) clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)

View File

@ -246,6 +246,7 @@
<div class="card ~neutral @low mb-2 unfocused"> <div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Ombi.title }}</span> <span class="heading">{{ .lang.Ombi.title }}</span>
<p class="content my-2">{{ .lang.Ombi.description }}</p> <p class="content my-2">{{ .lang.Ombi.description }}</p>
<aside class="aside ~warning my-2" id="ombi-stability-warning">{{ .lang.Ombi.stabilityWarning }}</aside>
<label class="row switch pb-4"> <label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span> <input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
@ -258,6 +259,23 @@
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key"> <input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label> </label>
<span class="heading">{{ .lang.Jellyseerr.title }}</span>
<p class="content my-2">{{ .lang.Jellyseerr.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="jellyseerr-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="jellyseerr-server" placeholder="https://jellyseerr.jellyf.in:5055">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low mt-4" id="jellyseerr-api_key">
</label>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="jellyseerr-import_existing"><span>{{ .lang.Jellyseerr.importExisting }}</span>
<p class="support mb-2 mt-1">{{ .lang.Jellyseerr.importExistingDescription }}</p>
</label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
@ -423,6 +441,7 @@
<div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email"> <div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email">
<span class="heading">{{ .lang.PasswordResets.title }}</span> <span class="heading">{{ .lang.PasswordResets.title }}</span>
<p class="content my-2">{{ .lang.PasswordResets.description }}</p> <p class="content my-2">{{ .lang.PasswordResets.description }}</p>
<p class="content my-2" id="password_resets-more-info">{{ .lang.PasswordResets.moreInfo }}</p>
<label class="row switch pb-4"> <label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span> <input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
@ -541,4 +560,3 @@
<script src="js/setup.js" type="module"></script> <script src="js/setup.js" type="module"></script>
</body> </body>
</html> </html>

0
images/jfa-go-icon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

0
images/jfa-go-icon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -5,20 +5,21 @@ import (
"time" "time"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
) )
func (app *appContext) SynchronizeJellyseerrUser(jfID string) { func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID) user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil { if err != nil {
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err) app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return return
} }
if imported { if imported {
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID) app.debug.Printf(lm.ImportJellyseerrUser, jfID, user.ID)
} }
notif, err := app.js.GetNotificationPreferencesByID(user.ID) notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil { if err != nil {
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", jfID, err) app.debug.Printf(lm.FailedGetJellyseerrNotificationPrefs, jfID, err)
return return
} }
@ -27,7 +28,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if ok && email.Addr != "" && user.Email != email.Addr { if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr}) err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil { if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err) app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
} else { } else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
} }
@ -51,7 +52,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if len(contactMethods) != 0 { if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods) err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil { if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
} }
} }
} }
@ -59,7 +60,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
func (app *appContext) SynchronizeJellyseerrUsers() { func (app *appContext) SynchronizeJellyseerrUsers() {
users, status, err := app.jf.GetUsers(false) users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 { if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return return
} }
// I'm sure Jellyseerr can handle it, // I'm sure Jellyseerr can handle it,

View File

@ -122,6 +122,7 @@ type setupLang struct {
Login langSection `json:"login"` Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"` JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"` Ombi langSection `json:"ombi"`
Jellyseerr langSection `json:"jellyseerr"`
Email langSection `json:"email"` Email langSection `json:"email"`
Messages langSection `json:"messages"` Messages langSection `json:"messages"`
Notifications langSection `json:"notifications"` Notifications langSection `json:"notifications"`

88
lang/form/ckb-iq.json Normal file
View File

@ -0,0 +1,88 @@
{
"meta": {
"name": "کوردی سۆرانی"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری جێڵیفن",
"createAccountHeader": "دروستکردنی هەژمار",
"accountDetails": "زانیارییەکان",
"emailAddress": "ئیمەیل",
"username": "ناوی بەکارهێنەر",
"oldPassword": "وشەی نهێنی کۆن",
"newPassword": "وشەی نهێنی نوێ",
"password": "وشەی نهێنی",
"reEnterPassword": "دووبارە وشەی نهێنی بنووسەوە",
"reEnterPasswordInvalid": "وشە نهێنییەکان یەک ناگرن.",
"createAccountButton": "هەژمار دروستبکە",
"passwordRequirementsHeader": "داواکارییەکانی وشەی نهێنی",
"successHeader": "سەرکەوت!",
"confirmationRequired": "دووپاتکردنەوەی ئیمەیل داواکراوە",
"confirmationRequiredMessage": "تکایە سەیری نامەکانی ئیمەیلەکەت بکە بۆ دووپاتکردنەوەی ناونیشانەکەت.",
"yourAccountIsValidUntil": "هەژمارەکەت تاکو {date} کاردەکات.",
"sendPIN": "ئەم ژمارە نهێنییەی خوارەوە بۆ بۆتەکە بنێرە، پاشان وەرەوە بۆ پەیوەستکردنی هەژمارەکەت.",
"sendPINDiscord": "{command} لە چەناڵی {server_channel}ی دیسکۆردەکەت بنوسە، پاشان ئەم ژمارە نهێنییەی خوارەوە بنێرە.",
"matrixEnterUser": "",
"welcomeUser": "{user}، بەخێربێیت!",
"addContactMethod": "",
"editContactMethod": "",
"joinTheServer": "",
"customMessagePlaceholderHeader": "",
"customMessagePlaceholderContent": "",
"userPageSuccessMessage": "",
"resetPassword": "",
"resetPasswordThroughJellyfin": "",
"resetPasswordThroughLink": "",
"resetPasswordThroughLinkStart": "",
"resetPasswordThroughLinkEnd": "",
"resetPasswordUsername": "",
"resetPasswordEmail": "",
"resetPasswordContactMethod": "",
"resetSent": "",
"resetSentDescription": "",
"changePassword": "",
"referralsDescription": "",
"referralsWithExpiryDescription": "",
"copyReferral": "",
"invitedBy": ""
},
"notifications": {
"errorUserExists": "",
"errorInvalidCode": "",
"errorAccountLinked": "",
"errorEmailLinked": "",
"errorTelegramVerification": "",
"errorDiscordVerification": "",
"errorMatrixVerification": "",
"errorInvalidPIN": "",
"errorUnknown": "",
"errorNoEmail": "",
"errorCaptcha": "",
"errorPassword": "",
"errorNoMatch": "",
"errorOldPassword": "",
"passwordChanged": "",
"verified": ""
},
"validationStrings": {
"length": {
"singular": "",
"plural": ""
},
"uppercase": {
"singular": "",
"plural": ""
},
"lowercase": {
"singular": "",
"plural": ""
},
"number": {
"singular": "",
"plural": ""
},
"special": {
"singular": "",
"plural": ""
}
}
}

View File

@ -17,7 +17,7 @@
"confirmationRequired": "E-Mail Bestätigung erforderlich", "confirmationRequired": "E-Mail Bestätigung erforderlich",
"confirmationRequiredMessage": "Bitte überprüfe deinen Posteingang und bestätige deine E-Mail-Adresse.", "confirmationRequiredMessage": "Bitte überprüfe deinen Posteingang und bestätige deine E-Mail-Adresse.",
"yourAccountIsValidUntil": "Dein Konto wird bis zum {date} gültig sein.", "yourAccountIsValidUntil": "Dein Konto wird bis zum {date} gültig sein.",
"sendPIN": "Sende die untenstehende PIN an den Bot und komm dann hierher zurück, um dein Konto zu verbinden.", "sendPIN": "Sende die PIN unten an den Bot und komm dann hierher zurück, um dein Konto zu verknüpfen.",
"sendPINDiscord": "Gib auf Discord {command} in {server_channel} ein und sende die untenstehende PIN.", "sendPINDiscord": "Gib auf Discord {command} in {server_channel} ein und sende die untenstehende PIN.",
"matrixEnterUser": "Gib deine Benutzer-ID ein und drücke auf Absenden. Anschließend erhälst du ein PIN, die hier eingegeben wird um fortzufahren.", "matrixEnterUser": "Gib deine Benutzer-ID ein und drücke auf Absenden. Anschließend erhälst du ein PIN, die hier eingegeben wird um fortzufahren.",
"oldPassword": "Altes Passwort", "oldPassword": "Altes Passwort",
@ -30,7 +30,20 @@
"joinTheServer": "Server beitreten:", "joinTheServer": "Server beitreten:",
"userPageSuccessMessage": "Du kannst die Details deines Accounts später in {myAccount} Seite einsehen und ändern.", "userPageSuccessMessage": "Du kannst die Details deines Accounts später in {myAccount} Seite einsehen und ändern.",
"resetPassword": "Passwort zurücksetzen", "resetPassword": "Passwort zurücksetzen",
"resetPasswordThroughJellyfin": "Um das Passwort zurückzusetzen, besuche {jfLink} und drücke auf die Schaltfläche \"Passwort vergessen\"." "resetPasswordThroughJellyfin": "Um das Passwort zurückzusetzen, besuche {jfLink} und drücke auf die Schaltfläche \"Passwort vergessen\".",
"resetPasswordThroughLinkStart": "Um dein Passwort zurückzusetzen, gib eine der folgenden Möglichkeiten ein:",
"resetPasswordContactMethod": "Den Benutzernamen einer Kontaktmethode, die mit deinem Konto verknüpft ist",
"changePassword": "Passwort ändern",
"resetPasswordThroughLink": "Um dein Passwort zurückzusetzen, gib deinen Benutzernamen, deine E-Mail-Adresse oder einer verlinkten Kontaktmethode ein und sende ihn ab. Du erhältst einen Link zum Zurücksetzen deines Passworts.",
"resetSent": "Infos zum Zurücksetzen wurden gesendet.",
"resetSentDescription": "Wenn ein Konto mit dem angegebenen Benutzernamen/der angegebenen Kontaktmethode existiert, wurde ein Link zum Zurücksetzen des Passworts über alle verfügbaren Kontaktmethoden verschickt. Der Code ist 30 Minuten gültig.",
"referralsDescription": "Lade Freunde & Familie mit diesem Link zu Jellyfin ein. Wenn er abläuft, kannst du hier einen neuen Link anfordern.",
"copyReferral": "Link kopieren",
"invitedBy": "Du wurdest von {user} eingeladen.",
"resetPasswordThroughLinkEnd": "Drücke dann auf Abschicken. Du erhältst einen Link, mit dem du dein Passwort zurücksetzen kannst.",
"resetPasswordUsername": "Dein Jellyfin-Benutzername",
"resetPasswordEmail": "Deine E-Mail Adresse",
"referralsWithExpiryDescription": "Lade Freunde & Familie mit diesem Link zu Jellyfin ein. Der Link wird deaktiviert, sobald er abläuft."
}, },
"validationStrings": { "validationStrings": {
"length": { "length": {
@ -67,6 +80,10 @@
"errorNoEmail": "E-Mail Adresse erforderlich.", "errorNoEmail": "E-Mail Adresse erforderlich.",
"errorCaptcha": "Captcha falsch.", "errorCaptcha": "Captcha falsch.",
"errorPassword": "Prüfe die Passwortanforderungen.", "errorPassword": "Prüfe die Passwortanforderungen.",
"errorNoMatch": "Passwörter stimmen nicht überein." "errorNoMatch": "Passwörter stimmen nicht überein.",
"errorAccountLinked": "Konto wird bereits verwendet.",
"errorEmailLinked": "E-Mail wird bereits verwendet.",
"passwordChanged": "Das Passwort wurde geändert.",
"errorOldPassword": "Das alte Passwort ist falsch."
} }
} }

View File

@ -47,7 +47,7 @@
"title": "General", "title": "General",
"listenAddress": "Listen Address", "listenAddress": "Listen Address",
"urlBase": "URL Base", "urlBase": "URL Base",
"urlBaseNotice": "Only needed if using a reverse proxy on a subdomain (e.g 'jellyf.in/accounts').", "urlBaseNotice": "Only needed if using a reverse proxy on a subfolder (e.g 'jellyf.in/accounts').",
"lightTheme": "Light", "lightTheme": "Light",
"darkTheme": "Dark", "darkTheme": "Dark",
"useHTTPS": "Use HTTPS", "useHTTPS": "Use HTTPS",
@ -94,7 +94,13 @@
"ombi": { "ombi": {
"title": "Ombi", "title": "Ombi",
"description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup is finished, go to Settings to set a default profile for new ombi users.", "description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup is finished, go to Settings to set a default profile for new ombi users.",
"apiKeyNotice": "Find this in the first tab of Ombi settings." "apiKeyNotice": "Find this in the first tab of Ombi settings.",
"stabilityWarning": "Warning: Ombi integration is unstable, and can cause issues. Jellyseerr is recommended instead. See {n} for more info."
},
"jellyseerr": {
"title": "Jellyseerr",
"description": "Jellyseerr is an alternative to Ombi, and integrates with jfa-go slightly better. Again, after setup is finished, go to Settings to create a profile and add a template for new Jellyseerr accounts.",
"importExistingDescription": "If enabled, your existing users will have contact details and preferences from jfa-go synchronized."
}, },
"messages": { "messages": {
"title": "Messages", "title": "Messages",
@ -134,6 +140,7 @@
"passwordResets": { "passwordResets": {
"title": "Password Resets", "title": "Password Resets",
"description": "When a user tries to reset their password, Jellyfin creates a file named 'passwordreset-*.json' which contains a PIN. jfa-go reads the file and sends the PIN to the user. If you enabled the \"User Page\" feature, a reset can also be performed there, given a username, email, or contact method.", "description": "When a user tries to reset their password, Jellyfin creates a file named 'passwordreset-*.json' which contains a PIN. jfa-go reads the file and sends the PIN to the user. If you enabled the \"User Page\" feature, a reset can also be performed there, given a username, email, or contact method.",
"moreInfo": "More information about the different ways of resetting passwords can be found on {n}.",
"pathToJellyfin": "Path to Jellyfin configuration directory", "pathToJellyfin": "Path to Jellyfin configuration directory",
"pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear. This is not necessary if you only want to use self-service password resets through the \"User Page\".", "pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear. This is not necessary if you only want to use self-service password resets through the \"User Page\".",
"resetLinks": "Send a link instead of a PIN", "resetLinks": "Send a link instead of a PIN",

3
logmessages/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/hrfee/logmessages
go 1.22.4

371
logmessages/logmessages.go Normal file
View File

@ -0,0 +1,371 @@
package logmessages
/* Log strings for (almost) all the program.
* Helps avoid writing redundant, slightly different
* strings constantly.
* Also would help if I were to ever set up translation
* for logs. Mostly split by file, but obviously there's
* re-use, and occasionally related stuff is grouped.
*/
const (
Jellyseerr = "Jellyseerr"
Jellyfin = "Jellyfin"
Ombi = "Ombi"
Discord = "Discord"
Telegram = "Telegram"
Matrix = "Matrix"
Email = "Email"
// main.go
FailedLogging = "Failed to start log wrapper: %v\n"
NoConfig = "Couldn't find default config file"
Write = "Wrote to \"%s\""
FailedWriting = "Failed to write to \"%s\": %v"
FailedCreateDir = "Failed to create directory \"%s\": %v"
FailedReading = "Failed to read from \"%s\": %v"
FailedOpen = "Failed to open \"%s\": %v"
FailedStat = "Failed to stat \"%s\": %v"
PathNotFound = "Path \"%s\" not found"
CopyConfig = "Copied default configuration to \"%s\""
FailedCopyConfig = "Failed to copy default configuration to \"%s\": %v"
LoadConfig = "Loaded config file \"%s\""
FailedLoadConfig = "Failed to load config file \"%s\": %v"
ModifyConfig = "Config saved to \"%s\""
SocketPath = "Socket Path: \"%s\""
FailedSocketConnect = "Couldn't establish socket connection at \"%s\": %v"
SocketCheckRunning = "Make sure jfa-go is running."
FailedSocketRead = "Couldn't read message on socket \"%s\": %v"
SocketWrite = "Command sent."
FailedSocketWrite = "Coudln't write message on socket \"%s\": %v"
FailedLangLoad = "Failed to load language files: %v"
UsingTLS = "Using TLS/HTTP2"
UsingOmbi = "Starting " + " + Ombi + " + " client"
UsingJellyseerr = "Starting " + Jellyseerr + " client"
UsingEmby = "Using Emby server type (EXPERIMENTAL: PWRs are not available, and support is limited.)"
UsingJellyfin = "Using " + Jellyfin + " server type"
UsingJellyfinAuth = "Using " + Jellyfin + " for authentication"
UsingLocalAuth = "Using local username/pw authentication (NOT RECOMMENDED)"
AuthJellyfin = "Authenticated with " + Jellyfin + " @ \"%s\""
FailedAuthJellyfin = "Failed to authenticate with " + Jellyfin + " @ \"%s\" (code %d): %v"
InitDiscord = "Initialized Discord daemon"
FailedInitDiscord = "Failed to initialize Discord daemon: %v"
InitTelegram = "Initialized Telegram daemon"
FailedInitTelegram = "Failed to initialize Telegram daemon: %v"
InitMatrix = "Initialized Matrix daemon"
FailedInitMatrix = "Failed to initialize Matrix daemon: %v"
InitRouter = "Initializing router"
LoadRoutes = "Loading Routes"
LoadingSetup = "Loading setup @ \"%s\""
ServingSetup = "Loaded, visit \"%s\" to start."
InvalidSSLCert = "Failed loading SSL Certificate \"%s\": %v"
InvalidSSLKey = "Failed loading SSL Keyfile \"%s\": %v"
FailServeSSL = "Failure serving with SSL/TLS: %v"
FailServe = "Failure serving: %v"
Serving = "Loaded @ \"%s\""
QuitReceived = "Restart/Quit signal received, please be patient."
Quitting = "Shutting down..."
Restarting = "Restarting..."
FailedHardRestartWindows = "hard restarts not available on windows"
Quit = "Server shut down."
FailedQuit = "Server shutdown failed: %v"
// api-activities.go
FailedDBReadActivities = "Failed to read activities from DB: %v"
// api-backups.go
IgnoreInvalidFilename = "Invalid filename \"%s\", ignoring: %v"
GetUpload = "Retrieved uploaded file \"%s\""
FailedGetUpload = "Failed to retrieve file from form data: %v"
// api-invites.go
DeleteOldInvite = "Deleting old invite \"%s\""
DeleteInvite = "Deleting invite \"%s\""
FailedDeleteInvite = "Failed to delete invite \"%s\": %v"
GenerateInvite = "Generating new invite"
FailedGenerateInvite = "Failed to generate new invite: %v"
InvalidInviteCode = "Invalid invite code \"%s\""
FailedSendToTooltipNoUser = "Failed: \"%s\" not found"
FailedSendToTooltipMultiUser = "Failed: \"%s\" linked to multiple users"
FailedParseTime = "Failed to parse time value: %v"
FailedGetContactMethod = "Failed to get contact method for \"%s\", make sure one is set."
SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\""
// *jellyseerr*.go
FailedGetUsers = "Failed to get user(s) from %s: %v"
// FIXME: Once done, look back at uses of FailedGetUsers for places where this would make more sense.
FailedGetUser = "Failed to get user \"%s\" from %s: %v"
FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
FailedSyncContactMethods = "Failed to sync contact methods with %s: %v"
ImportJellyseerrUser = "Triggered import for " + Jellyseerr + " user \"%s\" (New ID: %d)"
FailedImportUser = "Failed to get or trigger import for %s user \"%s\": %v"
// api-messages.go
FailedGetCustomMessage = "Failed to get custom message \"%s\""
SetContactPrefForService = "Set contact preference for %s (\"%s\"): %t"
// Matrix
InvalidPIN = "Invalid PIN \"%s\""
ExpiredPIN = "Expired PIN \"%s\""
InvalidPassword = "Invalid Password"
UnauthorizedPIN = "Unauthorized PIN \"%s\""
FailedCreateRoom = "Failed to create room: %v"
// api-profiles.go
SetDefaultProfile = "Setting default profile to \"%s\""
FailedApplyProfile = "Failed to apply profile for %s user \"%s\": %v"
ApplyProfile = "Applying settings from profile \"%s\""
FailedGetProfile = "Failed to find profile \"%s\""
FailedApplyTemplate = "Failed to apply %s template for %s user \"%s\": %v"
FallbackToDefault = ", using default"
CreateProfileFromUser = "Creating profile from user \"%s\""
FailedGetJellyfinDisplayPrefs = "Failed to get DisplayPreferences for user \"%s\" from " + Jellyfin + ": %v"
ProfileNoHomescreen = "No homescreen template in profile \"%s\""
Profile = "profile"
Lang = "language"
User = "user"
ApplyingTemplatesFrom = "Applying templates from %s: \"%s\" to %d users"
DelayingRequests = "Delay will be added between requests (count = %d)"
// api-userpage.go
EmailConfirmationRequired = "User \"%s\" requires email confirmation"
ChangePassword = "Changed password for %s user \"%s\""
FailedChangePassword = "Failed to change password for %s user \"%s\": %v"
GetReferralTemplate = "Found referral template \"%s\""
FailedGetReferralTemplate = "Failed to find referral template \"%s\": %v"
DeleteOldReferral = "Deleting old referral \"%s\""
RenewOldReferral = "Renewing old referral \"%s\""
// api-users.go
CreateUser = "Created %s user \"%s\""
FailedCreateUser = "Failed to create new %s user \"%s\": %v"
LinkUser = "Linked %s user \"%s\""
FailedLinkUser = "Failed to link %s user \"%s\" with \"%s\": %v"
DeleteUser = "Deleted %s user \"%s\""
FailedDeleteUser = "Failed to delete %s user \"%s\": %v"
FailedDeleteUsers = "Failed to delete %s user(s): %v"
UserExists = "user already exists"
AccountLinked = "account already linked and require_unique enabled"
AccountUnverified = "unverified"
FailedSetDiscordMemberRole = "Failed to apply/remove " + Discord + " member role: %v"
FailedSetEmailAddress = "Failed to set email address for %s user \"%s\": %v"
AdditionalErrors = "Additional errors from %s: %v"
IncorrectCaptcha = "captcha incorrect"
ExtendCreateExpiry = "Extended or created expiry for user \"%s\""
UserEmailAdjusted = "Email for user \"%s\" adjusted"
UserAdminAdjusted = "Admin state for user \"%s\" set to %t"
UserLabelAdjusted = "Label for user \"%s\" set to \"%s\""
// api.go
ApplyUpdate = "Applied update"
FailedApplyUpdate = "Failed to apply update: %v"
UpdateManual = "update is manual"
// backups.go
DeleteOldBackup = "Deleted old backup \"%s\""
FailedDeleteOldBackup = "Failed to delete old backup \"%s\": %v"
CreateBackup = "Created database backup \"%+v\""
FailedCreateBackup = "Faled to create database backup: %v"
MoveOldDB = "Moved existing database to \"%s\""
FailedMoveOldDB = "Failed to move existing database to \"%s\": %v"
RestoreDB = "Restored database from \"%s\""
FailedRestoreDB = "Failed to resotre database from \"%s\": %v"
// config.go
EnableAllPWRMethods = "No PWR method preferences set in [user_page], all will be enabled"
InitProxy = "Initialized proxy @ \"%s\""
FailedInitProxy = "Failed to initialize proxy @ \"%s\": %v\nStartup will pause for a bit to grab your attention."
// discord.go
StartDaemon = "Started %s daemon"
FailedStartDaemon = "Failed to start %s daemon: %v"
FailedGetDiscordGuildMembers = "Failed to get " + Discord + " guild members: %v"
FailedGetDiscordGuild = "Failed to get " + Discord + " guild: %v"
FailedGetDiscordRoles = "Failed to get " + Discord + " roles: %v"
FailedCreateDiscordInviteChannel = "Failed to create " + Discord + " invite channel: %v"
InviteChannelEmpty = "no invite channel set in settings"
FailedGetDiscordChannels = "Failed to get " + Discord + " channel(s): %v"
FailedGetDiscordChannel = "Failed to get " + Discord + " channel \"%s\": %v"
MonitorAllDiscordChannels = "Will monitor all " + Discord + " channels"
FailedCreateDiscordDMChannel = "Failed to create " + Discord + " private DM channel with \"%s\": %v"
NotFound = "not found"
RegisterDiscordChoice = "Registered " + Discord + " %s choice \"%s\""
FailedRegisterDiscordChoices = "Failed to register " + Discord + " %s choices: %v"
FailedDeregDiscordChoice = "Failed to deregister " + Discord + " %s choice \"%s\": %v"
RegisterDiscordCommand = "Registered " + Discord + " command \"%s\""
FailedRegisterDiscordCommand = "Failed to register " + Discord + " command \"%s\": %v"
FailedGetDiscordCommands = "Failed to get " + Discord + " commands: %v"
FailedDeregDiscordCommand = "Failed to deregister " + Discord + " command \"%s\": %v"
FailedReply = "Failed to reply to %s message from \"%s\": %v"
FailedMessage = "Failed to send %s message to \"%s\": %v"
IgnoreOutOfChannelMessage = "Ignoring out-of-channel %s message"
FailedGenerateDiscordInvite = "Failed to generate " + Discord + " invite: %v"
// email.go
FailedInitSMTP = "Failed to initialize SMTP mailer: %v"
FailedGeneratePWRLink = "Failed to generate PWR link: %v"
// housekeeping-d.go
hk = "Housekeeping: "
hkcu = hk + "cleaning up "
HousekeepingEmail = hkcu + Email + " addresses"
HousekeepingDiscord = hkcu + Discord + " IDs"
HousekeepingTelegram = hkcu + Telegram + " IDs"
HousekeepingMatrix = hkcu + Matrix + " IDs"
HousekeepingCaptcha = hkcu + "PWR Captchas"
HousekeepingActivity = hkcu + "Activity log"
HousekeepingInvites = hkcu + "Invites"
ActivityLogTxnTooBig = hk + "Activity log delete transaction was too big, going one-by-one"
// matrix*.go
FailedSyncMatrix = "Failed to sync " + Matrix + " daemon: %v"
FailedCreateMatrixRoom = "Failed to create " + Matrix + " room with user \"%s\": %v"
MatrixOLMLog = "Matrix/OLM: %v"
MatrixOLMTraceLog = "Matrix/OLM [TRACE]:"
FailedDecryptMatrixMessage = "Failed to decrypt " + Matrix + " E2EE'd message: %v"
FailedEnableMatrixEncryption = "Failed to enable encryption in " + Matrix + " room \"%s\": %v"
// NOTE: "migrations.go" is the one file where log messages are not part of logmessages/logmessages.go.
// pwreset.go
PWRExpired = "PWR for user \"%s\" already expired @ %s, check system time!"
// router.go
UseDefaultHTML = "Using default HTML \"%s\""
UseCustomHTML = "Using custom HTML \"%s\""
FailedLoadTemplates = "Failed to load %s templates: %v"
Internal = "internal"
External = "external"
RegisterPprof = "Registered pprof"
SwaggerWarning = "Warning: Swagger should not be used on a public instance."
// storage.go
ConnectDB = "Connected to DB \"%s\""
FailedConnectDB = "Failed to open/connect to database \"%s\": %v"
// updater.go
NoUpdate = "No new updates available"
FoundUpdate = "Found update"
FailedGetUpdateTag = "Failed to get latest tag: %v"
FailedGetUpdate = "Failed to get update: %v"
UpdateTagDetails = "Update/Tag details: %+v"
// user-auth.go
UserPage = "userpage"
UserPageRequiresJellyfinAuth = "Jellyfin login must be enabled for user page access."
// user-d.go
CheckUserExpiries = "Checking for user expiry"
DeleteExpiryForOldUser = "Deleting expiry for old user \"%s\""
DeleteExpiredUser = "Deleting expired user \"%s\""
DisableExpiredUser = "Disabling expired user \"%s\""
FailedDeleteOrDisableExpiredUser = "Failed to delete/disable expired user \"%s\": %v"
// views.go
FailedServerPush = "Failed to use HTTP/2 Server Push: %v"
IgnoreBotPWR = "Ignore PWR magic link visit from bot"
ReCAPTCHA = "ReCAPTCHA"
FailedGenerateCaptcha = "Failed to generate captcha: %v"
CaptchaNotFound = "Captcha \"%s\" not found in invite \"%s\""
FailedVerifyReCAPTCHA = "Failed to verify reCAPTCHA: %v"
InvalidHostname = "invalid hostname (wanted \"%s\", got \"%s\")"
)
const (
FailedGetCookies = "Failed to get cookie(s) \"%s\": %v"
FailedParseJWT = "Failed to parse JWT: %v"
FailedCastJWT = "JWT claims unreadable"
InvalidJWT = "JWT was invalidated, of incorrect type or has expired"
LocallyInvalidatedJWT = "JWT is listed as invalidated"
FailedSignJWT = "Failed to sign JWT: %v"
RequestingToken = "Token requested (%s)"
TokenLoginAttempt = "login attempt"
TokenRefresh = "refresh token"
UserTokenLoginAttempt = UserPage + " " + TokenLoginAttempt
UserTokenRefresh = UserPage + " " + TokenRefresh
GenerateToken = "Token generated for user \"%s\""
FailedGenerateToken = "Failed to generate token: %v"
FailedAuthRequest = "Failed to authorize request: %v"
InvalidAuthHeader = "invalid auth header"
NonAdminToken = "token not for admin use"
NonAdminUser = "user \"%s\" not admin"
InvalidUserOrPass = "invalid user/pass"
EmptyUserOrPass = "invalid user/pass"
UserDisabled = "user is disabled"
)
const (
FailedConstructExpiryAdmin = "Failed to construct expiry notification for \"%s\": %v"
FailedSendExpiryAdmin = "Failed to send expiry notification for \"%s\" to \"%s\": %v"
SentExpiryAdmin = "Sent expiry notification for \"%s\" to \"%s\""
FailedConstructCreationAdmin = "Failed to construct creation notification for \"%s\": %v"
FailedSendCreationAdmin = "Failed to send creation notification for \"%s\" to \"%s\": %v"
SentCreationAdmin = "Sent creation notification for \"%s\" to \"%s\""
FailedConstructInviteMessage = "Failed to construct invite message for \"%s\": %v"
FailedSendInviteMessage = "Failed to send invite message for \"%s\" to \"%s\": %v"
SentInviteMessage = "Sent invite message for \"%s\" to \"%s\""
FailedConstructConfirmationEmail = "Failed to construct confirmation email for \"%s\": %v"
FailedSendConfirmationEmail = "Failed to send confirmation email for \"%s\" to \"%s\": %v"
SentConfirmationEmail = "Sent confirmation email for \"%s\" to \"%s\""
FailedConstructPWRMessage = "Failed to construct PWR message for \"%s\": %v"
FailedSendPWRMessage = "Failed to send PWR message for \"%s\" to \"%s\": %v"
SentPWRMessage = "Sent PWR message for \"%s\" to \"%s\""
FailedConstructWelcomeMessage = "Failed to construct welcome message for \"%s\": %v"
FailedSendWelcomeMessage = "Failed to send welcome message for \"%s\" to \"%s\": %v"
SentWelcomeMessage = "Sent welcome message for \"%s\" to \"%s\""
FailedConstructEnableDisableMessage = "Failed to construct enable/disable message for \"%s\": %v"
FailedSendEnableDisableMessage = "Failed to send enable/disable message for \"%s\" to \"%s\": %v"
SentEnableDisableMessage = "Sent enable/disable message for \"%s\" to \"%s\""
FailedConstructDeletionMessage = "Failed to construct account deletion message for \"%s\": %v"
FailedSendDeletionMessage = "Failed to send account deletion message for \"%s\" to \"%s\": %v"
SentDeletionMessage = "Sent account deletion message for \"%s\" to \"%s\""
FailedConstructExpiryAdjustmentMessage = "Failed to construct expiry adjustment message for \"%s\": %v"
FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v"
SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\""
FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v"
FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v"
SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\""
FailedConstructAnnouncementMessage = "Failed to construct announcement message for \"%s\": %v"
FailedSendAnnouncementMessage = "Failed to send announcement message for \"%s\" to \"%s\": %v"
SentAnnouncementMessage = "Sent announcement message for \"%s\" to \"%s\""
)

150
main.go
View File

@ -27,6 +27,7 @@ import (
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger" "github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi" "github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
@ -101,8 +102,9 @@ type appContext struct {
// Keeping jf name because I can't think of a better one // Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi ombi *OmbiWrapper
js *jellyseerr.Jellyseerr js *JellyseerrWrapper
thirdPartyServices []ThirdPartyService
datePattern string datePattern string
timePattern string timePattern string
storage Storage storage Storage
@ -111,6 +113,7 @@ type appContext struct {
telegram *TelegramDaemon telegram *TelegramDaemon
discord *DiscordDaemon discord *DiscordDaemon
matrix *MatrixDaemon matrix *MatrixDaemon
contactMethods []ContactMethodLinker
info, debug, err *logger.Logger info, debug, err *logger.Logger
host string host string
port int port int
@ -213,22 +216,21 @@ func start(asDaemon, firstCall bool) {
firstRun = true firstRun = true
dConfig, err := fs.ReadFile(localFS, "config-default.ini") dConfig, err := fs.ReadFile(localFS, "config-default.ini")
if err != nil { if err != nil {
app.err.Fatalf("Couldn't find default config file") app.err.Fatalf(lm.NoConfig)
} }
nConfig, err := os.Create(app.configPath) nConfig, err := os.Create(app.configPath)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(filepath.Dir(app.configPath), 0760) err = os.MkdirAll(filepath.Dir(app.configPath), 0760)
} }
if err != nil { if err != nil {
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.configPath) app.err.Fatalf(lm.FailedWriting, app.configPath, err)
app.err.Fatalf("Error: %s", err)
} }
defer nConfig.Close() defer nConfig.Close()
_, err = nConfig.Write(dConfig) _, err = nConfig.Write(dConfig)
if err != nil { if err != nil {
app.err.Fatalf("Couldn't copy default config.") app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err)
} }
app.info.Printf("Copied default configuration to \"%s\"", app.configPath) app.info.Printf(lm.CopyConfig, app.configPath)
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.Load(app.configPath)
tempConfig.Section("").Key("first_run").SetValue("true") tempConfig.Section("").Key("first_run").SetValue("true")
tempConfig.SaveTo(app.configPath) tempConfig.SaveTo(app.configPath)
@ -237,8 +239,9 @@ func start(asDaemon, firstCall bool) {
var debugMode bool var debugMode bool
var address string var address string
if err := app.loadConfig(); err != nil { if err := app.loadConfig(); err != nil {
app.err.Fatalf("Failed to load config file \"%s\": %v", app.configPath, err) app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
} }
app.info.Printf(lm.LoadConfig, app.configPath)
if app.config.Section("").Key("first_run").MustBool(false) { if app.config.Section("").Key("first_run").MustBool(false) {
firstRun = true firstRun = true
@ -270,7 +273,7 @@ func start(asDaemon, firstCall bool) {
os.Remove(SOCK) os.Remove(SOCK)
listener, err := net.Listen("unix", SOCK) listener, err := net.Listen("unix", SOCK)
if err != nil { if err != nil {
app.err.Fatalf("Couldn't establish socket connection at %s\n", SOCK) app.err.Fatalf(lm.FailedSocketConnect, SOCK, err)
} }
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
@ -286,13 +289,13 @@ func start(asDaemon, firstCall bool) {
for { for {
con, err := listener.Accept() con, err := listener.Accept()
if err != nil { if err != nil {
app.err.Printf("Couldn't read message on %s: %s", SOCK, err) app.err.Printf(lm.FailedSocketRead, SOCK, err)
continue continue
} }
buf := make([]byte, 512) buf := make([]byte, 512)
nr, err := con.Read(buf) nr, err := con.Read(buf)
if err != nil { if err != nil {
app.err.Printf("Couldn't read message on %s: %s", SOCK, err) app.err.Printf(lm.FailedSocketRead, SOCK, err)
continue continue
} }
command := string(buf[0:nr]) command := string(buf[0:nr])
@ -317,13 +320,13 @@ func start(asDaemon, firstCall bool) {
err = app.storage.loadLang(langFS, os.DirFS(externalLang)) err = app.storage.loadLang(langFS, os.DirFS(externalLang))
} }
if err != nil { if err != nil {
app.info.Fatalf("Failed to load language files: %+v\n", err) app.info.Fatalf(lm.FailedLangLoad, err)
} }
if !firstRun { if !firstRun {
app.host = app.config.Section("ui").Key("host").String() app.host = app.config.Section("ui").Key("host").String()
if app.config.Section("advanced").Key("tls").MustBool(false) { if app.config.Section("advanced").Key("tls").MustBool(false) {
app.info.Println("Using TLS/HTTP2") app.info.Println(lm.UsingTLS)
app.port = app.config.Section("advanced").Key("tls_port").MustInt(8057) app.port = app.config.Section("advanced").Key("tls_port").MustInt(8057)
} else { } else {
app.port = app.config.Section("ui").Key("port").MustInt(8056) app.port = app.config.Section("ui").Key("port").MustInt(8056)
@ -348,29 +351,32 @@ func start(asDaemon, firstCall bool) {
} }
address = fmt.Sprintf("%s:%d", app.host, app.port) address = fmt.Sprintf("%s:%d", app.host, app.port)
app.debug.Printf("Loaded config file \"%s\"", app.configPath) // NOTE: As of writing this, the order in app.thirdPartServices doesn't matter,
// but in future it might (like app.contactMethods does), so append to the end!
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.debug.Printf("Connecting to Ombi") app.ombi = &OmbiWrapper{}
app.debug.Printf(lm.UsingOmbi)
ombiServer := app.config.Section("ombi").Key("server").String() ombiServer := app.config.Section("ombi").Key("server").String()
app.ombi = ombi.NewOmbi( app.ombi.Ombi = ombi.NewOmbi(
ombiServer, ombiServer,
app.config.Section("ombi").Key("api_key").String(), app.config.Section("ombi").Key("api_key").String(),
common.NewTimeoutHandler("Ombi", ombiServer, true), common.NewTimeoutHandler("Ombi", ombiServer, true),
) )
app.thirdPartyServices = append(app.thirdPartyServices, app.ombi)
} }
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
app.debug.Printf("Connecting to Jellyseerr") app.js = &JellyseerrWrapper{}
app.debug.Printf(lm.UsingJellyseerr)
jellyseerrServer := app.config.Section("jellyseerr").Key("server").String() jellyseerrServer := app.config.Section("jellyseerr").Key("server").String()
app.js = jellyseerr.NewJellyseerr( app.js.Jellyseerr = jellyseerr.NewJellyseerr(
jellyseerrServer, jellyseerrServer,
app.config.Section("jellyseerr").Key("api_key").String(), app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
) )
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false) app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true // app.js.LogRequestBodies = true
app.thirdPartyServices = append(app.thirdPartyServices, app.js)
} }
@ -398,10 +404,9 @@ func start(asDaemon, firstCall bool) {
if stringServerType == "emby" { if stringServerType == "emby" {
serverType = mediabrowser.EmbyServer serverType = mediabrowser.EmbyServer
timeoutHandler = mediabrowser.NewNamedTimeoutHandler("Emby", "\""+server+"\"", true) timeoutHandler = mediabrowser.NewNamedTimeoutHandler("Emby", "\""+server+"\"", true)
app.info.Println("Using Emby server type") app.info.Println(lm.UsingEmby)
fmt.Println(warning("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available."))
} else { } else {
app.info.Println("Using Jellyfin server type") app.info.Println(lm.UsingJellyfin)
} }
app.jf, err = mediabrowser.NewServer( app.jf, err = mediabrowser.NewServer(
@ -415,7 +420,7 @@ func start(asDaemon, firstCall bool) {
cacheTimeout, cacheTimeout,
) )
if err != nil { if err != nil {
app.err.Fatalf("Failed to authenticate with Jellyfin @ \"%s\": %v", server, err) app.err.Fatalf(lm.FailedAuthJellyfin, server, -1, err)
} }
if debugMode { if debugMode {
app.jf.Verbose = true app.jf.Verbose = true
@ -433,9 +438,9 @@ func start(asDaemon, firstCall bool) {
} }
_, status, err = app.jf.MustAuthenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String(), retryOpts) _, status, err = app.jf.MustAuthenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String(), retryOpts)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Fatalf("Failed to authenticate with Jellyfin @ \"%s\" (%d): %v", server, status, err) app.err.Fatalf(lm.FailedAuthJellyfin, server, status, err)
} }
app.info.Printf("Authenticated with \"%s\"", server) app.info.Printf(lm.AuthJellyfin, server)
runMigrations(app) runMigrations(app)
@ -448,8 +453,9 @@ func start(asDaemon, firstCall bool) {
user.Username = app.config.Section("ui").Key("username").String() user.Username = app.config.Section("ui").Key("username").String()
user.Password = app.config.Section("ui").Key("password").String() user.Password = app.config.Section("ui").Key("password").String()
app.adminUsers = append(app.adminUsers, user) app.adminUsers = append(app.adminUsers, user)
app.info.Println(lm.UsingLocalAuth)
} else { } else {
app.debug.Println("Using Jellyfin for authentication") app.debug.Println(lm.UsingJellyfinAuth)
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout) app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
if debugMode { if debugMode {
app.authJf.Verbose = true app.authJf.Verbose = true
@ -512,34 +518,42 @@ func start(asDaemon, firstCall bool) {
defer backupDaemon.Shutdown() defer backupDaemon.Shutdown()
} }
if telegramEnabled { // NOTE: The order in which these are placed in app.contactMethods matters.
app.telegram, err = newTelegramDaemon(app) // Add new ones to the end.
if err != nil {
app.err.Printf("Failed to authenticate with Telegram: %v", err)
telegramEnabled = false
} else {
go app.telegram.run()
defer app.telegram.Shutdown()
}
}
if discordEnabled { if discordEnabled {
app.discord, err = newDiscordDaemon(app) app.discord, err = newDiscordDaemon(app)
if err != nil { if err != nil {
app.err.Printf("Failed to authenticate with Discord: %v", err) app.err.Printf(lm.FailedInitDiscord, err)
discordEnabled = false discordEnabled = false
} else { } else {
app.debug.Println(lm.InitDiscord)
go app.discord.run() go app.discord.run()
defer app.discord.Shutdown() defer app.discord.Shutdown()
app.contactMethods = append(app.contactMethods, app.discord)
}
}
if telegramEnabled {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
app.err.Printf(lm.FailedInitTelegram, err)
telegramEnabled = false
} else {
app.debug.Println(lm.InitTelegram)
go app.telegram.run()
defer app.telegram.Shutdown()
app.contactMethods = append(app.contactMethods, app.telegram)
} }
} }
if matrixEnabled { if matrixEnabled {
app.matrix, err = newMatrixDaemon(app) app.matrix, err = newMatrixDaemon(app)
if err != nil { if err != nil {
app.err.Printf("Failed to initialize Matrix daemon: %v", err) app.err.Printf(lm.FailedInitMatrix, err)
matrixEnabled = false matrixEnabled = false
} else { } else {
app.debug.Println(lm.InitMatrix)
go app.matrix.run() go app.matrix.run()
defer app.matrix.Shutdown() defer app.matrix.Shutdown()
app.contactMethods = append(app.contactMethods, app.matrix)
} }
} }
} else { } else {
@ -558,7 +572,7 @@ func start(asDaemon, firstCall bool) {
app.storage.lang.SetupPath = "setup" app.storage.lang.SetupPath = "setup"
err := app.storage.loadLangSetup(langFS) err := app.storage.loadLangSetup(langFS)
if err != nil { if err != nil {
app.info.Fatalf("Failed to load language files: %+v\n", err) app.info.Fatalf(lm.FailedLangLoad, err)
} }
} }
@ -566,14 +580,14 @@ func start(asDaemon, firstCall bool) {
// workaround for potentially broken windows mime types // workaround for potentially broken windows mime types
mime.AddExtensionType(".js", "application/javascript") mime.AddExtensionType(".js", "application/javascript")
app.info.Println("Initializing router") app.info.Println(lm.InitRouter)
router := app.loadRouter(address, debugMode) router := app.loadRouter(address, debugMode)
app.info.Println("Loading routes") app.info.Println(lm.LoadRoutes)
if !firstRun { if !firstRun {
app.loadRoutes(router) app.loadRoutes(router)
} else { } else {
app.loadSetup(router) app.loadSetup(router)
app.info.Printf("Loading setup @ %s", address) app.info.Printf(lm.LoadingSetup, address)
} }
go func() { go func() {
if app.config.Section("advanced").Key("tls").MustBool(false) { if app.config.Section("advanced").Key("tls").MustBool(false) {
@ -581,45 +595,45 @@ func start(asDaemon, firstCall bool) {
key := app.config.Section("advanced").Key("tls_key").MustString("") key := app.config.Section("advanced").Key("tls_key").MustString("")
if err := SRV.ListenAndServeTLS(cert, key); err != nil { if err := SRV.ListenAndServeTLS(cert, key); err != nil {
filesToCheck := []string{cert, key} filesToCheck := []string{cert, key}
fileNames := []string{"Certificate", "Key"} fileNames := []string{lm.InvalidSSLCert, lm.InvalidSSLKey}
for i, v := range filesToCheck { for i, v := range filesToCheck {
_, err := os.Stat(v) _, err := os.Stat(v)
if err != nil { if err != nil {
app.err.Printf("SSL/TLS %s: %v\n", fileNames[i], err) app.err.Printf(fileNames[i], v, err)
} }
} }
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
app.err.Printf("Failure serving with SSL/TLS: %s", err) app.err.Printf(lm.FailServeSSL, err)
} else { } else {
app.err.Fatalf("Failure serving with SSL/TLS: %s", err) app.err.Fatalf(lm.FailServeSSL, err)
} }
} }
} else { } else {
if err := SRV.ListenAndServe(); err != nil { if err := SRV.ListenAndServe(); err != nil {
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
app.err.Printf("Failure serving: %s", err) app.err.Printf(lm.FailServe, err)
} else { } else {
app.err.Fatalf("Failure serving: %s", err) app.err.Fatalf(lm.FailServe, err)
} }
} }
} }
}() }()
if firstRun { if firstRun {
app.info.Printf("Loaded, visit %s to start.", address) app.info.Printf(lm.ServingSetup, address)
} else { } else {
app.info.Printf("Loaded @ %s", address) app.info.Printf(lm.Serving, address)
} }
waitForRestart() waitForRestart()
app.info.Printf("Restart/Quit signal received, give me a second!") app.info.Printf(lm.QuitReceived)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel() defer cancel()
if err := SRV.Shutdown(ctx); err != nil { if err := SRV.Shutdown(ctx); err != nil {
app.err.Fatalf("Server shutdown error: %s", err) app.err.Fatalf(lm.FailedQuit, err)
} }
app.info.Println("Server shut down.") app.info.Println(lm.Quit)
return return
} }
@ -631,7 +645,7 @@ func shutdown() {
} }
func (app *appContext) shutdown() { func (app *appContext) shutdown() {
app.info.Println("Shutting down...") app.info.Println(lm.Quitting)
shutdown() shutdown()
} }
@ -715,15 +729,17 @@ func printVersion() {
fmt.Println(info("jfa-go version: %s (%s)%s\n", hiwhite(version), white(commit), tray)) fmt.Println(info("jfa-go version: %s (%s)%s\n", hiwhite(version), white(commit), tray))
} }
const SYSTEMD_SERVICE = "jfa-go.service"
func main() { func main() {
f, err := logOutput() f, err := logOutput()
if err != nil { if err != nil {
fmt.Printf("Failed to start logging: %v\n", err) fmt.Printf(lm.FailedLogging, err)
} }
defer f() defer f()
printVersion() printVersion()
SOCK = filepath.Join(temp, SOCK) SOCK = filepath.Join(temp, SOCK)
fmt.Println("Socket:", SOCK) fmt.Printf(lm.SocketPath+"\n", SOCK)
if flagPassed("test") { if flagPassed("test") {
TEST = true TEST = true
} }
@ -752,24 +768,26 @@ func main() {
} else if flagPassed("stop") { } else if flagPassed("stop") {
con, err := net.Dial("unix", SOCK) con, err := net.Dial("unix", SOCK)
if err != nil { if err != nil {
fmt.Printf("Couldn't dial socket %s, are you sure jfa-go is running?\n", SOCK) fmt.Printf(lm.FailedSocketConnect+"\n", SOCK, err)
fmt.Println(lm.SocketCheckRunning)
os.Exit(1) os.Exit(1)
} }
_, err = con.Write([]byte("stop")) _, err = con.Write([]byte("stop"))
if err != nil { if err != nil {
fmt.Printf("Couldn't send command to socket %s, are you sure jfa-go is running?\n", SOCK) fmt.Printf(lm.FailedSocketWrite+"\n", SOCK, err)
fmt.Println(lm.SocketCheckRunning)
os.Exit(1) os.Exit(1)
} }
fmt.Println("Sent.") fmt.Println(lm.SocketWrite)
} else if flagPassed("daemon") { } else if flagPassed("daemon") {
start(true, true) start(true, true)
} else if flagPassed("systemd") { } else if flagPassed("systemd") {
service, err := fs.ReadFile(localFS, "jfa-go.service") service, err := fs.ReadFile(localFS, SYSTEMD_SERVICE)
if err != nil { if err != nil {
fmt.Printf("Couldn't read jfa-go.service: %v\n", err) fmt.Printf(lm.FailedReading+"\n", SYSTEMD_SERVICE, err)
os.Exit(1) os.Exit(1)
} }
absPath, err := filepath.Abs(os.Args[0]) absPath, err := os.Executable()
if err != nil { if err != nil {
absPath = os.Args[0] absPath = os.Args[0]
} }
@ -780,13 +798,13 @@ func main() {
} }
} }
service = []byte(strings.Replace(string(service), "{executable}", command, 1)) service = []byte(strings.Replace(string(service), "{executable}", command, 1))
err = os.WriteFile("jfa-go.service", service, 0666) err = os.WriteFile(SYSTEMD_SERVICE, service, 0666)
if err != nil { if err != nil {
fmt.Printf("Couldn't write jfa-go.service: %v\n", err) fmt.Printf(lm.FailedWriting+"\n", SYSTEMD_SERVICE, err)
os.Exit(1) os.Exit(1)
} }
fmt.Println(info(`If you want to execute jfa-go with special arguments, re-run this command with them. fmt.Println(info(`If you want to execute jfa-go with special arguments, re-run this command with them.
Move the newly created "jfa-go.service" file to ~/.config/systemd/user (Creating it if necessary). Move the newly created SYSTEMD_SERVICE file to ~/.config/systemd/user (Creating it if necessary).
Then run "systemctl --user daemon-reload". Then run "systemctl --user daemon-reload".
You can then run: You can then run:

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@ -31,15 +32,6 @@ type UnverifiedUser struct {
User *MatrixUser User *MatrixUser
} }
type MatrixUser struct {
RoomID string
Encrypted bool
UserID string
Lang string
Contact bool
JellyfinID string `badgerhold:"key"`
}
var matrixFilter = mautrix.Filter{ var matrixFilter = mautrix.Filter{
Room: mautrix.RoomFilter{ Room: mautrix.RoomFilter{
Timeline: mautrix.FilterPart{ Timeline: mautrix.FilterPart{
@ -118,13 +110,13 @@ func (d *MatrixDaemon) generateAccessToken(homeserver, username, password string
func (d *MatrixDaemon) run() { func (d *MatrixDaemon) run() {
startTime := d.start startTime := d.start
d.app.info.Println("Starting Matrix bot daemon") d.app.info.Println(lm.StartDaemon, lm.Matrix)
syncer := d.bot.Syncer.(*mautrix.DefaultSyncer) syncer := d.bot.Syncer.(*mautrix.DefaultSyncer)
HandleSyncerCrypto(startTime, d, syncer) HandleSyncerCrypto(startTime, d, syncer)
syncer.OnEventType(event.EventMessage, d.handleMessage) syncer.OnEventType(event.EventMessage, d.handleMessage)
if err := d.bot.Sync(); err != nil { if err := d.bot.Sync(); err != nil {
d.app.err.Printf("Matrix sync failed: %v", err) d.app.err.Printf(lm.FailedSyncMatrix, err)
} }
} }
@ -170,7 +162,7 @@ func (d *MatrixDaemon) commandLang(evt *event.Event, code, lang string) {
list, list,
) )
if err != nil { if err != nil {
d.app.err.Printf("Matrix: Failed to send message to \"%s\": %v", evt.Sender, err) d.app.err.Printf(lm.FailedReply, lm.Matrix, evt.Sender, err)
} }
return return
} }
@ -203,7 +195,7 @@ func (d *MatrixDaemon) CreateRoom(userID string) (roomID id.RoomID, encrypted bo
func (d *MatrixDaemon) SendStart(userID string) (ok bool) { func (d *MatrixDaemon) SendStart(userID string) (ok bool) {
roomID, encrypted, err := d.CreateRoom(userID) roomID, encrypted, err := d.CreateRoom(userID)
if err != nil { if err != nil {
d.app.err.Printf("Failed to create room for user \"%s\": %v", userID, err) d.app.err.Printf(lm.FailedCreateMatrixRoom, userID, err)
return return
} }
lang := "en-us" lang := "en-us"
@ -226,7 +218,7 @@ func (d *MatrixDaemon) SendStart(userID string) (ok bool) {
roomID, roomID,
) )
if err != nil { if err != nil {
d.app.err.Printf("Matrix: Failed to send welcome message to \"%s\": %v", userID, err) d.app.err.Printf(lm.FailedMessage, lm.Matrix, userID, err)
return return
} }
ok = true ok = true
@ -276,6 +268,57 @@ func (d *MatrixDaemon) UserExists(userID string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// Exists returns whether or not the given user exists.
func (d *MatrixDaemon) Exists(user ContactMethodUser) bool {
return d.UserExists(user.Name())
}
// User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up. // 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 // Message the user first, to avoid E2EE by default
func (d *MatrixDaemon) PIN(req newUserDTO) string { return req.MatrixPIN }
func (d *MatrixDaemon) Name() string { return lm.Matrix }
func (d *MatrixDaemon) Required() bool {
return d.app.config.Section("telegram").Key("required").MustBool(false)
}
func (d *MatrixDaemon) UniqueRequired() bool {
return d.app.config.Section("telegram").Key("require_unique").MustBool(false)
}
// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
func (d *MatrixDaemon) TokenVerified(pin string) (token UnverifiedUser, ok bool) {
token, ok = d.tokens[pin]
// delete(t.verifiedTokens, pin)
return
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *MatrixDaemon) DeleteVerifiedToken(PIN string) {
delete(d.tokens, PIN)
}
func (d *MatrixDaemon) UserVerified(PIN string) (ContactMethodUser, bool) {
token, ok := d.TokenVerified(PIN)
if !ok {
return &MatrixUser{}, false
}
return token.User, ok
}
func (d *MatrixDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil }
func (m *MatrixUser) Name() string { return m.UserID }
func (m *MatrixUser) SetMethodID(id any) { m.UserID = id.(string) }
func (m *MatrixUser) MethodID() any { return m.UserID }
func (m *MatrixUser) SetJellyfin(id string) { m.JellyfinID = id }
func (m *MatrixUser) Jellyfin() string { return m.JellyfinID }
func (m *MatrixUser) SetAllowContactFromDTO(req newUserDTO) { m.Contact = req.MatrixContact }
func (m *MatrixUser) SetAllowContact(contact bool) { m.Contact = contact }
func (m *MatrixUser) AllowContact() bool { return m.Contact }
func (m *MatrixUser) Store(st *Storage) {
st.SetMatrixKey(m.Jellyfin(), *m)
}

View File

@ -1,10 +1,13 @@
//go:build e2ee
// +build e2ee // +build e2ee
package main package main
import ( import (
"fmt"
"strings" "strings"
lm "github.com/hrfee/jfa-go/logmessages"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@ -65,22 +68,22 @@ type olmLogger struct {
} }
func (o olmLogger) Error(message string, args ...interface{}) { func (o olmLogger) Error(message string, args ...interface{}) {
o.app.err.Printf("OLM: "+message+"\n", args) o.app.err.Printf(lm.MatrixOlmLog, fmt.Sprintf(message, args))
} }
func (o olmLogger) Warn(message string, args ...interface{}) { func (o olmLogger) Warn(message string, args ...interface{}) {
o.app.info.Printf("OLM: "+message+"\n", args) o.app.info.Printf(lm.MatrixOlmLog, fmt.Sprintf(message, args))
} }
func (o olmLogger) Debug(message string, args ...interface{}) { func (o olmLogger) Debug(message string, args ...interface{}) {
o.app.debug.Printf("OLM: "+message+"\n", args) o.app.debug.Printf(lm.MatrixOlmLog, fmt.Sprintf(message, args))
} }
func (o olmLogger) Trace(message string, args ...interface{}) { func (o olmLogger) Trace(message string, args ...interface{}) {
if strings.HasPrefix(message, "Got membership state event") { if strings.HasPrefix(message, "Got membership state event") {
return return
} }
o.app.debug.Printf("OLM [TRACE]: "+message+"\n", args) o.app.debug.Printf(lm.MatrixOlmTracelog, fmt.Sprintf(message, args))
} }
func InitMatrixCrypto(d *MatrixDaemon) (err error) { func InitMatrixCrypto(d *MatrixDaemon) (err error) {
@ -155,7 +158,7 @@ func HandleSyncerCrypto(startTime int64, d *MatrixDaemon, syncer *mautrix.Defaul
// return // return
// } // }
if err != nil { if err != nil {
d.app.err.Printf("Failed to decrypt Matrix message: %v", err) d.app.err.Printf(lm.FailedDecryptMatrixMessage, err)
return return
} }
d.handleMessage(source, decrypted) d.handleMessage(source, decrypted)
@ -180,7 +183,7 @@ func EncryptRoom(d *MatrixDaemon, room *mautrix.RespCreateRoom, userID id.UserID
if err == nil { if err == nil {
encrypted = true encrypted = true
} else { } else {
d.app.debug.Printf("Matrix: Failed to enable encryption in room: %v", err) d.app.debug.Printf(lm.FailedEnableMatrixEncryption, room.RoomID, err)
return return
} }
d.isEncrypted[room.RoomID] = encrypted d.isEncrypted[room.RoomID] = encrypted

View File

@ -9,6 +9,8 @@ import (
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
// NOTE: This is the one file where log messages are not part of logmessages/logmessages.go
func runMigrations(app *appContext) { func runMigrations(app *appContext) {
migrateProfiles(app) migrateProfiles(app)
migrateBootstrap(app) migrateBootstrap(app)

View File

@ -31,7 +31,7 @@ type newUserDTO struct {
type newUserResponse struct { type newUserResponse struct {
User bool `json:"user" binding:"required"` // Whether user was created successfully User bool `json:"user" binding:"required"` // Whether user was created successfully
Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled)
Error string `json:"error"` // Optional error message. Error string `json:"error"` // Optional error message.
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
lm "github.com/hrfee/jfa-go/logmessages"
) )
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page. // GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
@ -39,16 +40,16 @@ func (app *appContext) GenResetLink(pin string) (string, error) {
} }
func (app *appContext) StartPWR() { func (app *appContext) StartPWR() {
app.info.Println("Starting password reset daemon") app.info.Println(lm.StartDaemon, "PWR")
path := app.config.Section("password_resets").Key("watch_directory").String() path := app.config.Section("password_resets").Key("watch_directory").String()
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
app.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path) app.err.Printf(lm.FailedStartDaemon, "PWR", fmt.Sprintf(lm.PathNotFound, path))
return return
} }
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
app.err.Printf("Couldn't initialise password reset daemon") app.err.Printf(lm.FailedStartDaemon, "PWR", err)
return return
} }
defer watcher.Close() defer watcher.Close()
@ -56,7 +57,7 @@ func (app *appContext) StartPWR() {
go pwrMonitor(app, watcher) go pwrMonitor(app, watcher)
err = watcher.Add(path) err = watcher.Add(path)
if err != nil { if err != nil {
app.err.Printf("Failed to start password reset daemon: %s", err) app.err.Printf(lm.FailedStartDaemon, "PWR", err)
} }
waitForRestart() waitForRestart()
@ -84,43 +85,36 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
var pwr PasswordReset var pwr PasswordReset
data, err := os.ReadFile(event.Name) data, err := os.ReadFile(event.Name)
if err != nil { if err != nil {
app.debug.Printf("PWR: Failed to read file: %v", err) app.debug.Printf(lm.FailedReading, event.Name, err)
return return
} }
err = json.Unmarshal(data, &pwr) err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil { if len(pwr.Pin) == 0 || err != nil {
app.debug.Printf("PWR: Failed to read PIN: %v", err) app.debug.Printf(lm.FailedReading, event.Name, err)
continue continue
} }
app.info.Printf("New password reset for user \"%s\"", pwr.Username) app.info.Printf("New password reset for user \"%s\"", pwr.Username)
if currentTime := time.Now(); pwr.Expiry.After(currentTime) { if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
user, status, err := app.jf.UserByName(pwr.Username, false) user, status, err := app.jf.UserByName(pwr.Username, false)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil || user.ID == "" {
app.err.Printf("Failed to get users from Jellyfin: Code %d", status) app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
app.debug.Printf("Error: %s", err)
return return
} }
uid := user.ID uid := user.ID
if uid == "" {
app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username)
return
}
name := app.getAddressOrName(uid) name := app.getAddressOrName(uid)
if name != "" { if name != "" {
msg, err := app.email.constructReset(pwr, app, false) msg, err := app.email.constructReset(pwr, app, false)
if err != nil { if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\"", pwr.Username) app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else if err := app.sendByID(msg, uid); err != nil { } else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\"", name) app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else { } else {
app.info.Printf("Sent password reset message to \"%s\"", name) app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
} }
} }
} else { } else {
app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry) app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
} }
} }
@ -128,7 +122,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
if !ok { if !ok {
return return
} }
app.err.Printf("Password reset daemon: %s", err) app.err.Printf(lm.FailedStartDaemon, "PWR", err)
} }
} }
} }

View File

@ -1,7 +1,11 @@
package main package main
import "fmt" import (
"fmt"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) HardRestart() error { func (app *appContext) HardRestart() error {
return fmt.Errorf("hard restarts not available on windows") return fmt.Errorf(lm.FailedHardRestartWindows)
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/gin-contrib/pprof" "github.com/gin-contrib/pprof"
"github.com/gin-contrib/static" "github.com/gin-contrib/static"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
swaggerFiles "github.com/swaggo/files" swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger" ginSwagger "github.com/swaggo/gin-swagger"
) )
@ -21,17 +22,17 @@ func (app *appContext) loadHTML(router *gin.Engine) {
templatePath := "html" templatePath := "html"
htmlFiles, err := fs.ReadDir(localFS, templatePath) htmlFiles, err := fs.ReadDir(localFS, templatePath)
if err != nil { if err != nil {
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath) app.err.Fatalf(lm.FailedReading, templatePath, err)
return return
} }
loadInternal := []string{} loadInternal := []string{}
loadExternal := []string{} loadExternal := []string{}
for _, f := range htmlFiles { for _, f := range htmlFiles {
if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) {
app.debug.Printf("Using default \"%s\"", f.Name()) app.debug.Printf(lm.UseDefaultHTML, f.Name())
loadInternal = append(loadInternal, FSJoin(templatePath, f.Name())) loadInternal = append(loadInternal, FSJoin(templatePath, f.Name()))
} else { } else {
app.info.Printf("Using custom \"%s\"", f.Name()) app.info.Printf(lm.UseCustomHTML, f.Name())
loadExternal = append(loadExternal, filepath.Join(filepath.Join(customPath, f.Name()))) loadExternal = append(loadExternal, filepath.Join(filepath.Join(customPath, f.Name())))
} }
} }
@ -39,13 +40,13 @@ func (app *appContext) loadHTML(router *gin.Engine) {
if len(loadInternal) != 0 { if len(loadInternal) != 0 {
tmpl, err = template.ParseFS(localFS, loadInternal...) tmpl, err = template.ParseFS(localFS, loadInternal...)
if err != nil { if err != nil {
app.err.Fatalf("Failed to load templates: %v", err) app.err.Fatalf(lm.FailedLoadTemplates, lm.Internal, err)
} }
} }
if len(loadExternal) != 0 { if len(loadExternal) != 0 {
tmpl, err = tmpl.ParseFiles(loadExternal...) tmpl, err = tmpl.ParseFiles(loadExternal...)
if err != nil { if err != nil {
app.err.Fatalf("Failed to load external templates: %v", err) app.err.Fatalf(lm.FailedLoadTemplates, lm.External, err)
} }
} }
router.SetHTMLTemplate(tmpl) router.SetHTMLTemplate(tmpl)
@ -96,7 +97,7 @@ func (app *appContext) loadRouter(address string, debug bool) *gin.Engine {
router.Use(static.Serve("/", app.webFS)) router.Use(static.Serve("/", app.webFS))
router.NoRoute(app.NoRouteHandler) router.NoRoute(app.NoRouteHandler)
if *PPROF { if *PPROF {
app.debug.Println("Loading pprof") app.debug.Println(lm.RegisterPprof)
pprof.Register(router) pprof.Register(router)
} }
SRV = &http.Server{ SRV = &http.Server{
@ -134,7 +135,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/lang/:page/:file", app.ServeLang) router.GET(p+"/lang/:page/:file", app.ServeLang)
router.GET(p+"/token/login", app.getTokenLogin) router.GET(p+"/token/login", app.getTokenLogin)
router.GET(p+"/token/refresh", app.getTokenRefresh) router.GET(p+"/token/refresh", app.getTokenRefresh)
router.POST(p+"/newUser", app.NewUser) router.POST(p+"/newUser", app.NewUserFromInvite)
router.Use(static.Serve(p+"/invite/", app.webFS)) router.Use(static.Serve(p+"/invite/", app.webFS))
router.GET(p+"/invite/:invCode", app.InviteProxy) router.GET(p+"/invite/:invCode", app.InviteProxy)
if app.config.Section("captcha").Key("enabled").MustBool(false) { if app.config.Section("captcha").Key("enabled").MustBool(false) {
@ -165,7 +166,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
} }
} }
if *SWAGGER { if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) app.info.Print(warning(lm.SwaggerWarning))
for _, p := range routePrefixes { for _, p := range routePrefixes {
router.GET(p+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET(p+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
@ -181,7 +182,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/logout", app.Logout) router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers) api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers) api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserAdmin) api.POST(p+"/users", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry) api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers) api.POST(p+"/users/enable", app.EnableDisableUsers)

View File

@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/easyproxy" "github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
) )
@ -104,7 +105,7 @@ func (app *appContext) TestJF(gc *gin.Context) {
case 404: case 404:
msg = "error404" msg = "error404"
} }
app.info.Printf("Auth failed with code %d (%s)", status, err) app.err.Printf(lm.FailedAuthJellyfin, req.Server, status, err)
if msg != "" { if msg != "" {
respond(status, msg, gc) respond(status, msg, gc)
} else { } else {
@ -151,16 +152,20 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.StartPage, &fallback.StartPage, &english.StartPage) patchLang(&lang.StartPage, &fallback.StartPage, &english.StartPage)
patchLang(&lang.EndPage, &fallback.EndPage, &english.EndPage)
patchLang(&lang.General, &fallback.General, &english.General)
patchLang(&lang.Updates, &fallback.Updates, &english.Updates) patchLang(&lang.Updates, &fallback.Updates, &english.Updates)
patchLang(&lang.Proxy, &fallback.Proxy, &english.Proxy) patchLang(&lang.Proxy, &fallback.Proxy, &english.Proxy)
patchLang(&lang.EndPage, &fallback.EndPage, &english.EndPage)
patchLang(&lang.Language, &fallback.Language, &english.Language) patchLang(&lang.Language, &fallback.Language, &english.Language)
patchLang(&lang.Login, &fallback.Login, &english.Login) patchLang(&lang.Login, &fallback.Login, &english.Login)
patchLang(&lang.JellyfinEmby, &fallback.JellyfinEmby, &english.JellyfinEmby) patchLang(&lang.JellyfinEmby, &fallback.JellyfinEmby, &english.JellyfinEmby)
patchLang(&lang.Ombi, &fallback.Ombi, &english.Ombi)
patchLang(&lang.Jellyseerr, &fallback.Jellyseerr, &english.Jellyseerr)
patchLang(&lang.Email, &fallback.Email, &english.Email) patchLang(&lang.Email, &fallback.Email, &english.Email)
patchLang(&lang.Messages, &fallback.Messages, &english.Messages) patchLang(&lang.Messages, &fallback.Messages, &english.Messages)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchLang(&lang.UserPage, &fallback.UserPage, &english.UserPage) patchLang(&lang.UserPage, &fallback.UserPage, &english.UserPage)
patchLang(&lang.WelcomeEmails, &fallback.WelcomeEmails, &english.WelcomeEmails)
patchLang(&lang.PasswordResets, &fallback.PasswordResets, &english.PasswordResets) patchLang(&lang.PasswordResets, &fallback.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &fallback.InviteEmails, &english.InviteEmails) patchLang(&lang.InviteEmails, &fallback.InviteEmails, &english.InviteEmails)
patchLang(&lang.PasswordValidation, &fallback.PasswordValidation, &english.PasswordValidation) patchLang(&lang.PasswordValidation, &fallback.PasswordValidation, &english.PasswordValidation)
@ -170,16 +175,20 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.StartPage, &english.StartPage) patchLang(&lang.StartPage, &english.StartPage)
patchLang(&lang.EndPage, &english.EndPage)
patchLang(&lang.General, &english.General)
patchLang(&lang.Updates, &english.Updates) patchLang(&lang.Updates, &english.Updates)
patchLang(&lang.Proxy, &english.Proxy) patchLang(&lang.Proxy, &english.Proxy)
patchLang(&lang.EndPage, &english.EndPage)
patchLang(&lang.Language, &english.Language) patchLang(&lang.Language, &english.Language)
patchLang(&lang.Login, &english.Login) patchLang(&lang.Login, &english.Login)
patchLang(&lang.JellyfinEmby, &english.JellyfinEmby) patchLang(&lang.JellyfinEmby, &english.JellyfinEmby)
patchLang(&lang.Ombi, &english.Ombi)
patchLang(&lang.Jellyseerr, &english.Jellyseerr)
patchLang(&lang.Email, &english.Email) patchLang(&lang.Email, &english.Email)
patchLang(&lang.Messages, &english.Messages) patchLang(&lang.Messages, &english.Messages)
patchLang(&lang.Notifications, &english.Notifications) patchLang(&lang.Notifications, &english.Notifications)
patchLang(&lang.UserPage, &english.UserPage) patchLang(&lang.UserPage, &english.UserPage)
patchLang(&lang.WelcomeEmails, &english.WelcomeEmails)
patchLang(&lang.PasswordResets, &english.PasswordResets) patchLang(&lang.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &english.InviteEmails) patchLang(&lang.InviteEmails, &english.InviteEmails)
patchLang(&lang.PasswordValidation, &english.PasswordValidation) patchLang(&lang.PasswordValidation, &english.PasswordValidation)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/fs" "io/fs"
"log" "log"
"os" "os"
@ -13,6 +14,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger" "github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
@ -175,10 +177,10 @@ func (app *appContext) ConnectDB() {
opts.ValueDir = app.storage.db_path opts.ValueDir = app.storage.db_path
db, err := badgerhold.Open(opts) db, err := badgerhold.Open(opts)
if err != nil { if err != nil {
app.err.Fatalf("Failed to open db \"%s\": %v", app.storage.db_path, err) app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
} }
app.storage.db = db app.storage.db = db
app.info.Printf("Connected to DB \"%s\"", app.storage.db_path) app.info.Printf(lm.ConnectDB, app.storage.db_path)
} }
// GetEmails returns a copy of the store. // GetEmails returns a copy of the store.
@ -508,6 +510,15 @@ func (st *Storage) GetDefaultProfile() Profile {
return defaultProfile return defaultProfile
} }
// MustGetProfileKey returns the profile at key k, or if missing, the default profile.
func (st *Storage) MustGetProfileKey(k string) Profile {
p, ok := st.GetProfileKey(k)
if !ok {
p = st.GetDefaultProfile()
}
return p
}
// GetCustomContent returns a copy of the store. // GetCustomContent returns a copy of the store.
func (st *Storage) GetCustomContent() []CustomContent { func (st *Storage) GetCustomContent() []CustomContent {
result := []CustomContent{} result := []CustomContent{}
@ -583,12 +594,35 @@ func (st *Storage) DeleteActivityKey(k string) {
st.db.Delete(k, Activity{}) st.db.Delete(k, Activity{})
} }
type TelegramUser struct { type ThirdPartyService interface {
JellyfinID string `badgerhold:"key"` // ok implies user imported, err can be any issue that occurs during
ChatID int64 `badgerhold:"index"` ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool)
Username string `badgerhold:"index"` AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error)
Lang string Enabled(app *appContext, profile *Profile) bool
Contact bool // Whether to contact through telegram or not Name() string
}
type ContactMethodLinker interface {
PIN(req newUserDTO) string
Name() string
Required() bool
UniqueRequired() bool
UserVerified(PIN string) (ContactMethodUser, bool)
PostVerificationTasks(PIN string, u ContactMethodUser) error
DeleteVerifiedToken(PIN string)
Exists(ContactMethodUser) bool
}
type ContactMethodUser interface {
SetMethodID(id any)
MethodID() any
Name() string
SetJellyfin(id string)
Jellyfin() string
SetAllowContactFromDTO(req newUserDTO)
SetAllowContact(contact bool)
AllowContact() bool
Store(st *Storage)
} }
type DiscordUser struct { type DiscordUser struct {
@ -601,6 +635,24 @@ type DiscordUser struct {
JellyfinID string `json:"-" badgerhold:"key"` JellyfinID string `json:"-" badgerhold:"key"`
} }
type TelegramUser struct {
TelegramVerifiedToken
JellyfinID string `badgerhold:"key"`
ChatID int64 `badgerhold:"index"`
Username string `badgerhold:"index"`
Lang string
Contact bool // Whether to contact through telegram or not
}
type MatrixUser struct {
RoomID string
Encrypted bool
UserID string
Lang string
Contact bool
JellyfinID string `badgerhold:"key"`
}
type EmailAddress struct { type EmailAddress struct {
Addr string `badgerhold:"index"` Addr string `badgerhold:"index"`
Label string // User Label. Label string // User Label.
@ -685,6 +737,16 @@ type Invite struct {
UseReferralExpiry bool `json:"use_referral_expiry"` UseReferralExpiry bool `json:"use_referral_expiry"`
} }
func (invite Invite) Source() (ActivitySource, string) {
sourceType := ActivityAnon
source := ""
if invite.ReferrerJellyfinID != "" {
sourceType = ActivityUser
source = invite.ReferrerJellyfinID
}
return sourceType, source
}
type Captcha struct { type Captcha struct {
Answer string Answer string
Image []byte // image/png Image []byte // image/png
@ -718,22 +780,27 @@ type Lang struct {
func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
err = st.loadLangCommon(filesystems...) err = st.loadLangCommon(filesystems...)
if err != nil { if err != nil {
err = fmt.Errorf("common: %v", err)
return return
} }
err = st.loadLangAdmin(filesystems...) err = st.loadLangAdmin(filesystems...)
if err != nil { if err != nil {
err = fmt.Errorf("admin: %v", err)
return return
} }
err = st.loadLangEmail(filesystems...) err = st.loadLangEmail(filesystems...)
if err != nil { if err != nil {
err = fmt.Errorf("email: %v", err)
return return
} }
err = st.loadLangUser(filesystems...) err = st.loadLangUser(filesystems...)
if err != nil { if err != nil {
err = fmt.Errorf("user: %v", err)
return return
} }
err = st.loadLangPWR(filesystems...) err = st.loadLangPWR(filesystems...)
if err != nil { if err != nil {
err = fmt.Errorf("pwr: %v", err)
return return
} }
err = st.loadLangTelegram(filesystems...) err = st.loadLangTelegram(filesystems...)

View File

@ -7,6 +7,7 @@ import (
"time" "time"
tg "github.com/go-telegram-bot-api/telegram-bot-api" tg "github.com/go-telegram-bot-api/telegram-bot-api"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@ -15,9 +16,27 @@ const (
) )
type TelegramVerifiedToken struct { type TelegramVerifiedToken struct {
ChatID int64 JellyfinID string `badgerhold:"key"` // optional, for ensuring a user-requested change is only accessed by them.
Username string ChatID int64 `badgerhold:"index"`
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them. Username string `badgerhold:"index"`
}
func (tv TelegramVerifiedToken) ToUser() *TelegramUser {
return &TelegramUser{
TelegramVerifiedToken: tv,
}
}
func (t *TelegramVerifiedToken) Name() string { return t.Username }
func (t *TelegramVerifiedToken) SetMethodID(id any) { t.ChatID = id.(int64) }
func (t *TelegramVerifiedToken) MethodID() any { return t.ChatID }
func (t *TelegramVerifiedToken) SetJellyfin(id string) { t.JellyfinID = id }
func (t *TelegramVerifiedToken) Jellyfin() string { return t.JellyfinID }
func (t *TelegramUser) SetAllowContactFromDTO(req newUserDTO) { t.Contact = req.TelegramContact }
func (t *TelegramUser) SetAllowContact(contact bool) { t.Contact = contact }
func (t *TelegramUser) AllowContact() bool { return t.Contact }
func (t *TelegramUser) Store(st *Storage) {
st.SetTelegramKey(t.Jellyfin(), *t)
} }
// VerifToken stores details about a pending user verification token. // VerifToken stores details about a pending user verification token.
@ -96,12 +115,12 @@ func (t *TelegramDaemon) NewAssignedAuthToken(id string) string {
} }
func (t *TelegramDaemon) run() { func (t *TelegramDaemon) run() {
t.app.info.Println("Starting Telegram bot daemon") t.app.info.Println(lm.StartDaemon, lm.Telegram)
u := tg.NewUpdate(0) u := tg.NewUpdate(0)
u.Timeout = 60 u.Timeout = 60
updates, err := t.bot.GetUpdatesChan(u) updates, err := t.bot.GetUpdatesChan(u)
if err != nil { if err != nil {
t.app.err.Printf("Failed to start Telegram daemon: %v", err) t.app.err.Printf(lm.FailedStartDaemon, lm.Telegram, err)
telegramEnabled = false telegramEnabled = false
return return
} }
@ -199,7 +218,7 @@ func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang strin
content += t.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "/lang"}) content += t.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "/lang"})
err := t.Reply(upd, content) err := t.Reply(upd, content)
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf(lm.FailedReply, lm.Telegram, upd.Message.From.UserName, err)
} }
} }
@ -211,7 +230,7 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string
} }
err := t.Reply(upd, list) err := t.Reply(upd, list)
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf(lm.FailedReply, lm.Telegram, upd.Message.From.UserName, err)
} }
return return
} }
@ -232,14 +251,14 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string)
if !ok || time.Now().After(token.Expiry) { if !ok || time.Now().After(token.Expiry) {
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN")) err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"))
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf(lm.FailedReply, lm.Telegram, upd.Message.From.UserName, err)
} }
delete(t.tokens, upd.Message.Text) delete(t.tokens, upd.Message.Text)
return return
} }
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess")) err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"))
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf(lm.FailedReply, lm.Telegram, upd.Message.From.UserName, err)
} }
t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{ t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{
ChatID: upd.Message.Chat.ID, ChatID: upd.Message.Chat.ID,
@ -273,7 +292,39 @@ func (t *TelegramDaemon) UserExists(username string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// DeleteVerifiedToken removes the token with the given PIN. // Exists returns whether or not the given user exists.
func (t *TelegramDaemon) DeleteVerifiedToken(pin string) { func (t *TelegramDaemon) Exists(user ContactMethodUser) bool {
delete(t.verifiedTokens, pin) return t.UserExists(user.Name())
} }
// DeleteVerifiedToken removes the token with the given PIN.
func (t *TelegramDaemon) DeleteVerifiedToken(PIN string) {
delete(t.verifiedTokens, PIN)
}
func (t *TelegramDaemon) PIN(req newUserDTO) string { return req.TelegramPIN }
func (t *TelegramDaemon) Name() string { return lm.Telegram }
func (t *TelegramDaemon) Required() bool {
return t.app.config.Section("telegram").Key("required").MustBool(false)
}
func (t *TelegramDaemon) UniqueRequired() bool {
return t.app.config.Section("telegram").Key("require_unique").MustBool(false)
}
func (t *TelegramDaemon) UserVerified(PIN string) (ContactMethodUser, bool) {
token, ok := t.TokenVerified(PIN)
if !ok {
return &TelegramUser{}, false
}
tu := token.ToUser()
if lang, ok := t.languages[tu.ChatID]; ok {
tu.Lang = lang
}
return tu, ok
}
func (t *TelegramDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil }

View File

@ -221,12 +221,16 @@ class LangSelect extends Select {
} }
} }
const replaceLink = (elName: string, sect: string, name: string, url: string, text: string) => html(elName, window.lang.var(sect, name, `<a class="underline" target="_blank" href="${url}">${text}</a>`));
window.lang = new lang(window.langFile as LangFile); window.lang = new lang(window.langFile as LangFile);
html("language-description", window.lang.var("language", "description", `<a target="_blank" href="https://weblate.jfa-go.com">Weblate</a>`)); replaceLink("language-description", "language", "description", "https://weblate.jfa-go.com", "Weblate");
html("email-description", window.lang.var("email", "description", `<a target="_blank" href="https://mailgun.com">Mailgun</a>`)); replaceLink("email-description", "email", "description", "https://mailgun.com", "Mailgun");
html("email-dateformat-notice", window.lang.var("email", "dateFormatNotice", `<a target="_blank" href="https://strftime.ninja/">strftime.ninja</a>`)); replaceLink("email-dateformat-notice", "email", "dateFormatNotice", "https://strftime.timpetricola.com/", "strftime.timpetricola.com");
html("updates-description", window.lang.var("updates", "description", `<a target="_blank" href="https://builds.hrfee.dev/view/hrfee/jfa-go">buildrone</a>`)); replaceLink("updates-description", "updates", "description", "https://builds.hrfee.dev/view/hrfee/jfa-go", "buildrone");
html("messages-description", window.lang.var("messages", "description", `<a target="_blank" href="https://wiki.jfa-go.com">Wiki</a>`)); replaceLink("messages-description", "messages", "description", "https://wiki.jfa-go.com", "Wiki");
replaceLink("password_resets-more-info", "passwordResets", "moreInfo", "https://wiki.jfa-go.com/docs/pwr/", "wiki.jfa-go.com");
replaceLink("ombi-stability-warning", "ombi", "stabilityWarning", "https://wiki.jfa-go.com/docs/ombi/", "wiki.jfa-go.com");
const settings = { const settings = {
"jellyfin": { "jellyfin": {
@ -318,6 +322,12 @@ const settings = {
"server": new Input(get("ombi-server"), "", "", "enabled", true, "ombi"), "server": new Input(get("ombi-server"), "", "", "enabled", true, "ombi"),
"api_key": new Input(get("ombi-api_key"), "", "", "enabled", true, "ombi") "api_key": new Input(get("ombi-api_key"), "", "", "enabled", true, "ombi")
}, },
"jellyseerr": {
"enabled": new Checkbox(get("jellyseerr-enabled"), "", false, "jellyseerr", "enabled"),
"server": new Input(get("jellyseerr-server"), "", "", "enabled", true, "jellyseerr"),
"api_key": new Input(get("jellyseerr-api_key"), "", "", "enabled", true, "jellyseerr"),
"import_existing": new Checkbox(get("jellyseerr-import_existing"), "enabled", true, "jellyseerr", "import_existing")
},
"advanced": { "advanced": {
"tls": new Checkbox(get("advanced-tls"), "", false, "advanced", "tls"), "tls": new Checkbox(get("advanced-tls"), "", false, "advanced", "tls"),
"tls_port": new Input(get("advanced-tls_port"), "", "", "tls", true, "advanced"), "tls_port": new Input(get("advanced-tls_port"), "", "", "tls", true, "advanced"),

View File

@ -4,7 +4,8 @@
"target": "es2017", "target": "es2017",
"lib": ["dom", "es2017"], "lib": ["dom", "es2017"],
"typeRoots": ["./typings", "../node_modules/@types"], "typeRoots": ["./typings", "../node_modules/@types"],
"moduleResolution": "nodenext", "module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true "esModuleInterop": true
} }
} }

View File

@ -16,6 +16,8 @@ import (
"strings" "strings"
"time" "time"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/common"
) )
@ -560,15 +562,16 @@ func (app *appContext) checkForUpdates() {
if err != nil && strings.Contains(err.Error(), "strconv.ParseInt") { if err != nil && strings.Contains(err.Error(), "strconv.ParseInt") {
app.err.Println("No new updates available.") app.err.Println("No new updates available.")
} else if status != -1 { // -1 means updates disabled, we don't need to log it. } else if status != -1 { // -1 means updates disabled, we don't need to log it.
app.err.Printf("Failed to get latest tag (%d): %v", status, err) app.err.Printf(lm.FailedGetUpdateTag, err)
} }
return return
} }
if tag != app.tag && tag.IsNew() { if tag != app.tag && tag.IsNew() {
app.info.Println("Update found") app.info.Println(lm.FoundUpdate)
app.debug.Printf(lm.UpdateTagDetails, tag)
update, status, err := app.updater.GetUpdate(tag) update, status, err := app.updater.GetUpdate(tag)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get update (%d): %v", status, err) app.err.Printf(lm.FailedGetUpdate, err)
return return
} }
app.tag = tag app.tag = tag

View File

@ -1,9 +1,11 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
) )
func (app *appContext) userAuth() gin.HandlerFunc { func (app *appContext) userAuth() gin.HandlerFunc {
@ -13,7 +15,7 @@ func (app *appContext) userAuth() gin.HandlerFunc {
func (app *appContext) userAuthenticate(gc *gin.Context) { func (app *appContext) userAuthenticate(gc *gin.Context) {
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true) jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
if !jellyfinLogin { if !jellyfinLogin {
app.err.Println("Enable Jellyfin Login to use the User Page feature.") app.err.Printf(lm.FailedAuthRequest, lm.UserPageRequiresJellyfinAuth)
respond(500, "Contact Admin", gc) respond(500, "Contact Admin", gc)
return return
} }
@ -27,7 +29,6 @@ func (app *appContext) userAuthenticate(gc *gin.Context) {
gc.Set("jfId", jfID) gc.Set("jfId", jfID)
gc.Set("userMode", true) gc.Set("userMode", true)
app.debug.Println("Auth succeeded")
gc.Next() gc.Next()
} }
@ -41,11 +42,11 @@ func (app *appContext) userAuthenticate(gc *gin.Context) {
// @Security getUserTokenAuth // @Security getUserTokenAuth
func (app *appContext) getUserTokenLogin(gc *gin.Context) { func (app *appContext) getUserTokenLogin(gc *gin.Context) {
if !app.config.Section("ui").Key("jellyfin_login").MustBool(true) { if !app.config.Section("ui").Key("jellyfin_login").MustBool(true) {
app.err.Println("Enable Jellyfin Login to use the User Page feature.") app.err.Printf(lm.FailedAuthRequest, lm.UserPageRequiresJellyfinAuth)
respond(500, "Contact Admin", gc) respond(500, "Contact Admin", gc)
return return
} }
app.logIpInfo(gc, true, "UserToken requested (login attempt)") app.logIpInfo(gc, true, fmt.Sprintf(lm.RequestingToken, lm.UserTokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, true) username, password, ok := app.decodeValidateLoginHeader(gc, true)
if !ok { if !ok {
return return
@ -58,12 +59,11 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
token, refresh, err := CreateToken(user.ID, user.ID, false) token, refresh, err := CreateToken(user.ID, user.ID, false)
if err != nil { if err != nil {
app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err) app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate user token", gc) respond(500, "Couldn't generate user token", gc)
return return
} }
app.debug.Printf("Token generated for non-admin user \"%s\"", username)
uri := "/my" uri := "/my"
if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) { if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) {
uri = "/accounts/my" uri = "/accounts/my"
@ -81,12 +81,12 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
func (app *appContext) getUserTokenRefresh(gc *gin.Context) { func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true) jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
if !jellyfinLogin { if !jellyfinLogin {
app.err.Println("Enable Jellyfin Login to use the User Page feature.") app.err.Printf(lm.FailedAuthRequest, lm.UserPageRequiresJellyfinAuth)
respond(500, "Contact Admin", gc) respond(500, "Contact Admin", gc)
return return
} }
app.logIpInfo(gc, true, "UserToken request (refresh token)") app.logIpInfo(gc, true, fmt.Sprintf(lm.RequestingToken, lm.UserTokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh") claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh")
if !ok { if !ok {
return return
@ -96,7 +96,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
jwt, refresh, err := CreateToken(jfID, jfID, false) jwt, refresh, err := CreateToken(jfID, jfID, false)
if err != nil { if err != nil {
app.err.Printf("getUserToken failed: Couldn't generate user token (%s)", err) app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate user token", gc) respond(500, "Couldn't generate user token", gc)
return return
} }

View File

@ -3,6 +3,7 @@ package main
import ( import (
"time" "time"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
@ -21,17 +22,17 @@ func (app *appContext) checkUsers() {
if len(app.storage.GetUserExpiries()) == 0 { if len(app.storage.GetUserExpiries()) == 0 {
return return
} }
app.info.Println("Daemon: Checking for user expiry") app.info.Println(lm.CheckUserExpiries)
users, status, err := app.jf.GetUsers(false) users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 { if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return return
} }
mode := "disable" mode := "disable"
term := "Disabling" phrase := lm.DisableExpiredUser
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" { if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete" mode = "delete"
term = "Deleting" phrase = lm.DeleteExpiredUser
} }
contact := false contact := false
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) { if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
@ -45,7 +46,7 @@ func (app *appContext) checkUsers() {
for _, expiry := range app.storage.GetUserExpiries() { for _, expiry := range app.storage.GetUserExpiries() {
id := expiry.JellyfinID id := expiry.JellyfinID
if _, ok := userExists[id]; !ok { if _, ok := userExists[id]; !ok {
app.info.Printf("Deleting expiry for non-existent user \"%s\"", id) app.info.Printf(lm.DeleteExpiryForOldUser, id)
app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
} else if time.Now().After(expiry.Expiry) { } else if time.Now().After(expiry.Expiry) {
found := false found := false
@ -58,11 +59,10 @@ func (app *appContext) checkUsers() {
} }
} }
if !found { if !found {
app.info.Printf("Expired user already deleted, ignoring.")
app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue continue
} }
app.info.Printf("%s expired user \"%s\"", term, user.Name) app.info.Printf(phrase, user.Name)
// Record activity // Record activity
activity := Activity{ activity := Activity{
@ -72,18 +72,24 @@ func (app *appContext) checkUsers() {
} }
if mode == "delete" { if mode == "delete" {
status, err = app.jf.DeleteUser(id) deleted := false
err, deleted = app.DeleteUser(user)
// Silence unimportant errors
if deleted {
err = nil
}
activity.Type = ActivityDeletion activity.Type = ActivityDeletion
// Store the user name, since there's no longer a user ID to reference back to
activity.Value = user.Name activity.Value = user.Name
} else if mode == "disable" { } else if mode == "disable" {
user.Policy.IsDisabled = true
// Admins can't be disabled // Admins can't be disabled
// so they're not an admin anymore, sorry
user.Policy.IsAdministrator = false user.Policy.IsAdministrator = false
status, err = app.jf.SetPolicy(id, user.Policy) err, _, _ = app.SetUserDisabled(user, true)
activity.Type = ActivityDisabled activity.Type = ActivityDisabled
} }
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err) app.err.Printf(lm.FailedDeleteOrDisableExpiredUser, user.ID, err)
continue continue
} }
@ -98,11 +104,11 @@ func (app *appContext) checkUsers() {
name := app.getAddressOrName(user.ID) name := app.getAddressOrName(user.ID)
msg, err := app.email.constructUserExpired(app, false) msg, err := app.email.constructUserExpired(app, false)
if err != nil { if err != nil {
app.err.Printf("Failed to construct expiry message for \"%s\": %s", user.Name, err) app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf("Failed to send expiry message to \"%s\": %s", name, err) app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, name, err)
} else { } else {
app.info.Printf("Sent expiry notification to \"%s\"", name) app.err.Printf(lm.SentExpiryMessage, user.ID, name)
} }
} }
} }

223
users.go Normal file
View File

@ -0,0 +1,223 @@
package main
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
// ReponseFunc responds to the user, generally by HTTP response
// The cases when more than this occurs are given below.
type ResponseFunc func(gc *gin.Context)
// LogFunc prints a log line once called.
type LogFunc func()
type ContactMethodConf struct {
Email, Discord, Telegram, Matrix bool
}
type ContactMethodUsers struct {
Email emailStore
Discord DiscordUser
Telegram TelegramVerifiedToken
Matrix MatrixUser
}
type ContactMethodValidation struct {
Verified ContactMethodConf
Users ContactMethodUsers
}
type NewUserParams struct {
Req newUserDTO
SourceType ActivitySource
Source string
ContextForIPLogging *gin.Context
Profile *Profile
}
type NewUserData struct {
Created bool
Success bool
User mediabrowser.User
Message string
Status int
Log func()
}
// Called after a new-user-creating route has done pre-steps (veryfing contact methods for example).
func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData) {
// Some helper functions which will behave as our app.info/error/debug
deferLogInfo := func(s string, args ...any) {
out.Log = func() {
app.info.Printf(s, args)
}
}
/* deferLogDebug := func(s string, args ...any) {
out.Log = func() {
app.debug.Printf(s, args)
}
} */
deferLogError := func(s string, args ...any) {
out.Log = func() {
app.err.Printf(s, args)
}
}
existingUser, _, _ := app.jf.UserByName(p.Req.Username, false)
if existingUser.Name != "" {
out.Message = lm.UserExists
deferLogInfo(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message)
out.Status = 401
return
}
var status int
var err error
out.User, status, err = app.jf.NewUser(p.Req.Username, p.Req.Password)
if !(status == 200 || status == 204) || err != nil {
out.Message = err.Error()
deferLogError(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message)
out.Status = 401
return
}
out.Created = true
// Invalidate Cache to be safe
app.jf.CacheExpiry = time.Now()
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: out.User.ID,
SourceType: p.SourceType,
Source: p.Source,
InviteCode: p.Req.Code, // Left blank when an admin does this
Value: out.User.Name,
Time: time.Now(),
}, p.ContextForIPLogging, (p.SourceType != ActivityAdmin))
if p.Profile != nil {
status, err = app.jf.SetPolicy(out.User.ID, p.Profile.Policy)
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, out.User.ID, err)
}
status, err = app.jf.SetConfiguration(out.User.ID, p.Profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(out.User.ID, p.Profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, out.User.ID, err)
}
for _, tps := range app.thirdPartyServices {
if !tps.Enabled(app, p.Profile) {
continue
}
// When ok and err != nil, its a non-fatal failure that we lot without the "FailedImportUser".
err, ok := tps.ImportUser(out.User.ID, p.Req, *p.Profile)
if !ok {
app.err.Printf(lm.FailedImportUser, tps.Name(), p.Req.Username, err)
} else if err != nil {
app.info.Println(err)
}
}
}
// Welcome email is sent by each user of this method separately..
out.Status = 200
out.Success = true
return
}
func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time) (failed bool) {
if !app.config.Section("welcome_email").Key("enabled").MustBool(false) {
// we didn't "fail", we "politely declined"
// failed = true
return
}
failed = true
name := app.getAddressOrName(user.ID)
if name == "" {
return
}
msg, err := app.email.constructWelcome(user.Name, expiry, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf(lm.FailedSendWelcomeMessage, user.ID, name, err)
} else {
app.info.Printf(lm.SentWelcomeMessage, user.ID, name)
failed = false
}
return
}
func (app *appContext) SetUserDisabled(user mediabrowser.User, disabled bool) (err error, change bool, activityType ActivityType) {
activityType = ActivityEnabled
if disabled {
activityType = ActivityDisabled
}
change = user.Policy.IsDisabled != disabled
user.Policy.IsDisabled = disabled
var status int
status, err = app.jf.SetPolicy(user.ID, user.Policy)
if !(status == 200 || status == 204) && err == nil {
err = fmt.Errorf("failed (code %d)", status)
}
if err != nil {
return
}
if app.discord != nil && app.config.Section("discord").Key("disable_enable_role").MustBool(false) {
cmUser, ok := app.storage.GetDiscordKey(user.ID)
if ok {
if err := app.discord.SetRoleDisabled(cmUser.MethodID().(string), disabled); err != nil {
app.err.Printf(lm.FailedSetDiscordMemberRole, err)
}
}
}
return
}
func (app *appContext) DeleteUser(user mediabrowser.User) (err error, deleted bool) {
var status int
if app.ombi != nil {
var tpUser map[string]any
tpUser, status, err = app.getOmbiUser(user.ID)
if status == 200 && err == nil {
if id, ok := tpUser["id"]; ok {
status, err = app.ombi.DeleteUser(id.(string))
if status != 200 && err == nil {
err = fmt.Errorf("failed (code %d)", status)
}
if err != nil {
app.err.Printf(lm.FailedDeleteUser, lm.Ombi, user.ID, err)
}
}
}
}
if app.discord != nil && app.config.Section("discord").Key("disable_enable_role").MustBool(false) {
cmUser, ok := app.storage.GetDiscordKey(user.ID)
if ok {
if err := app.discord.RemoveRole(cmUser.MethodID().(string)); err != nil {
app.err.Printf(lm.FailedSetDiscordMemberRole, err)
}
}
}
status, err = app.jf.DeleteUser(user.ID)
if status != 200 && status != 204 && err == nil {
err = fmt.Errorf("failed (code %d)", status)
}
if err != nil {
return
}
deleted = true
return
}

243
views.go
View File

@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
@ -16,6 +17,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"github.com/steambap/captcha" "github.com/steambap/captcha"
@ -66,10 +68,9 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) {
toPush = []string{} toPush = []string{}
} }
if pusher := gc.Writer.Pusher(); pusher != nil { if pusher := gc.Writer.Pusher(); pusher != nil {
app.debug.Println("Using HTTP2 Server push")
for _, f := range toPush { for _, f := range toPush {
if err := pusher.Push(app.URLBase+f, nil); err != nil { if err := pusher.Push(app.URLBase+f, nil); err != nil {
app.debug.Printf("Failed HTTP2 ServerPush of \"%s\": %+v", f, err) app.debug.Printf(lm.FailedServerPush, err)
} }
} }
} }
@ -139,13 +140,13 @@ func (app *appContext) AdminPage(gc *gin.Context) {
var license string var license string
l, err := fs.ReadFile(localFS, "LICENSE") l, err := fs.ReadFile(localFS, "LICENSE")
if err != nil { if err != nil {
app.debug.Printf("Failed to load LICENSE: %s", err) app.debug.Printf(lm.FailedReading, "LICENSE", err)
license = "" license = ""
} }
license = string(l) license = string(l)
fontLicense, err := fs.ReadFile(localFS, filepath.Join("web", "fonts", "OFL.txt")) fontLicense, err := fs.ReadFile(localFS, filepath.Join("web", "fonts", "OFL.txt"))
if err != nil { if err != nil {
app.debug.Printf("Failed to load OFL.txt: %s", err) app.debug.Printf(lm.FailedReading, "fontLicense", err)
} }
license += "---Hanken Grotesk---\n\n" license += "---Hanken Grotesk---\n\n"
@ -236,7 +237,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
"server_channel": app.discord.serverChannelName, "server_channel": app.discord.serverChannelName,
})) }))
data["discordServerName"] = app.discord.serverName data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != "" data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
} }
if data["linkResetEnabled"].(bool) { if data["linkResetEnabled"].(bool) {
data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true) data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
@ -312,7 +313,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
defer gcHTML(gc, http.StatusOK, "password-reset.html", data) defer gcHTML(gc, http.StatusOK, "password-reset.html", data)
// If it's a bot, pretend to be a success so the preview is nice. // If it's a bot, pretend to be a success so the preview is nice.
if isBot { if isBot {
app.debug.Println("PWR: Ignoring magic link visit from bot") app.debug.Println(lm.IgnoreBotPWR)
data["success"] = true data["success"] = true
data["pin"] = "NO-BO-TS" data["pin"] = "NO-BO-TS"
return return
@ -338,13 +339,13 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
if !isInternal && !setPassword { if !isInternal && !setPassword {
resp, status, err = app.jf.ResetPassword(pin) resp, status, err = app.jf.ResetPassword(pin)
} else if time.Now().After(pwr.Expiry) { } else if time.Now().After(pwr.Expiry) {
app.debug.Printf("Ignoring PWR request due to expired internal PIN: %s", pin) app.debug.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, pin))
app.NoRouteHandler(gc) app.NoRouteHandler(gc)
return return
} else { } else {
status, err = app.jf.ResetPasswordAdmin(pwr.ID) status, err = app.jf.ResetPasswordAdmin(pwr.ID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err) app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", err)
} else { } else {
status, err = app.jf.SetPassword(pwr.ID, "", pin) status, err = app.jf.SetPassword(pwr.ID, "", pin)
} }
@ -358,7 +359,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
username = resp.UsersReset[0] username = resp.UsersReset[0]
} }
} else { } else {
app.err.Printf("Password Reset failed (%d): %v", status, err) app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", err)
} }
// Only log PWRs we know the user for. // Only log PWRs we know the user for.
@ -378,21 +379,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(username, false) jfUser, status, err := app.jf.UserByName(username, false)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err) app.err.Printf(lm.FailedGetUser, username, lm.Jellyfin, err)
return return
} }
ombiUser, status, err := app.getOmbiUser(jfUser.ID) ombiUser, status, err := app.getOmbiUser(jfUser.ID)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err) app.err.Printf(lm.FailedGetUser, username, lm.Ombi, err)
return return
} }
ombiUser["password"] = pin ombiUser["password"] = pin
status, err = app.ombi.ModifyUser(ombiUser) status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err) app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
return return
} }
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
} }
} }
@ -460,7 +461,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
} }
capt, err := captcha.New(300, 100) capt, err := captcha.New(300, 100)
if err != nil { if err != nil {
app.err.Printf("Failed to generate captcha: %v", err) app.err.Printf(lm.FailedGenerateCaptcha, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -470,7 +471,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
captchaID := genAuthToken() captchaID := genAuthToken()
var buf bytes.Buffer var buf bytes.Buffer
if err := capt.WriteImage(bufio.NewWriter(&buf)); err != nil { if err := capt.WriteImage(bufio.NewWriter(&buf)); err != nil {
app.err.Printf("Failed to render captcha: %v", err) app.err.Printf(lm.FailedGenerateCaptcha, err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
@ -503,8 +504,12 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
ok := true ok := true
if !isPWR { if !isPWR {
inv, ok := app.storage.GetInvitesKey(code) inv, ok := app.storage.GetInvitesKey(code)
if !ok || (!isPWR && inv.Captchas == nil) { if !ok {
app.debug.Printf("Couldn't find invite \"%s\"", code) app.debug.Printf(lm.InvalidInviteCode, code)
return false
}
if !isPWR && inv.Captchas == nil {
app.debug.Printf(lm.CaptchaNotFound, id, code)
return false return false
} }
c, ok = inv.Captchas[id] c, ok = inv.Captchas[id]
@ -512,7 +517,7 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
c, ok = app.pwrCaptchas[code] c, ok = app.pwrCaptchas[code]
} }
if !ok { if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id) app.debug.Printf(lm.CaptchaNotFound, id, code)
return false return false
} }
return strings.ToLower(c.Answer) == strings.ToLower(text) return strings.ToLower(c.Answer) == strings.ToLower(text)
@ -534,8 +539,11 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 { if err == nil && resp.StatusCode != 200 {
app.err.Printf("Failed to read reCAPTCHA status (%d): %+v\n", resp.Status, err) err = fmt.Errorf("failed (error %d)", resp.StatusCode)
}
if err != nil {
app.err.Printf(lm.FailedVerifyReCAPTCHA, err)
return false return false
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -543,18 +551,19 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
err = json.Unmarshal(body, &data) err = json.Unmarshal(body, &data)
if err != nil { if err != nil {
app.err.Printf("Failed to unmarshal reCAPTCHA response: %+v\n", err) app.err.Printf(lm.FailedVerifyReCAPTCHA, err)
return false return false
} }
hostname := app.config.Section("captcha").Key("recaptcha_hostname").MustString("") hostname := app.config.Section("captcha").Key("recaptcha_hostname").MustString("")
if strings.ToLower(data.Hostname) != strings.ToLower(hostname) && data.Hostname != "" { if strings.ToLower(data.Hostname) != strings.ToLower(hostname) && data.Hostname != "" {
app.debug.Printf("Invalidating reCAPTCHA request: Hostnames didn't match (Wanted \"%s\", got \"%s\"\n", hostname, data.Hostname) err = fmt.Errorf(lm.InvalidHostname, hostname, data.Hostname)
app.err.Printf(lm.FailedVerifyReCAPTCHA, err)
return false return false
} }
if len(data.ErrorCodes) > 0 { if len(data.ErrorCodes) > 0 {
app.err.Printf("reCAPTCHA returned errors: %+v\n", data.ErrorCodes) app.err.Printf(lm.AdditionalErrors, lm.ReCAPTCHA, data.ErrorCodes)
return false return false
} }
@ -607,13 +616,108 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) {
return return
} }
func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lang string, gc *gin.Context) {
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var req newUserDTO
if app.ConfirmationKeys == nil {
fail()
return
}
invKeys, ok := app.ConfirmationKeys[invite.Code]
if !ok {
fail()
return
}
req, ok = invKeys[key]
if !ok {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.debug.Printf(lm.FailedParseJWT, err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["invite"].(string) == invite.Code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf(lm.InvalidJWT)
return
}
sourceType, source := invite.Source()
var profile *Profile = nil
if invite.Profile != "" {
p, ok := app.storage.GetProfileKey(invite.Profile)
if !ok {
app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile)
p = app.storage.GetDefaultProfile()
}
profile = &p
}
nu := app.NewUserPostVerification(NewUserParams{
Req: req,
SourceType: sourceType,
Source: source,
ContextForIPLogging: gc,
Profile: profile,
})
if !nu.Success {
nu.Log()
}
if !nu.Created {
respond(nu.Status, nu.Message, gc)
fail()
return
}
app.checkInvite(req.Code, true, req.Username)
jfLink := app.config.Section("ui").Key("redirect_url").String()
if app.config.Section("ui").Key("auto_redirect").MustBool(false) {
gc.Redirect(301, jfLink)
} else {
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"strings": app.storage.lang.User[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"jfLink": jfLink,
})
}
app.confirmationKeysLock.Lock()
// Re-fetch invKeys just incase an update occurred
invKeys, ok = app.ConfirmationKeys[invite.Code]
if !ok {
fail()
return
}
delete(invKeys, key)
app.ConfirmationKeys[invite.Code] = invKeys
app.confirmationKeysLock.Unlock()
return
}
func (app *appContext) InviteProxy(gc *gin.Context) { func (app *appContext) InviteProxy(gc *gin.Context) {
app.pushResources(gc, FormPage) app.pushResources(gc, FormPage)
code := gc.Param("invCode")
lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang) lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang)
/* 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. */ /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */
// if app.checkInvite(code, false, "") { // if app.checkInvite(code, false, "") {
inv, ok := app.storage.GetInvitesKey(code) invite, ok := app.storage.GetInvitesKey(gc.Param("invCode"))
if !ok { if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{ gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc), "urlBase": app.getURLBase(gc),
@ -623,76 +727,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
}) })
return return
} }
if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var req newUserDTO
if app.ConfirmationKeys == nil {
fail()
return
}
invKeys, ok := app.ConfirmationKeys[code] if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
if !ok { app.NewUserFromConfirmationKey(invite, key, lang, gc)
fail()
return
}
req, ok = invKeys[key]
if !ok {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.err.Printf("Failed to parse key: %s", err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf("Invalid key")
return
}
f, success := app.newUser(req, true, gc)
if !success {
app.err.Printf("Failed to create new user")
// Not meant for us. Calling this will be a mess, but at least it might give us some information.
f(gc)
fail()
return
}
jfLink := app.config.Section("ui").Key("redirect_url").String()
if app.config.Section("ui").Key("auto_redirect").MustBool(false) {
gc.Redirect(301, jfLink)
} else {
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"strings": app.storage.lang.User[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"jfLink": jfLink,
})
}
delete(invKeys, key)
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[code] = invKeys
app.confirmationKeysLock.Unlock()
return return
} }
email := ""
if invite, ok := app.storage.GetInvitesKey(code); ok { email := invite.SendTo
email = invite.SendTo
}
if strings.Contains(email, "Failed") || !strings.Contains(email, "@") { if strings.Contains(email, "Failed") || !strings.Contains(email, "@") {
email = "" email = ""
} }
@ -707,8 +748,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
userPageAddress += "/my/account" userPageAddress += "/my/account"
fromUser := "" fromUser := ""
if inv.ReferrerJellyfinID != "" { if invite.ReferrerJellyfinID != "" {
sender, status, err := app.jf.UserByID(inv.ReferrerJellyfinID, false) sender, status, err := app.jf.UserByID(invite.ReferrerJellyfinID, false)
if status == 200 && err == nil { if status == 200 && err == nil {
fromUser = sender.Name fromUser = sender.Name
} }
@ -730,13 +771,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"strings": app.storage.lang.User[lang].Strings, "strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].validationStringsJSON, "validationStrings": app.storage.lang.User[lang].validationStringsJSON,
"notifications": app.storage.lang.User[lang].notificationsJSON, "notifications": app.storage.lang.User[lang].notificationsJSON,
"code": code, "code": invite.Code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry, "userExpiry": invite.UserExpiry,
"userExpiryMonths": inv.UserMonths, "userExpiryMonths": invite.UserMonths,
"userExpiryDays": inv.UserDays, "userExpiryDays": invite.UserDays,
"userExpiryHours": inv.UserHours, "userExpiryHours": invite.UserHours,
"userExpiryMinutes": inv.UserMinutes, "userExpiryMinutes": invite.UserMinutes,
"userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"), "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang, "langName": lang,
"passwordReset": false, "passwordReset": false,
@ -771,7 +812,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"server_channel": app.discord.serverChannelName, "server_channel": app.discord.serverChannelName,
})) }))
data["discordServerName"] = app.discord.serverName data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != "" data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
} }
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
data["customSuccessCard"] = true data["customSuccessCard"] = true