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/out
tinyproxy.conf
static/banner.svg

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @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)
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() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
@ -83,7 +84,7 @@ func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
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))
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)
return
}
@ -103,15 +104,15 @@ func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
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)
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()
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf("Saved to \"%s\"\n", fullpath)
app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath
app.restart(gc)
}

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
@ -29,6 +30,7 @@ func GenerateInviteCode() string {
return inviteCode
}
// checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data.
func (app *appContext) checkInvites() {
currentTime := time.Now()
for _, data := range app.storage.GetInvites() {
@ -52,60 +54,11 @@ func (app *appContext) checkInvites() {
if !currentTime.After(expiry) {
continue
}
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)
app.deleteExpiredInvite(data)
}
}
// 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 {
currentTime := time.Now()
inv, match := app.storage.GetInvitesKey(code)
@ -114,54 +67,8 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
expiry := inv.ValidTill
if currentTime.After(expiry) {
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
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)
}
}
app.deleteExpiredInvite(inv)
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 {
del := false
newInv := inv
@ -187,6 +94,67 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
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.
// @Produce json
// @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
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO
app.debug.Println("Generating new invite")
app.debug.Println(lm.GenerateInvite)
gc.BindJSON(&req)
currentTime := time.Now()
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) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", invite.Code)
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
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 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
@ -249,8 +216,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
@ -259,10 +228,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
} 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
// @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
@ -332,7 +300,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
if err != nil {
app.err.Printf("Failed to parse usedBy time: %v", err)
app.err.Printf(lm.FailedParseTime, err)
}
unix = date.Unix()
}
@ -347,7 +315,6 @@ func (app *appContext) GetInvites(gc *gin.Context) {
invite.SendTo = inv.SendTo
}
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
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId")
@ -397,10 +364,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
// "" means "Don't apply profile"
if _, ok := app.storage.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)
return
}
@ -424,11 +390,11 @@ func (app *appContext) SetNotify(gc *gin.Context) {
gc.BindJSON(&req)
changed := false
for code, settings := range req {
app.debug.Printf("%s: Notification settings change requested", code)
invite, ok := app.storage.GetInvitesKey(code)
if !ok {
app.err.Printf("%s Notification setting change failed: Invalid code", code)
respond(400, "Invalid invite code", gc)
msg := fmt.Sprintf(lm.InvalidInviteCode, code)
app.err.Println(msg)
respond(400, msg, gc)
return
}
var address string
@ -436,9 +402,8 @@ func (app *appContext) SetNotify(gc *gin.Context) {
if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable {
app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user contact method", gc)
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
return
}
address = gc.GetString("jfId")
@ -453,15 +418,12 @@ func (app *appContext) SetNotify(gc *gin.Context) {
} /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
changed = true
}
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
changed = true
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
invite.Notify[address][notifyType] = settings[notifyType]
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true
}
}
if changed {
app.storage.SetInvitesKey(code, invite)
@ -480,7 +442,6 @@ func (app *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
@ -495,10 +456,10 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
app.info.Printf(lm.DeleteInvite, req.Code)
respondBool(200, true, gc)
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)
}

View File

@ -1,10 +1,13 @@
package main
import (
"fmt"
"net/url"
"strconv"
"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.
@ -15,14 +18,12 @@ import (
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
app.debug.Println("Jellyseerr users requested")
users, err := app.js.GetUsers()
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)
return
}
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
userlist := make([]ombiUser, len(users))
i := 0
for _, u := range users {
@ -60,14 +61,14 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
}
u, err := app.js.UserByID(jellyseerrID)
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)
return
}
profile.Jellyseerr.User = u.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
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)
return
}
@ -98,3 +99,67 @@ func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile)
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/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@ -134,7 +135,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
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)
return
}
@ -328,7 +329,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
linkExistingOmbiDiscordTelegram(app)
@ -361,11 +362,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser)
if change {
msg := ""
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
}
}
@ -374,11 +371,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
msg := ""
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
}
}
@ -387,11 +380,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
msg := ""
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
}
}
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
app.storage.SetEmailsKey(req.ID, email)
if change {
msg := ""
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs)
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)
@ -446,7 +431,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin)
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)
return
}
@ -469,7 +454,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
}
pin := gc.Param("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)
respondBool(400, false, gc)
return
@ -487,7 +472,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get]
// @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@ -555,7 +540,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode")
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)
return
}
@ -563,12 +548,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
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)
return
}
@ -596,7 +581,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
}
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil {
app.err.Printf("Matrix: Failed to generate token: %v", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(401, "Unauthorized", gc)
return
}
@ -607,7 +592,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
app.err.Printf(lm.FailedWriting, app.configPath, err)
respondBool(500, false, gc)
return
}
@ -631,7 +616,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
}
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
if err != nil {
app.err.Printf("Matrix: Failed to create room: %v", err)
app.err.Printf(lm.FailedCreateRoom, err)
respondBool(500, false, gc)
return
}
@ -701,7 +686,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); 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{
@ -739,7 +724,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); 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{
@ -775,7 +760,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); 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{

View File

@ -1,18 +1,18 @@
package main
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
)
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 {
return nil, code, err
@ -22,6 +22,14 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
if e, ok := app.storage.GetEmailsKey(jfID); ok {
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 {
ombiAddr := ""
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 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
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
ombiUsers, code, err := app.ombi.GetUsers()
ombiUsers, code, err := ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
@ -66,10 +74,9 @@ func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{},
// @Security Bearer
// @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) {
app.debug.Println("Ombi users requested")
users, status, err := app.ombi.GetUsers()
if err != nil || status != 200 {
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err)
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get users", gc)
return
}
@ -105,7 +112,7 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
}
template, code, err := app.ombi.TemplateByID(req.ID)
if err != nil || code != 200 || len(template) == 0 {
app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err)
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get user", gc)
return
}
@ -136,7 +143,11 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
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 {
switch v.(type) {
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
}
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
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
@ -14,7 +16,6 @@ import (
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfiles(gc *gin.Context) {
app.debug.Println("Profiles requested")
out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{},
@ -52,10 +53,11 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{}
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 {
app.err.Printf("Profile not found: \"%s\"", req.Name)
respond(500, "Profile not found", gc)
msg := fmt.Sprintf(lm.FailedGetProfile, req.Name)
app.err.Println(msg)
respond(500, msg, gc)
return
}
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
@ -79,13 +81,12 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested")
var req newProfileDTO
gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
user, status, err := app.jf.UserByID(req.ID, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err)
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get user", gc)
return
}
@ -94,12 +95,12 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
Policy: user.Policy,
Homescreen: req.Homescreen,
}
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
if req.Homescreen {
profile.Configuration = user.Configuration
profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
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)
return
}
@ -145,13 +146,13 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
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
}
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respond(400, "Invalid profile", gc)
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/http"
"os"
"strings"
@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"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)
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)
return
}
@ -133,8 +135,9 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
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)
if err != nil {
app.err.Printf("Failed to parse key: %s", err)
app.err.Printf(lm.FailedParseJWT, err)
fail()
// respond(500, "unknownError", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
app.err.Printf("Failed to parse key: %s", err)
app.err.Println(lm.FailedCastJWT)
fail()
// respond(500, "unknownError", gc)
return
}
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Printf("Invalid key")
app.err.Println(lm.InvalidJWT)
fail()
// respond(400, "invalidKey", gc)
return
@ -212,7 +215,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Time: time.Now(),
}, gc, true)
app.info.Println("Email list modified")
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, "/my/account")
return
}
@ -231,7 +234,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO
gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc)
return
@ -251,7 +253,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf("Failed to generate confirmation token: %v", err)
app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
return
}
@ -262,15 +264,15 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
if status == 200 && err == nil {
name = user.Name
}
app.debug.Printf("%s: Email confirmation required", id)
app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false)
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 {
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else {
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
}
return
}
@ -290,7 +292,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@ -338,7 +340,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin)
app.discord.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
@ -358,7 +360,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); 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{
@ -413,7 +415,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); 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{
@ -477,12 +479,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
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)
return
}
@ -523,7 +525,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); 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{
@ -551,7 +553,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); 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{
@ -606,7 +608,6 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address")
if address == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop()
respondBool(400, false, gc)
return
@ -616,7 +617,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf("Ignoring PWR request: User not found")
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
for range timerWait {
respondBool(204, true, gc)
@ -626,7 +627,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}
pwr, err = app.GenInternalReset(jfUser.ID)
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 {
respondBool(204, true, gc)
return
@ -647,16 +648,16 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}, app, false,
)
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 {
respondBool(204, true, gc)
return
}
return
} 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 {
app.info.Printf("Sent password reset message to \"%s\"", address)
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
}
for range timerWait {
respondBool(204, true, gc)
@ -683,14 +684,13 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
validation := app.validator.validate(req.New)
for _, val := range validation {
if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation)
return
}
}
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
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)
return
}
@ -718,16 +718,16 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
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
}
ombiUser["password"] = req.New
status, err = app.ombi.ModifyUser(ombiUser)
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
}
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")
@ -735,7 +735,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else {
app.debug.Printf("Couldn't get cookies: %s", err)
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
}
respondBool(204, true, gc)
}
@ -761,7 +761,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
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)
return
}
@ -782,6 +782,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
@ -791,6 +792,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.RenewOldReferral, inv.Code)
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
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
import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
@ -122,14 +124,14 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
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)
return
}
isInternal := false
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)
return
}
@ -138,7 +140,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
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)
delete(app.internalPWRs, req.PIN)
return
@ -148,7 +150,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
status, err := app.jf.ResetPasswordAdmin(userID)
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)
return
}
@ -156,7 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} else {
resp, status, err := app.jf.ResetPassword(req.PIN)
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)
return
}
@ -176,7 +178,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
user, status, err = app.jf.UserByName(username, false)
}
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)
return
}
@ -195,31 +197,33 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
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)
return
}
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 {
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)
return
}
} */
ombiUser, status, err := app.getOmbiUser(user.ID)
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)
return
}
ombiUser["password"] = req.Password
status, err = app.ombi.ModifyUser(ombiUser)
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)
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)
}
@ -231,7 +235,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
@ -341,7 +344,6 @@ func (app *appContext) GetConfig(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req configDTO
gc.BindJSON(&req)
// 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")
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)
return
}
app.debug.Println("Config saved")
app.info.Printf(lm.ModifyConfig, app.configPath)
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) {
app.info.Println("Restarting...")
if TRAY {
TRAYRESTART <- true
} else {
RESTART <- true
}
// Safety Sleep (Ensure shutdown tasks get done)
time.Sleep(time.Second)
app.Restart()
}
app.loadConfig()
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{
"length": app.config.Section("password_validation").Key("min_length").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
func (app *appContext) ApplyUpdate(gc *gin.Context) {
if !app.update.CanUpdate {
respond(400, "Update is manual", gc)
app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual)
respond(400, lm.UpdateManual, gc)
return
}
err := app.update.update()
if err != nil {
app.err.Printf("Failed to apply update: %v", err)
app.err.Printf(lm.FailedApplyUpdate, err)
respondBool(500, false, gc)
return
}
@ -452,8 +447,9 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
func (app *appContext) Logout(gc *gin.Context) {
cookie, err := gc.Cookie("refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
@ -526,11 +522,7 @@ func (app *appContext) ServeLang(gc *gin.Context) {
// @Security Bearer
// @tags Other
func (app *appContext) restart(gc *gin.Context) {
app.info.Println("Restarting...")
err := app.Restart()
if err != nil {
app.err.Printf("Couldn't restart, try restarting manually: %v", err)
}
app.Restart()
}
// @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!
func (app *appContext) Restart() error {
app.info.Println(lm.Restarting)
if TRAY {
TRAYRESTART <- true
} else {

65
auth.go
View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
@ -41,6 +42,8 @@ func (app *appContext) webAuth() gin.HandlerFunc {
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.
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string
@ -72,32 +75,26 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header")
app.authLog(lm.InvalidAuthHeader)
respond(401, "Unauthorized", gc)
return
}
token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil {
app.debug.Printf("Auth denied: %s", err)
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(401, "Unauthorized", gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
app.debug.Println("Invalid JWT")
app.authLog(lm.FailedCastJWT)
respond(401, "Unauthorized", gc)
return
}
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)
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))
respond(401, "Unauthorized", gc)
ok = false
@ -115,7 +112,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
isAdminToken := claims["admin"].(bool)
if !isAdminToken {
app.debug.Printf("Auth denied: Token was not for admin access")
app.authLog(lm.NonAdminToken)
respond(401, "Unauthorized", gc)
return
}
@ -130,14 +127,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
}
if !match {
app.debug.Printf("Couldn't find user ID \"%s\"", userID)
app.authLog(fmt.Sprintf(lm.NonAdminUser, userID))
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfID)
gc.Set("userId", userID)
gc.Set("userMode", false)
app.debug.Println("Auth succeeded")
gc.Next()
}
@ -160,7 +156,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1]
ok = false
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)
return
}
@ -173,16 +169,16 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
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)
return
}
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)
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)
return
}
@ -199,7 +195,7 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
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)
if !ok {
return
@ -209,13 +205,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
for _, user := range app.adminUsers {
if user.Username == username && user.Password == password {
match = true
app.debug.Println("Found existing user")
userID = user.UserID
break
}
}
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)
return
}
@ -233,7 +228,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
@ -243,12 +238,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
newUser := User{
UserID: userID,
}
app.debug.Printf("Token generated for user \"%s\"", username)
app.debug.Printf(lm.GenerateToken, username)
app.adminUsers = append(app.adminUsers, newUser)
}
token, refresh, err := CreateToken(userID, jfID, true)
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)
return
}
@ -261,35 +256,29 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
ok = false
cookie, err := gc.Cookie(cookieName)
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)
return
}
for _, token := range app.invalidTokens {
if cookie == token {
app.debug.Println("getTokenRefresh: Invalid token")
respond(401, "Invalid token", gc)
app.authLog(lm.LocallyInvalidatedJWT)
respond(401, lm.InvalidJWT, gc)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.debug.Println("getTokenRefresh: Invalid token")
respond(400, "Invalid token", gc)
app.authLog(lm.FailedParseJWT)
respond(400, lm.InvalidJWT, gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
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)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
respond(401, "Invalid token", gc)
app.authLog(lm.InvalidJWT)
respond(401, lm.InvalidJWT, gc)
ok = false
return
}
@ -304,7 +293,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
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")
if !ok {
return
@ -313,7 +302,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID, true)
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)
return
}

View File

@ -7,6 +7,8 @@ import (
"sort"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
@ -60,12 +62,12 @@ func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
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
}
items, err := os.ReadDir(path)
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
}
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))
if err != nil {
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
app.debug.Printf(lm.FailedParseTime, err)
continue
}
backups.dates[i] = t
@ -101,36 +103,36 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
sort.Sort(backups)
for _, item := range backups.files[:toDelete] {
fullpath := filepath.Join(path, item.Name())
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
return
}
app.debug.Printf(lm.DeleteOldBackup, fullpath)
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
app.err.Printf(lm.FailedOpen, fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf("Failed to create backup: %v\n", err)
app.err.Printf(lm.FailedCreateBackup, err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err)
app.err.Printf(lm.FailedStat, fullpath, err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails)
app.debug.Printf(lm.CreateBackup, fileDetails)
return
}
@ -139,25 +141,25 @@ func (app *appContext) loadPendingBackup() {
return
}
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)
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()
defer app.storage.db.Close()
f, err := os.Open(LOADBAK)
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)
f.Close()
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 = ""
}
@ -165,7 +167,6 @@ func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
)

View File

@ -7,8 +7,10 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1"
)
@ -140,7 +142,7 @@ func (app *appContext) loadConfig() error {
}
}
if allDisabled {
fmt.Println("SETALLTRUE")
app.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
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.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
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")

View File

@ -1120,6 +1120,15 @@
"value": "",
"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": {
"name": "Language",
"required": false,

View File

@ -6,25 +6,26 @@ import (
"time"
dg "github.com/bwmarrin/discordgo"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
type DiscordDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string
guildID 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.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
Channel, InviteChannel struct{ ID, Name string }
guildID 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.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@ -92,13 +93,11 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
}
func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.messageHandler)
d.bot.AddHandler(d.commandHandler)
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
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
}
// 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
guild, err := d.bot.Guild(d.guildID)
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.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.channelName = channel
d.Channel.Name = channel
d.serverChannelName += "/" + channel
}
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
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"))
@ -145,7 +144,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
var r []*dg.Role
r, err = d.bot.GuildRoles(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get roles: %v", err)
d.app.err.Printf(lm.FailedGetDiscordRoles, err)
return
}
for _, role := range r {
@ -168,44 +167,62 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
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.
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite
var err error
if d.inviteChannelName == "" {
d.app.err.Println("Discord: Cannot create invite without channel specified in settings.")
if d.InviteChannel.Name == "" {
d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return
}
if d.inviteChannelID == "" {
if d.InviteChannel.ID == "" {
channels, err := d.bot.GuildChannels(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel list: %v", err)
d.app.err.Printf(lm.FailedGetDiscordChannels, err)
return
}
found := false
for _, channel := range channels {
// channel, err := d.bot.Channel(ch.ID)
// if err != nil {
// d.app.err.Printf("Discord: Couldn't get channel: %v", err)
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
// return
// }
if channel.Name == d.inviteChannelName {
d.inviteChannelID = channel.ID
if channel.Name == d.InviteChannel.Name {
d.InviteChannel.ID = channel.ID
found = true
break
}
}
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
}
}
// channel, err := d.bot.Channel(d.inviteChannelID)
// 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
// }
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],
// Channel: channel,
// Inviter: d.bot.State.User,
@ -214,13 +231,13 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
Temporary: false,
})
if err != nil {
d.app.err.Printf("Discord: Failed to create invite: %v", err)
d.app.err.Printf(lm.FailedGenerateDiscordInvite, err)
return
}
inviteURL = "https://discord.gg/" + inv.Code
guild, err := d.bot.Guild(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
return
}
iconURL = guild.IconURL("256")
@ -255,7 +272,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
1000,
)
if err != nil {
d.app.err.Printf("Discord: Failed to get members: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err)
return nil
}
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) {
u, err := d.bot.User(ID)
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
}
user.ID = ID
@ -294,7 +311,7 @@ func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
user.Discriminator = u.Discriminator
channel, err := d.bot.UserChannelCreate(ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create DM channel: %v", err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, ID, err)
return
}
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))
i := 0
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{
Name: d.app.storage.lang.Telegram[code].Meta.Name,
Value: code,
@ -392,7 +409,7 @@ func (d *DiscordDaemon) registerCommands() {
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(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{
Name: profile.Name,
Value: profile.Name,
@ -409,9 +426,9 @@ func (d *DiscordDaemon) registerCommands() {
for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
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 {
d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name)
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
}
@ -420,12 +437,12 @@ func (d *DiscordDaemon) registerCommands() {
func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get commands: %v", err)
d.app.err.Printf(lm.FailedGetDiscordCommands, err)
return
}
for _, cmd := range existingCommands {
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()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(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{
Name: 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])
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 {
d.commandIDs[3] = cmd.ID
}
@ -452,19 +469,20 @@ func (d *DiscordDaemon) UpdateCommands() {
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
if i.GuildID != "" && d.Channel.Name != "" {
if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.app.err.Println(lm.MonitorAllDiscordChannels)
d.Channel.Name = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
if channel.Name == d.Channel.Name {
d.Channel.ID = channel.ID
}
}
if d.channelID != i.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
if d.Channel.ID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
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) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
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
}
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 {
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
}
}
@ -521,7 +539,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
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)
return
@ -535,7 +553,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
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.JellyfinID = user.JellyfinID
@ -566,7 +584,7 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
},
})
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
}
}
@ -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) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
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
}
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
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
if !ok {
d.app.err.Printf("Failed to verify admin")
}
if !requesterEmail.Admin {
d.app.err.Printf("User is not admin")
//add response message
if !(ok && requesterEmail.Admin) {
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
// FIXME: add response message
return
}
@ -629,7 +644,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
ValidTill: validTill,
UserLabel: userLabel,
Profile: "Default",
Label: fmt.Sprintf("Discord: %s", RenderDiscordUsername(recipient)),
Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
}
if profileName != "" {
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) {
d.app.debug.Printf("%s: Sending invite message", invite.Code)
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@ -653,14 +667,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
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 {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@ -669,10 +683,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
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 {
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{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@ -681,7 +695,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
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)
}
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 {
channels := make([]string, len(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.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
u, ok := 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.
@ -900,7 +780,44 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0
}
// DeleteVerifiedUser removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
delete(d.verifiedTokens, pin)
// Exists returns whether or not the given user exists.
func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
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/html"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"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))
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 {
app.err.Printf("Error while initiating SMTP mailer: %v", err)
app.err.Printf(lm.FailedInitSMTP, err)
}
} else if method == "mailgun" {
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.
template["pin_code"] = pwr.Pin
} else {
app.info.Println("Couldn't generate PWR link: %v", err)
app.info.Println(lm.FailedGeneratePWRLink, err)
template["pin"] = pwr.Pin
}
} else {

View File

@ -1,6 +1,10 @@
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
@ -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) run() {
d.app.info.Printf("%s started", d.name)
d.app.info.Printf(lm.StartDaemon, d.name)
for {
select {
case <-d.ShutdownChannel:

7
go.mod
View File

@ -1,6 +1,6 @@
module github.com/hrfee/jfa-go
go 1.20
go 1.22.4
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/logmessages => ./logmessages
replace github.com/hrfee/jfa-go/linecache => ./linecache
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/easyproxy v0.0.0-00010101000000-000000000000
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/mediabrowser v0.3.13
github.com/itchyny/timefmt-go v0.1.5
@ -91,6 +93,7 @@ require (
github.com/gorilla/mux v1.8.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/logmessages v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.6 // indirect

View File

@ -4,6 +4,7 @@ import (
"time"
"github.com/dgraph-io/badger/v3"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
@ -12,7 +13,7 @@ import (
// meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh.
func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses")
app.debug.Println(lm.HousekeepingEmail)
emails := app.storage.GetEmails()
for _, email := range emails {
_, _, 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.
func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord()
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
switch err.(type) {
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)
default:
if user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue
}
}
@ -44,7 +50,7 @@ func (app *appContext) clearDiscord() {
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
app.debug.Println(lm.HousekeepingMatrix)
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, _, 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.
func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
app.debug.Println(lm.HousekeepingTelegram)
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, _, err := app.jf.UserByID(telegramUser.JellyfinID, false)
@ -75,7 +81,7 @@ func (app *appContext) clearTelegram() {
}
func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
app.debug.Println(lm.HousekeepingCaptcha)
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
@ -86,7 +92,7 @@ func (app *appContext) clearPWRCaptchas() {
}
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)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
@ -103,7 +109,7 @@ func (app *appContext) clearActivities() {
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{}
if errorSource == 0 {
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 {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.debug.Println(lm.HousekeepingInvites)
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
@ -128,7 +134,7 @@ func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaem
d.Name("Housekeeping daemon")
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)
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)

View File

@ -246,6 +246,7 @@
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Ombi.title }}</span>
<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">
<input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
@ -258,6 +259,23 @@
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</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">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
@ -423,6 +441,7 @@
<div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email">
<span class="heading">{{ .lang.PasswordResets.title }}</span>
<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">
<input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
@ -541,4 +560,3 @@
<script src="js/setup.js" type="module"></script>
</body>
</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"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
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
}
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)
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
}
@ -27,7 +28,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
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 {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
@ -51,7 +52,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
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() {
users, status, err := app.jf.GetUsers(false)
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
}
// I'm sure Jellyseerr can handle it,

View File

@ -122,6 +122,7 @@ type setupLang struct {
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"`
Jellyseerr langSection `json:"jellyseerr"`
Email langSection `json:"email"`
Messages langSection `json:"messages"`
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",
"confirmationRequiredMessage": "Bitte überprüfe deinen Posteingang und bestätige deine E-Mail-Adresse.",
"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.",
"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",
@ -30,7 +30,20 @@
"joinTheServer": "Server beitreten:",
"userPageSuccessMessage": "Du kannst die Details deines Accounts später in {myAccount} Seite einsehen und ändern.",
"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": {
"length": {
@ -67,6 +80,10 @@
"errorNoEmail": "E-Mail Adresse erforderlich.",
"errorCaptcha": "Captcha falsch.",
"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",
"listenAddress": "Listen Address",
"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",
"darkTheme": "Dark",
"useHTTPS": "Use HTTPS",
@ -94,7 +94,13 @@
"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.",
"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": {
"title": "Messages",
@ -134,6 +140,7 @@
"passwordResets": {
"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.",
"moreInfo": "More information about the different ways of resetting passwords can be found on {n}.",
"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\".",
"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/jellyseerr"
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
@ -101,8 +102,9 @@ type appContext struct {
// Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi
js *jellyseerr.Jellyseerr
ombi *OmbiWrapper
js *JellyseerrWrapper
thirdPartyServices []ThirdPartyService
datePattern string
timePattern string
storage Storage
@ -111,6 +113,7 @@ type appContext struct {
telegram *TelegramDaemon
discord *DiscordDaemon
matrix *MatrixDaemon
contactMethods []ContactMethodLinker
info, debug, err *logger.Logger
host string
port int
@ -213,22 +216,21 @@ func start(asDaemon, firstCall bool) {
firstRun = true
dConfig, err := fs.ReadFile(localFS, "config-default.ini")
if err != nil {
app.err.Fatalf("Couldn't find default config file")
app.err.Fatalf(lm.NoConfig)
}
nConfig, err := os.Create(app.configPath)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(filepath.Dir(app.configPath), 0760)
}
if err != nil {
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.configPath)
app.err.Fatalf("Error: %s", err)
app.err.Fatalf(lm.FailedWriting, app.configPath, err)
}
defer nConfig.Close()
_, err = nConfig.Write(dConfig)
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.Section("").Key("first_run").SetValue("true")
tempConfig.SaveTo(app.configPath)
@ -237,8 +239,9 @@ func start(asDaemon, firstCall bool) {
var debugMode bool
var address string
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) {
firstRun = true
@ -270,7 +273,7 @@ func start(asDaemon, firstCall bool) {
os.Remove(SOCK)
listener, err := net.Listen("unix", SOCK)
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)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
@ -286,13 +289,13 @@ func start(asDaemon, firstCall bool) {
for {
con, err := listener.Accept()
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", SOCK, err)
app.err.Printf(lm.FailedSocketRead, SOCK, err)
continue
}
buf := make([]byte, 512)
nr, err := con.Read(buf)
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", SOCK, err)
app.err.Printf(lm.FailedSocketRead, SOCK, err)
continue
}
command := string(buf[0:nr])
@ -317,13 +320,13 @@ func start(asDaemon, firstCall bool) {
err = app.storage.loadLang(langFS, os.DirFS(externalLang))
}
if err != nil {
app.info.Fatalf("Failed to load language files: %+v\n", err)
app.info.Fatalf(lm.FailedLangLoad, err)
}
if !firstRun {
app.host = app.config.Section("ui").Key("host").String()
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)
} else {
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)
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) {
app.debug.Printf("Connecting to Ombi")
app.ombi = &OmbiWrapper{}
app.debug.Printf(lm.UsingOmbi)
ombiServer := app.config.Section("ombi").Key("server").String()
app.ombi = ombi.NewOmbi(
app.ombi.Ombi = ombi.NewOmbi(
ombiServer,
app.config.Section("ombi").Key("api_key").String(),
common.NewTimeoutHandler("Ombi", ombiServer, true),
)
app.thirdPartyServices = append(app.thirdPartyServices, app.ombi)
}
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()
app.js = jellyseerr.NewJellyseerr(
app.js.Jellyseerr = jellyseerr.NewJellyseerr(
jellyseerrServer,
app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
)
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true
app.thirdPartyServices = append(app.thirdPartyServices, app.js)
}
@ -398,10 +404,9 @@ func start(asDaemon, firstCall bool) {
if stringServerType == "emby" {
serverType = mediabrowser.EmbyServer
timeoutHandler = mediabrowser.NewNamedTimeoutHandler("Emby", "\""+server+"\"", true)
app.info.Println("Using Emby server type")
fmt.Println(warning("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available."))
app.info.Println(lm.UsingEmby)
} else {
app.info.Println("Using Jellyfin server type")
app.info.Println(lm.UsingJellyfin)
}
app.jf, err = mediabrowser.NewServer(
@ -415,7 +420,7 @@ func start(asDaemon, firstCall bool) {
cacheTimeout,
)
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 {
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)
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)
@ -448,8 +453,9 @@ func start(asDaemon, firstCall bool) {
user.Username = app.config.Section("ui").Key("username").String()
user.Password = app.config.Section("ui").Key("password").String()
app.adminUsers = append(app.adminUsers, user)
app.info.Println(lm.UsingLocalAuth)
} 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)
if debugMode {
app.authJf.Verbose = true
@ -512,34 +518,42 @@ func start(asDaemon, firstCall bool) {
defer backupDaemon.Shutdown()
}
if telegramEnabled {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
app.err.Printf("Failed to authenticate with Telegram: %v", err)
telegramEnabled = false
} else {
go app.telegram.run()
defer app.telegram.Shutdown()
}
}
// NOTE: The order in which these are placed in app.contactMethods matters.
// Add new ones to the end.
if discordEnabled {
app.discord, err = newDiscordDaemon(app)
if err != nil {
app.err.Printf("Failed to authenticate with Discord: %v", err)
app.err.Printf(lm.FailedInitDiscord, err)
discordEnabled = false
} else {
app.debug.Println(lm.InitDiscord)
go app.discord.run()
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 {
app.matrix, err = newMatrixDaemon(app)
if err != nil {
app.err.Printf("Failed to initialize Matrix daemon: %v", err)
app.err.Printf(lm.FailedInitMatrix, err)
matrixEnabled = false
} else {
app.debug.Println(lm.InitMatrix)
go app.matrix.run()
defer app.matrix.Shutdown()
app.contactMethods = append(app.contactMethods, app.matrix)
}
}
} else {
@ -558,7 +572,7 @@ func start(asDaemon, firstCall bool) {
app.storage.lang.SetupPath = "setup"
err := app.storage.loadLangSetup(langFS)
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
mime.AddExtensionType(".js", "application/javascript")
app.info.Println("Initializing router")
app.info.Println(lm.InitRouter)
router := app.loadRouter(address, debugMode)
app.info.Println("Loading routes")
app.info.Println(lm.LoadRoutes)
if !firstRun {
app.loadRoutes(router)
} else {
app.loadSetup(router)
app.info.Printf("Loading setup @ %s", address)
app.info.Printf(lm.LoadingSetup, address)
}
go func() {
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("")
if err := SRV.ListenAndServeTLS(cert, key); err != nil {
filesToCheck := []string{cert, key}
fileNames := []string{"Certificate", "Key"}
fileNames := []string{lm.InvalidSSLCert, lm.InvalidSSLKey}
for i, v := range filesToCheck {
_, err := os.Stat(v)
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 {
app.err.Printf("Failure serving with SSL/TLS: %s", err)
app.err.Printf(lm.FailServeSSL, err)
} else {
app.err.Fatalf("Failure serving with SSL/TLS: %s", err)
app.err.Fatalf(lm.FailServeSSL, err)
}
}
} else {
if err := SRV.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
app.err.Printf("Failure serving: %s", err)
app.err.Printf(lm.FailServe, err)
} else {
app.err.Fatalf("Failure serving: %s", err)
app.err.Fatalf(lm.FailServe, err)
}
}
}
}()
if firstRun {
app.info.Printf("Loaded, visit %s to start.", address)
app.info.Printf(lm.ServingSetup, address)
} else {
app.info.Printf("Loaded @ %s", address)
app.info.Printf(lm.Serving, address)
}
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)
defer cancel()
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
}
@ -631,7 +645,7 @@ func shutdown() {
}
func (app *appContext) shutdown() {
app.info.Println("Shutting down...")
app.info.Println(lm.Quitting)
shutdown()
}
@ -715,15 +729,17 @@ func printVersion() {
fmt.Println(info("jfa-go version: %s (%s)%s\n", hiwhite(version), white(commit), tray))
}
const SYSTEMD_SERVICE = "jfa-go.service"
func main() {
f, err := logOutput()
if err != nil {
fmt.Printf("Failed to start logging: %v\n", err)
fmt.Printf(lm.FailedLogging, err)
}
defer f()
printVersion()
SOCK = filepath.Join(temp, SOCK)
fmt.Println("Socket:", SOCK)
fmt.Printf(lm.SocketPath+"\n", SOCK)
if flagPassed("test") {
TEST = true
}
@ -752,24 +768,26 @@ func main() {
} else if flagPassed("stop") {
con, err := net.Dial("unix", SOCK)
if err != nil {
fmt.Printf("Couldn't dial socket %s, are you sure jfa-go is running?\n", SOCK)
fmt.Printf(lm.FailedSocketConnect+"\n", SOCK, err)
fmt.Println(lm.SocketCheckRunning)
os.Exit(1)
}
_, err = con.Write([]byte("stop"))
if err != nil {
fmt.Printf("Couldn't send command to socket %s, are you sure jfa-go is running?\n", SOCK)
fmt.Printf(lm.FailedSocketWrite+"\n", SOCK, err)
fmt.Println(lm.SocketCheckRunning)
os.Exit(1)
}
fmt.Println("Sent.")
fmt.Println(lm.SocketWrite)
} else if flagPassed("daemon") {
start(true, true)
} else if flagPassed("systemd") {
service, err := fs.ReadFile(localFS, "jfa-go.service")
service, err := fs.ReadFile(localFS, SYSTEMD_SERVICE)
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)
}
absPath, err := filepath.Abs(os.Args[0])
absPath, err := os.Executable()
if err != nil {
absPath = os.Args[0]
}
@ -780,13 +798,13 @@ func main() {
}
}
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 {
fmt.Printf("Couldn't write jfa-go.service: %v\n", err)
fmt.Printf(lm.FailedWriting+"\n", SYSTEMD_SERVICE, err)
os.Exit(1)
}
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".
You can then run:

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/gomarkdown/markdown"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@ -31,15 +32,6 @@ type UnverifiedUser struct {
User *MatrixUser
}
type MatrixUser struct {
RoomID string
Encrypted bool
UserID string
Lang string
Contact bool
JellyfinID string `badgerhold:"key"`
}
var matrixFilter = mautrix.Filter{
Room: mautrix.RoomFilter{
Timeline: mautrix.FilterPart{
@ -118,13 +110,13 @@ func (d *MatrixDaemon) generateAccessToken(homeserver, username, password string
func (d *MatrixDaemon) run() {
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)
HandleSyncerCrypto(startTime, d, syncer)
syncer.OnEventType(event.EventMessage, d.handleMessage)
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,
)
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
}
@ -203,7 +195,7 @@ func (d *MatrixDaemon) CreateRoom(userID string) (roomID id.RoomID, encrypted bo
func (d *MatrixDaemon) SendStart(userID string) (ok bool) {
roomID, encrypted, err := d.CreateRoom(userID)
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
}
lang := "en-us"
@ -226,7 +218,7 @@ func (d *MatrixDaemon) SendStart(userID string) (ok bool) {
roomID,
)
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
}
ok = true
@ -276,6 +268,57 @@ func (d *MatrixDaemon) UserExists(userID string) bool {
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.
// 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
package main
import (
"fmt"
"strings"
lm "github.com/hrfee/jfa-go/logmessages"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
@ -65,22 +68,22 @@ type olmLogger struct {
}
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{}) {
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{}) {
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{}) {
if strings.HasPrefix(message, "Got membership state event") {
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) {
@ -155,7 +158,7 @@ func HandleSyncerCrypto(startTime int64, d *MatrixDaemon, syncer *mautrix.Defaul
// return
// }
if err != nil {
d.app.err.Printf("Failed to decrypt Matrix message: %v", err)
d.app.err.Printf(lm.FailedDecryptMatrixMessage, err)
return
}
d.handleMessage(source, decrypted)
@ -180,7 +183,7 @@ func EncryptRoom(d *MatrixDaemon, room *mautrix.RespCreateRoom, userID id.UserID
if err == nil {
encrypted = true
} else {
d.app.debug.Printf("Matrix: Failed to enable encryption in room: %v", err)
d.app.debug.Printf(lm.FailedEnableMatrixEncryption, room.RoomID, err)
return
}
d.isEncrypted[room.RoomID] = encrypted

View File

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

View File

@ -31,7 +31,7 @@ type newUserDTO struct {
type newUserResponse struct {
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.
}

View File

@ -8,6 +8,7 @@ import (
"time"
"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.
@ -39,16 +40,16 @@ func (app *appContext) GenResetLink(pin string) (string, error) {
}
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()
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
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
app.err.Printf("Couldn't initialise password reset daemon")
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
return
}
defer watcher.Close()
@ -56,7 +57,7 @@ func (app *appContext) StartPWR() {
go pwrMonitor(app, watcher)
err = watcher.Add(path)
if err != nil {
app.err.Printf("Failed to start password reset daemon: %s", err)
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
}
waitForRestart()
@ -84,43 +85,36 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
var pwr PasswordReset
data, err := os.ReadFile(event.Name)
if err != nil {
app.debug.Printf("PWR: Failed to read file: %v", err)
app.debug.Printf(lm.FailedReading, event.Name, err)
return
}
err = json.Unmarshal(data, &pwr)
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
}
app.info.Printf("New password reset for user \"%s\"", pwr.Username)
if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
user, status, err := app.jf.UserByName(pwr.Username, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
if !(status == 200 || status == 204) || err != nil || user.ID == "" {
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
return
}
uid := user.ID
if uid == "" {
app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username)
return
}
name := app.getAddressOrName(uid)
if name != "" {
msg, err := app.email.constructReset(pwr, app, false)
if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\"", pwr.Username)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
} else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\"", name)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
} else {
app.info.Printf("Sent password reset message to \"%s\"", name)
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
}
}
} 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 {
return
}
app.err.Printf("Password reset daemon: %s", err)
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
}
}
}

View File

@ -1,7 +1,11 @@
package main
import "fmt"
import (
"fmt"
lm "github.com/hrfee/jfa-go/logmessages"
)
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/static"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
@ -21,17 +22,17 @@ func (app *appContext) loadHTML(router *gin.Engine) {
templatePath := "html"
htmlFiles, err := fs.ReadDir(localFS, templatePath)
if err != nil {
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
app.err.Fatalf(lm.FailedReading, templatePath, err)
return
}
loadInternal := []string{}
loadExternal := []string{}
for _, f := range htmlFiles {
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()))
} 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())))
}
}
@ -39,13 +40,13 @@ func (app *appContext) loadHTML(router *gin.Engine) {
if len(loadInternal) != 0 {
tmpl, err = template.ParseFS(localFS, loadInternal...)
if err != nil {
app.err.Fatalf("Failed to load templates: %v", err)
app.err.Fatalf(lm.FailedLoadTemplates, lm.Internal, err)
}
}
if len(loadExternal) != 0 {
tmpl, err = tmpl.ParseFiles(loadExternal...)
if err != nil {
app.err.Fatalf("Failed to load external templates: %v", err)
app.err.Fatalf(lm.FailedLoadTemplates, lm.External, err)
}
}
router.SetHTMLTemplate(tmpl)
@ -96,7 +97,7 @@ func (app *appContext) loadRouter(address string, debug bool) *gin.Engine {
router.Use(static.Serve("/", app.webFS))
router.NoRoute(app.NoRouteHandler)
if *PPROF {
app.debug.Println("Loading pprof")
app.debug.Println(lm.RegisterPprof)
pprof.Register(router)
}
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+"/token/login", app.getTokenLogin)
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.GET(p+"/invite/:invCode", app.InviteProxy)
if app.config.Section("captcha").Key("enabled").MustBool(false) {
@ -165,7 +166,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
}
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 {
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)
api.DELETE(p+"/users", app.DeleteUsers)
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.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers)

View File

@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
)
@ -104,7 +105,7 @@ func (app *appContext) TestJF(gc *gin.Context) {
case 404:
msg = "error404"
}
app.info.Printf("Auth failed with code %d (%s)", status, err)
app.err.Printf(lm.FailedAuthJellyfin, req.Server, status, err)
if msg != "" {
respond(status, msg, gc)
} else {
@ -151,16 +152,20 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
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.Proxy, &fallback.Proxy, &english.Proxy)
patchLang(&lang.EndPage, &fallback.EndPage, &english.EndPage)
patchLang(&lang.Language, &fallback.Language, &english.Language)
patchLang(&lang.Login, &fallback.Login, &english.Login)
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.Messages, &fallback.Messages, &english.Messages)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchLang(&lang.UserPage, &fallback.UserPage, &english.UserPage)
patchLang(&lang.WelcomeEmails, &fallback.WelcomeEmails, &english.WelcomeEmails)
patchLang(&lang.PasswordResets, &fallback.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &fallback.InviteEmails, &english.InviteEmails)
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 == "" {
patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.StartPage, &english.StartPage)
patchLang(&lang.EndPage, &english.EndPage)
patchLang(&lang.General, &english.General)
patchLang(&lang.Updates, &english.Updates)
patchLang(&lang.Proxy, &english.Proxy)
patchLang(&lang.EndPage, &english.EndPage)
patchLang(&lang.Language, &english.Language)
patchLang(&lang.Login, &english.Login)
patchLang(&lang.JellyfinEmby, &english.JellyfinEmby)
patchLang(&lang.Ombi, &english.Ombi)
patchLang(&lang.Jellyseerr, &english.Jellyseerr)
patchLang(&lang.Email, &english.Email)
patchLang(&lang.Messages, &english.Messages)
patchLang(&lang.Notifications, &english.Notifications)
patchLang(&lang.UserPage, &english.UserPage)
patchLang(&lang.WelcomeEmails, &english.WelcomeEmails)
patchLang(&lang.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &english.InviteEmails)
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 (
"encoding/json"
"fmt"
"io/fs"
"log"
"os"
@ -13,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
"gopkg.in/ini.v1"
@ -175,10 +177,10 @@ func (app *appContext) ConnectDB() {
opts.ValueDir = app.storage.db_path
db, err := badgerhold.Open(opts)
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.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.
@ -508,6 +510,15 @@ func (st *Storage) GetDefaultProfile() Profile {
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.
func (st *Storage) GetCustomContent() []CustomContent {
result := []CustomContent{}
@ -583,12 +594,35 @@ func (st *Storage) DeleteActivityKey(k string) {
st.db.Delete(k, Activity{})
}
type TelegramUser struct {
JellyfinID string `badgerhold:"key"`
ChatID int64 `badgerhold:"index"`
Username string `badgerhold:"index"`
Lang string
Contact bool // Whether to contact through telegram or not
type ThirdPartyService interface {
// ok implies user imported, err can be any issue that occurs during
ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool)
AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error)
Enabled(app *appContext, profile *Profile) bool
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 {
@ -601,6 +635,24 @@ type DiscordUser struct {
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 {
Addr string `badgerhold:"index"`
Label string // User Label.
@ -685,6 +737,16 @@ type Invite struct {
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 {
Answer string
Image []byte // image/png
@ -718,22 +780,27 @@ type Lang struct {
func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
err = st.loadLangCommon(filesystems...)
if err != nil {
err = fmt.Errorf("common: %v", err)
return
}
err = st.loadLangAdmin(filesystems...)
if err != nil {
err = fmt.Errorf("admin: %v", err)
return
}
err = st.loadLangEmail(filesystems...)
if err != nil {
err = fmt.Errorf("email: %v", err)
return
}
err = st.loadLangUser(filesystems...)
if err != nil {
err = fmt.Errorf("user: %v", err)
return
}
err = st.loadLangPWR(filesystems...)
if err != nil {
err = fmt.Errorf("pwr: %v", err)
return
}
err = st.loadLangTelegram(filesystems...)

View File

@ -7,6 +7,7 @@ import (
"time"
tg "github.com/go-telegram-bot-api/telegram-bot-api"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
@ -15,9 +16,27 @@ const (
)
type TelegramVerifiedToken struct {
ChatID int64
Username string
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them.
JellyfinID string `badgerhold:"key"` // optional, for ensuring a user-requested change is only accessed by them.
ChatID int64 `badgerhold:"index"`
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.
@ -96,12 +115,12 @@ func (t *TelegramDaemon) NewAssignedAuthToken(id string) string {
}
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.Timeout = 60
updates, err := t.bot.GetUpdatesChan(u)
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
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"})
err := t.Reply(upd, content)
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)
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
}
@ -232,14 +251,14 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string)
if !ok || time.Now().After(token.Expiry) {
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"))
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)
return
}
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"))
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{
ChatID: upd.Message.Chat.ID,
@ -273,7 +292,39 @@ func (t *TelegramDaemon) UserExists(username string) bool {
return err != nil || c > 0
}
// DeleteVerifiedToken removes the token with the given PIN.
func (t *TelegramDaemon) DeleteVerifiedToken(pin string) {
delete(t.verifiedTokens, pin)
// Exists returns whether or not the given user exists.
func (t *TelegramDaemon) Exists(user ContactMethodUser) bool {
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);
html("language-description", window.lang.var("language", "description", `<a target="_blank" href="https://weblate.jfa-go.com">Weblate</a>`));
html("email-description", window.lang.var("email", "description", `<a target="_blank" href="https://mailgun.com">Mailgun</a>`));
html("email-dateformat-notice", window.lang.var("email", "dateFormatNotice", `<a target="_blank" href="https://strftime.ninja/">strftime.ninja</a>`));
html("updates-description", window.lang.var("updates", "description", `<a target="_blank" href="https://builds.hrfee.dev/view/hrfee/jfa-go">buildrone</a>`));
html("messages-description", window.lang.var("messages", "description", `<a target="_blank" href="https://wiki.jfa-go.com">Wiki</a>`));
replaceLink("language-description", "language", "description", "https://weblate.jfa-go.com", "Weblate");
replaceLink("email-description", "email", "description", "https://mailgun.com", "Mailgun");
replaceLink("email-dateformat-notice", "email", "dateFormatNotice", "https://strftime.timpetricola.com/", "strftime.timpetricola.com");
replaceLink("updates-description", "updates", "description", "https://builds.hrfee.dev/view/hrfee/jfa-go", "buildrone");
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 = {
"jellyfin": {
@ -318,6 +322,12 @@ const settings = {
"server": new Input(get("ombi-server"), "", "", "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": {
"tls": new Checkbox(get("advanced-tls"), "", false, "advanced", "tls"),
"tls_port": new Input(get("advanced-tls_port"), "", "", "tls", true, "advanced"),

View File

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

View File

@ -16,6 +16,8 @@ import (
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/common"
)
@ -560,15 +562,16 @@ func (app *appContext) checkForUpdates() {
if err != nil && strings.Contains(err.Error(), "strconv.ParseInt") {
app.err.Println("No new updates available.")
} 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
}
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)
if status != 200 || err != nil {
app.err.Printf("Failed to get update (%d): %v", status, err)
app.err.Printf(lm.FailedGetUpdate, err)
return
}
app.tag = tag

View File

@ -1,9 +1,11 @@
package main
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) userAuth() gin.HandlerFunc {
@ -13,7 +15,7 @@ func (app *appContext) userAuth() gin.HandlerFunc {
func (app *appContext) userAuthenticate(gc *gin.Context) {
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
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)
return
}
@ -27,7 +29,6 @@ func (app *appContext) userAuthenticate(gc *gin.Context) {
gc.Set("jfId", jfID)
gc.Set("userMode", true)
app.debug.Println("Auth succeeded")
gc.Next()
}
@ -41,11 +42,11 @@ func (app *appContext) userAuthenticate(gc *gin.Context) {
// @Security getUserTokenAuth
func (app *appContext) getUserTokenLogin(gc *gin.Context) {
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)
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)
if !ok {
return
@ -58,12 +59,11 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
token, refresh, err := CreateToken(user.ID, user.ID, false)
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)
return
}
app.debug.Printf("Token generated for non-admin user \"%s\"", username)
uri := "/my"
if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) {
uri = "/accounts/my"
@ -81,12 +81,12 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(true)
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)
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")
if !ok {
return
@ -96,7 +96,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
jwt, refresh, err := CreateToken(jfID, jfID, false)
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)
return
}

View File

@ -3,6 +3,7 @@ package main
import (
"time"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
@ -21,17 +22,17 @@ func (app *appContext) checkUsers() {
if len(app.storage.GetUserExpiries()) == 0 {
return
}
app.info.Println("Daemon: Checking for user expiry")
app.info.Println(lm.CheckUserExpiries)
users, status, err := app.jf.GetUsers(false)
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
}
mode := "disable"
term := "Disabling"
phrase := lm.DisableExpiredUser
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
term = "Deleting"
phrase = lm.DeleteExpiredUser
}
contact := false
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() {
id := expiry.JellyfinID
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)
} else if time.Now().After(expiry.Expiry) {
found := false
@ -58,11 +59,10 @@ func (app *appContext) checkUsers() {
}
}
if !found {
app.info.Printf("Expired user already deleted, ignoring.")
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue
}
app.info.Printf("%s expired user \"%s\"", term, user.Name)
app.info.Printf(phrase, user.Name)
// Record activity
activity := Activity{
@ -72,18 +72,24 @@ func (app *appContext) checkUsers() {
}
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
// Store the user name, since there's no longer a user ID to reference back to
activity.Value = user.Name
} else if mode == "disable" {
user.Policy.IsDisabled = true
// Admins can't be disabled
// so they're not an admin anymore, sorry
user.Policy.IsAdministrator = false
status, err = app.jf.SetPolicy(id, user.Policy)
err, _, _ = app.SetUserDisabled(user, true)
activity.Type = ActivityDisabled
}
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
}
@ -98,11 +104,11 @@ func (app *appContext) checkUsers() {
name := app.getAddressOrName(user.ID)
msg, err := app.email.constructUserExpired(app, false)
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 {
app.err.Printf("Failed to send expiry message to \"%s\": %s", name, err)
app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, name, err)
} 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"
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
@ -16,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/gomarkdown/markdown"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/steambap/captcha"
@ -66,10 +68,9 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) {
toPush = []string{}
}
if pusher := gc.Writer.Pusher(); pusher != nil {
app.debug.Println("Using HTTP2 Server push")
for _, f := range toPush {
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
l, err := fs.ReadFile(localFS, "LICENSE")
if err != nil {
app.debug.Printf("Failed to load LICENSE: %s", err)
app.debug.Printf(lm.FailedReading, "LICENSE", err)
license = ""
}
license = string(l)
fontLicense, err := fs.ReadFile(localFS, filepath.Join("web", "fonts", "OFL.txt"))
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"
@ -236,7 +237,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
"server_channel": app.discord.serverChannelName,
}))
data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != ""
data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
}
if data["linkResetEnabled"].(bool) {
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)
// If it's a bot, pretend to be a success so the preview is nice.
if isBot {
app.debug.Println("PWR: Ignoring magic link visit from bot")
app.debug.Println(lm.IgnoreBotPWR)
data["success"] = true
data["pin"] = "NO-BO-TS"
return
@ -338,13 +339,13 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
if !isInternal && !setPassword {
resp, status, err = app.jf.ResetPassword(pin)
} 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)
return
} else {
status, err = app.jf.ResetPasswordAdmin(pwr.ID)
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 {
status, err = app.jf.SetPassword(pwr.ID, "", pin)
}
@ -358,7 +359,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
username = resp.UsersReset[0]
}
} 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.
@ -378,21 +379,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(username, false)
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
}
ombiUser, status, err := app.getOmbiUser(jfUser.ID)
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
}
ombiUser["password"] = pin
status, err = app.ombi.ModifyUser(ombiUser)
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
}
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)
if err != nil {
app.err.Printf("Failed to generate captcha: %v", err)
app.err.Printf(lm.FailedGenerateCaptcha, err)
respondBool(500, false, gc)
return
}
@ -470,7 +471,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
captchaID := genAuthToken()
var buf bytes.Buffer
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)
return
}
@ -503,8 +504,12 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
ok := true
if !isPWR {
inv, ok := app.storage.GetInvitesKey(code)
if !ok || (!isPWR && inv.Captchas == nil) {
app.debug.Printf("Couldn't find invite \"%s\"", code)
if !ok {
app.debug.Printf(lm.InvalidInviteCode, code)
return false
}
if !isPWR && inv.Captchas == nil {
app.debug.Printf(lm.CaptchaNotFound, id, code)
return false
}
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]
}
if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id)
app.debug.Printf(lm.CaptchaNotFound, id, code)
return false
}
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")
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
app.err.Printf("Failed to read reCAPTCHA status (%d): %+v\n", resp.Status, err)
if err == nil && resp.StatusCode != 200 {
err = fmt.Errorf("failed (error %d)", resp.StatusCode)
}
if err != nil {
app.err.Printf(lm.FailedVerifyReCAPTCHA, err)
return false
}
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)
err = json.Unmarshal(body, &data)
if err != nil {
app.err.Printf("Failed to unmarshal reCAPTCHA response: %+v\n", err)
app.err.Printf(lm.FailedVerifyReCAPTCHA, err)
return false
}
hostname := app.config.Section("captcha").Key("recaptcha_hostname").MustString("")
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
}
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
}
@ -607,13 +616,108 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) {
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) {
app.pushResources(gc, FormPage)
code := gc.Param("invCode")
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. */
// if app.checkInvite(code, false, "") {
inv, ok := app.storage.GetInvitesKey(code)
invite, ok := app.storage.GetInvitesKey(gc.Param("invCode"))
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
@ -623,76 +727,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
})
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 !ok {
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()
if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
app.NewUserFromConfirmationKey(invite, key, lang, gc)
return
}
email := ""
if invite, ok := app.storage.GetInvitesKey(code); ok {
email = invite.SendTo
}
email := invite.SendTo
if strings.Contains(email, "Failed") || !strings.Contains(email, "@") {
email = ""
}
@ -707,8 +748,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
userPageAddress += "/my/account"
fromUser := ""
if inv.ReferrerJellyfinID != "" {
sender, status, err := app.jf.UserByID(inv.ReferrerJellyfinID, false)
if invite.ReferrerJellyfinID != "" {
sender, status, err := app.jf.UserByID(invite.ReferrerJellyfinID, false)
if status == 200 && err == nil {
fromUser = sender.Name
}
@ -730,13 +771,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].validationStringsJSON,
"notifications": app.storage.lang.User[lang].notificationsJSON,
"code": code,
"code": invite.Code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry,
"userExpiryMonths": inv.UserMonths,
"userExpiryDays": inv.UserDays,
"userExpiryHours": inv.UserHours,
"userExpiryMinutes": inv.UserMinutes,
"userExpiry": invite.UserExpiry,
"userExpiryMonths": invite.UserMonths,
"userExpiryDays": invite.UserDays,
"userExpiryHours": invite.UserHours,
"userExpiryMinutes": invite.UserMinutes,
"userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang,
"passwordReset": false,
@ -771,7 +812,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"server_channel": app.discord.serverChannelName,
}))
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 {
data["customSuccessCard"] = true