Compare commits

...

78 Commits

Author SHA1 Message Date
Harvey Tindall
ca4fbc0ad5 backups: change update button wording 2023-12-21 21:40:24 +00:00
Harvey Tindall
b259dd7b00 backups: add wiki link 2023-12-21 21:38:42 +00:00
Harvey Tindall
dc2c2f1164 backups: show uploaded backups on-page 2023-12-21 21:11:40 +00:00
Harvey Tindall
bc2e9cffda backups: move code to own files 2023-12-21 18:17:03 +00:00
Harvey Tindall
ade032241a backups: upload and restore backup in-app 2023-12-21 18:12:58 +00:00
Harvey Tindall
eff313be41 backups: restore local backups in-app 2023-12-21 17:42:07 +00:00
Harvey Tindall
ff73c72b0e backups: add -restore cli argument 2023-12-21 17:27:28 +00:00
Harvey Tindall
1bb83c88d9 backups: add filesize to list 2023-12-21 16:51:33 +00:00
Harvey Tindall
195813c058 backups: triggerable in ui, viewable, downloadable
new "Backups" menu in settings lists all available backups, lets you
trigger a new one, and lets you download them.
2023-12-21 16:47:17 +00:00
Harvey Tindall
733ab37539 backups: add backup daemon to run every n minutes, keep x most recent backups 2023-12-21 13:03:16 +00:00
Harvey Tindall
c0c91b4aad drone: source buildrone key from drone in docker build 2023-12-20 20:06:44 +00:00
Harvey Tindall
83712a6937 pwr: fix set password for jellyfin PWRs 2023-12-20 19:04:40 +00:00
Harvey Tindall
290d02d248 pwr: include pwr-pin in build process, whoops
copying the PIN on the external PWR link page wasn't working since the
code's typescript wasn't being compiled.
2023-12-20 18:40:18 +00:00
Harvey Tindall
9cd402a15d logs: fix file identifier 2023-12-20 18:28:42 +00:00
Harvey Tindall
1a6897637f userpage: allow manual disable of pwr through username/email/contact
Checkboxes added to userpage settings allowing enabling/disabling of
specific ways of starting a PWR. For #312.
2023-12-20 18:18:39 +00:00
Harvey Tindall
213b1e7f9e accounts: allow setting exact expiry date
set with a text input field which uses the same date parsing library as
the search function. Parsed expiry date will appear once you've typed
something in, so you can make sure it's right.
2023-12-20 17:20:59 +00:00
Harvey Tindall
10c8d4ad2f accounts: add "remove expiry" 2023-11-16 11:19:49 +00:00
Harvey Tindall
4fcb58aefa userpage: fix referral card when no message set 2023-11-11 16:02:01 +00:00
Harvey Tindall
8c2a35f755 userpage: fix messages reset buttons 2023-11-11 15:59:05 +00:00
Harvey Tindall
a66c522b73 referrals: add "use expiry" option
adds an option when enabling referrals to use the duration of the source
invited (i.e., months, days, hours) for the referral invite. If enabled,
the user won't be able to make a new referral link after it expires. For
referrals enabled for new users via a profile, the clock starts ticking
as soon as the account is created.
2023-11-10 15:07:29 +00:00
mLgz0rn
d0de1142ae translation from Weblate (Danish)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/da/
2023-10-27 01:54:24 +02:00
Richard de Boer
8d6ad7e3c8 Translated using Weblate (Dutch)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
8ae5dd97b2 Translated using Weblate (Dutch)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
cf747c1ddb Translated using Weblate (Dutch)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
8cb53d1c6f translation from Weblate (Dutch)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
bd8ecebf89 translation from Weblate (Dutch)
Currently translated at 100.0% (194 of 194 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-10-27 01:54:24 +02:00
mLgz0rn
09158b5bb5 translation from Weblate (Danish)
Currently translated at 100.0% (194 of 194 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
aa30f1c392 Translated using Weblate (Danish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
4a2fc6d418 Translated using Weblate (Danish)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
1846e31bf5 Translated using Weblate (Danish)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/da/
2023-10-27 01:54:23 +02:00
Harvey Tindall
1be20d471d Merge activity log
Activity log
2023-10-23 19:19:47 +01:00
Harvey Tindall
3739634b63 activity: fix "shown" counter when not in search 2023-10-23 18:36:32 +01:00
Harvey Tindall
3951116bdc activity: reload invites on link click 2023-10-23 18:18:08 +01:00
Harvey Tindall
a288ba4461 Merge remote-tracking branch 'origin/main' into activity-log 2023-10-23 18:00:56 +01:00
Harvey Tindall
f34ba5df18 invites: fix sending invite to @username discord format
whether something was an email or not was being decided by checking for
an "@", so the new format didn't work.
2023-10-23 17:59:18 +01:00
Harvey Tindall
44d7e173e3 activity: add limiting settings
limit to keeping n most recent logs, and/or logs younger than {n} days
in settings > Activity Log.
2023-10-23 12:50:42 +01:00
Harvey Tindall
663389693f activity: add counter for total, loaded and shown
total: number of activities in the DB
loaded: How many the web UI has loaded
shown: How many are shown (differs when in a search).
2023-10-23 11:34:04 +01:00
Harvey Tindall
591b843148 activity: add a "load all" button 2023-10-22 16:22:25 +01:00
Harvey Tindall
de3c06129d activity: pseudo links work on refresh 2023-10-22 15:02:03 +01:00
Harvey Tindall
0238c6778c activity: pseudo links work on click 2023-10-22 14:02:22 +01:00
Harvey Tindall
d00f3fcfbc admin: /activity pseudo-page now works 2023-10-22 12:31:06 +01:00
Harvey Tindall
47ce8a9ec4 activity: refresh, load more buttons, ui adjustments 2023-10-22 01:03:48 +01:00
Harvey Tindall
2d83718f81 activity: sort, load more, compromises for client-side search
my initial intent before starting search was for it to be server-sided,
considering this activity log could rack up 100s or 1000s of entries,
and then I forgot and did it client-sided.

this commit adds a feature to load more results when scrolled to the
bottom, and when a search returns few or no results (this is limited, so
it wont loop infinitely). Also finally got rid of the useless left
column, since my ideas didn't match my implementation.

also, sorting is only by date, can't be bothered with anything else.
2023-10-22 00:31:30 +01:00
Harvey Tindall
a0db685af2 activity: functional search (client-side)
search with filters for each type of card, and all the info in them.
Gonna somehow need to figure out what to do about pagination.
2023-10-21 16:24:14 +01:00
Harvey Tindall
4fa0630aef accounts: modularize search
now part of ts/modules/search.ts, UI of the activity page is gonna be
very similar so it made sense to.
2023-10-21 14:33:09 +01:00
Harvey Tindall
3cad30a8e5 activity: add delete button 2023-10-21 13:38:11 +01:00
Harvey Tindall
44172074b9 activity: render all activities correctly
the activity type, usernames, time, referrer, and invite code are
displayed correctly for all types of activity.
2023-10-21 13:00:06 +01:00
Harvey Tindall
1032e4e747 activity: more presentable cards, fixes
fixed some missing data (being stored and being shown), improved layout,
also usernames are now injected by the route.
2023-10-20 22:16:40 +01:00
Harvey Tindall
a73dfddd3f activity: partially functional frontend code
doesn't fill in all the blanks yet, but almost there ish. Filters &
stuff not done yet, just loads everything.
2023-10-20 18:14:32 +01:00
Harvey Tindall
274324557c activity: start stubbed out example card, beginning frontend code
completely broken, just need to commit so I can move between devices.
2023-10-20 00:06:10 +01:00
Harvey Tindall
5a0677bac8 activity: allow multiple types in route filter 2023-10-19 22:44:27 +01:00
Harvey Tindall
df1581d48e activity: route to show activity activity log
filterable by type, sortable by time, and paginated.
2023-10-19 22:10:42 +01:00
Harvey Tindall
9d1c7bba6f activity: log account link/unlinks 2023-10-19 21:17:03 +01:00
Harvey Tindall
b620c0d9ae activity: implement most initial logging
resetPassword, changePassword, delete/createInvite, enable/disable,
creation/deletion of invites & users are all done, only remaining one is
account linking.
2023-10-19 18:56:35 +01:00
Harvey Tindall
2c787b4d46 activity: log creations 2023-10-19 18:14:40 +01:00
Harvey Tindall
69dcaf3797 activity: Add initial data structure 2023-10-19 17:59:34 +01:00
Harvey Tindall
43e36ee6fc setup: Include proxy, test JF with it
Found on the 2nd page.
2023-10-19 17:19:52 +01:00
Harvey Tindall
53c9569a37 build: add notray windows build
better for daemonization with stuff like nssm.
2023-10-19 16:25:05 +01:00
Harvey Tindall
c39a9e80e7 daemon: ensure correct error before wiping user data
ensure the error is specifically "User not found", rather than a
connection error or such. For #303.
2023-10-19 15:04:31 +01:00
Harvey Tindall
3d0f756264 Merge SMTP Auth Option from @SquaredPotato
feat: Add SMTP authentication types to settings
2023-10-14 13:43:37 +01:00
Stefan Schokker
85de1c97ff feat: Add SMTP authentication types to settings 2023-10-14 14:29:34 +02:00
Harvey Tindall
2c8afecfbb lowercase lang 2023-10-14 13:19:05 +01:00
Harvey Tindall
4924700c52 Merge settings-search
Adds searchbox to settings
2023-10-14 13:17:50 +01:00
brixik1
28d321986a Translated using Weblate (Czech)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/cs/
2023-10-13 15:23:45 +02:00
brixik1
943d523f3f Translated using Weblate (Czech)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/cs/
2023-10-13 15:23:45 +02:00
brixik1
8f88b6aaa2 Translated using Weblate (Czech)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/cs/
2023-10-13 15:23:45 +02:00
brixik1
7f60598d4a Translated using Weblate (Czech)
Currently translated at 99.1% (119 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/cs/
2023-10-13 15:23:45 +02:00
brixik1
18e82fd04b translation from Weblate (Czech)
Currently translated at 100.0% (189 of 189 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/cs/
2023-10-13 15:23:45 +02:00
brixik1
d7d7146e12 Translated using Weblate (Czech)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/cs/
2023-10-13 15:23:45 +02:00
brixik1
aaa5217398 translation from Weblate (Czech)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/cs/
2023-10-13 15:23:45 +02:00
brixik1
9610b89fa5 Translated using Weblate (Czech)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/cs/
2023-10-13 15:23:45 +02:00
brixik1
9809611d0d add translation from Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
b1e38ba15d Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
35a765aa01 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
82411f1868 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
b0e01144f4 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
04f354b3d1 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
918f3ad588 add translation from Weblate (Czech) 2023-10-13 15:23:45 +02:00
63 changed files with 4033 additions and 552 deletions

View File

@@ -131,6 +131,9 @@ steps:
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
@@ -140,13 +143,15 @@ steps:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa
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 && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- 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:

View File

@@ -29,6 +29,7 @@ before:
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr-pin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr-pin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}
@@ -54,6 +55,7 @@ builds:
goos:
- linux
- darwin
- windows
goarch:
- arm
- arm64

View File

@@ -121,6 +121,7 @@ typescript:
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr-pin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr-pin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify

186
api-activities.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"github.com/gin-gonic/gin"
"github.com/timshannon/badgerhold/v4"
)
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
return "creation"
case ActivityDeletion:
return "deletion"
case ActivityDisabled:
return "disabled"
case ActivityEnabled:
return "enabled"
case ActivityContactLinked:
return "contactLinked"
case ActivityContactUnlinked:
return "contactUnlinked"
case ActivityChangePassword:
return "changePassword"
case ActivityResetPassword:
return "resetPassword"
case ActivityCreateInvite:
return "createInvite"
case ActivityDeleteInvite:
return "deleteInvite"
}
return "unknown"
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
return ActivityUser
case "admin":
return ActivityAdmin
case "anon":
return ActivityAnon
case "daemon":
return ActivityDaemon
}
return ActivityAnon
}
func activitySourceToString(v ActivitySource) string {
switch v {
case ActivityUser:
return "user"
case ActivityAdmin:
return "admin"
case ActivityAnon:
return "anon"
case ActivityDaemon:
return "daemon"
}
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
activityTypes := make([]interface{}, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
}
if !req.Ascending {
query = query.Reverse()
}
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf("Failed to read activities from DB: %v\n", err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
}
for i, act := range results {
resp.Activities[i] = ActivityDTO{
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
resp.Activities[i].Value = ""
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, status, err := app.jf.UserByID(act.Source, false)
if status == 200 && err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
gc.JSON(200, resp)
}
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
// @Produce json
// @Param id path string true "ID of activity to delete"
// @Success 200 {object} boolResponse
// @Router /activity/{id} [delete]
// @Security Bearer
// @tags Activity
func (app *appContext) DeleteActivity(gc *gin.Context) {
app.storage.DeleteActivityKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} GetActivityCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}

117
api-backups.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// @Summary Creates a backup of the database.
// @Router /backups [post]
// @Success 200 {object} CreateBackupDTO
// @Security Bearer
// @tags Backups
func (app *appContext) CreateBackup(gc *gin.Context) {
backup := app.makeBackup()
gc.JSON(200, backup)
}
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
// @Param fname path string true "backup filename"
// @Router /backups/{fname} [get]
// @Produce octet-stream
// @Produce json
// @Success 200 {body} file
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
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)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
gc.FileAttachment(fullpath, fname)
}
// @Summary Get a list of backups.
// @Router /backups [get]
// @Produce json
// @Success 200 {object} GetBackupsDTO
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackups(gc *gin.Context) {
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
sort.Sort(backups)
resp := GetBackupsDTO{}
resp.Backups = make([]CreateBackupDTO, backups.count)
for i, item := range backups.files[:backups.count] {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
resp.Backups[i].Date = backups.dates[i].Unix()
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
}
}
gc.JSON(200, resp)
}
// @Summary Restore a backup file stored locally to the server.
// @Param fname path string true "backup filename"
// @Router /backups/restore/{fname} [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
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)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
LOADBAK = fullpath
app.restart(gc)
}
// @Summary Restore a backup file uploaded by the user.
// @Param file formData file true ".bak file"
// @Router /backups/restore [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
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)
respondBool(400, false, gc)
return
}
app.debug.Printf("Got uploaded file \"%s\"\n", 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)
LOADBAK = fullpath
app.restart(gc)
}

View File

@@ -45,14 +45,24 @@ func (app *appContext) checkInvites() {
app.storage.SetInvitesKey(data.Code, data)
}
if data.IsReferral {
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
continue
}
expiry := data.ValidTill
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)
@@ -85,6 +95,14 @@ func (app *appContext) checkInvites() {
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(),
})
}
}
@@ -128,14 +146,35 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
wait.Wait()
}
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
}
}
match = false
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
} else if used {
del := false
newInv := inv
if newInv.RemainingUses == 1 {
del = true
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
@@ -192,7 +231,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", invite.Code)
if discordEnabled && !strings.Contains(req.SendTo, "@") {
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)
@@ -236,6 +275,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
}
}
app.storage.SetInvitesKey(invite.Code, invite)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreateInvite,
UserID: "",
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -429,10 +480,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
})
app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc)
return

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -35,8 +36,8 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("Login").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("Page").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
}
filter := gc.Query("filter")
@@ -677,7 +678,18 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetDiscordKey(req.JellyfinID, user)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
@@ -697,6 +709,16 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
return
} */
app.storage.DeleteDiscordKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -715,6 +737,16 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
return
} */
app.storage.DeleteTelegramKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -733,5 +765,15 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
return
} */
app.storage.DeleteMatrixKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
respondBool(200, true, gc)
}

View File

@@ -130,15 +130,17 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
// @Produce json
// @Param profile path string true "name of profile to enable referrals for."
// @Param invite path string true "invite code to create referral template from."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/referral/{profile}/{invite} [post]
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
respond(400, "Invalid invite code", gc)
@@ -154,9 +156,15 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
// Generate new code for referral template
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.UseReferralExpiry = useExpiry
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
// inv.ReferrerJellyfinID = ...

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
@@ -207,6 +208,16 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
@@ -359,6 +370,16 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
dcUser.Contact = existingUser.Contact
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -397,6 +418,16 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
tgUser.Contact = existingUser.Contact
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -468,6 +499,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
}
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
}
@@ -480,6 +521,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -491,6 +542,16 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -502,6 +563,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
respondBool(200, true, gc)
}
@@ -519,6 +590,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true
})
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
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")
@@ -529,7 +603,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR
var err error
jfUser, ok := app.ReverseUserSearch(address)
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf("Ignoring PWR request: User not found")
@@ -620,6 +694,15 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityChangePassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
@@ -666,21 +749,37 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil {
if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf("Ignoring referral request, couldn't find template.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if inv.UseReferralExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = gc.GetString("jfId")
app.storage.SetInvitesKey(inv.Code, inv)
} else if time.Now().After(inv.ValidTill) {
// 3. We found an invite for us, but it's expired.
// We delete it from storage, and put it back with a fresh code and expiry.
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
}
app.debug.Printf("Ignoring referral request, expired.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
@@ -691,5 +790,6 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
})
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
@@ -45,6 +46,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
return
}
id := user.ID
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
})
profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" {
if p, ok := app.storage.GetProfileKey(req.Profile); ok {
@@ -303,6 +315,24 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
}
id := user.ID
// Record activity
sourceType := ActivityAnon
source := ""
if invite.ReferrerJellyfinID != "" {
sourceType = ActivityUser
source = invite.ReferrerJellyfinID
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: id,
SourceType: sourceType,
Source: source,
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
})
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
@@ -337,6 +367,19 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
emailStore.ReferralTemplateKey = profile.ReferralTemplateKey
// Store here, just incase email are disabled (whether this is even possible, i don't know)
app.storage.SetEmailsKey(id, emailStore)
// If UseReferralExpiry is enabled, create the ref now so the clock starts ticking
refInv := Invite{}
err = app.storage.db.Get(profile.ReferralTemplateKey, &refInv)
if refInv.UseReferralExpiry {
refInv.Code = GenerateInviteCode()
expiryDelta := refInv.ValidTill.Sub(refInv.Created)
refInv.Created = time.Now()
refInv.ValidTill = refInv.Created.Add(expiryDelta)
refInv.IsReferral = true
refInv.ReferrerJellyfinID = id
app.storage.SetInvitesKey(refInv.Code, refInv)
}
}
}
// if app.config.Section("password_resets").Key("enabled").MustBool(false) {
@@ -353,6 +396,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if app.storage.deprecatedDiscord == nil {
app.storage.deprecatedDiscord = discordStore{}
}
// Note we don't log an activity here, since it's part of creating a user.
app.storage.SetDiscordKey(user.ID, discordUser)
delete(app.discord.verifiedTokens, req.DiscordPIN)
}
@@ -539,6 +583,10 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
sendMail = false
}
}
activityType := ActivityDisabled
if req.Enabled {
activityType = ActivityEnabled
}
for _, userID := range req.Users {
user, status, err := app.jf.UserByID(userID, false)
if status != 200 || err != nil {
@@ -553,6 +601,16 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err)
continue
}
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: activityType,
UserID: userID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Time: time.Now(),
})
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
app.err.Printf("Failed to send account enabled/disabled email: %v", err)
@@ -605,6 +663,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
}
}
}
username := ""
if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil {
username = user.Name
}
status, err := app.jf.DeleteUser(userID)
if !(status == 200 || status == 204) || err != nil {
msg := fmt.Sprintf("%d: %v", status, err)
@@ -614,6 +678,17 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] += msg
}
}
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeletion,
UserID: userID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
})
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
app.err.Printf("Failed to send account deletion email: %v", err)
@@ -632,7 +707,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Extend time before the user(s) expiry, or create and expiry if it doesn't exist.
// @Summary Extend time before the user(s) expiry, or create an expiry if it doesn't exist.
// @Produce json
// @Param extendExpiryDTO body extendExpiryDTO true "Extend expiry object"
// @Success 200 {object} boolResponse
@@ -644,7 +719,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
var req extendExpiryDTO
gc.BindJSON(&req)
app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users))
if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 {
if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 && req.Timestamp <= 0 {
respondBool(400, false, gc)
return
}
@@ -656,29 +731,47 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
} else {
app.debug.Printf("Created expiry for \"%s\"", id)
}
expiry := UserExpiry{Expiry: base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)}
expiry := UserExpiry{}
if req.Timestamp != 0 {
expiry.Expiry = time.Unix(req.Timestamp, 0)
} else {
expiry.Expiry = base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
}
app.storage.SetUserExpiryKey(id, expiry)
}
respondBool(204, true, gc)
}
// @Summary Remove an expiry from a user's account.
// @Produce json
// @Param id path string true "id of user to extend expiry of."
// @Success 200 {object} boolResponse
// @Router /users/{id}/expiry [delete]
// @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Enable referrals for the given user(s) based on the rules set in the given invite code, or profile.
// @Produce json
// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users"
// @Param mode path string true "mode of template sourcing from 'invite' or 'profile'."
// @Param source path string true "invite code or profile name, depending on what mode is."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/referral/{mode}/{source} [post]
// @Router /users/referral/{mode}/{source}/{useExpiry} [post]
// @Security Bearer
// @tags Users
func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
var req EnableDisableReferralDTO
gc.BindJSON(&req)
mode := gc.Param("mode")
source := gc.Param("source")
source := gc.Param("source")
useExpiry := gc.Param("useExpiry") == "with-expiry"
baseInv := Invite{}
if mode == "profile" {
profile, ok := app.storage.GetProfileKey(source)
@@ -706,10 +799,16 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
// 2. Generate referral invite.
inv := baseInv
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = u
inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv)
}
}
@@ -1097,6 +1196,20 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
emailStore.Addr = address
app.storage.SetEmailsKey(id, emailStore)
activityType := ActivityContactLinked
if address == "" {
activityType = ActivityContactUnlinked
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: activityType,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {

11
api.go
View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -157,6 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
username = resp.UsersReset[0]
}
var user mediabrowser.User
var status int
var err error
@@ -170,6 +172,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityResetPassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
prevPassword := req.PIN
if isInternal {
prevPassword = ""

View File

@@ -23,6 +23,7 @@ func (app *appContext) loadArgs(firstCall bool) {
HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF {
os.Setenv("PPROF", "1")
}
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
}
if os.Getenv("SWAGGER") == "1" {

180
backups.go Normal file
View File

@@ -0,0 +1,180 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
BACKUP_PREFIX = "jfa-go-db-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type BackupList struct {
files []os.DirEntry
dates []time.Time
count int
}
func (bl BackupList) Len() int { return len(bl.files) }
func (bl BackupList) Swap(i, j int) {
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
}
func (bl BackupList) Less(i, j int) bool {
// Push non-backup files to the end of the array,
// Since they didn't have a date parsed.
if bl.dates[i].IsZero() {
return false
}
if bl.dates[j].IsZero() {
return true
}
// Sort by oldest first
return bl.dates[j].After(bl.dates[i])
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
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)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
backups.count = 0
for i, item := range items {
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
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)
continue
}
backups.dates[i] = t
backups.count++
}
return backups
}
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
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)
return
}
}
}
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)
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)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
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.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)
}
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.info.Printf("Restored backup \"%s\".", LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *housekeepingDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
}
return &daemon
}

View File

@@ -76,6 +76,10 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
@@ -108,6 +112,10 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
@@ -118,6 +126,20 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
app.MustSetValue("invite_emails", "url_base", url2)
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
fmt.Println("SETALLTRUE")
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)

View File

@@ -515,6 +515,31 @@
}
}
},
"activity_log": {
"order": [],
"meta": {
"name": "Activity Log",
"description": "Settings for data retention of the activity log."
},
"settings": {
"keep_n_records": {
"name": "Number of records to keep",
"required": false,
"requires_restart": true,
"type": "number",
"value": 1000,
"description": "How many of the most recent activities to keep. Set to 0 to disable."
},
"delete_after_days": {
"name": "Delete activities older than (days):",
"required": false,
"requires_restart": true,
"type": "number",
"value": 90,
"description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable."
}
}
},
"captcha": {
"order": [],
"meta": {
@@ -604,6 +629,7 @@
"name": "Show Link on Admin Login page",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Whether or not to show a link to the \"My Account\" page on the admin login screen, to direct lost users."
@@ -612,6 +638,7 @@
"name": "User Referrals",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Users are given their own \"invite\" to send to others."
@@ -623,6 +650,41 @@
"depends_true": "referrals",
"required": "false",
"description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings."
},
"allow_pwr_username": {
"name": "Allow PWR with username",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their username."
},
"allow_pwr_email": {
"name": "Allow PWR with email address",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their email address."
},
"allow_pwr_contact_method": {
"name": "Allow PWR with Discord/Telegram/Matrix",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
},
"pwr_note": {
"name": "PWR Methods",
"type": "note",
"depends_true": "enabled",
"value": "",
"required": "false",
"description": "Select at least one PWR initiation method. If none are selected, all will be enabled."
}
}
},
@@ -918,6 +980,22 @@
"type": "bool",
"value": true,
"description": "Warning, disabling this makes you much more vulnerable to man-in-the-middle attacks"
},
"auth_type": {
"name": "Authentication type",
"required": false,
"requires_restart": false,
"advanced": false,
"type": "select",
"options": [
["0", "Plain"],
["1", "Login"],
["2", "CRAM-MD5"],
["3", "None"],
["4", "Auto"]
],
"value": 4,
"description": "SMTP authentication method"
}
}
},
@@ -1479,6 +1557,48 @@
}
}
},
"backups": {
"order": [],
"meta": {
"name": "Backups",
"description": "Settings for database backups. Press the \"Backups\" button above to create, download and restore backups."
},
"settings": {
"enabled": {
"name": "Scheduled Backups",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable to generate database backups on a schedule."
},
"path": {
"name": "Backup Path",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Path to directory to store backups in. defaults to <data_directory>/backups."
},
"every_n_minutes": {
"name": "Backup frequency (Minutes)",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "number",
"value": 1440,
"description": "Backup after this many minutes has passed since the last. Resets every restart."
},
"keep_n_backups": {
"name": "Number of backups to keep",
"required": false,
"requires_restart": true,
"type": "number",
"value": 20,
"description": "Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one."
}
}
},
"welcome_email": {
"order": [],
"meta": {

View File

@@ -3,6 +3,10 @@
color: rgba(0, 0, 0, 0) !important;
}
.loader.rel {
position: relative;
}
.loader .dot {
--diameter: 0.5rem;
--radius: calc(var(--diameter) / 2);
@@ -15,6 +19,12 @@
left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
}
.loader.rel .dot {
position: absolute;
top: 50%;
}
.loader.loader-sm .dot {
--deviation: 10%;
}

View File

@@ -1,6 +1,12 @@
package main
import "time"
import (
"time"
"github.com/dgraph-io/badger/v3"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
// clearEmails removes stored emails for users which no longer exist.
// meant to be called with other such housekeeping functions, so assumes
@@ -9,11 +15,14 @@ func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses")
emails := app.storage.GetEmails()
for _, email := range emails {
_, status, err := app.jf.UserByID(email.JellyfinID, false)
if status == 200 && err == nil {
_, _, err := app.jf.UserByID(email.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteEmailsKey(email.JellyfinID)
default:
continue
}
app.storage.DeleteEmailsKey(email.JellyfinID)
}
}
@@ -22,11 +31,14 @@ func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
_, status, err := app.jf.UserByID(discordUser.JellyfinID, false)
if status == 200 && err == nil {
_, _, 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:
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
continue
}
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
}
}
@@ -35,11 +47,14 @@ func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, status, err := app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
_, _, err := app.jf.UserByID(matrixUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
default:
continue
}
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
}
}
@@ -48,11 +63,45 @@ func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, status, err := app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
_, _, err := app.jf.UserByID(telegramUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
default:
continue
}
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
}
}
func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
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)
err := error(nil)
errorSource := 0
if maxAgeDays != 0 {
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
}
if err == nil && keepCount != 0 {
// app.debug.Printf("Keeping %d records", keepCount)
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
if err != nil {
errorSource = 1
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
list := []Activity{}
if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
} else {
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
}
for _, record := range list {
app.storage.DeleteActivityKey(record.ID)
}
}
}
@@ -75,10 +124,13 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
}}
daemon.jobs = []func(app *appContext){
func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
}
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)

View File

@@ -92,7 +92,8 @@ func NewEmailer(app *appContext) *Emailer {
if app.proxyEnabled {
proxyConf = &app.proxyConfig
}
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), proxyConf)
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)
}
@@ -118,7 +119,7 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, proxy *easyproxy.ProxyConfig) (err error) {
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
if sslTLS {
@@ -127,7 +128,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
sender.Client.Encryption = sMail.EncryptionSTARTTLS
}
if username != "" || password != "" {
sender.Client.Authentication = sMail.AuthLogin
sender.Client.Authentication = authType
sender.Client.Username = username
sender.Client.Password = password
}
@@ -898,55 +899,65 @@ func (app *appContext) getAddressOrName(jfID string) string {
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string) (user mediabrowser.User, ok bool) {
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
var status int
var err error = nil
if matchUsername {
user, status, err = app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
}
}
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchEmail {
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
// Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db.
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}

View File

@@ -130,6 +130,11 @@
<div class="select ~neutral @low mb-4 unfocused">
<select id="enable-referrals-user-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-user-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@@ -144,6 +149,11 @@
<div class="select ~neutral @low mb-4 mt-2">
<select id="enable-referrals-profile-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-profile-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@@ -171,39 +181,49 @@
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
</div>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<option>0</option>
</select>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
</div>
@@ -308,6 +328,47 @@
</div>
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
<ul>
<li>{{ .strings.backupsCopy }}</li>
<li>{{ .strings.backupsFormatNote }}</li>
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
</ul>
</div>
<div class="flex flex-row flex-wrap my-2">
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
<tbody id="backups-list"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
@@ -475,6 +536,7 @@
<div>
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
</div>
</header>
@@ -668,7 +730,15 @@
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
@@ -719,6 +789,57 @@
</div>
</div>
</div>
<div id="tab-activity" class="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row justify-between py-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm">
<span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records" class="mx-2"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
</div>
<div class="flex justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
</div>
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div class="flex-expand">
@@ -730,7 +851,8 @@
</label>
</div>
<div>
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div>

View File

@@ -122,6 +122,32 @@
</select>
</div>
</label>
<span class="heading">{{ .lang.Proxy.title }}</span>
<p class="content my-2" id="proxy-description">{{ .lang.Proxy.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span>{{ .lang.Proxy.protocol }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="advanced-proxy_protocol">
<option value="http">HTTP</option>
<option value="socks">SOCKS5</option>
</select>
</div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Proxy.address }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_address">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_user">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.password }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_password">
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">

View File

@@ -48,11 +48,17 @@
</div>
{{ if .pwrEnabled }}
<div id="modal-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLink }}
{{ .strings.resetPasswordThroughLinkStart }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}
@@ -155,7 +161,7 @@
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col">{{ .strings.referralsDescription }}</aside>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="row flex-expand">
<div class="user-referrals-info"></div>
<div class="grid my-2">

View File

@@ -116,6 +116,7 @@ type setupLang struct {
EndPage langSection `json:"endPage"`
General langSection `json:"general"`
Updates langSection `json:"updates"`
Proxy langSection `json:"proxy"`
Language langSection `json:"language"`
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`

229
lang/admin/cs-cz.json Normal file
View File

@@ -0,0 +1,229 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"invites": "Pozvánky",
"invite": "Pozvat",
"accounts": "Účty",
"settings": "Nastavení",
"inviteMonths": "Měsíce",
"inviteDays": "Dny",
"inviteHours": "Hodiny",
"inviteMinutes": "Minut",
"inviteNumberOfUses": "Počet použití",
"inviteDuration": "Doba trvání pozvánky",
"warning": "Varování",
"inviteInfiniteUsesWarning": "pozvánky s nekonečným využitím mohou být zneužity",
"inviteSendToEmail": "Poslat komu",
"create": "Vytvořit",
"apply": "Aplikovat",
"select": "Vybrat",
"name": "Název",
"date": "Datum",
"setExpiry": "Nastavit expiraci",
"updates": "Aktualizace",
"update": "Aktualizace",
"download": "Stažení",
"search": "Vyhledávání",
"advancedSettings": "Pokročilé nastavení",
"lastActiveTime": "Naposled aktivní",
"from": "Z",
"after": "Po",
"before": "Před",
"user": "Uživatel",
"userExpiry": "Vypršení platnosti",
"userExpiryDescription": "Zadanou dobu po každé registraci jfa-go smaže/zakáže účet. Toto chování můžete změnit v nastavení.",
"aboutProgram": "O",
"version": "Verze",
"commitNoun": "Zavázat se",
"newUser": "Nový uživatel",
"profile": "Profil",
"unknown": "Neznámý",
"label": "Štítek",
"userLabel": "Uživatelský štítek",
"userLabelDescription": "Štítek, který se použije pro uživatele vytvořené pomocí této pozvánky.",
"logs": "Protokoly",
"announce": "Oznámit",
"templates": "Šablony",
"subject": "Předmět",
"message": "Zpráva",
"variables": "Proměnné",
"conditionals": "Podmínky",
"preview": "Náhled",
"reset": "Resetovat",
"donate": "Darovat",
"unlink": "Odpojit účet",
"sendPWR": "Odeslat resetování hesla",
"contactThrough": "Kontakt přes:",
"extendExpiry": "Prodloužit platnost",
"sendPWRManual": "Uživatel {n} nemá žádný způsob kontaktu, stisknutím tlačítka Kopírovat získáte odkaz, který mu chcete poslat.",
"sendPWRSuccess": "Odkaz pro resetování hesla byl odeslán.",
"sendPWRSuccessManual": "Pokud jej uživatel neobdržel, stisknutím tlačítka Kopírovat získáte odkaz, který mu můžete ručně odeslat.",
"sendPWRValidFor": "Odkaz je platný 30m.",
"customizeMessages": "Přizpůsobit zprávy",
"customizeMessagesDescription": "Pokud nechcete používat šablony zpráv jfa-go, můžete si vytvořit vlastní pomocí Markdown.",
"markdownSupported": "Markdown je podporován.",
"modifySettings": "Upravit nastavení",
"modifySettingsDescription": "Použít nastavení ze stávajícího profilu nebo je získat přímo od uživatele.",
"enableReferrals": "Povolit doporučení",
"disableReferrals": "Zakázat doporučení",
"enableReferralsDescription": "Poskytněte uživatelům osobní doporučující odkaz podobný pozvánce, kterou můžete poslat přátelům/rodině. Lze je získat ze šablony doporučení v profilu nebo z existující pozvánky.",
"enableReferralsProfileDescription": "Poskytněte uživatelům vytvořeným pomocí tohoto profilu osobní doporučující odkaz podobný pozvánce, aby jej poslali přátelům/rodině. Vytvořte pozvánku s požadovaným nastavením a poté ji vyberte zde. Každé doporučení pak bude založeno na této pozvánce. Po dokončení můžete pozvánku smazat.",
"applyHomescreenLayout": "Použít rozložení domovské obrazovky",
"sendDeleteNotificationEmail": "Odeslat zprávu s upozorněním",
"sendDeleteNotifiationExample": "Váš účet byl smazán.",
"settingsRestart": "Restartovat",
"settingsRestarting": "Restartování…",
"settingsRestartRequired": "Je potřeba restart",
"settingsRestartRequiredDescription": "K použití některých změn, které jste změnili, je nutný restart. Restartovat hned nebo později?",
"settingsApplyRestartLater": "Použít, restartovat později",
"settingsApplyRestartNow": "Použít a restartovat",
"settingsApplied": "Nastavení byla použita.",
"settingsRefreshPage": "Obnovte stránku během několika sekund.",
"settingsRequiredOrRestartMessage": "Poznámka: {n} označuje povinné pole, {n} označuje, že změny vyžadují restart.",
"settingsSave": "Uložit",
"ombiProfile": "Ombi uživatelský profil",
"ombiUserDefaultsDescription": "Vytvořte uživatele Ombi a nakonfigurujte jej, poté jej vyberte níže. Když je tento profil vybrán, jeho nastavení/oprávnění budou uložena a použita pro nové uživatele Ombi vytvořené jfa-go.",
"userProfiles": "Uživatelské profily",
"userProfilesDescription": "Profily se použijí pro uživatele, když si vytvoří účet. Profil zahrnuje přístupová práva ke knihovně a rozvržení domovské obrazovky.",
"userProfilesIsDefault": "Výchozí",
"userProfilesLibraries": "Knihovny",
"addProfile": "Přidat profil",
"addProfileDescription": "Vytvořte uživatele Jellyfin a nakonfigurujte jej, poté jej vyberte níže. Když se tento profil použije na pozvánku, vytvoří se noví uživatelé s nastavením.",
"addProfileNameOf": "Jméno profilu",
"addProfileStoreHomescreenLayout": "Uložit rozložení domovské obrazovky",
"inviteNoUsersCreated": "Ještě žádný!",
"inviteUsersCreated": "Vytvoření uživatelé",
"inviteNoProfile": "Žádný profil",
"inviteDateCreated": "Vytvořeno",
"inviteNoInvites": "Žádný",
"inviteExpiresInTime": "Platnost vyprší za {n}",
"notifyEvent": "Upozornit na:",
"notifyInviteExpiry": "Při vypršení platnosti",
"notifyUserCreation": "Při vytvoření uživatele",
"sendPIN": "Požádejte uživatele, aby robotovi zaslal níže uvedený PIN.",
"searchDiscordUser": "Začněte psát uživatelské jméno Discord a vyhledejte uživatele.",
"findDiscordUser": "Najít uživatele Discordu",
"linkMatrixDescription": "Zadejte uživatelské jméno a heslo uživatele, který chcete použít jako robot. Po odeslání se aplikace restartuje.",
"matrixHomeServer": "Adresa domovského serveru",
"saveAsTemplate": "Uložit jako šablonu",
"deleteTemplate": "Smazat šablonu",
"templateEnterName": "Zadejte název pro uložení této šablony.",
"accessJFA": "Přístup k jfa-go",
"accessJFASettings": "Nelze změnit, protože v Nastavení > Obecné bylo nastaveno \"Pouze správce\" nebo \"Povolit vše\".",
"sortingBy": "Řazení podle",
"filters": "Filtry",
"clickToRemoveFilter": "Kliknutím tento filtr odstraníte.",
"clearSearch": "Vymazat vyhledávání",
"actions": "Akce",
"searchOptions": "Možnosti hledání",
"matchText": "Shoda textu",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Uživatelská stránka: Přihlášení",
"userPagePage": "Uživatelská stránka: Stránka",
"buildTime": "Čas sestavení",
"builtBy": "Postaven",
"loginNotAdmin": "Nejste správce?"
},
"notifications": {
"changedEmailAddress": "Změněna e-mailová adresa uživatele {n}.",
"userCreated": "Uživatel {n} byl vytvořen.",
"createProfile": "Vytvořen profil {n}.",
"saveSettings": "Nastavení byla uložena",
"saveEmail": "Email byl uložen.",
"sentAnnouncement": "Oznámení odesláno.",
"savedAnnouncement": "Oznámení uloženo.",
"setOmbiProfile": "Uložený ombi profil.",
"updateApplied": "Aktualizace byla použita, restartujte prosím.",
"updateAppliedRefresh": "Aktualizace byla použita, obnovte ji.",
"telegramVerified": "Účet telegramu ověřen.",
"accountConnected": "Účet připojen.",
"referralsEnabled": "Doporučení povolena.",
"errorSettingsAppliedNoHomescreenLayout": "Nastavení byla použita, ale použití rozvržení domovské obrazovky mohlo selhat.",
"errorHomescreenAppliedNoSettings": "Bylo použito rozvržení domovské obrazovky, ale použití nastavení mohlo selhat.",
"errorSettingsFailed": "Aplikace se nezdařila.",
"errorSaveEmail": "Uložení e-mailu se nezdařilo.",
"errorBlankFields": "Pole zůstala prázdná",
"errorDeleteProfile": "Smazání profilu {n} se nezdařilo",
"errorLoadProfiles": "Načtení profilů se nezdařilo.",
"errorCreateProfile": "Nepodařilo se vytvořit profil {n}",
"errorSetDefaultProfile": "Nepodařilo se nastavit výchozí profil.",
"errorLoadUsers": "Uživatele se nepodařilo načíst.",
"errorLoadSettings": "Nastavení se nepodařilo načíst.",
"errorSetOmbiProfile": "Uložení profilu ombi se nezdařilo.",
"errorLoadOmbiUsers": "Uživatele ombi se nepodařilo načíst.",
"errorChangedEmailAddress": "E-mailovou adresu uživatele {n} se nepodařilo změnit.",
"errorFailureCheckLogs": "Selhalo (zkontrolujte konzolu/protokoly)",
"errorPartialFailureCheckLogs": "Částečná chyba (zkontrolujte konzolu/protokoly)",
"errorUserCreated": "Nepodařilo se vytvořit uživatele {n}.",
"errorSendWelcomeEmail": "Nepodařilo se odeslat uvítací zprávu (zkontrolujte konzolu/protokoly)",
"errorApplyUpdate": "Aktualizaci se nepodařilo použít, zkuste to ručně.",
"errorCheckUpdate": "Kontrola aktualizace se nezdařila.",
"errorNoReferralTemplate": "Profil neobsahuje šablonu doporučení, přidejte si ji v nastavení.",
"updateAvailable": "Je k dispozici nová aktualizace, zkontrolujte nastavení.",
"noUpdatesAvailable": "Nejsou k dispozici žádné nové aktualizace."
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Upravit nastavení pro {n} uživatele",
"plural": "Upravit nastavení pro {n} uživatelů"
},
"enableReferralsFor": {
"singular": "Povolit doporučení pro {n} uživatele",
"plural": "Povolit doporučení pro {n} uživatelů"
},
"deleteNUsers": {
"singular": "Smazat {n} uživatele",
"plural": "Smazat {n} uživatelů"
},
"disableUsers": {
"singular": "Zakázat {n} uživatele",
"plural": "Zakázat {n} uživatelů"
},
"reEnableUsers": {
"singular": "Znovu povolte {n} uživatele",
"plural": "Znovu povolit {n} uživatelů"
},
"addUser": {
"singular": "Přidat uživatele",
"plural": "Přidat uživatele"
},
"deleteUser": {
"singular": "Smazat uživatele",
"plural": "Smazat uživatele"
},
"deletedUser": {
"singular": "Smazán {n} uživatel.",
"plural": "Smazaní {n} uživatelé."
},
"disabledUser": {
"singular": "Deaktivován {n} uživatel.",
"plural": "Zakázaných {n} uživatelů."
},
"enabledUser": {
"singular": "Povoleno {n} uživatele.",
"plural": "Povolených {n} uživatelů."
},
"announceTo": {
"singular": "Oznámeno {n} uživateli",
"plural": "Oznámit {n} uživatelům"
},
"appliedSettings": {
"singular": "Nastavení byla použita na {n} uživatele.",
"plural": "Nastavení byla použita na {n} uživatelů."
},
"extendExpiry": {
"singular": "Prodloužit platnost pro {n} uživatele",
"plural": "Prodloužit platnost pro {n} uživatelů"
},
"setExpiry": {
"singular": "Nastavit vypršení platnosti pro {n} uživatele",
"plural": "Nastavit vypršení platnosti pro {n} uživatelů"
},
"extendedExpiry": {
"singular": "Prodloužená platnost pro {n} uživatele.",
"plural": "Prodloužená platnost pro {n} uživatelů."
}
}
}

View File

@@ -37,9 +37,9 @@
"profile": "Profil",
"unknown": "Ukendt",
"label": "Etiket",
"announce": "Annoncere",
"announce": "Meddelelse",
"subject": "Emne",
"message": "Meddelelse",
"message": "Besked",
"variables": "Variabler",
"conditionals": "Betingelser",
"preview": "Eksempel",
@@ -47,13 +47,13 @@
"donate": "Doner",
"contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb",
"customizeMessages": "Tilpas Meddelelser",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"customizeMessages": "Tilpas Beskeder",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"markdownSupported": "Markdown understøttes.",
"modifySettings": "Rediger indstillinger",
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
"applyHomescreenLayout": "Anvend startskærmens layout",
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
"sendDeleteNotificationEmail": "Send notifikations besked",
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
"settingsRestart": "Genstart",
"settingsRestarting": "Genstarter…",
@@ -102,7 +102,35 @@
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
"accessJFA": "Få adgang til jfa-go",
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
"after": "Efter",
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
"userPageLogin": "Brugerside: Login",
"buildTime": "Bygnings Tid",
"invite": "inviter",
"loginNotAdmin": "Ikke en Admin?",
"userLabel": "Brugeretiket",
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
"sortingBy": "Sortering Efter",
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
"clearSearch": "Ryd søgning",
"actions": "Handlinger",
"unlink": "Fjern linket til konto",
"enableReferrals": "Aktiver henvisninger",
"disableReferrals": "Deaktiver henvisninger",
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
"before": "Før",
"noResultsFound": "Ingen Resultater Fundet",
"settingsDependsOn": "{setting}: afhænger af {dependency}",
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
"filters": "Filtre",
"searchOptions": "Søge Indstillinger",
"matchText": "Match Tekst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Brugerside: Side",
"builtBy": "Bygget Af"
},
"notifications": {
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
@@ -133,14 +161,16 @@
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
"savedAnnouncement": "Meddelelse gemt.",
"setOmbiProfile": "Gemt i ombi profilen.",
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
"referralsEnabled": "Henvisninger aktiveret.",
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -180,8 +210,8 @@
"plural": "Aktiveret {n} brugere."
},
"announceTo": {
"singular": "Annoncer til {n} bruger",
"plural": "Annoncer til {n} brugere"
"singular": "Send Meddelelse til {n} bruger",
"plural": "Send Meddelelse til {n} brugere"
},
"appliedSettings": {
"singular": "Anvendte indstillinger til {n} bruger.",
@@ -198,6 +228,10 @@
"setExpiry": {
"singular": "Indstil udløb for {n} bruger",
"plural": "Indstil udløb for {n} brugere"
},
"enableReferralsFor": {
"singular": "Aktiver Henvisninger for {n} bruger",
"plural": "Aktiver Henvisninger for {n} brugere"
}
}
}
}

View File

@@ -6,6 +6,7 @@
"invites": "Invites",
"invite": "Invite",
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
@@ -21,7 +22,6 @@
"select": "Select",
"name": "Name",
"date": "Date",
"setExpiry": "Set expiry",
"updates": "Updates",
"update": "Update",
"download": "Download",
@@ -54,10 +54,17 @@
"reset": "Reset",
"donate": "Donate",
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:",
"extendExpiry": "Extend expiry",
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@@ -71,6 +78,8 @@
"disableReferrals": "Disable Referrals",
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
@@ -118,6 +127,7 @@
"accessJFA": "Access jfa-go",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
"sortingBy": "Sorting By",
"sortDirection": "Sort Direction",
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
@@ -129,9 +139,64 @@
"userPagePage": "User Page: Page",
"buildTime": "Build Time",
"builtBy": "Built By",
"loginNotAdmin": "Not an Admin?"
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}",
"accountUnlinked": "{contactMethod} removed: {user}",
"accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}",
"accountDeleted": "Account deleted: {user}",
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {invite}",
"inviteExpired": "Invite expired: {invite}",
"fromInvite": "From Invite",
"byAdmin": "By Admin",
"byUser": "By User",
"byJfaGo": "By jfa-go",
"activityID": "Activity ID",
"title": "Title",
"usersMentioned": "User mentioned",
"actor": "Actor",
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled",
"accountEnabledFilter": "Account Enabled",
"contactLinkedFilter": "Contact Linked",
"contactUnlinkedFilter": "Contact Unlinked",
"passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired",
"loadMore": "Load More",
"loadAll": "Load All",
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
"backupDownloadRestore": "Download / Restore",
"backupUpload": "Upload & Restore Backup",
"backupDownload": "Download Backup",
"backupRestore": "Restore Backup",
"backupNow": "Backup Now",
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
@@ -145,6 +210,9 @@
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorInviteNoLongerExists": "Invite no longer exists.",
"errorInviteNotFound": "Invite not found.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.",
@@ -166,6 +234,8 @@
"errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
},

View File

@@ -102,7 +102,35 @@
"ombiProfile": "Ombi gebruikersprofiel",
"logs": "Logs",
"accessJFA": "Toegang tot jfa-go",
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.",
"noResultsFound": "Geen resultaten gevonden",
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
"builtBy": "Build door",
"buildTime": "Build moment",
"userPageLogin": "Gebruikerspagina: Inloggen",
"loginNotAdmin": "Geen beheerder?",
"before": "Voor",
"unlink": "Ontkoppel account",
"after": "Na",
"invite": "Uitnodiging",
"userLabel": "Gebruikerslabel",
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
"enableReferrals": "Verwijzingen inschakelen",
"disableReferrals": "Verwijzingen uitschakelen",
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
"settingsDependsOn": "{setting}: hangt af van {dependency}",
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
"sortingBy": "Sorteren naar",
"filters": "Filters",
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
"clearSearch": "Zoekopdracht verwijderen",
"actions": "Acties",
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina"
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@@ -140,7 +168,9 @@
"accountConnected": "Account gekoppeld.",
"savedAnnouncement": "Aankondiging opgeslagen.",
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -198,6 +228,10 @@
"setExpiry": {
"singular": "Stel verloop in voor {n} gebruiker",
"plural": "Stel verloop in voor {n} gebruikers"
},
"enableReferralsFor": {
"plural": "Verwijzingen activeren voor {1} gebruikers",
"singular": "Verwijzingen activeren voor {1} gebruiker"
}
}
}
}

67
lang/common/cs-cz.json Normal file
View File

@@ -0,0 +1,67 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"username": "Uživatelské jméno",
"password": "Heslo",
"emailAddress": "Emailová adresa",
"name": "Název",
"submit": "Odeslat",
"send": "Poslat",
"success": "Hotovo",
"continue": "Pokračovat",
"error": "Chyba",
"copy": "Kopírovat",
"copied": "Zkopírováno",
"time24h": "Čas 24 hodin",
"time12h": "Čas 12 hodin",
"linkTelegram": "Link Telegram",
"contactEmail": "Kontakt přes Email",
"contactTelegram": "Kontakt přes Telegram",
"linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Kontakt přes Discord",
"theme": "Téma",
"refresh": "Obnovit",
"required": "Požadované",
"login": "Přihlásit se",
"logout": "Odhlásit se",
"admin": "Admin",
"enabled": "Povoleno",
"disabled": "Zakázáno",
"reEnable": "Znovu povolit",
"disable": "Zakázat",
"contactMethods": "Kontaktní metody",
"accountStatus": "Stav účtu",
"notSet": "Nenastaveno",
"expiry": "Uplynutí",
"add": "Přidat",
"edit": "Upravit",
"delete": "Vymazat",
"myAccount": "Můj účet",
"referrals": "Doporučení",
"inviteRemainingUses": "Zbývající použití"
},
"notifications": {
"errorLoginBlank": "Uživatelské jméno a/nebo heslo zůstalo prázdné.",
"errorConnection": "Nelze se připojit k jfa-go.",
"errorUnknown": "Neznámá chyba.",
"error401Unauthorized": "Neoprávněný. Zkuste stránku obnovit.",
"errorSaveSettings": "Nastavení se nepodařilo uložit."
},
"quantityStrings": {
"year": {
"singular": "{n} rok",
"plural": "{n} let"
},
"month": {
"singular": "{n} měsíc",
"plural": "{n} měsíců"
},
"day": {
"singular": "{n} den",
"plural": "{n} dní"
}
}
}

View File

@@ -5,7 +5,7 @@
"strings": {
"username": "Brugernavn",
"password": "Adgangskode",
"emailAddress": "E-mail Adresse",
"emailAddress": "Email adresse",
"name": "Navn",
"submit": "Indsend",
"send": "Send",
@@ -36,7 +36,12 @@
"add": "Tilføj",
"edit": "Rediger",
"delete": "Slet",
"inviteRemainingUses": "Resterende anvendelser"
"inviteRemainingUses": "Resterende anvendelser",
"referrals": "Henvisninger",
"contactMethods": "Kontakt Metoder",
"accountStatus": "Kontostatus",
"notSet": "Ikke sat",
"myAccount": "Min Konto"
},
"notifications": {
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
@@ -45,5 +50,18 @@
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"singular": "{n} År",
"plural": "{n} År"
},
"month": {
"singular": "{n} Månede",
"plural": "{n} Måneder"
},
"day": {
"singular": "{n} Dag",
"plural": "{n} Dage"
}
}
}

View File

@@ -36,7 +36,12 @@
"add": "Voeg toe",
"edit": "Bewerken",
"delete": "Verwijderen",
"inviteRemainingUses": "Resterend aantal keer te gebruiken"
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
"referrals": "Verwijzingen",
"contactMethods": "Contactmethodes",
"accountStatus": "Account status",
"notSet": "Niet ingesteld",
"myAccount": "Mijn account"
},
"notifications": {
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
@@ -45,5 +50,18 @@
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSaveSettings": "Opslaan van instellingen mislukt."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"singular": "{n} jaar",
"plural": "{n} jaar"
},
"month": {
"singular": "{n} maand",
"plural": "{n} maanden"
},
"day": {
"singular": "{n} dag",
"plural": "{n} dagen"
}
}
}

77
lang/email/cs-cz.json Normal file
View File

@@ -0,0 +1,77 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"ifItWasNotYou": "Pokud jste to nebyl vy, ignorujte to.",
"helloUser": "Ahoj {username},",
"reason": "Důvod"
},
"userCreated": {
"name": "Vytvoření uživatele",
"title": "Upozornění: Uživatel vytvořen",
"aUserWasCreated": "Uživatel byl vytvořen pomocí kódu {code}.",
"time": "Čas",
"notificationNotice": "Poznámka: Zprávy s upozorněním lze přepínat na řídicím panelu správce."
},
"inviteExpiry": {
"name": "Platnost pozvánky",
"title": "Upozornění: Platnost pozvánky vypršela",
"inviteExpired": "Platnost pozvánky vypršela.",
"expiredAt": "Platnost kódu {code} vypršela v {time}.",
"notificationNotice": "Poznámka: Zprávy s upozorněním lze přepínat na řídicím panelu správce."
},
"passwordReset": {
"name": "Resetovat heslo",
"title": "Požadováno resetování hesla - Jellyfin",
"someoneHasRequestedReset": "Někdo nedávno požádal o reset hesla na Jellyfin.",
"ifItWasYou": "Pokud jste to byli vy, zadejte do výzvy níže uvedený kód PIN.",
"ifItWasYouLink": "Pokud jste to byli vy, klikněte na odkaz níže.",
"codeExpiry": "Platnost kódu vyprší {date} v {time} UTC, což je za {expiresInMinutes}.",
"pin": "PIN"
},
"userDeleted": {
"name": "Smazání uživatele",
"title": "Váš účet byl smazán - Jellyfin",
"yourAccountWasDeleted": "Váš účet Jellyfin byl smazán."
},
"userDisabled": {
"name": "Uživatel zakázán",
"title": "Váš účet byl deaktivován - Jellyfin",
"yourAccountWasDisabled": "Váš účet byl deaktivován."
},
"userEnabled": {
"name": "Uživatel povolen",
"title": "Váš účet byl znovu aktivován - Jellyfin",
"yourAccountWasEnabled": "Váš účet byl znovu aktivován."
},
"inviteEmail": {
"name": "Pozvací e-mail",
"title": "Pozvat - Jellyfin",
"hello": "Ahoj",
"youHaveBeenInvited": "Byli jste pozváni do Jellyfinu.",
"toJoin": "Chcete-li se připojit, postupujte podle níže uvedeného odkazu.",
"inviteExpiry": "Platnost této pozvánky vyprší {date} v {time}, což je za {expiresInMinutes}, proto jednejte rychle.",
"linkButton": "Nastavte si účet"
},
"welcomeEmail": {
"name": "Vítejte",
"title": "Vítejte v Jellyfin",
"welcome": "Vítejte v Jellyfin!",
"youCanLoginWith": "Přihlásit se můžete pomocí níže uvedených údajů",
"yourAccountWillExpire": "Platnost vašeho účtu vyprší dne {date}.",
"jellyfinURL": "URL"
},
"emailConfirmation": {
"name": "Potvrzující email",
"title": "Potvrďte svůj email - Jellyfin",
"clickBelow": "Kliknutím na odkaz níže potvrďte svou e-mailovou adresu a začněte používat Jellyfin.",
"confirmEmail": "Potvrdit email"
},
"userExpired": {
"name": "Vypršení platnosti uživatele",
"title": "Platnost vašeho účtu vypršela Jellyfin",
"yourAccountHasExpired": "Platnost vašeho účtu vypršela.",
"contactTheAdmin": "Pro více informací kontaktujte administrátora."
}
}

82
lang/form/cs-cz.json Normal file
View File

@@ -0,0 +1,82 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"pageTitle": "Vytvořte účet Jellyfin",
"createAccountHeader": "Vytvořit účet",
"accountDetails": "Podrobnosti",
"emailAddress": "Email",
"username": "Uživatelské jméno",
"oldPassword": "Staré heslo",
"newPassword": "Nové heslo",
"password": "Heslo",
"reEnterPassword": "Znovu zadejte heslo",
"reEnterPasswordInvalid": "Hesla nejsou stejná.",
"createAccountButton": "Vytvořit účet",
"passwordRequirementsHeader": "Požadavky na heslo",
"successHeader": "Hotovo!",
"confirmationRequired": "Vyžaduje se potvrzení e-mailem",
"confirmationRequiredMessage": "Zkontrolujte prosím svou e-mailovou schránku a ověřte svou adresu.",
"yourAccountIsValidUntil": "Váš účet bude platný do {date}.",
"sendPIN": "Odešlete robotovi níže uvedený PIN a poté se sem vraťte a propojte svůj účet.",
"sendPINDiscord": "Napište {command} do {server_channel} na Discordu a poté odešlete PIN níže.",
"matrixEnterUser": "Zadejte své uživatelské ID, stiskněte Odeslat a bude vám zaslán PIN. Chcete-li pokračovat, zadejte jej zde.",
"welcomeUser": "Vítejte, {user}!",
"addContactMethod": "Přidat metodu kontaktu",
"editContactMethod": "Upravit metodu kontaktu",
"joinTheServer": "Připojte se na server:",
"customMessagePlaceholderHeader": "Přizpůsobte si tuto kartu",
"customMessagePlaceholderContent": "Kliknutím na tlačítko upravit stránku uživatele v nastavení můžete přizpůsobit tuto kartu nebo ji zobrazit na přihlašovací obrazovce a nebojte se, uživatel to nevidí.",
"userPageSuccessMessage": "Podrobnosti o svém účtu můžete později zobrazit a změnit na stránce {myAccount}.",
"resetPassword": "Obnovit heslo",
"resetPasswordThroughJellyfin": "Chcete-li obnovit heslo, navštivte {jfLink} a stiskněte tlačítko \"Zapomenuté heslo\".",
"resetPasswordThroughLink": "Chcete-li obnovit heslo, zadejte své uživatelské jméno, e-mailovou adresu nebo uživatelské jméno propojené kontaktní metody a odešlete. Bude odeslán odkaz pro resetování hesla.",
"resetSent": "Resetování odesláno.",
"resetSentDescription": "Pokud existuje účet s daným uživatelským jménem/způsobem kontaktu, byl prostřednictvím všech dostupných způsobů kontaktu odeslán odkaz pro resetování hesla. Platnost kódu vyprší za 30 minut.",
"changePassword": "Změnit heslo",
"referralsDescription": "Pozvěte přátele a rodinu do Jellyfin pomocí tohoto odkazu. Vraťte se sem pro nový, pokud vyprší.",
"copyReferral": "Kopírovat odkaz",
"invitedBy": "Pozval vás uživatel {user}."
},
"notifications": {
"errorUserExists": "Uživatel již existuje.",
"errorInvalidCode": "Neplatný zvací kód.",
"errorAccountLinked": "Účet se již používá.",
"errorEmailLinked": "Email je již používán.",
"errorTelegramVerification": "Je vyžadováno ověření telegramem.",
"errorDiscordVerification": "Vyžaduje se ověření neshody.",
"errorMatrixVerification": "Vyžaduje se ověření matice.",
"errorInvalidPIN": "PIN je neplatný.",
"errorUnknown": "Neznámá chyba.",
"errorNoEmail": "Email je vyžadován.",
"errorCaptcha": "Captcha je nesprávná.",
"errorPassword": "Zkontrolujte požadavky na heslo.",
"errorNoMatch": "Hesla se neshodují.",
"errorOldPassword": "Staré heslo je nesprávné.",
"passwordChanged": "Heslo změněno.",
"verified": "Účet ověřen."
},
"validationStrings": {
"length": {
"singular": "Musí mít alespoň {n} znak",
"plural": "Musí mít nejméně {n} znaků"
},
"uppercase": {
"singular": "Musí mít alespoň {n} velkých písmen",
"plural": "Musí obsahovat alespoň {n} velkých písmen"
},
"lowercase": {
"singular": "Musí mít alespoň {n} malých písmen",
"plural": "Musí obsahovat alespoň {n} malých písmen"
},
"number": {
"singular": "Musí mít alespoň {n} číslo",
"plural": "Musí mít alespoň {n} čísel"
},
"special": {
"singular": "Musí mít alespoň {n} speciálních znaků",
"plural": "Musí obsahovat alespoň {n} speciálních znaků"
}
}
}

View File

@@ -19,7 +19,25 @@
"yourAccountIsValidUntil": "Din konto er gyldig indtil {date}.",
"sendPIN": "Send nedenstående pinkode til botten, og kom derefter tilbage her for at sammenkoble din konto.",
"sendPINDiscord": "Skriv {command} i {server_channel} på Discord, og send PIN-koden nedenfor.",
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte."
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte.",
"referralsDescription": "Inviter venner og familie til Jellyfin med dette link. Kom tilbage her for et nyt, hvis det udløber.",
"oldPassword": "Gammelt Kodeord",
"newPassword": "Nyt Kodeord",
"welcomeUser": "Velkommen, {user}!",
"addContactMethod": "Tilføj Kontakt Metode",
"editContactMethod": "Rediger Kontakt Metode",
"joinTheServer": "Tilslut dig serveren:",
"customMessagePlaceholderHeader": "Tilpas dette kort",
"customMessagePlaceholderContent": "Klik på knappen Rediger brugersiden i indstillinger for at tilpasse dette kort, eller vis et på login-skærmen, og bare rolig, brugeren kan ikke se dette.",
"userPageSuccessMessage": "Du kan se og ændre detaljer om din konto senere på {myAccount} siden.",
"resetPassword": "Nulstille Kodeord",
"resetPasswordThroughJellyfin": "For at nulstille din adgangskode skal du besøge {jfLink} og trykke på knappen \"Glemt adgangskode\".",
"resetPasswordThroughLink": "For at nulstille din adgangskode skal du indtaste dit brugernavn, din e-mail adresse eller et linket brugernavn til en kontakt metode og indsende. Et link vil blive sendt for at nulstille din adgangskode.",
"resetSent": "Nulstilling Sendt.",
"resetSentDescription": "Hvis der findes en konto med det givne brugernavn/kontakt metode, er et link til nulstilling af adgangskode blevet sendt via alle tilgængelige kontakt metoder. Koden udløber om 30 minutter.",
"changePassword": "Skift Kodeord",
"copyReferral": "Kopier Link",
"invitedBy": "Du blev inviteret af brugeren {user}."
},
"notifications": {
"errorUserExists": "Brugeren eksistere allerede.",
@@ -33,7 +51,11 @@
"errorNoEmail": "E-mail er påkrævet.",
"errorCaptcha": "Forkert Captcha.",
"errorPassword": "Tjek krav til adgangskode.",
"errorNoMatch": "Adgangskoder stemmer ikke overens."
"errorNoMatch": "Adgangskoder stemmer ikke overens.",
"errorEmailLinked": "E-mail er allerede i brug.",
"errorAccountLinked": "Kontoen er allerede i brug.",
"errorOldPassword": "Den gamle adgangskode er forkert.",
"passwordChanged": "Adgangskode Ændret."
},
"validationStrings": {
"length": {
@@ -57,4 +79,4 @@
"plural": "Skal mindst have {n} specialtegn"
}
}
}
}

View File

@@ -32,10 +32,16 @@
"resetPassword": "Reset Password",
"resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.",
"resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.",
"resetPasswordThroughLinkStart": "To reset your password, enter one of the following below:",
"resetPasswordThroughLinkEnd": "Then press submit. A link will be sent to reset your password.",
"resetPasswordUsername": "Your Jellyfin username",
"resetPasswordEmail": "Your email address",
"resetPasswordContactMethod": "The username of any contact method linked to your account",
"resetSent": "Reset Sent.",
"resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.",
"changePassword": "Change Password",
"referralsDescription": "Invite friends & family to Jellyfin with this link. Come back here for a new one if it expires.",
"referralsWithExpiryDescription": "Invite friends & family to Jellyfin with this link. The link will be disabled once it expires.",
"copyReferral": "Copy Link",
"invitedBy": "You were invited by user {user}."
},

View File

@@ -27,7 +27,17 @@
"editContactMethod": "Wijzig contact methode",
"joinTheServer": "Word lid van de server:",
"resetPassword": "Wachtwoord opnieuw instellen",
"changePassword": "Wachtwoord wijzigen"
"changePassword": "Wachtwoord wijzigen",
"resetSentDescription": "Als er een account met de opgegeven gebruikersnaam/contactmethode bestaat, is er een wachtwoordreset-link verstuurd via alle bekende contactmethodes. De link is 30 minuten geldig.",
"customMessagePlaceholderHeader": "Kaart aanpassen",
"customMessagePlaceholderContent": "Klik op de gebruikerspagina aanpassen knop in instellingen om deze kaart aan te passen, of om op het loginscherm te tonen. En wees maar niet bang: de gebruiker kan dit niet zien.",
"userPageSuccessMessage": "Je kunt details van je account later bekijken en aanpassen op de {myAccount} pagina.",
"resetPasswordThroughJellyfin": "Om je wachtwoord te resetten, ga naar {jfLink} en druk op de \"Wachtwoord vergeten\" knop.",
"resetPasswordThroughLink": "Om je wachtwoord te resetten, vul je gebruikersnaam, e-mailadres of gebruikersnaam van een gelinkte contactmethode in, en verstuur. Er wordt een wachtwoord-reset link gestuurd.",
"resetSent": "Reset-link verstuurd.",
"referralsDescription": "Nodig vrienden en familie uit met deze link. Kom hier terug voor een nieuwe als hij verloopt.",
"copyReferral": "Kopieer link",
"invitedBy": "Je bent uitgenodigd door gebruiker {user}."
},
"validationStrings": {
"length": {

16
lang/pwreset/cs-cz.json Normal file
View File

@@ -0,0 +1,16 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"passwordReset": "Resetovat heslo",
"reset": "Resetovat",
"resetFailed": "Obnovení hesla se nezdařilo",
"tryAgain": "Prosím zkuste to znovu.",
"youCanLogin": "Nyní se můžete přihlásit pomocí níže uvedeného kódu jako svého hesla.",
"youCanLoginOmbi": "Nyní se můžete přihlásit do Jellyfin & Ombi pomocí níže uvedeného kódu jako hesla.",
"youCanLoginPassword": "Nyní se můžete přihlásit pomocí svého nového hesla. Stisknutím níže pokračujte na Jellyfin.",
"changeYourPassword": "Po přihlášení nezapomeňte změnit heslo.",
"enterYourPassword": "Níže zadejte své nové heslo."
}
}

160
lang/setup/cs-cz.json Normal file
View File

@@ -0,0 +1,160 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"pageTitle": "Nastavení - jfa-go",
"next": "Další",
"back": "Zpět",
"optional": "Volitelný",
"serverType": "Typ serveru",
"disabled": "Zakázáno",
"enabled": "Povoleno",
"port": "Port",
"message": "Zpráva",
"serverAddress": "Adresa serveru",
"emailSubject": "Předmět emailu",
"URL": "URL",
"apiKey": "Klíč API",
"error": "Chyba",
"errorInvalidUserPass": "Neplatné uživatelské jméno či heslo.",
"errorNotAdmin": "Uživatel nemá oprávnění spravovat server.",
"errorUserDisabled": "Uživatel může být zakázán.",
"error404": "404, zkontrolujte interní URL.",
"errorConnectionRefused": "Spojení odmítnuto.",
"errorUnknown": "Neznámá chyba, zkontrolujte protokoly aplikace."
},
"startPage": {
"welcome": "Vítejte!",
"pressStart": "Chcete-li nastavit jfa-go, budete muset udělat několik věcí. Pokračujte stisknutím tlačítka start.",
"httpsNotice": "Ujistěte se, že na tuto stránku přistupujete přes HTTPS nebo v privátní síti.",
"start": "Start"
},
"endPage": {
"finished": "Ukončeno!",
"restartMessage": "Funkce jako roboti Discord/Telegram/Matrix, vlastní zprávy Markdown a uživatelsky přístupná stránka \"Můj účet\" najdete v Nastavení, takže si ji nezapomeňte prohlédnout. Kliknutím níže restartujte a poté obnovte stránku.",
"refreshPage": "Obnovit"
},
"language": {
"title": "Jazyk",
"description": "Komunitní překlady jsou k dispozici pro většinu částí jfa-go. Níže si můžete vybrat výchozí jazyky, ale uživatelé je stále mohou změnit, pokud si to přejí. Pokud chcete pomoci s překladem, přihlaste se k {n} a začněte přispívat!",
"defaultAdminLang": "Výchozí jazyk správce",
"defaultFormLang": "Výchozí jazyk vytváření účtu",
"defaultEmailLang": "Výchozí jazyk e-mailu"
},
"general": {
"title": "Všeobecné",
"listenAddress": "Posloucha adresu",
"urlBase": "URL Base",
"urlBaseNotice": "Je potřeba pouze při použití reverzního proxy na subdoméně (např. 'jellyf.in/accounts').",
"lightTheme": "Světlý",
"darkTheme": "Tmavý",
"useHTTPS": "Použijte HTTPS",
"httpsPort": "HTTPS Port",
"useHTTPSNotice": "Doporučeno pouze v případě, že nepoužíváte reverzní proxy.",
"pathToCertificate": "Cesta k certifikátu",
"pathToKeyFile": "Cesta k souboru klíče"
},
"updates": {
"title": "Aktualizace",
"description": "Povolte, abyste byli informováni, když jsou k dispozici nové aktualizace. jfa-go bude kontrolovat {n} každých 30 minut. Nejsou shromažďovány žádné IP adresy ani osobní údaje.",
"updateChannel": "Aktualizovat kanál",
"stable": "Stabilní",
"unstable": "Nestabilní"
},
"login": {
"title": "Přihlásit se",
"description": "Pro přístup na stránku správce se musíte přihlásit níže uvedeným způsobem:",
"authorizeWithJellyfin": "Autorizovat pomocí Jellyfin/Emby: Přihlašovací údaje jsou sdíleny s Jellyfinem, což umožňuje více uživatelů.",
"authorizeManual": "Uživatelské jméno a heslo: Ručně nastavte uživatelské jméno a heslo.",
"adminOnly": "Pouze správci (doporučeno)",
"allowAll": "Povolit všem uživatelům Jellyfin přihlášení",
"allowAllDescription": "Nedoporučuje se, měli byste povolit přihlášení jednotlivých uživatelů po nastavení.",
"authorizeManualUserPageNotice": "Pomocí tohoto deaktivujete funkci \"Uživatelská stránka\".",
"emailNotice": "Vaši e-mailovou adresu lze použít k přijímání upozornění."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Účet správce je nutný, protože rozhraní API neumožňuje vytváření uživatelů pomocí klíče API. Měli byste si vytvořit samostatný účet a zaškrtnout 'Povolit tomuto uživateli spravovat server'. Vše ostatní můžete zakázat. Až budete hotovi, zadejte zde přihlašovací údaje.",
"embyNotice": "Podpora Emby je omezená a nepodporuje resetování hesla.",
"internal": "Vnitřní",
"external": "Externí",
"replaceJellyfin": "Název serveru",
"replaceJellyfinNotice": "Pokud je uveden, nahradí to jakýkoli výskyt 'Jellyfin' v aplikaci.",
"addressExternalNotice": "Chcete-li použít stejnou adresu, ponechte prázdné.",
"testConnection": "Test připojení"
},
"ombi": {
"title": "Ombi",
"description": "Připojením k Ombi se vytvoří účet Jellyfin i Ombi, když se uživatel připojí přes jfa-go. Po dokončení nastavení přejděte do Nastavení a nastavte výchozí profil pro nové uživatele ombi.",
"apiKeyNotice": "Najdete to na první kartě nastavení Ombi."
},
"messages": {
"title": "Zprávy",
"description": "jfa-go může odesílat resetování hesla a různé zprávy prostřednictvím e-mailu, Discordu, telegramu a/nebo Matrixu. Níže si můžete nastavit e-mail a ostatní můžete nakonfigurovat v Nastavení později. Pokyny naleznete na {n}. Pokud to nepotřebujete, můžete zde tyto funkce zakázat."
},
"email": {
"title": "Email",
"description": "jfa-go může posílat PINy pro resetování hesla a různá upozornění prostřednictvím e-mailu. Můžete se připojit k serveru SMTP nebo použít {n} API.",
"method": "Způsob odeslání",
"useEmailAsUsername": "Jako uživatelské jméno použijte e-mailové adresy",
"useEmailAsUsernameNotice": "Pokud je povoleno, noví uživatelé se budou přihlašovat do Jellyfin/Emby pomocí své e-mailové adresy namísto uživatelského jména.",
"fromAddress": "Z adresy",
"senderName": "Jméno odesílatele",
"dateFormat": "Datový formát",
"dateFormatNotice": "Datum má formát strftime. Pro více informací navštivte {n}.",
"encryption": "Šifrování",
"mailgunApiURL": "API URL"
},
"notifications": {
"title": "Upozornění pro správce",
"description": "Je-li povoleno, můžete si vybrat (na pozvánku), že chcete dostávat zprávu, když pozvánka vyprší nebo když je vytvořen uživatel. Pokud jste nezvolili způsob přihlášení Jellyfin, ujistěte se, že jste uvedli svou e-mailovou adresu, nebo později přidejte jiný způsob kontaktu."
},
"userPage": {
"title": "Uživatelská stránka",
"description": "Uživatelská stránka (zobrazená jako \"Můj účet\") umožňuje uživatelům přístup k informacím o jejich účtu, jako jsou jejich způsoby kontaktu a vypršení platnosti účtu. Mohou si také změnit heslo, zahájit resetování hesla a propojit/změnit způsoby kontaktu, aniž by se vás museli ptát. Kromě toho mohou být uživatelům před a po přihlášení zobrazeny přizpůsobené zprávy Markdown.",
"customizeMessages": "Chcete-li je nastavit později, klikněte v nastavení na tlačítko Upravit vedle položky \"Stránka uživatele\".",
"requiredSettings": "Musí být nastaveno přihlášení do jfa-go přes Jellyfin. Zajistěte, aby bylo později pro samoobslužné resetování hesla vybráno \"resetovat heslo přes odkaz\"."
},
"welcomeEmails": {
"title": "Uvítací zprávy",
"description": "Pokud je povoleno, bude novým uživatelům odeslána zpráva s adresou URL Jellyfin/Emby a jejich uživatelským jménem."
},
"inviteEmails": {
"title": "Pozvací zprávy",
"description": "Pokud je povoleno, můžete posílat pozvánky přímo na e-mailovou adresu uživatele, uživatele Discordu nebo Matrixu. Protože možná používáte reverzní proxy, musíte zadat adresy URL, ze kterých se přistupuje k pozvánkám. Napište základ URL a připojte '/invite'."
},
"passwordResets": {
"title": "Obnovení hesla",
"description": "Když se uživatel pokusí resetovat své heslo, Jellyfin vytvoří soubor s názvem 'passwordreset-*.json', který obsahuje PIN. jfa-go přečte soubor a odešle PIN uživateli. Pokud jste povolili funkci \"Uživatelská stránka\", lze reset provést také tam, zadáte-li uživatelské jméno, e-mail nebo způsob kontaktu.",
"pathToJellyfin": "Cesta ke konfiguračnímu adresáři Jellyfin",
"pathToJellyfinNotice": "Pokud nevíte, kde to je, zkuste resetovat heslo v Jellyfin. Objeví se vyskakovací okno s '<cesta k jellyfin>/passwordreset-*.json'. Toto není nutné, pokud chcete používat pouze samoobslužné resetování hesla prostřednictvím \"Uživatelské stránky\".",
"resetLinks": "Místo PINu pošlete odkaz",
"resetLinksRequiredForUserPage": "Vyžadováno pro samoobslužné resetování hesla na Uživatelské stránce.",
"resetLinksNotice": "Pokud je povolena integrace Ombi, použijte tuto možnost k synchronizaci resetování hesla Jellyfin s Ombi.",
"resetLinksLanguage": "Výchozí odkaz k resetování jazyku",
"setPassword": "Nastavit heslo přes odkaz",
"setPasswordNotice": "Povolení znamená, že uživatel nemusí po resetování měnit své heslo z PIN. Bude také vynuceno ověření hesla."
},
"passwordValidation": {
"title": "Ověření hesla",
"description": "Pokud je povoleno, na stránce vytvoření účtu se zobrazí sada požadavků na heslo, jako je minimální délka, velká/malá písmena atd.",
"length": "Délka",
"uppercase": "Velká písmena",
"lowercase": "Malá písmena",
"numbers": "Čísla",
"special": "Speciální znaky (%, * atd.)"
},
"helpMessages": {
"title": "Zprávy nápovědy",
"description": "Tyto zprávy se zobrazí na stránce vytvoření účtu a v některých e-mailech.",
"contactMessage": "Kontaktní zpráva",
"contactMessageNotice": "Zobrazí se v dolní části všech stránek kromě admin.",
"helpMessage": "Zpráva nápovědy",
"helpMessageNotice": "Zobrazí se na stránce vytvoření účtu.",
"successMessage": "Zpráva o úspěchu",
"successMessageNotice": "Zobrazí se, když si uživatel vytvoří svůj účet.",
"emailMessage": "Emailová zpráva",
"emailMessageNotice": "Zobrazuje se ve spodní části e-mailů."
}
}

View File

@@ -11,7 +11,7 @@
"disabled": "Deaktiveret",
"enabled": "Aktiveret",
"port": "Port",
"message": "Meddelelse",
"message": "Besked",
"serverAddress": "Serveradresse",
"emailSubject": "E-mail emne",
"URL": "URL",
@@ -20,7 +20,9 @@
"errorInvalidUserPass": "Ugyldigt brugernavn/adgangskode.",
"errorUserDisabled": "Bruger kan være deaktiveret.",
"error404": "404, tjek den interne URL.",
"errorConnectionRefused": "Tilslutning afvist."
"errorConnectionRefused": "Tilslutning afvist.",
"error": "Fejl",
"errorUnknown": "Ukendt fejl, tjek app logfiler."
},
"startPage": {
"welcome": "Velkommen!",
@@ -30,7 +32,7 @@
},
"endPage": {
"finished": "Færdig!",
"restartMessage": "Du kan konfigurere Discord/Telegram/Matrix bots, tilpasse dine meddelelser og mere i Indstillinger. Klik herunder for at genstarte, og opdater siden.",
"restartMessage": "Funktioner som Discord/Telegram/Matrix bots, brugerdefinerede Markdown beskeder og en brugertilgængelig side \"Min konto\" kan findes i Indstillinger, så sørg for at gennemse den. Klik nedenfor for at genstarte, og opdater derefter siden.",
"refreshPage": "Opdater"
},
"language": {
@@ -68,7 +70,8 @@
"adminOnly": "Kun administratorbrugere (anbefalet)",
"emailNotice": "Din e-mail adresse kan bruges til at modtage underretninger.",
"allowAll": "Tillad alle Jellyfin brugere at logge ind",
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret."
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret.",
"authorizeManualUserPageNotice": "Brug af dette vil deaktivere \"Brugerside\" funktionen."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -113,14 +116,15 @@
},
"passwordResets": {
"title": "Nulstilling af Adgangskoder",
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en PIN-kode. jfa-go læser filen og sender PIN-koden til brugeren.",
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en Pinkode. jfa-go læser filen og sender Pinkoden til brugeren. jfa-go læser filen og sender Pinkoden til brugeren. Hvis du aktiverede funktionen \"Brugerside\", kan en nulstilling også udføres der, givet et brugernavn, email eller kontaktmetode.",
"pathToJellyfin": "Sti til Jellyfin's konfigurations mappe",
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises.",
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises. Dette er ikke nødvendigt, hvis du kun ønsker at bruge selvbetjenings nulstilling af adgangskode via \"Brugersiden\".",
"resetLinks": "Send et link i stedet for en PIN-kode",
"resetLinksNotice": "Hvis Ombi integration er aktiveret, skal du bruge denne til at synkronisere nulstilling af Jellyfin's adgangskode med Ombi.",
"resetLinksLanguage": "Standard sprog til nulstillings link",
"setPassword": "Angiv adgangskode gennem link",
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også."
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også.",
"resetLinksRequiredForUserPage": "Nødvendig for nulstilling af selvbetjenings adgangskode på brugersiden."
},
"passwordValidation": {
"title": "Validering af adgangskode",
@@ -146,5 +150,11 @@
"messages": {
"title": "Beskeder",
"description": "jfa-go kan sende nulstilling af adgangskoder og forskellige meddelelser via E-mail, Discord, Telegram og/eller Matrix. Du kan konfigurere E-mail herunder, og de andre kan konfigureres i Indstillinger senere. Instruktioner kan findes på {n}. Hvis du ikke har brug for dette, kan du deaktivere disse funktioner her."
},
"userPage": {
"description": "Brugersiden (vist som \"Min konto\") giver brugerne adgang til oplysninger om deres konto, såsom deres kontaktmetoder og kontoudløb. De kan også ændre deres adgangskode, starte en nulstilling af adgangskode og linke/ændre kontaktmetoder uden at skulle spørge dig. Derudover kan tilpassede Markdown beskeder vises til brugerne før og efter login.",
"title": "Brugerside",
"customizeMessages": "Klik på redigeringsknappen ved siden af \"Brugerside\" i indstillingerne for at indstille dem senere.",
"requiredSettings": "Log ind på jfa-go via Jellyfin skal indstilles. Sørg for, at \"nulstil adgangskode via link\" er valgt senere for selvbetjenings nulstilling af adgangskode."
}
}
}

View File

@@ -18,11 +18,12 @@
"apiKey": "API Key",
"error": "Error",
"errorInvalidUserPass": "Invalid username/password.",
"errorNotAdmin": "User is not allowed to manage server.",
"errorNotAdmin": "User is not aEnabledllowed to manage server.",
"errorUserDisabled": "User may be disabled.",
"error404": "404, check the internal URL.",
"errorConnectionRefused": "Connection refused.",
"errorUnknown": "Unknown error, check app logs."
"errorUnknown": "Unknown error, check app logs.",
"errorProxy": "Proxy configuration invalid."
},
"startPage": {
"welcome": "Welcome!",
@@ -62,6 +63,12 @@
"stable": "Stable",
"unstable": "Unstable"
},
"proxy": {
"title": "Proxy",
"description": "Have jfa-go make all connections through a HTTP/SOCKS5 proxy. Connection to Jellyfin will be tested through this.",
"protocol": "Protocol",
"address": "Address (Including Port)"
},
"login": {
"title": "Login",
"description": "To access the admin page, you need to login with a method below:",

View File

@@ -20,7 +20,9 @@
"errorUserDisabled": "De gebruiker kan uitgeschakeld zijn.",
"error404": "404, controleer de interne URL.",
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd."
"errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout"
},
"startPage": {
"welcome": "Welkom!",
@@ -30,7 +32,7 @@
},
"endPage": {
"finished": "Klaar!",
"restartMessage": "Je kunt Discord/Telegram/Matrix bots instellen, berichten aanpassen en meer bij Instellingen. Klik hieronder om te herstarten, en ververs de pagina.",
"restartMessage": "Instellingen als Discord/Telegram/Matrix bots, aangepaste Markdown berichten, en een gebruiker-toegankelijke \"Mijn Account\" pagina zijn te vinden onder \"Instellingen\", dus kijk daar even rond. Klik hieronder om te herstarten, en ververs de pagina.",
"refreshPage": "Verversen"
},
"language": {
@@ -61,7 +63,8 @@
"adminOnly": "Alleen beheerders (aanbevolen)",
"emailNotice": "Je e-mailadres kan gebruikt worden om meldingen te ontvangen.",
"allowAll": "Laat alle Jellyfin-gebruikers inloggen",
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup."
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup.",
"authorizeManualUserPageNotice": "Gebruik hiervan schakelt de \"Gebruikerspagina\" functie uit."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -106,14 +109,15 @@
},
"passwordResets": {
"title": "Wachtwoordresets",
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker.",
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker. Als je de \"Gebruikerspagina\" functionaliteit inschakelt, kan een reset ook daar worden gedaan aan de hand van een gebruikersnaam, e-mail, of contactmethode.",
"pathToJellyfin": "Pad naar Jellyfin configuratiemap",
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'.",
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'. Dit is niet nodig als je alleen zelfzervice wachtwoordresets via de \"Gebruikerspagina\" wilt gebruiken.",
"resetLinks": "Stuur een link in plaats van een pincode",
"resetLinksNotice": "Als Ombi-integratie is ingeschakeld, gebruik dan dit om Jellyfin wachtwoordresets te synchroniseren met Ombi.",
"resetLinksLanguage": "Standaard reset-link taal",
"setPassword": "Stel wachtwoord in via link",
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen."
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen.",
"resetLinksRequiredForUserPage": "Nodig voor zelfservice wachtwoordreset op de gebruikerspagina."
},
"passwordValidation": {
"title": "Wachtwoordvalidatie",
@@ -146,5 +150,11 @@
"messages": {
"title": "Berichten",
"description": "jfa-go kan wachtwoordresets en verschillende berichten sturen via E-mail, Discord, Telegram, en/of Matrix. Je kunt e-mail hieronder instellen, en de rest kan later bij Instellingen aangepast worden. Instructies staan op de {n}. Als je dit niet nodig hebt, kun je deze onderdelen hier uitschakelen."
},
"userPage": {
"description": "De gebruikerspagina (getoond als \"Mijn account\") geeft gebruikers toegang tot informatie over hun account, zoals contactmethodes en verloopdatum. Ze kunnen ook hun wachtwoord wijzigen, een wachtwoord-reset starten, en contactmethodes koppelen/wijzigen zonder het jou te hoeven vragen. Bovendien kunnen aanpasbare MArkdown berichten worden getoond aan gebruikers voor en na het inloggen.",
"title": "Gebruikerspagina",
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
}
}
}

16
lang/telegram/cs-cz.json Normal file
View File

@@ -0,0 +1,16 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"startMessage": "Ahoj!\nZde zadejte svůj PIN kód Jellyfin pro ověření svého účtu.",
"discordStartMessage": "Ahoj!\n Zadejte svůj PIN pomocí `/pin <PIN>` pro ověření svého účtu.",
"matrixStartMessage": "Ahoj\nZadejte níže uvedený PIN na přihlašovací stránce Jellyfin a ověřte svůj účet.",
"invalidPIN": "Tento PIN byl neplatný, zkuste to znovu.",
"pinSuccess": "Hotovo! Nyní se můžete vrátit na stránku registrace.",
"languageMessage": "Poznámka: Dostupné jazyky zobrazíte pomocí příkazu {command} a jazyk nastavíte pomocí příkazu {command} <kód jazyka>.",
"languageMessageDiscord": "Poznámka: nastavte svůj jazyk pomocí /lang <název jazyka>.",
"languageSet": "Jazyk nastaven na {language}.",
"discordDMs": "Zkontrolujte prosím své DM pro odpověď."
}
}

View File

@@ -9,8 +9,10 @@
"pinSuccess": "Sådan! Du kan nu gå tilbage til tilmeldingssiden.",
"languageMessage": "Note: Se tilgængelige sprog med {command}, og vælg sprog med {command} <sprog kode>.",
"discordStartMessage": "Hej!\n Indtast din pinkode med `/pin <PIN>` for at bekræfte din konto.",
"languageMessageDiscord": "Bemærk: Indstil dit sprog med /lang <sprognavn>.",
"languageSet": "Sprog indstillet til {language}.",
"discordDMs": "Tjek venligst dine DM's for et svar."
"languageMessageDiscord": "Note: Sæt dit sprog med /lang <sprog navn>.",
"languageSet": "Sprog sat til {language}.",
"discordDMs": "Tjek venligst dine DMs for et svar.",
"sentInvite": "Invitation Sendt.",
"sentInviteFailure": "Kunne ikke sende invitation, tjek logfilerne."
}
}
}

View File

@@ -11,6 +11,8 @@
"discordStartMessage": "Hallo!\nVoer je pincode in met `/pin <PINCODE>` om je account te verifiëren.",
"languageMessageDiscord": "Opmerking: stel je taal in met /lang <taal>.",
"languageSet": "Taal ingesteld als {language}.",
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord."
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord.",
"sentInviteFailure": "Fout bij versturen uitnodiging, bekijk de logs.",
"sentInvite": "Uitnodiging verstuurd."
}
}
}

View File

@@ -49,7 +49,7 @@ func Lshortfile(level int) string {
}
func lshortfile() string {
return Lshortfile(2)
return Lshortfile(3)
}
func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Logger) {

17
main.go
View File

@@ -56,6 +56,8 @@ var (
commit string
buildTimeUnix string
builtBy string
_LOADBAK *string
LOADBAK = ""
)
var temp = func() string {
@@ -355,8 +357,10 @@ func start(asDaemon, firstCall bool) {
}
app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
app.ConnectDB()
defer app.storage.db.Close()
// Read config-base for settings on web.
app.configBasePath = "config-base.json"
configBase, _ := fs.ReadFile(localFS, app.configBasePath)
@@ -475,6 +479,13 @@ func start(asDaemon, firstCall bool) {
go app.checkForUpdates()
}
var backupDaemon *housekeepingDaemon
if app.config.Section("backups").Key("enabled").MustBool(false) {
backupDaemon = newBackupDaemon(app)
go backupDaemon.run()
defer backupDaemon.Shutdown()
}
if telegramEnabled {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
@@ -638,12 +649,18 @@ func flagPassed(name string) (found bool) {
// @tag.name Profiles & Settings
// @tag.description Profile and settings related operations.
// @tag.name Activity
// @tag.description Routes related to the activity log.
// @tag.name Configuration
// @tag.description jfa-go settings.
// @tag.name Ombi
// @tag.description Ombi related operations.
// @tag.name Backups
// @tag.description Database backup/restore operations.
// @tag.name Other
// @tag.description Things that dont fit elsewhere.

View File

@@ -261,11 +261,12 @@ type customEmailDTO struct {
}
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
}
type checkUpdateDTO struct {
@@ -424,9 +425,50 @@ type GetMyReferralRespDTO struct {
Code string `json:"code"`
RemainingUses int `json:"remaining_uses"`
NoLimit bool `json:"no_limit"`
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral (if UseExpiry, a new one can't be made).
UseExpiry bool `json:"use_expiry"`
}
type EnableDisableReferralDTO struct {
Users []string `json:"users"`
}
type ActivityDTO struct {
ID string `json:"id"`
Type string `json:"type"`
UserID string `json:"user_id"`
Username string `json:"username"`
SourceType string `json:"source_type"`
Source string `json:"source"`
SourceUsername string `json:"source_username"`
InviteCode string `json:"invite_code"`
Value string `json:"value"`
Time int64 `json:"time"`
}
type GetActivitiesDTO struct {
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
Ascending bool `json:"ascending"`
}
type GetActivitiesRespDTO struct {
Activities []ActivityDTO `json:"activities"`
LastPage bool `json:"last_page"`
}
type GetActivityCountDTO struct {
Count uint64 `json:"count"`
}
type CreateBackupDTO struct {
Size string `json:"size"`
Name string `json:"name"`
Path string `json:"path"`
Date int64 `json:"date"`
}
type GetBackupsDTO struct {
Backups []CreateBackupDTO `json:"backups"`
}

View File

@@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/accounts", app.AdminPage)
router.GET(p+"/settings", app.AdminPage)
router.GET(p+"/activity", app.AdminPage)
router.GET(p+"/accounts/user/:userID", app.AdminPage)
router.GET(p+"/invites/:code", app.AdminPage)
router.GET(p+"/lang/:page/:file", app.ServeLang)
router.GET(p+"/token/login", app.getTokenLogin)
router.GET(p+"/token/refresh", app.getTokenRefresh)
@@ -170,6 +173,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers)
api.POST(p+"/invites", app.GenerateInvite)
api.GET(p+"/invites", app.GetInvites)
@@ -204,6 +208,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
api.GET(p+"/logs", app.GetLog)
api.POST(p+"/backups", app.CreateBackup)
api.GET(p+"/backups/:fname", app.GetBackup)
api.GET(p+"/backups", app.GetBackups)
api.POST(p+"/backups/restore/:fname", app.RestoreLocalBackup)
api.POST(p+"/backups/restore", app.RestoreBackup)
if telegramEnabled || discordEnabled || matrixEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
@@ -226,12 +235,16 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
api.POST(p+"/matrix/login", app.MatrixLogin)
if app.config.Section("user_page").Key("referrals").MustBool(false) {
api.POST(p+"/users/referral/:mode/:source", app.EnableReferralForUsers)
api.POST(p+"/users/referral/:mode/:source/:useExpiry", app.EnableReferralForUsers)
api.DELETE(p+"/users/referral", app.DisableReferralForUsers)
api.POST(p+"/profiles/referral/:profile/:invite", app.EnableReferralForProfile)
api.POST(p+"/profiles/referral/:profile/:invite/:useExpiry", app.EnableReferralForProfile)
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
}
api.POST(p+"/activity", app.GetActivities)
api.DELETE(p+"/activity/:id", app.DeleteActivity)
api.GET(p+"/activity/count", app.GetActivityCount)
if userPageEnabled {
user.GET("/details", app.MyDetails)
user.POST("/contact", app.SetMyContactMethods)

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/mediabrowser"
)
@@ -47,10 +48,15 @@ func (app *appContext) ServeSetup(gc *gin.Context) {
}
type testReq struct {
ServerType string `json:"type"`
Server string `json:"server"`
Username string `json:"username"`
Password string `json:"password"`
ServerType string `json:"type"`
Server string `json:"server"`
Username string `json:"username"`
Password string `json:"password"`
Proxy bool `json:"proxy"`
ProxyProtocol string `json:"proxy_protocol,omitempty"`
ProxyAddress string `json:"proxy_address,omitempty"`
ProxyUsername string `json:"proxy_user,omitempty"`
ProxyPassword string `json:"proxy_password,omitempty"`
}
func (app *appContext) TestJF(gc *gin.Context) {
@@ -64,6 +70,26 @@ func (app *appContext) TestJF(gc *gin.Context) {
serverType = mediabrowser.EmbyServer
}
tempjf, _ := mediabrowser.NewServer(serverType, req.Server, "jfa-go-setup", app.version, "auth", "auth", mediabrowser.NewNamedTimeoutHandler("authJF", req.Server, true), 30)
if req.Proxy {
conf := easyproxy.ProxyConfig{
Protocol: easyproxy.HTTP,
Addr: req.ProxyAddress,
User: req.ProxyUsername,
Password: req.ProxyPassword,
}
if strings.Contains(req.ProxyProtocol, "socks") {
conf.Protocol = easyproxy.SOCKS5
}
transport, err := easyproxy.NewTransport(conf)
if err != nil {
respond(400, "errorProxy", gc)
return
}
tempjf.SetTransport(transport)
}
user, status, err := tempjf.Authenticate(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
msg := ""
@@ -126,6 +152,7 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.StartPage, &fallback.StartPage, &english.StartPage)
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)
@@ -144,6 +171,7 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.StartPage, &english.StartPage)
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)

View File

@@ -21,6 +21,42 @@ type telegramStore map[string]TelegramUser
type matrixStore map[string]MatrixUser
type emailStore map[string]EmailAddress
type ActivityType int
const (
ActivityCreation ActivityType = iota
ActivityDeletion
ActivityDisabled
ActivityEnabled
ActivityContactLinked
ActivityContactUnlinked
ActivityChangePassword
ActivityResetPassword
ActivityCreateInvite
ActivityDeleteInvite
ActivityUnknown
)
type ActivitySource int
const (
ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer.
ActivityAdmin // Source = Admin's UserID, or blank if jellyfin login isn't on.
ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite
ActivityDaemon // Source = Blank, was deleted/disabled due to expiry by daemon
)
type Activity struct {
ID string `badgerhold:"key"`
Type ActivityType `badgerhold:"index"`
UserID string // ID of target user. For account creation, this will be the newly created account
SourceType ActivitySource
Source string
InviteCode string // Set for ActivityCreation, create/deleteInvite
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
Time time.Time
}
type UserExpiry struct {
JellyfinID string `badgerhold:"key"`
Expiry time.Time
@@ -514,6 +550,32 @@ func (st *Storage) DeleteCustomContentKey(k string) {
st.db.Delete(k, CustomContent{})
}
// GetActivityKey returns the value stored in the store's key.
func (st *Storage) GetActivityKey(k string) (Activity, bool) {
result := Activity{}
err := st.db.Get(k, &result)
ok := true
if err != nil {
// fmt.Printf("Failed to find custom content: %v\n", err)
ok = false
}
return result, ok
}
// SetActivityKey stores value v in key k.
func (st *Storage) SetActivityKey(k string, v Activity) {
v.ID = k
err := st.db.Upsert(k, v)
if err != nil {
// fmt.Printf("Failed to set custom content: %v\n", err)
}
}
// DeleteActivityKey deletes value at key k.
func (st *Storage) DeleteActivityKey(k string) {
st.db.Delete(k, Activity{})
}
type TelegramUser struct {
JellyfinID string `badgerhold:"key"`
ChatID int64 `badgerhold:"index"`
@@ -597,15 +659,15 @@ type Invite struct {
UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"`
// Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
IsReferral bool `json:"is_referral" badgerhold:"index"`
ReferrerJellyfinID string `json:"referrer_id"`
ReferrerTemplateForProfile string
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
IsReferral bool `json:"is_referral" badgerhold:"index"`
ReferrerJellyfinID string `json:"referrer_id"`
UseReferralExpiry bool `json:"use_referral_expiry"`
}
type Captcha struct {

View File

@@ -5,6 +5,7 @@ import { Tabs } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
import { Updater } from "./modules/update.js";
@@ -67,6 +68,10 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.logs = new Modal(document.getElementById("modal-logs"));
window.modals.backedUp = new Modal(document.getElementById("modal-backed-up"));
window.modals.backups = new Modal(document.getElementById("modal-backups"));
if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
}
@@ -89,6 +94,8 @@ var inviteCreator = new createInvite();
var accounts = new accountsList();
var activity = new activityList();
window.invites = new inviteList();
var settings = new settingsList();
@@ -120,6 +127,10 @@ const tabs: { url: string, reloader: () => void }[] = [
url: "accounts",
reloader: accounts.reload
},
{
url: "activity",
reloader: activity.reload
},
{
url: "settings",
reloader: settings.reload
@@ -137,6 +148,9 @@ for (let tab of tabs) {
}
}
let isInviteURL = window.invites.isInviteURL();
let isAccountURL = accounts.isAccountURL();
// Default tab
if ((window.URLBase + "/").includes(window.location.pathname)) {
window.tabs.switch(defaultTab.url, true);
@@ -146,7 +160,9 @@ document.addEventListener("tab-change", (event: CustomEvent) => {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get('lang');
let tab = window.URLBase + "/" + event.detail;
if (tab == window.URLBase + "/invites") {
if (event.detail == "") {
tab = window.location.pathname;
} else if (tab == window.URLBase + "/invites") {
if (window.location.pathname == window.URLBase + "/") {
tab = window.URLBase + "/";
} else if (window.URLBase) { tab = window.URLBase; }
@@ -167,6 +183,7 @@ const login = new Login(window.modals.login as Modal, "/", window.loginAppearanc
login.onLogin = () => {
console.log("Logged in.");
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {
@@ -179,6 +196,23 @@ login.onLogin = () => {
case "settings":
settings.reload();
break;
case "activity": // FIXME: fix URL clash with route
activity.reload();
break;
default:
console.log(isAccountURL, isInviteURL);
if (isInviteURL) {
window.invites.reload(() => {
window.invites.loadInviteURL();
window.tabs.switch("invites", false, true);
});
} else if (isAccountURL) {
accounts.reload(() => {
accounts.loadAccountURL();
window.tabs.switch("accounts", false, true);
});
}
break;
}
}

View File

@@ -3,6 +3,7 @@ import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
const dateParser = require("any-date-parser");
interface User {
@@ -39,7 +40,7 @@ interface announcementTemplate {
var addDiscord: (passData: string) => void;
class user implements User {
class user implements User, SearchableItem {
private _id = "";
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
@@ -73,6 +74,8 @@ class user implements User {
private _referralsEnabled: boolean;
private _referralsEnabledCheck: HTMLElement;
focus = () => this._row.scrollIntoView({ behavior: "smooth", block: "center" });
lastNotifyMethod = (): string => {
// Telegram, Matrix, Discord
const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != "";
@@ -269,7 +272,7 @@ class user implements User {
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span>
<input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
</div>
`;
`;
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
} else {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
@@ -766,7 +769,15 @@ export class accountsList {
private _enableExpiry = document.getElementById("accounts-enable-expiry") as HTMLSpanElement;
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _expiryDropdown = document.getElementById("accounts-expiry-dropdown") as HTMLElement;
private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement;
private _extendExpiryForm = document.getElementById("form-extend-expiry") as HTMLFormElement;
private _extendExpiryTextInput = document.getElementById("extend-expiry-text") as HTMLInputElement;
private _extendExpiryFieldInputs = document.getElementById("extend-expiry-field-inputs") as HTMLElement;
private _usingExtendExpiryTextInput = true;
private _extendExpiryDate = document.getElementById("extend-expiry-date") as HTMLElement;
private _removeExpiry = document.getElementById("accounts-remove-expiry") as HTMLSpanElement;
private _enableExpiryNotify = document.getElementById("expiry-extend-enable") as HTMLInputElement;
private _enableExpiryReason = document.getElementById("textarea-extend-enable") as HTMLTextAreaElement;
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
@@ -780,13 +791,14 @@ export class accountsList {
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _search = document.getElementById("accounts-search") as HTMLInputElement;
private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement;
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _ordering: string[] = [];
private _checkCount: number = 0;
private _inSearch = false;
// Whether the enable/disable button should enable or not.
private _shouldEnable = false;
@@ -836,7 +848,7 @@ export class accountsList {
}
}
private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
private _queries: { [field: string]: QueryType } = {
"id": {
// We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID",
@@ -887,7 +899,7 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnTableHeader: "accounts-header-access-jfa"
dependsOnElement: ".accounts-header-access-jfa"
},
"email": {
name: window.lang.strings("emailAddress"),
@@ -895,7 +907,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-email"
dependsOnElement: ".accounts-header-email"
},
"telegram": {
name: "Telegram",
@@ -903,7 +915,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-telegram"
dependsOnElement: ".accounts-header-telegram"
},
"matrix": {
name: "Matrix",
@@ -911,7 +923,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-matrix"
dependsOnElement: ".accounts-header-matrix"
},
"discord": {
name: "Discord",
@@ -919,7 +931,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-discord"
dependsOnElement: ".accounts-header-discord"
},
"expiry": {
name: window.lang.strings("expiry"),
@@ -927,7 +939,7 @@ export class accountsList {
bool: true,
string: false,
date: true,
dependsOnTableHeader: "accounts-header-expiry"
dependsOnElement: ".accounts-header-expiry"
},
"last-active": {
name: window.lang.strings("lastActiveTime"),
@@ -942,229 +954,12 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnTableHeader: "accounts-header-referrals"
dependsOnElement: ".accounts-header-referrals"
}
}
private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found");
search = (query: String): string[] => {
console.log(this._queries);
this._filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
// console.log("initial:", result);
// const words = query.split(" ");
let words: string[] = [];
let quoteSymbol = ``;
let queryStart = -1;
let lastQuote = -1;
for (let i = 0; i < query.length; i++) {
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
queryStart = i;
}
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
if (lastQuote != -1) {
lastQuote = -1;
quoteSymbol = ``;
} else {
lastQuote = i;
quoteSymbol = query[i];
}
}
if (query[i] == " " || i == query.length-1) {
if (lastQuote != -1) {
continue;
} else {
let end = i+1;
if (query[i] == " ") {
end = i;
while (i+1 < query.length && query[i+1] == " ") {
i += 1;
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
}
query = "";
for (let word of words) {
if (!word.includes(":")) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._queries)) continue;
const queryFormat = this._queries[split[0]];
if (queryFormat.bool) {
let isBool = false;
let boolState = false;
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
isBool = true;
boolState = true;
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
isBool = true;
boolState = false;
}
if (isBool) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}</span>
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
this._search.value = this._search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!((value && boolState) || (!value && !boolState))) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
continue
}
}
if (queryFormat.string) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._search.value = this._search.value.replace(regex, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
if (!(value.includes(split[1]))) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
let unmodifiedValue = split[1];
if (compareType != 2) {
split[1] = split[1].substring(1);
}
if (compareType == 2) compareType = 0;
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in attempt) attempt.month -= 1;
let date: Date = (Date as any).fromString(split[1]) as Date;
console.log("Read", attempt, "and", date);
if ("invalid" in (date as any)) continue;
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
this._search.value = this._search.value.replace(regex, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const unixValue = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
continue;
}
let value = new Date(unixValue*1000);
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
const temp = new Date(date.valueOf());
temp.setHours(value.getHours(), value.getMinutes());
value = temp;
console.log("just hours/minutes workaround, value set to", value);
}
let match = true;
if (compareType == 0) {
for (let pair of getterPairs) {
if (pair[0] in attempt) {
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
match = false;
break;
}
}
}
} else if (compareType == -1) {
match = (value < date);
} else if (compareType == 1) {
match = (value > date);
}
if (!match) {
result.splice(result.indexOf(id), 1);
}
}
}
}
return result;
};
get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) {
let count = 0;
@@ -1198,7 +993,7 @@ export class accountsList {
if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.parentElement.classList.add("unfocused");
}
this._extendExpiry.classList.add("unfocused");
this._expiryDropdown.classList.add("unfocused");
this._disableEnable.parentElement.classList.add("unfocused");
this._sendPWR.classList.add("unfocused");
} else {
@@ -1234,7 +1029,7 @@ export class accountsList {
for (let id of list) {
if (!anyNonExpiries && !this._users[id].expiry) {
anyNonExpiries = true;
this._extendExpiry.classList.add("unfocused");
this._expiryDropdown.classList.add("unfocused");
}
if (this._users[id].expiry) {
allNonExpiries = false;
@@ -1253,13 +1048,15 @@ export class accountsList {
}
this._settingExpiry = false;
if (!anyNonExpiries && !allNonExpiries) {
this._extendExpiry.classList.remove("unfocused");
this._expiryDropdown.classList.remove("unfocused");
this._extendExpiry.textContent = window.lang.strings("extendExpiry");
this._removeExpiry.classList.remove("unfocused");
}
if (allNonExpiries) {
this._extendExpiry.classList.remove("unfocused");
this._expiryDropdown.classList.remove("unfocused");
this._extendExpiry.textContent = window.lang.strings("setExpiry");
this._settingExpiry = true;
this._removeExpiry.classList.add("unfocused");
}
// Only show "Send PWR" if a maximum of 1 user selected doesn't have a contact method
if (noContactCount > 1) {
@@ -1792,7 +1589,7 @@ export class accountsList {
send["from"] = "invite";
send["id"] = this._referralsInviteSelect.value;
}
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]), send, (req: XMLHttpRequest) => {
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]) + "/" + (this._referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 400) {
@@ -1807,9 +1604,72 @@ export class accountsList {
};
this._enableReferralsProfile.checked = true;
this._enableReferralsInvite.checked = false;
this._referralsExpiry.checked = false;
window.modals.enableReferralsUser.show();
}
removeExpiry = () => {
const list = this._collectUsers();
let success = true;
for (let id of list) {
_delete("/users/" + id + "/expiry", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
success = false;
return;
}
});
if (!success) break;
}
if (success) {
window.notifications.customSuccess("modifySettingsSuccess", window.lang.quantity("appliedSettings", list.length));
} else {
window.notifications.customError("modifySettingsError", window.lang.notif("errorSettingsFailed"));
}
this.reload();
}
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
} else {
let fields: Array<HTMLSelectElement> = [
document.getElementById("extend-expiry-months") as HTMLSelectElement,
document.getElementById("extend-expiry-days") as HTMLSelectElement,
document.getElementById("extend-expiry-hours") as HTMLSelectElement,
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
if (!id) invalid = true;
else {
date = new Date(this._users[id].expiry*1000);
if (this._users[id].expiry == 0) date = new Date();
date.setMonth(date.getMonth() + (+fields[0].value))
date.setDate(date.getDate() + (+fields[1].value));
date.setHours(date.getHours() + (+fields[2].value));
date.setMinutes(date.getMinutes() + (+fields[3].value));
}
}
const submit = this._extendExpiryForm.querySelector(`input[type="submit"]`) as HTMLInputElement;
const submitSpan = submit.nextElementSibling;
if (invalid) {
submit.disabled = true;
submitSpan.classList.add("opacity-60");
this._extendExpiryDate.classList.add("unfocused");
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
this._extendExpiryDate.classList.remove("unfocused");
}
}
extendExpiry = (enableUser?: boolean) => {
const list = this._collectUsers();
let applyList: string[] = [];
@@ -1832,10 +1692,20 @@ export class accountsList {
}
document.getElementById("header-extend-expiry").textContent = header;
const extend = () => {
let send = { "users": applyList }
for (let field of ["months", "days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
let send = { "users": applyList, "timestamp": 0 }
if (this._usingExtendExpiryTextInput) {
let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
send["timestamp"] = Math.floor(date.getTime() / 1000);
if ("invalid" in (date as any)) {
window.notifications.customError("extendExpiryError", window.lang.notif("errorInvalidDate"));
return;
}
} else {
for (let field of ["months", "days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
}
}
_post("/users/extend", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) {
@@ -1848,8 +1718,7 @@ export class accountsList {
}
});
};
const form = document.getElementById("form-extend-expiry") as HTMLFormElement;
form.onsubmit = (event: Event) => {
this._extendExpiryForm.onsubmit = (event: Event) => {
event.preventDefault();
if (enableUser) {
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
@@ -1870,6 +1739,7 @@ export class accountsList {
extend();
}
}
this._extendExpiryTextInput.value = "";
window.modals.extendExpiry.show();
}
@@ -1894,6 +1764,25 @@ export class accountsList {
this._addUserProfile.innerHTML = innerHTML;
}
focusAccount = (userID: string) => {
console.log("focusing user", userID);
this._searchBox.value = `id:"${userID}"`;
this._search.onSearchBoxChange();
if (userID in this._users) this._users[userID].focus();
}
public static readonly _accountURLEvent = "account-url";
registerURLListener = () => document.addEventListener(accountsList._accountURLEvent, (event: CustomEvent) => {
this.focusAccount(event.detail);
});
isAccountURL = () => { return window.location.pathname.startsWith(window.URLBase + "/accounts/user/"); }
loadAccountURL = () => {
let userID = window.location.pathname.split(window.URLBase + "/accounts/user/")[1].split("?lang")[0];
this.focusAccount(userID);
}
constructor() {
this._populateNumbers();
this._users = {};
@@ -1901,7 +1790,7 @@ export class accountsList {
this._selectAll.onchange = () => {
this.selectAll = this._selectAll.checked;
};
document.addEventListener("accounts-reload", this.reload);
document.addEventListener("accounts-reload", () => this.reload());
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
this._addUserButton.onclick = () => {
@@ -1985,7 +1874,39 @@ export class accountsList {
this._announceButton.parentElement.classList.add("unfocused");
this._extendExpiry.onclick = () => { this.extendExpiry(); };
this._extendExpiry.classList.add("unfocused");
this._removeExpiry.onclick = () => { this.removeExpiry(); };
this._expiryDropdown.classList.add("unfocused");
this._extendExpiryDate.classList.add("unfocused");
this._extendExpiryTextInput.onkeyup = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
}
this._extendExpiryTextInput.onclick = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
};
this._extendExpiryFieldInputs.onclick = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
for (let field of ["months", "days", "hours", "minutes"]) {
(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
}
this._disableEnable.onclick = this.enableDisableUsers;
this._disableEnable.parentElement.classList.add("unfocused");
@@ -2014,34 +1935,23 @@ export class accountsList {
this._deleteNotify.checked = false;
}*/
const onchange = () => {
const query = this._search.value;
if (!query) {
// this.setVisibility(this._ordering, true);
this._inSearch = false;
} else {
this._inSearch = true;
// this.setVisibility(this.search(query), true);
}
const results = this.search(query);
this.setVisibility(results, true);
this._checkCheckCount();
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
filterList: document.getElementById("accounts-filter-list"),
search: this._searchBox,
queries: this._queries,
setVisibility: this.setVisibility,
clearSearchButtonSelector: ".accounts-search-clear",
onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
this._checkCheckCount();
}
};
this._search.oninput = onchange;
const clearSearchButtons = Array.from(document.getElementsByClassName("accounts-search-clear")) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._search.value = "";
onchange();
});
}
this._search = new Search(conf);
this._search.items = this._users;
this._announceTextarea.onkeyup = this.loadPreview;
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
@@ -2088,15 +1998,16 @@ export class accountsList {
document.addEventListener("header-click", (event: CustomEvent) => {
this._ordering = this._columns[event.detail].sort(this._users);
this._search.ordering = this._ordering;
this._activeSortColumn = event.detail;
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
this._sortingByButton.parentElement.classList.remove("hidden");
// console.log("ordering by", event.detail, ": ", this._ordering);
if (!(this._inSearch)) {
if (!(this._search.inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this.search(this._search.value);
const results = this._search.search(this._searchBox.value);
this.setVisibility(results, true);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
@@ -2110,87 +2021,12 @@ export class accountsList {
defaultSort();
this.showHideSearchOptionsHeader();
const filterList = document.getElementById("accounts-filter-list");
this._search.generateFilterList();
const fillInFilter = (name: string, value: string, offset?: number) => {
this._search.value = name + ":" + value + " " + this._search.value;
this._search.focus();
let newPos = name.length + 1 + value.length;
if (typeof offset !== 'undefined')
newPos += offset;
this._search.setSelectionRange(newPos, newPos);
this._search.oninput(null as any);
};
// Generate filter buttons
for (let queryName of Object.keys(this._queries)) {
const query = this._queries[queryName];
if ("show" in query && !query.show) continue;
if ("dependsOnTableHeader" in query && query.dependsOnTableHeader) {
const el = document.querySelector("."+query.dependsOnTableHeader);
if (el === null) continue;
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.innerHTML = `<span class="mr-2">${query.name}</span>`;
if (query.bool) {
const pos = document.createElement("button") as HTMLButtonElement;
pos.type = "button";
pos.ariaLabel = `Filter by "${query.name}": True`;
pos.classList.add("button", "~positive", "ml-2");
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
pos.addEventListener("click", () => fillInFilter(queryName, "true"));
const neg = document.createElement("button") as HTMLButtonElement;
neg.type = "button";
neg.ariaLabel = `Filter by "${query.name}": False`;
neg.classList.add("button", "~critical", "ml-2");
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
neg.addEventListener("click", () => fillInFilter(queryName, "false"));
container.appendChild(pos);
container.appendChild(neg);
}
if (query.string) {
const button = document.createElement("button") as HTMLButtonElement;
button.type = "button";
button.classList.add("button", "~urge", "ml-2");
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
// Position cursor between quotes
button.addEventListener("click", () => fillInFilter(queryName, `""`, -1));
container.appendChild(button);
}
if (query.date) {
const onDate = document.createElement("button") as HTMLButtonElement;
onDate.type = "button";
onDate.classList.add("button", "~urge", "ml-2");
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
onDate.addEventListener("click", () => fillInFilter(queryName, `"="`, -1));
const beforeDate = document.createElement("button") as HTMLButtonElement;
beforeDate.type = "button";
beforeDate.classList.add("button", "~urge", "ml-2");
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
beforeDate.addEventListener("click", () => fillInFilter(queryName, `"<"`, -1));
const afterDate = document.createElement("button") as HTMLButtonElement;
afterDate.type = "button";
afterDate.classList.add("button", "~urge", "ml-2");
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
afterDate.addEventListener("click", () => fillInFilter(queryName, `">"`, -1));
container.appendChild(onDate);
container.appendChild(beforeDate);
container.appendChild(afterDate);
}
filterList.appendChild(container);
}
this.registerURLListener();
}
reload = () => {
reload = (callback?: () => void) => {
_get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
@@ -2210,11 +2046,12 @@ export class accountsList {
}
// console.log("reload, so sorting by", this._activeSortColumn);
this._ordering = this._columns[this._activeSortColumn].sort(this._users);
if (!(this._inSearch)) {
this._search.ordering = this._ordering;
if (!(this._search.inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this.search(this._search.value);
const results = this._search.search(this._searchBox.value);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
@@ -2223,12 +2060,16 @@ export class accountsList {
this.setVisibility(results, true);
}
this._checkCheckCount();
if (callback) callback();
}
});
this.loadTemplates();
}
}
export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) };
type GetterReturnType = Boolean | boolean | String | Number | number;
type Getter = () => GetterReturnType;

736
ts/modules/activity.ts Normal file
View File

@@ -0,0 +1,736 @@
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js";
export interface activity {
id: string;
type: string;
user_id: string;
source_type: string;
source: string;
invite_code: string;
value: string;
time: number;
username: string;
source_username: string;
}
var activityTypeMoods = {
"creation": 1,
"deletion": -1,
"disabled": -1,
"enabled": 1,
"contactLinked": 1,
"contactUnlinked": -1,
"changePassword": 0,
"resetPassword": 0,
"createInvite": 1,
"deleteInvite": -1
};
// var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload");
export class Activity implements activity, SearchableItem {
private _card: HTMLElement;
private _title: HTMLElement;
private _time: HTMLElement;
private _timeUnix: number;
private _sourceType: HTMLElement;
private _source: HTMLElement;
private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _act: activity;
private _urlBase: string = ((): string => {
let link = window.location.href;
for (let split of ["#", "?", "/activity"]) {
link = link.split(split)[0];
}
if (link.slice(-1) != "/") { link += "/"; }
return link;
})();
_genUserText = (): string => {
return `<span class="font-medium">${this._act.username || this._act.user_id.substring(0, 5)}</span>`;
}
_genSrcUserText = (): string => {
return `<span class="font-medium">${this._act.source_username || this._act.source.substring(0, 5)}</span>`;
}
_genUserLink = (): string => {
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.user_id}">${this._genUserText()}</span>`;
}
_genSrcUserLink = (): string => {
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.source}">${this._genSrcUserText()}</span>`;
}
private _renderInvText = (): string => { return `<span class="font-medium font-mono">${this.value || this.invite_code || "???"}</span>`; }
private _genInvLink = (): string => {
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-invite" data-id="${this.invite_code}" data-href="${this._urlBase}invites/${this.invite_code}">${this._renderInvText()}</span>`;
}
get accountCreation(): boolean { return this.type == "creation"; }
get accountDeletion(): boolean { return this.type == "deletion"; }
get accountDisabled(): boolean { return this.type == "disabled"; }
get accountEnabled(): boolean { return this.type == "enabled"; }
get contactLinked(): boolean { return this.type == "contactLinked"; }
get contactUnlinked(): boolean { return this.type == "contactUnlinked"; }
get passwordChange(): boolean { return this.type == "changePassword"; }
get passwordReset(): boolean { return this.type == "resetPassword"; }
get inviteCreated(): boolean { return this.type == "createInvite"; }
get inviteDeleted(): boolean { return this.type == "deleteInvite"; }
get mentionedUsers(): string {
return (this.username + " " + this.source_username).toLowerCase();
}
get actor(): string {
let out = this.source_type + " ";
if (this.source_type == "admin" || this.source_type == "user") out += this.source_username;
return out.toLowerCase();
}
get referrer(): string {
if (this.type != "creation" || this.source_type != "user") return "";
return this.source_username.toLowerCase();
}
get type(): string { return this._act.type; }
set type(v: string) {
this._act.type = v;
let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative
for (let el of [this._card, this._delete]) {
el.classList.remove("~warning");
el.classList.remove("~neutral");
el.classList.remove("~urge");
if (mood == -1) {
el.classList.add("~warning");
} else if (mood == 0) {
el.classList.add("~neutral");
} else if (mood == 1) {
el.classList.add("~urge");
}
}
/* for (let i = 0; i < moodColours.length; i++) {
if (i-1 == mood) this._card.classList.add(moodColours[i]);
else this._card.classList.remove(moodColours[i]);
} */
if (this.type == "changePassword" || this.type == "resetPassword") {
let innerHTML = ``;
if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword");
else innerHTML = window.lang.strings("accountResetPassword");
innerHTML = innerHTML.replace("{user}", this._genUserLink());
this._title.innerHTML = innerHTML;
} else if (this.type == "contactLinked" || this.type == "contactUnlinked") {
let platform = this.value;
if (platform == "email") {
platform = window.lang.strings("emailAddress");
} else {
platform = platform.charAt(0).toUpperCase() + platform.slice(1);
}
let innerHTML = ``;
if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked");
else innerHTML = window.lang.strings("accountUnlinked");
innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform);
this._title.innerHTML = innerHTML;
} else if (this.type == "creation") {
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink());
if (this.source_type == "user") {
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span>${this._genSrcUserLink()}`;
} else {
this._referrer.textContent = ``;
}
} else if (this.type == "deletion") {
if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText());
this._expiryTypeBadge.classList.add("~critical");
this._expiryTypeBadge.classList.remove("~info");
this._expiryTypeBadge.textContent = window.lang.strings("deleted");
} else {
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText());
}
} else if (this.type == "enabled") {
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink());
} else if (this.type == "disabled") {
if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink());
this._expiryTypeBadge.classList.add("~info");
this._expiryTypeBadge.classList.remove("~critical");
this._expiryTypeBadge.textContent = window.lang.strings("disabled");
} else {
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink());
}
} else if (this.type == "createInvite") {
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink());
} else if (this.type == "deleteInvite") {
let innerHTML = ``;
if (this.source_type == "daemon") {
innerHTML = window.lang.strings("inviteExpired");
} else {
innerHTML = window.lang.strings("inviteDeleted");
}
this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText());
}
}
get time(): number { return this._timeUnix; }
set time(v: number) {
this._timeUnix = v;
this._time.textContent = toDateString(new Date(v*1000));
}
get source_type(): string { return this._act.source_type; }
set source_type(v: string) {
this._act.source_type = v;
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
this._sourceType.textContent = window.lang.strings("fromInvite");
} else if (this.source_type == "admin") {
this._sourceType.textContent = window.lang.strings("byAdmin");
} else if (this.source_type == "user" && this.type != "creation") {
this._sourceType.textContent = window.lang.strings("byUser");
} else if (this.source_type == "daemon") {
this._sourceType.textContent = window.lang.strings("byJfaGo");
}
}
get invite_code(): string { return this._act.invite_code; }
set invite_code(v: string) {
this._act.invite_code = v;
}
get value(): string { return this._act.value; }
set value(v: string) {
this._act.value = v;
}
get source(): string { return this._act.source; }
set source(v: string) {
this._act.source = v;
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
this._source.innerHTML = this._genInvLink();
} else if ((this.source_type == "admin" || this.source_type == "user") && this._act.source != "" && this._act.source_username != "") {
this._source.innerHTML = this._genSrcUserLink();
}
}
get id(): string { return this._act.id; }
set id(v: string) { this._act.id = v; }
get user_id(): string { return this._act.user_id; }
set user_id(v: string) { this._act.user_id = v; }
get username(): string { return this._act.username; }
set username(v: string) { this._act.username = v; }
get source_username(): string { return this._act.source_username; }
set source_username(v: string) { this._act.source_username = v; }
get title(): string { return this._title.textContent; }
matchesSearch = (query: string): boolean => {
// console.log(this.title, "matches", query, ":", this.title.includes(query));
return (
this.title.toLowerCase().includes(query) ||
this.username.toLowerCase().includes(query) ||
this.source_username.toLowerCase().includes(query)
);
}
constructor(act: activity) {
this._card = document.createElement("div");
this._card.classList.add("card", "@low", "my-2");
this._card.innerHTML = `
<div class="flex flex-col md:flex-row justify-between mb-2">
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
<div class="flex flex-col flex-none ml-0 md:ml-2">
<span class="font-medium md:text-sm text-xs activity-time" aria-label="${window.lang.strings("date")}"></span>
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
</div>
</div>
<div class="flex flex-col md:flex-row justify-between">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div>
<span class="content activity-referrer"></span>
</div>
<div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
</div>
</div>
`;
this._title = this._card.querySelector(".activity-title");
this._time = this._card.querySelector(".activity-time");
this._sourceType = this._card.querySelector(".activity-source-type");
this._source = this._card.querySelector(".activity-source");
this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
document.addEventListener("timefmt-change", () => {
this.time = this.time;
});
this._delete.addEventListener("click", this.delete);
this.update(act);
const pseudoUsers = this._card.getElementsByClassName("activity-pseudo-link-user") as HTMLCollectionOf<HTMLAnchorElement>;
const pseudoInvites = this._card.getElementsByClassName("activity-pseudo-link-invite") as HTMLCollectionOf<HTMLAnchorElement>;
for (let i = 0; i < pseudoUsers.length; i++) {
const navigate = (event: Event) => {
event.preventDefault()
window.tabs.switch("accounts");
document.dispatchEvent(accountURLEvent(pseudoUsers[i].getAttribute("data-id")));
window.history.pushState(null, document.title, pseudoUsers[i].getAttribute("data-href"));
}
pseudoUsers[i].onclick = navigate;
pseudoUsers[i].onkeydown = navigate;
}
for (let i = 0; i < pseudoInvites.length; i++) {
const navigate = (event: Event) => {
event.preventDefault();
window.invites.reload(() => {
window.tabs.switch("invites");
document.dispatchEvent(inviteURLEvent(pseudoInvites[i].getAttribute("data-id")));
window.history.pushState(null, document.title, pseudoInvites[i].getAttribute("data-href"));
});
}
pseudoInvites[i].onclick = navigate;
pseudoInvites[i].onkeydown = navigate;
}
}
update = (act: activity) => {
this._act = act;
this.source_type = act.source_type;
this.invite_code = act.invite_code;
this.time = act.time;
this.source = act.source;
this.value = act.value;
this.type = act.type;
}
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status == 200) {
window.notifications.customSuccess("activityDeleted", window.lang.notif("activityDeleted"));
}
document.dispatchEvent(activityReload);
});
asElement = () => { return this._card; };
}
interface ActivitiesDTO {
activities: activity[];
last_page: boolean;
}
export class activityList {
private _activityList: HTMLElement;
private _activities: { [id: string]: Activity } = {};
private _ordering: string[] = [];
private _filterArea = document.getElementById("activity-filter-area");
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
private _notFoundPanel = document.getElementById("activity-not-found");
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
private _loader = document.getElementById("activity-loader");
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
private _keepSearchingButton = document.getElementById("activity-keep-searching");
private _totalRecords = document.getElementById("activity-total-records");
private _loadedRecords = document.getElementById("activity-loaded-records");
private _shownRecords = document.getElementById("activity-shown-records");
private _total: number;
private _loaded: number;
private _shown: number;
get total(): number { return this._total; }
set total(v: number) {
this._total = v;
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
}
get loaded(): number { return this._loaded; }
set loaded(v: number) {
this._loaded = v;
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
}
get shown(): number { return this._shown; }
set shown(v: number) {
this._shown = v;
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
}
private _search: Search;
private _ascending: boolean;
private _hasLoaded: boolean;
private _lastLoad: number;
private _page: number = 0;
private _lastPage: boolean;
setVisibility = (activities: string[], visible: boolean) => {
this._activityList.textContent = ``;
for (let id of this._ordering) {
if (visible && activities.indexOf(id) != -1) {
this._activityList.appendChild(this._activities[id].asElement());
} else if (!visible && activities.indexOf(id) == -1) {
this._activityList.appendChild(this._activities[id].asElement());
}
}
}
reload = () => {
this._lastLoad = Date.now();
this._lastPage = false;
this._loadMoreButton.textContent = window.lang.strings("loadMore");
this._loadMoreButton.disabled = false;
this._loadAllButton.classList.remove("unfocused");
this._loadAllButton.disabled = false;
this.total = 0;
this.loaded = 0;
this.shown = 0;
// this._page = 0;
let limit = 10;
if (this._page != 0) {
limit *= this._page+1;
};
let send = {
"type": [],
"limit": limit,
"page": 0,
"ascending": this.ascending
}
_get("/activity/count", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
this.total = req.response["count"] as number;
});
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
this._hasLoaded = true;
// Allow refreshes every 15s
this._refreshButton.disabled = true;
setTimeout(() => this._refreshButton.disabled = false, 15000);
let resp = req.response as ActivitiesDTO;
// FIXME: Don't destroy everything each reload!
this._activities = {};
this._ordering = [];
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
this._search.items = this._activities;
this._search.ordering = this._ordering;
this.loaded = this._ordering.length;
if (this._search.inSearch) {
this._search.onSearchBoxChange(true);
this._loadAllButton.classList.remove("unfocused");
} else {
this.shown = this.loaded;
this.setVisibility(this._ordering, true);
this._loadAllButton.classList.add("unfocused");
this._notFoundPanel.classList.add("unfocused");
}
}, true);
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
this._lastLoad = Date.now();
this._loadMoreButton.disabled = true;
// this._loadAllButton.disabled = true;
const timeout = setTimeout(() => {
this._loadMoreButton.disabled = false;
// this._loadAllButton.disabled = false;
}, 1000);
this._page += 1;
let send = {
"type": [],
"limit": 10,
"page": this._page,
"ascending": this._ascending
};
// this._activityList.classList.add("unfocused");
// addLoader(this._loader, false, true);
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
let resp = req.response as ActivitiesDTO;
this._lastPage = resp.last_page;
if (this._lastPage) {
clearTimeout(timeout);
this._loadMoreButton.disabled = true;
removeLoader(this._loadAllButton);
this._loadAllButton.classList.add("unfocused");
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
}
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
// this._search.items = this._activities;
// this._search.ordering = this._ordering;
this.loaded = this._ordering.length;
if (this._search.inSearch || loadAll) {
if (this._lastPage) {
loadAll = false;
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
if (callback) callback();
// removeLoader(this._loader);
// this._activityList.classList.remove("unfocused");
}, true);
}
private _queries: { [field: string]: QueryType } = {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"date": {
name: window.lang.strings("date"),
getter: "date",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
};
get ascending(): boolean { return this._ascending; }
set ascending(v: boolean) {
this._ascending = v;
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
if (this._hasLoaded) {
this.reload();
}
}
detectScroll = () => {
if (!this._hasLoaded) return;
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
// window.notifications.customSuccess("scroll", "Reached bottom.");
// Wait .5s between loads
if (this._lastLoad + 500 > Date.now()) return;
this.loadMore();
}
}
private _prevResultCount = 0;
private _notFoundCallback = (notFound: boolean) => {
if (notFound) this._loadMoreButton.classList.add("unfocused");
else this._loadMoreButton.classList.remove("unfocused");
if (notFound && !this._lastPage) {
this._keepSearchingButton.classList.remove("unfocused");
this._keepSearchingDescription.classList.remove("unfocused");
} else {
this._keepSearchingButton.classList.add("unfocused");
this._keepSearchingDescription.classList.add("unfocused");
}
};
constructor() {
this._activityList = document.getElementById("activity-card-list");
document.addEventListener("activity-reload", this.reload);
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
search: this._searchBox,
clearSearchButtonSelector: ".activity-search-clear",
queries: this._queries,
setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
this.shown = visibleCount;
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
else this._loadAllButton.classList.add("unfocused");
if (visibleCount < 10 || loadAll) {
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
}
this._prevResultCount = visibleCount;
}
}
this._search = new Search(conf);
this._search.generateFilterList();
this._hasLoaded = false;
this.ascending = false;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
this._loadMoreButton.onclick = () => this.loadMore();
this._loadAllButton.onclick = () => {
addLoader(this._loadAllButton, true);
this.loadMore(() => {}, true);
};
/* this._keepSearchingButton.onclick = () => {
addLoader(this._keepSearchingButton, true);
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
this._refreshButton.onclick = this.reload;
window.onscroll = this.detectScroll;
}
}

View File

@@ -40,6 +40,31 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt
req.send(JSON.stringify(data));
};
export const _download = (url: string, fname: string): void => {
let req = new XMLHttpRequest();
if (window.URLBase) { url = window.URLBase + url; }
req.open("GET", url, true);
req.responseType = 'blob';
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onload = (e: Event) => {
let link = document.createElement("a") as HTMLAnchorElement;
link.href = URL.createObjectURL(req.response);
link.download = fname;
link.dispatchEvent(new MouseEvent("click"));
};
req.send();
};
export const _upload = (url: string, formData: FormData): void => {
let req = new XMLHttpRequest();
if (window.URLBase) { url = window.URLBase + url; }
req.open("POST", url, true);
req.setRequestHeader("Authorization", "Bearer " + window.token);
// req.setRequestHeader('Content-Type', 'multipart/form-data');
req.send(formData);
};
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => {
let req = new XMLHttpRequest();
req.open("POST", window.URLBase + url, true);
@@ -199,9 +224,10 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) {
}
}
export function addLoader(el: HTMLElement, small: boolean = true) {
export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) {
if (!el.classList.contains("loader")) {
el.classList.add("loader");
if (relative) el.classList.add("rel");
if (small) { el.classList.add("loader-sm"); }
const dot = document.createElement("span") as HTMLSpanElement;
dot.classList.add("dot")
@@ -213,6 +239,7 @@ export function removeLoader(el: HTMLElement, small: boolean = true) {
if (el.classList.contains("loader")) {
el.classList.remove("loader");
el.classList.remove("loader-sm");
el.classList.remove("rel");
const dot = el.querySelector("span.dot");
if (dot) { dot.remove(); }
}

View File

@@ -261,6 +261,8 @@ class DOMInvite implements Invite {
}
}
focus = () => this._container.scrollIntoView({ behavior: "smooth", block: "center" });
constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data.
this._container = document.createElement('div') as HTMLDivElement;
@@ -423,6 +425,26 @@ export class inviteList implements inviteList {
invites: { [code: string]: DOMInvite };
focusInvite = (inviteCode: string, errorMsg: string = window.lang.notif("errorInviteNoLongerExists")) => {
for (let code of Object.keys(this.invites)) {
this.invites[code].expanded = code == inviteCode;
}
if (inviteCode in this.invites) this.invites[inviteCode].focus();
else window.notifications.customError("inviteDoesntExistError", errorMsg);
};
public static readonly _inviteURLEvent = "invite-url";
registerURLListener = () => document.addEventListener(inviteList._inviteURLEvent, (event: CustomEvent) => {
this.focusInvite(event.detail);
})
isInviteURL = () => { return window.location.pathname.startsWith(window.URLBase + "/invites/"); }
loadInviteURL = () => {
let inviteCode = window.location.pathname.split(window.URLBase + "/invites/")[1].split("?lang")[0];
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
}
constructor() {
this._list = document.getElementById('invites') as HTMLDivElement;
this.empty = true;
@@ -436,6 +458,8 @@ export class inviteList implements inviteList {
this.empty = true;
}
}, false);
this.registerURLListener();
}
get empty(): boolean { return this._empty; }
@@ -468,7 +492,7 @@ export class inviteList implements inviteList {
this._list.appendChild(domInv.asElement());
}
reload = () => _get("/invites", null, (req: XMLHttpRequest) => {
reload = (callback?: () => void) => _get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let data = req.response;
if (req.status == 200) {
@@ -497,10 +521,13 @@ export class inviteList implements inviteList {
this.invites[code].remove();
delete this.invites[code];
}
if (callback) callback();
}
})
}
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) };
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite {
let parsed: Invite = {};

View File

@@ -225,6 +225,7 @@ export class ProfileEditor {
enableReferrals = (name: string) => {
const referralsInviteSelect = document.getElementById("enable-referrals-profile-invites") as HTMLSelectElement;
const referralsExpiry = document.getElementById("enable-referrals-profile-expiry") as HTMLInputElement;
_get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
@@ -257,7 +258,7 @@ export class ProfileEditor {
"invite": referralsInviteSelect.value
};
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"], send, (req: XMLHttpRequest) => {
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"] + "/" + (referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 400) {
@@ -270,6 +271,7 @@ export class ProfileEditor {
}
});
};
referralsExpiry.checked = false;
window.modals.profiles.close();
window.modals.enableReferralsProfile.show();
};

390
ts/modules/search.ts Normal file
View File

@@ -0,0 +1,390 @@
const dateParser = require("any-date-parser");
export interface QueryType {
name: string;
description?: string;
getter: string;
bool: boolean;
string: boolean;
date: boolean;
dependsOnElement?: string; // Format for querySelector
show?: boolean;
}
export interface SearchConfiguration {
filterArea: HTMLElement;
sortingByButton: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
loadMore?: () => void;
}
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
}
export class Search {
private _c: SearchConfiguration;
private _ordering: string[] = [];
private _items: { [id: string]: SearchableItem };
inSearch: boolean;
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
let words: string[] = [];
let quoteSymbol = ``;
let queryStart = -1;
let lastQuote = -1;
for (let i = 0; i < query.length; i++) {
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
queryStart = i;
}
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
if (lastQuote != -1) {
lastQuote = -1;
quoteSymbol = ``;
} else {
lastQuote = i;
quoteSymbol = query[i];
}
}
if (query[i] == " " || i == query.length-1) {
if (lastQuote != -1) {
continue;
} else {
let end = i+1;
if (query[i] == " ") {
end = i;
while (i+1 < query.length && query[i+1] == " ") {
i += 1;
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
}
query = "";
for (let word of words) {
if (!word.includes(":")) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]];
if (queryFormat.bool) {
let isBool = false;
let boolState = false;
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
isBool = true;
boolState = true;
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
isBool = true;
boolState = false;
}
if (isBool) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}</span>
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!((value && boolState) || (!value && !boolState))) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
continue
}
}
if (queryFormat.string) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
if (!(value.includes(split[1]))) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
let unmodifiedValue = split[1];
if (compareType != 2) {
split[1] = split[1].substring(1);
}
if (compareType == 2) compareType = 0;
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in attempt) attempt.month -= 1;
let date: Date = (Date as any).fromString(split[1]) as Date;
console.log("Read", attempt, "and", date);
if ("invalid" in (date as any)) continue;
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
continue;
}
let value = new Date(unixValue*1000);
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
const temp = new Date(date.valueOf());
temp.setHours(value.getHours(), value.getMinutes());
value = temp;
console.log("just hours/minutes workaround, value set to", value);
}
let match = true;
if (compareType == 0) {
for (let pair of getterPairs) {
if (pair[0] in attempt) {
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
match = false;
break;
}
}
}
} else if (compareType == -1) {
match = (value < date);
} else if (compareType == 1) {
match = (value > date);
}
if (!match) {
result.splice(result.indexOf(id), 1);
}
}
}
}
return result;
}
showHideSearchOptionsHeader = () => {
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
this._c.searchOptionsHeader.classList.add("hidden");
}
}
get items(): { [id: string]: SearchableItem } { return this._items; }
set items(v: { [id: string]: SearchableItem }) {
this._items = v;
}
get ordering(): string[] { return this._ordering; }
set ordering(v: string[]) { this._ordering = v; }
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
const query = this._c.search.value;
if (!query) {
this.inSearch = false;
} else {
this.inSearch = true;
}
const results = this.search(query);
this._c.setVisibility(results, true);
this._c.onSearchCallback(results.length, newItems, loadAll);
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
}
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
}
fillInFilter = (name: string, value: string, offset?: number) => {
this._c.search.value = name + ":" + value + " " + this._c.search.value;
this._c.search.focus();
let newPos = name.length + 1 + value.length;
if (typeof offset !== 'undefined')
newPos += offset;
this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any);
};
generateFilterList = () => {
// Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) {
const query = this._c.queries[queryName];
if ("show" in query && !query.show) continue;
if ("dependsOnElement" in query && query.dependsOnElement) {
const el = document.querySelector(query.dependsOnElement);
if (el === null) continue;
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.innerHTML = `
<div class="flex flex-col mr-2">
<span>${query.name}</span>
<span class="support">${query.description || ""}</span>
</div>
`;
if (query.bool) {
const pos = document.createElement("button") as HTMLButtonElement;
pos.type = "button";
pos.ariaLabel = `Filter by "${query.name}": True`;
pos.classList.add("button", "~positive", "ml-2");
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
pos.addEventListener("click", () => this.fillInFilter(queryName, "true"));
const neg = document.createElement("button") as HTMLButtonElement;
neg.type = "button";
neg.ariaLabel = `Filter by "${query.name}": False`;
neg.classList.add("button", "~critical", "ml-2");
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
neg.addEventListener("click", () => this.fillInFilter(queryName, "false"));
container.appendChild(pos);
container.appendChild(neg);
}
if (query.string) {
const button = document.createElement("button") as HTMLButtonElement;
button.type = "button";
button.classList.add("button", "~urge", "ml-2");
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
// Position cursor between quotes
button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1));
container.appendChild(button);
}
if (query.date) {
const onDate = document.createElement("button") as HTMLButtonElement;
onDate.type = "button";
onDate.classList.add("button", "~urge", "ml-2");
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1));
const beforeDate = document.createElement("button") as HTMLButtonElement;
beforeDate.type = "button";
beforeDate.classList.add("button", "~urge", "ml-2");
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1));
const afterDate = document.createElement("button") as HTMLButtonElement;
afterDate.type = "button";
afterDate.classList.add("button", "~urge", "ml-2");
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1));
container.appendChild(onDate);
container.appendChild(beforeDate);
container.appendChild(afterDate);
}
this._c.filterList.appendChild(container);
}
}
constructor(c: SearchConfiguration) {
this._c = c;
this._c.search.oninput = () => this.onSearchBoxChange();
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
this.onSearchBoxChange();
});
}
}
}

View File

@@ -1,7 +1,14 @@
import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, insertText } from "../modules/common.js";
import { _get, _post, _delete, _download, _upload, toggleLoader, addLoader, removeLoader, insertText, toClipboard, toDateString } from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
interface BackupDTO {
size: string;
name: string;
path: string;
date: number;
}
interface settingsBoolEvent extends Event {
detail: boolean;
}
@@ -635,6 +642,8 @@ export class settingsList {
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement;
private _backupSortAscending = true;
addSection = (name: string, s: Section, subButton?: HTMLElement) => {
const section = new sectionPanel(s, name);
@@ -736,6 +745,68 @@ export class settingsList {
}
});
setBackupSort = (ascending: boolean) => {
this._backupSortAscending = ascending;
this._backupSortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${ascending ? "up" : "down"}-s-line ml-2"></i>`;
this._getBackups();
};
private _backup = () => _post("/backups", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
const backupDTO = req.response as BackupDTO;
if (backupDTO.path == "") {
window.notifications.customError("backupError", window.lang.strings("errorFailureCheckLogs"));
return;
}
const location = document.getElementById("settings-backed-up-location");
const download = document.getElementById("settings-backed-up-download");
location.innerHTML = window.lang.strings("backupCanBeFound").replace("{filepath}", `<span class="text-black dark:text-white font-mono bg-inherit">"`+backupDTO.path+`"</span>`);
download.innerHTML = `
<i class="ri-download-line"></i>
<span class="ml-2">${window.lang.strings("download")}</span>
<span class="badge ~info @low ml-2">${backupDTO.size}</span>
`;
download.parentElement.onclick = () => _download("/backups/" + backupDTO.name, backupDTO.name);
window.modals.backedUp.show();
}, true);
private _getBackups = () => _get("/backups", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
const backups = req.response["backups"] as BackupDTO[];
const table = document.getElementById("backups-list");
table.textContent = ``;
if (!this._backupSortAscending) {
backups.reverse();
}
for (let b of backups) {
const tr = document.createElement("tr") as HTMLTableRowElement;
tr.innerHTML = `
<td class="whitespace-nowrap"><span class="text-black dark:text-white font-mono bg-inherit">${b.name}</span> <span class="button ~info @low ml-2 backup-copy" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span></td>
<td>${toDateString(new Date(b.date*1000))}</td>
<td class="table-inline justify-center">
<span class="backup-download button ~positive @low" title="${window.lang.strings("backupDownload")}">
<i class="ri-download-line"></i>
<span class="badge ~positive @low ml-2">${b.size}</span>
</span>
<span class="backup-restore button ~critical @low ml-2 py-[inherit]" title="${window.lang.strings("backupRestore")}"><i class="icon ri-restart-line"></i></span>
</td>
`;
tr.querySelector(".backup-copy").addEventListener("click", () => {
toClipboard(b.path);
window.notifications.customPositive("pathCopied", "", window.lang.notif("pathCopied"));
});
tr.querySelector(".backup-download").addEventListener("click", () => _download("/backups/" + b.name, b.name));
tr.querySelector(".backup-restore").addEventListener("click", () => {
_post("/backups/restore/"+b.name, null, () => {});
window.modals.backups.close();
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
window.modals.settingsRefresh.show();
});
table.appendChild(tr);
}
});
constructor() {
this._sections = {};
this._buttons = {};
@@ -748,7 +819,32 @@ export class settingsList {
this._saveButton.onclick = this._save;
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
document.getElementById("settings-logs").onclick = this._showLogs;
document.getElementById("settings-backups-backup").onclick = () => {
window.modals.backups.close();
this._backup();
};
document.getElementById("settings-backups").onclick = () => {
this.setBackupSort(this._backupSortAscending);
window.modals.backups.show();
};
this._backupSortDirection.onclick = () => this.setBackupSort(!(this._backupSortAscending));
const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement;
const filedlg = document.getElementById("backups-file") as HTMLInputElement;
document.getElementById("settings-backups-upload").onclick = () => {
filedlg.click();
};
filedlg.addEventListener("change", () => {
if (filedlg.files.length == 0) return;
const form = new FormData();
form.append("backups-file", filedlg.files[0], filedlg.files[0].name);
_upload("/backups/restore", form);
window.modals.backups.close();
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
window.modals.settingsRefresh.show();
});
advancedEnableToggle.onchange = () => {
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
const parent = advancedEnableToggle.parentElement;

View File

@@ -20,7 +20,7 @@ export class Tabs implements Tabs {
get current(): string { return this._current; }
set current(tabID: string) { this.switch(tabID); }
switch = (tabID: string, noRun: boolean = false) => {
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
this._current = tabID;
for (let t of this.tabs) {
if (t.tabID == tabID) {
@@ -28,7 +28,7 @@ export class Tabs implements Tabs {
if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); }
document.dispatchEvent(new CustomEvent("tab-change", { detail: tabID }));
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
} else {
t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge");

View File

@@ -85,6 +85,13 @@ class Checkbox {
}
});
}
if (this._el.hasAttribute("checked")) {
this._el.checked = true;
} else {
this._el.checked = false;
}
this.broadcast();
}
}
@@ -315,10 +322,14 @@ const settings = {
"tls": new Checkbox(get("advanced-tls"), "", false, "advanced", "tls"),
"tls_port": new Input(get("advanced-tls_port"), "", "", "tls", true, "advanced"),
"tls_cert": new Input(get("advanced-tls_cert"), "", "", "tls", true, "advanced"),
"tls_key": new Input(get("advanced-tls_key"), "", "", "tls", true, "advanced")
"tls_key": new Input(get("advanced-tls_key"), "", "", "tls", true, "advanced"),
"proxy": new Checkbox(get("advanced-proxy"), "", false, "advanced", "proxy"),
"proxy_protocol": new Select(get("advanced-proxy_protocol"), "proxy", true, "advanced"),
"proxy_address": new Input(get("advanced-proxy_address"), "", "", "proxy", true, "advanced"),
"proxy_user": new Input(get("advanced-proxy_user"), "", "", "proxy", true, "advanced"),
"proxy_password": new Input(get("advanced-proxy_password"), "", "", "proxy", true, "advanced")
}
};
const checkTheme = () => {
if (settings["ui"]["theme"].value.includes("Dark")) {
document.documentElement.classList.add("dark-theme");
@@ -553,7 +564,12 @@ window.onpopstate = (event: PopStateEvent) => {
"type": settings["jellyfin"]["type"].value,
"server": settings["jellyfin"]["server"].value,
"username": settings["jellyfin"]["username"].value,
"password": settings["jellyfin"]["password"].value
"password": settings["jellyfin"]["password"].value,
"proxy": settings["advanced"]["proxy"].value == "true",
"proxy_protocol": settings["advanced"]["proxy_protocol"].value,
"proxy_address": settings["advanced"]["proxy_address"].value,
"proxy_user": settings["advanced"]["proxy_user"].value,
"proxy_password": settings["advanced"]["proxy_password"].value
};
_post("/jellyfin/test", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {

View File

@@ -80,7 +80,7 @@ declare interface Tabs {
current: string;
tabs: Array<Tab>;
addTab: (tabID: string, preFunc?: () => void, postFunc?: () => void) => void;
switch: (tabID: string, noRun?: boolean) => void;
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
}
declare interface Tab {
@@ -117,6 +117,8 @@ declare interface Modals {
email?: Modal;
enableReferralsUser?: Modal;
enableReferralsProfile?: Modal;
backedUp?: Modal;
backups?: Modal;
}
interface Invite {
@@ -139,7 +141,9 @@ interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: () => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
// Finally added to typescript, dont need this anymore.

View File

@@ -116,6 +116,7 @@ interface MyReferral {
remaining_uses: number;
no_limit: boolean;
expiry: number;
use_expiry: boolean;
}
interface ContactDTO {
@@ -252,6 +253,7 @@ class ReferralCard {
private _url: string;
private _expiry: Date;
private _expiryUnix: number;
private _useExpiry: boolean;
private _remainingUses: number;
private _noLimit: boolean;
@@ -259,6 +261,7 @@ class ReferralCard {
private _infoArea: HTMLDivElement;
private _remainingUsesEl: HTMLSpanElement;
private _expiryEl: HTMLSpanElement;
private _descriptionEl: HTMLSpanElement;
get code(): string { return this._code; }
set code(c: string) {
@@ -294,11 +297,22 @@ class ReferralCard {
this._expiry = new Date(expiryUnix * 1000);
this._expiryEl.textContent = toDateString(this._expiry);
}
get use_expiry(): boolean { return this._useExpiry; }
set use_expiry(v: boolean) {
this._useExpiry = v;
if (v) {
this._descriptionEl.textContent = window.lang.strings("referralsWithExpiryDescription");
} else {
this._descriptionEl.textContent = window.lang.strings("referralsDescription");
}
}
constructor(card: HTMLElement) {
this._card = card;
this._button = this._card.querySelector(".user-referrals-button") as HTMLButtonElement;
this._infoArea = this._card.querySelector(".user-referrals-info") as HTMLDivElement;
this._descriptionEl = this._card.querySelector(".user-referrals-description") as HTMLSpanElement;
this._infoArea.innerHTML = `
<div class="row my-3">
@@ -344,6 +358,7 @@ class ReferralCard {
this.no_limit = referral.no_limit;
this.expiry = referral.expiry;
this._card.classList.remove("unfocused");
this.use_expiry = referral.use_expiry;
};
}
@@ -620,9 +635,8 @@ document.addEventListener("details-reload", () => {
}
}
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
if (typeof(messageCard) != "undefined" && messageCard != null) {
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
setBestRowSpan(messageCard, false);
// contactCard.querySelector(".content").classList.add("h-100");
} else if (!statusCard.classList.contains("unfocused")) {

View File

@@ -211,7 +211,7 @@ func (ud *Updater) GetTag() (Tag, int, error) {
var tag Tag
err = json.Unmarshal(body, &tag)
if tag.Version == "" {
err = errors.New("Tag was empty")
err = errors.New("Tag at \"" + url + "\" was empty")
}
return tag, resp.StatusCode, err
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
type userDaemon struct {
@@ -60,10 +61,10 @@ func (app *appContext) checkUsers() {
return
}
mode := "disable"
termPlural := "Disabling"
term := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
termPlural = "Deleting"
term = "Deleting"
}
contact := false
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
@@ -94,19 +95,33 @@ func (app *appContext) checkUsers() {
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue
}
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
app.info.Printf("%s expired user \"%s\"", term, user.Name)
// Record activity
activity := Activity{
UserID: id,
SourceType: ActivityDaemon,
Time: time.Now(),
}
if mode == "delete" {
status, err = app.jf.DeleteUser(id)
activity.Type = ActivityDeletion
activity.Value = user.Name
} else if mode == "disable" {
user.Policy.IsDisabled = true
// Admins can't be disabled
user.Policy.IsAdministrator = false
status, err = app.jf.SetPolicy(id, user.Policy)
activity.Type = ActivityDisabled
}
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err)
continue
}
app.storage.SetActivityKey(shortuuid.New(), activity)
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
app.jf.CacheExpiry = time.Now()
if contact {

View File

@@ -17,6 +17,7 @@ import (
"github.com/golang-jwt/jwt"
"github.com/gomarkdown/markdown"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/steambap/captcha"
)
@@ -38,6 +39,10 @@ func (app *appContext) loadCSSHeader() string {
func (app *appContext) getURLBase(gc *gin.Context) string {
if strings.HasPrefix(gc.Request.URL.String(), app.URLBase) {
// Hack to fix the common URL base /accounts
if app.URLBase == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") {
return ""
}
return app.URLBase
}
return ""
@@ -229,6 +234,11 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != ""
}
if data["linkResetEnabled"].(bool) {
data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
data["resetPasswordEmail"] = app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
data["resetPasswordContactMethod"] = app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
}
pageMessagesExist := map[string]bool{}
pageMessages := map[string]CustomContent{}
@@ -270,7 +280,8 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
}
pwr, isInternal := app.internalPWRs[pin]
if isInternal && setPassword {
// if isInternal && setPassword {
if setPassword {
data["helpMessage"] = app.config.Section("ui").Key("help_message").String()
data["successMessage"] = app.config.Section("ui").Key("success_message").String()
data["jfLink"] = app.config.Section("ui").Key("redirect_url").String()
@@ -314,7 +325,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
var status int
var err error
var username string
if !isInternal {
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)
@@ -329,6 +340,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
}
username = pwr.Username
}
if (status == 200 || status == 204) && err == nil && (isInternal || resp.Success) {
data["success"] = true
data["pin"] = pin
@@ -338,6 +350,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
} else {
app.err.Printf("Password Reset failed (%d): %v", status, err)
}
// Only log PWRs we know the user for.
if username != "" {
jfUser, status, err := app.jf.UserByName(username, false)
if err == nil && status == 200 {
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityResetPassword,
UserID: jfUser.ID,
SourceType: ActivityUser,
Source: jfUser.ID,
Time: time.Now(),
})
}
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(username, false)
if status != 200 || err != nil {