Compare commits
149 Commits
paramateri
...
stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
788952d0a6 | ||
|
|
faf782458f | ||
|
|
863472657b | ||
|
|
b5f28da452 | ||
|
|
60ccc51232 | ||
|
|
1780aa567f | ||
|
|
6a8b21c5f2 | ||
|
|
fc4cd4cd27 | ||
|
|
465ed9f84f | ||
|
|
d88194b9bd | ||
|
|
6ebc7d18bf | ||
|
|
0fe574fbd9 | ||
|
|
c7ba9944f0 | ||
|
|
8781e48601 | ||
|
|
eb941794a8 | ||
|
|
0783749e6e | ||
|
|
87c0f54a8d | ||
|
|
febbe27a0d | ||
|
|
e67f1bf1a9 | ||
|
|
60dbfa2d1e | ||
|
|
0b43ad4ed5 | ||
|
|
94efe9f746 | ||
|
|
5fe0e0ab9f | ||
|
|
db1e812190 | ||
|
|
aab8d6ed77 | ||
|
|
5d49a56d94 | ||
|
|
492d5715fe | ||
|
|
0595224daa | ||
|
|
58098a45af | ||
|
|
a0bafadc39 | ||
|
|
2190f482d1 | ||
|
|
024b692b8c | ||
|
|
6a5e97b788 | ||
|
|
b8a1e416d4 | ||
|
|
3ea8f272f7 | ||
|
|
7c3f84ba9c | ||
|
|
0094ce7d57 | ||
|
|
c2b08a326d | ||
|
|
ba183660a9 | ||
|
|
423d8f5063 | ||
|
|
3c38a0edbf | ||
|
|
4df313fa43 | ||
|
|
35f1c06d34 | ||
|
|
12e745691e | ||
|
|
25ed44a5f3 | ||
|
|
4ea695d81e | ||
|
|
dd91a5cb86 | ||
|
|
9998aff69a | ||
|
|
5ebcb9d51c | ||
|
|
c60f93dfe8 | ||
|
|
95e77b2e21 | ||
|
|
5a335a1465 | ||
|
|
4e7256fb6c | ||
|
|
dd8119d952 | ||
|
|
4f8fd7fb5b | ||
|
|
bd573f34c0 | ||
|
|
37576f332c | ||
|
|
81f137eed1 | ||
|
|
cae22a9316 | ||
|
|
cbb8de01b7 | ||
|
|
a2e263a7d1 | ||
|
|
7a51acbfe4 | ||
|
|
aa04ede019 | ||
|
|
9cca1d97cd | ||
|
|
e7fcdf0e65 | ||
|
|
d123d6aa9e | ||
|
|
42d5785025 | ||
|
|
bdd14604d5 | ||
|
|
908e9f07c0 | ||
|
|
488ba7be38 | ||
|
|
a0165f6f02 | ||
|
|
92f825963a | ||
|
|
010ce5ff7a | ||
|
|
bc4c63b998 | ||
|
|
537b45951e | ||
|
|
a92f449e7f | ||
|
|
bcb6346f81 | ||
|
|
7cb66e26e5 | ||
|
|
41ddf73e4f | ||
|
|
d8cb4454b5 | ||
|
|
4f02c44e39 | ||
|
|
3c87b78dd9 | ||
|
|
eb619b6544 | ||
|
|
79e8b24d7a | ||
|
|
80fd7c9842 | ||
|
|
9409370984 | ||
|
|
006fde502e | ||
|
|
c02cfffc9b | ||
|
|
688e941d64 | ||
|
|
0fd3981d9b | ||
|
|
617f7ee133 | ||
|
|
42d1abe130 | ||
|
|
d8e624ad22 | ||
|
|
30acc4f9b8 | ||
|
|
c93211b68f | ||
|
|
1d7d82b793 | ||
|
|
b40abafb95 | ||
|
|
18f8921eba | ||
|
|
285215cf4b | ||
|
|
fe4097a724 | ||
|
|
364b010ceb | ||
|
|
37bdf50bb0 | ||
|
|
70e35b8bd7 | ||
|
|
2657e74803 | ||
|
|
372514709d | ||
|
|
c922dc5b50 | ||
|
|
6fff8a887e | ||
|
|
0a7093a3b4 | ||
|
|
d8fe593323 | ||
|
|
4dcec4b9c7 | ||
|
|
ac56ad1400 | ||
|
|
d09ee59a1a | ||
|
|
3299398806 | ||
|
|
b53120f271 | ||
|
|
1dfe13951f | ||
|
|
732ce1bc57 | ||
|
|
94e076401e | ||
|
|
fb83094532 | ||
|
|
dec5197bfd | ||
|
|
ebff016b5d | ||
|
|
da0dc7f1c0 | ||
|
|
f6044578c0 | ||
|
|
699cbee240 | ||
|
|
ef253de56b | ||
|
|
9715f90a48 | ||
|
|
792296e3bc | ||
|
|
31d3e52229 | ||
|
|
4a92712c90 | ||
|
|
47188da5c2 | ||
|
|
bdae52fad7 | ||
|
|
1ec3ddad9f | ||
|
|
64a144034d | ||
|
|
d0f740f99d | ||
|
|
58c7b695c9 | ||
|
|
b19efc4ee6 | ||
|
|
8ba6131d22 | ||
|
|
c5683dbc71 | ||
|
|
3067db9c31 | ||
|
|
28440a9096 | ||
|
|
07d02f8302 | ||
|
|
01a75c3e23 | ||
|
|
4cc5fd7189 | ||
|
|
16c5420c6f | ||
|
|
eab33d9f6d | ||
|
|
471021623b | ||
|
|
e7f4de2202 | ||
|
|
44e8035ff0 | ||
|
|
e38ac62ae4 | ||
|
|
b47a481678 |
@@ -12,6 +12,24 @@ clone:
|
||||
depth: 0
|
||||
|
||||
steps:
|
||||
- name: precompile
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
commands:
|
||||
- npm i
|
||||
- make precompile
|
||||
- go mod download
|
||||
- name: test
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
commands:
|
||||
- make test
|
||||
- name: build
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
|
||||
@@ -5,7 +5,6 @@ when:
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
secrets: [ BUILT_BY ]
|
||||
settings:
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
@@ -16,7 +15,8 @@ steps:
|
||||
registry: docker.io
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build_args:
|
||||
- BUILT_BY: $BUILT_BY
|
||||
- BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
- name: buildrone
|
||||
image: docker.io/python
|
||||
environment:
|
||||
|
||||
@@ -5,7 +5,6 @@ when:
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
secrets: [ BUILT_BY ]
|
||||
settings:
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
@@ -16,7 +15,8 @@ steps:
|
||||
registry: docker.io
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build_args:
|
||||
- BUILT_BY: $BUILT_BY
|
||||
- BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
- name: buildrone
|
||||
image: docker.io/python
|
||||
environment:
|
||||
|
||||
11
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
|
||||
.DEFAULT_GOAL := all
|
||||
|
||||
GOESBUILD ?= off
|
||||
@@ -97,7 +97,7 @@ else
|
||||
endif
|
||||
|
||||
ifeq (, $(shell which swag))
|
||||
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
|
||||
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
else
|
||||
SWAGINSTALL :=
|
||||
endif
|
||||
@@ -216,13 +216,16 @@ ifeq ($(INTERNAL), on)
|
||||
endif
|
||||
|
||||
GO_SRC = $(shell find ./ -name "*.go")
|
||||
GO_TARGET = build/jfa-go
|
||||
GO_TARGET = build/jfa-go
|
||||
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
|
||||
$(info Downloading deps)
|
||||
$(GOBINARY) mod download
|
||||
$(info Building)
|
||||
mkdir -p build
|
||||
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
|
||||
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
|
||||
|
||||
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
|
||||
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
|
||||
|
||||
all: $(BUILDDEPS) $(GO_TARGET)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
|
||||
|
||||
#### Does/Will it still work?
|
||||
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
|
||||
|
||||
255
activitysort.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
ACTIVITY_DEFAULT_SORT_FIELD = "Time"
|
||||
// This will be default anyway, as the default value of a bool field is false.
|
||||
// ACTIVITY_DEFAULT_SORT_ASCENDING = false
|
||||
)
|
||||
|
||||
func activityDTONameToField(field string) string {
|
||||
// Only "ID" and "Time" of these are actually searched by the UI.
|
||||
// We support the rest though for other consumers of the API.
|
||||
switch field {
|
||||
case "id":
|
||||
return "ID"
|
||||
case "type":
|
||||
return "Type"
|
||||
case "user_id":
|
||||
return "UserID"
|
||||
case "username":
|
||||
return "Username"
|
||||
case "source_type":
|
||||
return "SourceType"
|
||||
case "source":
|
||||
return "Source"
|
||||
case "source_username":
|
||||
return "SourceUsername"
|
||||
case "invite_code":
|
||||
return "InviteCode"
|
||||
case "value":
|
||||
return "Value"
|
||||
case "time":
|
||||
return "Time"
|
||||
case "ip":
|
||||
return "IP"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func activityTypeGetterNameToType(getter string) ActivityType {
|
||||
switch getter {
|
||||
case "accountCreation":
|
||||
return ActivityCreation
|
||||
case "accountDeletion":
|
||||
return ActivityDeletion
|
||||
case "accountDisabled":
|
||||
return ActivityDisabled
|
||||
case "accountEnabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "passwordChange":
|
||||
return ActivityChangePassword
|
||||
case "passwordReset":
|
||||
return ActivityResetPassword
|
||||
case "inviteCreated":
|
||||
return ActivityCreateInvite
|
||||
case "inviteDeleted":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
// andField appends to the existing query if not nil, and otherwise creates a new one.
|
||||
func andField(q *badgerhold.Query, field string) *badgerhold.Criterion {
|
||||
if q == nil {
|
||||
return badgerhold.Where(field)
|
||||
}
|
||||
return q.And(field)
|
||||
}
|
||||
|
||||
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
|
||||
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
// Special case for activity type:
|
||||
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
|
||||
// For other API consumers, we also handle the former later.
|
||||
activityType := activityTypeGetterNameToType(q.Field)
|
||||
if activityType != ActivityUnknown {
|
||||
criterion := andField(query, "Type")
|
||||
if q.Operator != EqualOperator {
|
||||
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
|
||||
}
|
||||
if q.Value.(bool) == true {
|
||||
query = criterion.Eq(activityType)
|
||||
} else {
|
||||
query = criterion.Ne(activityType)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
fieldName := activityDTONameToField(q.Field)
|
||||
// Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately).
|
||||
if fieldName == "unknown" || fieldName == "Time" {
|
||||
// Caller is expected to fall back to ActivityDBQueryFromSpecialField after this.
|
||||
return nil
|
||||
}
|
||||
criterion := andField(query, fieldName)
|
||||
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
query = criterion.Lt(q.Value)
|
||||
case EqualOperator:
|
||||
query = criterion.Eq(q.Value)
|
||||
case GreaterOperator:
|
||||
query = criterion.Gt(q.Value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app.
|
||||
func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
||||
var baseQuery *badgerhold.Query = nil
|
||||
// I don't believe you can just do Where("*"), so instead run for each field.
|
||||
// FIXME: Match username and source_username and source_type and type
|
||||
for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} {
|
||||
criterion := badgerhold.Where(fieldName)
|
||||
// No case-insentive Contains method, so we use MatchFunc instead
|
||||
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
// _, ok := field.(string)
|
||||
// if !ok {
|
||||
// return false, fmt.Errorf("field not string: %s", fieldName)
|
||||
// }
|
||||
lower := strings.ToLower(field.(string))
|
||||
for _, term := range terms {
|
||||
if strings.Contains(lower, term) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if baseQuery == nil {
|
||||
baseQuery = f
|
||||
} else {
|
||||
baseQuery = baseQuery.Or(f)
|
||||
}
|
||||
}
|
||||
|
||||
return baseQuery
|
||||
}
|
||||
|
||||
func (act Activity) SourceIsUser() bool {
|
||||
return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != ""
|
||||
}
|
||||
|
||||
func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
return act.Value
|
||||
}
|
||||
if act.UserID == "" {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.UserID, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if !act.SourceIsUser() {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.Source, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
switch q.Field {
|
||||
case "mentionedUsers":
|
||||
query = matchMentionedUsersAsQuery(jf, query, q)
|
||||
case "actor":
|
||||
query = matchActorAsQuery(jf, query, q)
|
||||
case "referrer":
|
||||
query = matchReferrerAsQuery(jf, query, q)
|
||||
case "time":
|
||||
query = matchTimeAsQuery(query, q)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown activity query field %s", q.Field))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type.
|
||||
func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "UserID")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf)
|
||||
return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchActorAsQuery is a custom match function for the "actor" getter/query type.
|
||||
func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "SourceType")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
matchString := activitySourceToString(act.SourceType)
|
||||
if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() {
|
||||
matchString += " " + act.MustGetSourceUsername(jf)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type.
|
||||
func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "Type")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
if act.Type != ActivityCreation || act.SourceType != ActivityUser || !act.SourceIsUser() {
|
||||
return false, nil
|
||||
}
|
||||
sourceUsername := act.MustGetSourceUsername(jf)
|
||||
if q.Class == BoolQuery {
|
||||
val := sourceUsername != ""
|
||||
if q.Value.(bool) == false {
|
||||
val = !val
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
return strings.Contains(strings.ToLower(sourceUsername), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go.
|
||||
func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
operator := Equal
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
operator = Lesser
|
||||
case EqualOperator:
|
||||
operator = Equal
|
||||
case GreaterOperator:
|
||||
operator = Greater
|
||||
}
|
||||
criterion := andField(query, "Time")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
return q.Value.(DateAttempt).CompareWithOperator(ra.Field().(time.Time), operator), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
@@ -6,32 +6,6 @@ import (
|
||||
"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:
|
||||
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
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 stringToActivitySource(v string) ActivitySource {
|
||||
switch v {
|
||||
case "user":
|
||||
@@ -86,73 +86,82 @@ func activitySourceToString(v ActivitySource) string {
|
||||
return "anon"
|
||||
}
|
||||
|
||||
// generateActivitiesQuery generates a badgerhold query from QueryDTOs and search terms, which can then be searched, counted, or whatever you want.
|
||||
func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerhold.Query {
|
||||
|
||||
var query *badgerhold.Query
|
||||
if len(req.SearchTerms) != 0 {
|
||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||
} else {
|
||||
query = nil
|
||||
}
|
||||
|
||||
for _, q := range req.Queries {
|
||||
nq := q.AsDBQuery(query)
|
||||
if nq == nil {
|
||||
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
|
||||
}
|
||||
query = nq
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
|
||||
// @Produce json
|
||||
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
|
||||
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
req := ServerSearchReqDTO{}
|
||||
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.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
} else {
|
||||
req.SortByField = activityDTONameToField(req.SortByField)
|
||||
}
|
||||
|
||||
query := app.generateActivitiesQuery(req.ServerFilterReqDTO)
|
||||
|
||||
query = query.SortBy(req.SortByField)
|
||||
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(lm.FailedDBReadActivities, err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
LastPage: len(results) != req.Limit,
|
||||
}
|
||||
|
||||
resp.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(),
|
||||
IP: act.IP,
|
||||
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(),
|
||||
IP: act.IP,
|
||||
Username: act.MustGetUsername(app.jf),
|
||||
SourceUsername: act.MustGetSourceUsername(app.jf),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
// Username would've been in here, clear it to avoid confusion to the consumer
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, err := app.jf.UserByID(act.Source, false)
|
||||
if err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +182,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
|
||||
|
||||
// @Summary Returns the total number of activities stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetActivityCountDTO
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := GetActivityCountDTO{}
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
@@ -186,3 +195,26 @@ func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Returns the total number of activities matching the given filtering. Fails silently.
|
||||
// @Produce json
|
||||
// @Param ServerFilterReqDTO body ServerFilterReqDTO true "search parameters"
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /activity/count [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
req := ServerFilterReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
|
||||
query := app.generateActivitiesQuery(req)
|
||||
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, query)
|
||||
if err != nil {
|
||||
// app.err.Printf(lm.FailedDBReadActivities, err)
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
119
api-invites.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -102,6 +104,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
|
||||
app.InvalidateWebUserCache()
|
||||
}
|
||||
}
|
||||
wait := app.sendAdminExpiryNotification(data)
|
||||
@@ -122,7 +125,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
|
||||
|
||||
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
|
||||
notify := data.Notify
|
||||
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 {
|
||||
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
|
||||
return nil
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
@@ -133,7 +136,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(data.Code, data, app, false)
|
||||
msg, err := app.email.constructExpiry(data, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
|
||||
} else {
|
||||
@@ -194,43 +197,47 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
invite.UserMinutes = req.UserMinutes
|
||||
}
|
||||
invite.ValidTill = validTill
|
||||
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
|
||||
} else if len(users) > 1 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
|
||||
} else {
|
||||
invite.SendTo = req.SendTo
|
||||
addressValid = true
|
||||
discord = users[0].User.ID
|
||||
}
|
||||
} else if emailEnabled {
|
||||
addressValid = true
|
||||
invite.SendTo = req.SendTo
|
||||
}
|
||||
if addressValid {
|
||||
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
|
||||
if err != nil {
|
||||
// Slight misuse of the template
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
|
||||
|
||||
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
} else {
|
||||
var err error
|
||||
if discord != "" {
|
||||
err = app.discord.SendDM(msg, discord)
|
||||
if req.SendTo != "" {
|
||||
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
|
||||
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
|
||||
} else {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
|
||||
} else if len(users) > 1 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
|
||||
} else {
|
||||
err = app.email.send(msg, req.SendTo)
|
||||
invite.SendTo = req.SendTo
|
||||
addressValid = true
|
||||
discord = users[0].User.ID
|
||||
}
|
||||
} else if emailEnabled {
|
||||
addressValid = true
|
||||
invite.SendTo = req.SendTo
|
||||
}
|
||||
if addressValid {
|
||||
msg, err := app.email.constructInvite(invite, false)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
|
||||
app.err.Println(invite.SendTo)
|
||||
// Slight misuse of the template
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
|
||||
|
||||
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
} else {
|
||||
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
|
||||
var err error
|
||||
if discord != "" {
|
||||
err = app.discord.SendDM(msg, discord)
|
||||
} else {
|
||||
err = app.email.send(msg, req.SendTo)
|
||||
}
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
|
||||
app.err.Println(invite.SendTo)
|
||||
} else {
|
||||
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +265,46 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Get the number of invites stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /invites/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) GetInviteCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false))
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get the number of invites stored in the database that have been used (but are still valid).
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /invites/count/used [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false).And("UsedBy").MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
switch usedBy := field.(type) {
|
||||
case [][]string:
|
||||
return len(usedBy) > 0, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get invites.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getInvitesDTO
|
||||
@@ -297,7 +344,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
// These used to be stored formatted instead of as a unix timestamp.
|
||||
unix, err := strconv.ParseInt(pair[1], 10, 64)
|
||||
if err != nil {
|
||||
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
|
||||
date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedParseTime, err)
|
||||
}
|
||||
|
||||
237
api-messages.go
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,25 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
if _, ok := app.storage.lang.Email[lang]; !ok {
|
||||
lang = app.storage.lang.chosenEmailLang
|
||||
}
|
||||
adminLang := lang
|
||||
if _, ok := app.storage.lang.Admin[lang]; !ok {
|
||||
adminLang = app.storage.lang.chosenAdminLang
|
||||
}
|
||||
list := emailListDTO{
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
|
||||
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
|
||||
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
|
||||
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
|
||||
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
|
||||
"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("UserLogin").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
|
||||
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
|
||||
list := emailListDTO{}
|
||||
for _, cc := range customContent {
|
||||
if cc.ContentType == CustomTemplate {
|
||||
continue
|
||||
}
|
||||
ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
|
||||
if cc.Description != nil {
|
||||
ccDescription.Description = cc.Description(&app.storage.lang, lang)
|
||||
}
|
||||
list[cc.Name] = ccDescription
|
||||
}
|
||||
|
||||
filter := gc.Query("filter")
|
||||
@@ -73,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
_, ok := customContent[id]
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
message.Content = req.Content
|
||||
message.Enabled = true
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
@@ -123,146 +114,91 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
lang := app.storage.lang.chosenEmailLang
|
||||
id := gc.Param("id")
|
||||
var content string
|
||||
var err error
|
||||
var msg *Message
|
||||
var variables []string
|
||||
var conditionals []string
|
||||
var values map[string]interface{}
|
||||
username := app.storage.lang.Email[lang].Strings.get("username")
|
||||
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
|
||||
customMessage, ok := app.storage.GetCustomContentKey(id)
|
||||
contentInfo, ok := customContent[id]
|
||||
// FIXME: Add announcement to customContent
|
||||
if !ok && id != "Announcement" {
|
||||
app.err.Printf(lm.FailedGetCustomMessage, id)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if id == "WelcomeEmail" {
|
||||
conditionals = []string{"{yourAccountWillExpire}"}
|
||||
customMessage.Conditionals = conditionals
|
||||
} else if id == "UserPage" {
|
||||
variables = []string{"{username}"}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "UserLogin" {
|
||||
variables = []string{}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "PostSignupCard" {
|
||||
variables = []string{"{username}", "{myAccountURL}"}
|
||||
customMessage.Variables = variables
|
||||
|
||||
content, ok := app.storage.GetCustomContentKey(id)
|
||||
|
||||
if contentInfo.Variables == nil {
|
||||
contentInfo.Variables = []string{}
|
||||
}
|
||||
if contentInfo.Conditionals == nil {
|
||||
contentInfo.Conditionals = []string{}
|
||||
}
|
||||
if contentInfo.Placeholders == nil {
|
||||
contentInfo.Placeholders = map[string]any{}
|
||||
}
|
||||
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
variables = customMessage.Variables
|
||||
// Generate content from real email, if the user hasn't already customised this message.
|
||||
if content.Content == "" {
|
||||
var msg *Message
|
||||
switch id {
|
||||
// FIXME: Add announcement to customContent
|
||||
case "UserCreated":
|
||||
msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
|
||||
case "InviteExpiry":
|
||||
msg, err = app.email.constructExpiry(Invite{}, true)
|
||||
case "PasswordReset":
|
||||
msg, err = app.email.constructReset(PasswordReset{}, true)
|
||||
case "UserDeleted":
|
||||
msg, err = app.email.constructDeleted("", "", true)
|
||||
case "UserDisabled":
|
||||
msg, err = app.email.constructDisabled("", "", true)
|
||||
case "UserEnabled":
|
||||
msg, err = app.email.constructEnabled("", "", true)
|
||||
case "UserExpiryAdjusted":
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
|
||||
case "ExpiryReminder":
|
||||
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
|
||||
case "InviteEmail":
|
||||
msg, err = app.email.constructInvite(Invite{Code: ""}, true)
|
||||
case "WelcomeEmail":
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, true)
|
||||
case "EmailConfirmation":
|
||||
msg, err = app.email.constructConfirmation("", "", "", true)
|
||||
case "UserExpired":
|
||||
msg, err = app.email.constructUserExpired("", true)
|
||||
case "Announcement":
|
||||
case "UserPage":
|
||||
case "UserLogin":
|
||||
case "PostSignupCard":
|
||||
// These don't have any example content
|
||||
msg = nil
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if msg != nil {
|
||||
content.Content = msg.Text
|
||||
}
|
||||
}
|
||||
switch id {
|
||||
case "Announcement":
|
||||
// Just send the email html
|
||||
content = ""
|
||||
case "UserCreated":
|
||||
if noContent {
|
||||
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
|
||||
case "InviteExpiry":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiry("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
|
||||
case "PasswordReset":
|
||||
if noContent {
|
||||
msg, err = app.email.constructReset(PasswordReset{}, app, true)
|
||||
}
|
||||
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
|
||||
case "UserDeleted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDeleted("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserDisabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDisabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserEnabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructEnabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserExpiryAdjusted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
|
||||
}
|
||||
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
|
||||
case "InviteEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructInvite("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
|
||||
case "WelcomeEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
|
||||
}
|
||||
values = app.email.welcomeValues(username, time.Now(), app, false, true)
|
||||
case "EmailConfirmation":
|
||||
if noContent {
|
||||
msg, err = app.email.constructConfirmation("", "", "", app, true)
|
||||
}
|
||||
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
|
||||
case "UserExpired":
|
||||
if noContent {
|
||||
msg, err = app.email.constructUserExpired(app, true)
|
||||
}
|
||||
values = app.email.userExpiredValues(app, false)
|
||||
case "UserLogin", "UserPage", "PostSignupCard":
|
||||
values = map[string]interface{}{}
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
|
||||
content = msg.Text
|
||||
variables = make([]string, strings.Count(content, "{"))
|
||||
i := 0
|
||||
found := false
|
||||
buf := ""
|
||||
for _, c := range content {
|
||||
if !found && c != '{' && c != '}' {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
buf += string(c)
|
||||
if c == '}' {
|
||||
found = false
|
||||
variables[i] = buf
|
||||
buf = ""
|
||||
i++
|
||||
}
|
||||
}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
if variables == nil {
|
||||
variables = []string{}
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, customMessage)
|
||||
var mail *Message
|
||||
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
|
||||
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
|
||||
var mail *Message = nil
|
||||
if contentInfo.ContentType == CustomMessage {
|
||||
mail, err = app.email.construct(EmptyCustomContent, CustomContent{
|
||||
Name: EmptyCustomContent.Name,
|
||||
Enabled: true,
|
||||
Content: "<div class=\"preview-content\"></div>",
|
||||
}, map[string]any{})
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
} else if id == "PostSignupCard" {
|
||||
// Jankiness follows.
|
||||
// Specific workaround for the currently-unique "Post signup card".
|
||||
// Source content from "Success Message" setting.
|
||||
if noContent {
|
||||
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
|
||||
if content.Content == "" {
|
||||
content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
|
||||
if app.config.Section("user_page").Key("enabled").MustBool(false) {
|
||||
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
|
||||
content.Content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
|
||||
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
|
||||
})
|
||||
}
|
||||
@@ -271,13 +207,15 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else {
|
||||
} else if contentInfo.ContentType == CustomCard {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else {
|
||||
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
|
||||
}
|
||||
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
|
||||
gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
|
||||
}
|
||||
|
||||
// @Summary Returns a new Telegram verification PIN, and the bot username.
|
||||
@@ -333,6 +271,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
}
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -398,6 +337,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -626,6 +566,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
Lang: "en-us",
|
||||
Contact: true,
|
||||
})
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -697,6 +638,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
}, gc, false)
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -734,6 +676,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -770,6 +713,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -799,5 +743,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
|
||||
func (app *appContext) CreateProfile(gc *gin.Context) {
|
||||
var req newProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
app.InvalidateJellyfinCache()
|
||||
user, err := app.jf.UserByID(req.ID, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
|
||||
@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
}
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, id)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
||||
msg, err := app.email.constructConfirmation("", name, key, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
@@ -643,7 +643,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
}, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
@@ -796,6 +796,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
gc.JSON(200, GetMyReferralRespDTO{
|
||||
Code: inv.Code,
|
||||
RemainingUses: inv.RemainingUses,
|
||||
|
||||
162
api-users.go
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -188,7 +189,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
|
||||
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
|
||||
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
@@ -261,7 +262,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
}
|
||||
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
|
||||
c.User.SetJellyfin(nu.User.ID)
|
||||
c.User.Store(&(app.storage))
|
||||
c.User.Store(app.storage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +290,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
continue
|
||||
}
|
||||
go func(addr string) {
|
||||
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
|
||||
msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
|
||||
} else {
|
||||
@@ -337,8 +338,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
// FIXME: figure these out in a nicer way? this relies on the current ordering,
|
||||
// which may not be fixed.
|
||||
if discordEnabled {
|
||||
if req.completeContactMethods[0].User != nil {
|
||||
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
|
||||
if req.completeContactMethods[0].User != nil {
|
||||
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
|
||||
}
|
||||
if telegramEnabled && req.completeContactMethods[1].User != nil {
|
||||
telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
|
||||
@@ -379,19 +380,6 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
"SetPolicy": map[string]string{},
|
||||
}
|
||||
sendMail := messagesEnabled
|
||||
var msg *Message
|
||||
var err error
|
||||
if sendMail {
|
||||
if req.Enabled {
|
||||
msg, err = app.email.constructEnabled(req.Reason, app, false)
|
||||
} else {
|
||||
msg, err = app.email.constructDisabled(req.Reason, app, false)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
activityType := ActivityDisabled
|
||||
if req.Enabled {
|
||||
activityType = ActivityEnabled
|
||||
@@ -403,6 +391,18 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
|
||||
continue
|
||||
}
|
||||
var msg *Message
|
||||
if sendMail {
|
||||
if req.Enabled {
|
||||
msg, err = app.email.constructEnabled(user.Name, req.Reason, false)
|
||||
} else {
|
||||
msg, err = app.email.constructDisabled(user.Name, req.Reason, false)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
err, _, _ = app.SetUserDisabled(user, !req.Enabled)
|
||||
if err != nil {
|
||||
errors["SetPolicy"][user.ID] = err.Error()
|
||||
@@ -426,7 +426,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
app.InvalidateUserCaches()
|
||||
if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 {
|
||||
gc.JSON(500, errors)
|
||||
return
|
||||
@@ -448,15 +448,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
gc.BindJSON(&req)
|
||||
errors := map[string]string{}
|
||||
sendMail := messagesEnabled
|
||||
var msg *Message
|
||||
var err error
|
||||
if sendMail {
|
||||
msg, err = app.email.constructDeleted(req.Reason, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
for _, userID := range req.Users {
|
||||
user, err := app.jf.UserByID(userID, false)
|
||||
if err != nil {
|
||||
@@ -464,6 +455,15 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
errors[userID] = err.Error()
|
||||
}
|
||||
|
||||
var msg *Message = nil
|
||||
if sendMail {
|
||||
msg, err = app.email.constructDeleted(user.Name, req.Reason, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
|
||||
deleted := false
|
||||
err, deleted = app.DeleteUser(user)
|
||||
if err != nil {
|
||||
@@ -494,7 +494,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
app.InvalidateUserCaches()
|
||||
if len(errors) == len(req.Users) {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf(lm.FailedDeleteUsers, lm.Jellyfin, errors[req.Users[0]])
|
||||
@@ -540,7 +540,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
|
||||
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
|
||||
return
|
||||
@@ -551,6 +551,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
|
||||
}(id, expiry.Expiry)
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
@@ -562,6 +563,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
|
||||
// @tags Users
|
||||
func (app *appContext) RemoveExpiry(gc *gin.Context) {
|
||||
app.storage.DeleteUserExpiryKey(gc.Param("id"))
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -623,6 +625,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
|
||||
inv.UseReferralExpiry = useExpiry
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
}
|
||||
|
||||
// @Summary Disable referrals for the given user(s).
|
||||
@@ -646,6 +649,7 @@ func (app *appContext) DisableReferralForUsers(gc *gin.Context) {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(u, user)
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -674,7 +678,10 @@ func (app *appContext) Announce(gc *gin.Context) {
|
||||
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
|
||||
continue
|
||||
}
|
||||
msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name)
|
||||
msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
|
||||
Enabled: true,
|
||||
Content: req.Message,
|
||||
}, map[string]any{"username": user.Name})
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
|
||||
respondBool(500, false, gc)
|
||||
@@ -687,7 +694,10 @@ func (app *appContext) Announce(gc *gin.Context) {
|
||||
}
|
||||
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
|
||||
} else {
|
||||
msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
|
||||
msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
|
||||
Enabled: true,
|
||||
Content: req.Message,
|
||||
}, map[string]any{"username": ""})
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
|
||||
respondBool(500, false, gc)
|
||||
@@ -807,7 +817,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
|
||||
app.internalPWRs[pwr.PIN] = pwr
|
||||
sendAddress := app.getAddressOrName(id)
|
||||
if sendAddress == "" || len(req.Users) == 1 {
|
||||
resp.Link, err = app.GenResetLink(pwr.PIN)
|
||||
resp.Link, err = GenResetLink(pwr.PIN)
|
||||
linkCount++
|
||||
if sendAddress == "" {
|
||||
resp.Manual = true
|
||||
@@ -820,7 +830,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
}, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, id, err)
|
||||
@@ -840,6 +850,8 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
// userSummary generates a respUser for to be displayed to the user, or sorted/filtered.
|
||||
// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation.
|
||||
func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
|
||||
@@ -894,7 +906,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
|
||||
}
|
||||
|
||||
// @Summary Get a list of Jellyfin users.
|
||||
// @Summary Returns the total number of Jellyfin users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /users/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetUserCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
users, err := app.jf.GetUsers(false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
resp.Count = uint64(len(users))
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get a list of -all- Jellyfin users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
@@ -903,19 +933,61 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
// @tags Users
|
||||
func (app *appContext) GetUsers(gc *gin.Context) {
|
||||
var resp getUsersDTO
|
||||
users, err := app.jf.GetUsers(false)
|
||||
resp.UserList = make([]respUser, len(users))
|
||||
// We're sending all users, so this is always true
|
||||
resp.LastPage = true
|
||||
var err error
|
||||
resp.UserList, err = app.userCache.GetUserDTOs(app, true)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
i := 0
|
||||
for _, jfUser := range users {
|
||||
user := app.userSummary(jfUser)
|
||||
resp.UserList[i] = user
|
||||
i++
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get a paginated, searchable list of Jellyfin users.
|
||||
// @Produce json
|
||||
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
|
||||
// @Success 200 {object} getUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /users [post]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) SearchUsers(gc *gin.Context) {
|
||||
req := ServerSearchReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
if req.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
}
|
||||
|
||||
var resp getUsersDTO
|
||||
userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
var filtered []*respUser
|
||||
if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
|
||||
filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
|
||||
} else {
|
||||
filtered = slices.Clone(userList)
|
||||
}
|
||||
|
||||
if req.SortByField == USER_DEFAULT_SORT_FIELD {
|
||||
if req.Ascending != USER_DEFAULT_SORT_ASCENDING {
|
||||
slices.Reverse(filtered)
|
||||
}
|
||||
} else {
|
||||
app.userCache.Sort(filtered, req.SortByField, req.Ascending)
|
||||
}
|
||||
|
||||
startIndex := (req.Page * req.Limit)
|
||||
if startIndex < len(filtered) {
|
||||
endIndex := min(startIndex+req.Limit, len(filtered))
|
||||
resp.UserList = filtered[startIndex:endIndex]
|
||||
}
|
||||
resp.LastPage = len(resp.UserList) != req.Limit
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
@@ -948,6 +1020,7 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) {
|
||||
app.info.Printf(lm.UserAdminAdjusted, id, admin)
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
@@ -980,6 +1053,7 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
@@ -1019,6 +1093,7 @@ func (app *appContext) modifyEmail(jfID string, addr string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
}
|
||||
|
||||
// @Summary Modify user's email addresses.
|
||||
@@ -1118,7 +1193,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
|
||||
} else if req.From == "user" {
|
||||
applyingFromType = lm.User
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
app.InvalidateJellyfinCache()
|
||||
user, err := app.jf.UserByID(req.ID, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, req.ID, lm.Jellyfin, err)
|
||||
@@ -1229,5 +1304,6 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) {
|
||||
code = 500
|
||||
}
|
||||
app.InvalidateUserCaches()
|
||||
gc.JSON(code, errors)
|
||||
}
|
||||
|
||||
21
api.go
@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
|
||||
gc.Abort()
|
||||
}
|
||||
|
||||
func (app *appContext) loadStrftime() {
|
||||
app.datePattern = app.config.Section("messages").Key("date_format").String()
|
||||
app.timePattern = `%H:%M`
|
||||
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
|
||||
app.timePattern = `%I:%M %p`
|
||||
}
|
||||
func prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, datePattern)
|
||||
time = timefmt.Format(dt, timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, app.datePattern)
|
||||
time = timefmt.Format(dt, app.timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) formatDatetime(dt time.Time) string {
|
||||
d, t := app.prettyTime(dt)
|
||||
func formatDatetime(dt time.Time) string {
|
||||
d, t := prettyTime(dt)
|
||||
return d + " " + t
|
||||
}
|
||||
|
||||
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
if req["restart-program"] != nil && req["restart-program"].(bool) {
|
||||
app.Restart()
|
||||
}
|
||||
app.loadConfig()
|
||||
app.ReloadConfig()
|
||||
// Patch new settings for next GetConfig
|
||||
app.PatchConfigBase()
|
||||
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
|
||||
|
||||
18
args.go
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
func (app *appContext) loadArgs(firstCall bool) {
|
||||
@@ -28,6 +30,9 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
|
||||
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
|
||||
|
||||
flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!")
|
||||
flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.")
|
||||
|
||||
flag.Parse()
|
||||
if *help {
|
||||
flag.Usage()
|
||||
@@ -45,6 +50,19 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
if *_LOADBAK != "" {
|
||||
LOADBAK = *_LOADBAK
|
||||
}
|
||||
|
||||
if NO_API_AUTH_DO_NOT_USE && *DEBUG {
|
||||
NO_API_AUTH_DO_NOT_USE = false
|
||||
forceJfID := NO_API_AUTH_FORCE_JFID
|
||||
NO_API_AUTH_FORCE_JFID = ""
|
||||
buf := bufio.NewReader(os.Stdin)
|
||||
app.err.Print(lm.NoAPIAuthPrompt)
|
||||
sentence, err := buf.ReadBytes('\n')
|
||||
if err == nil && strings.ContainsRune(string(sentence), 'y') {
|
||||
NO_API_AUTH_DO_NOT_USE = true
|
||||
NO_API_AUTH_FORCE_JFID = forceJfID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv("SWAGGER") == "1" {
|
||||
|
||||
61
auth.go
@@ -40,7 +40,11 @@ func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
|
||||
}
|
||||
|
||||
func (app *appContext) webAuth() gin.HandlerFunc {
|
||||
return app.authenticate
|
||||
if NO_API_AUTH_DO_NOT_USE {
|
||||
return app.bogusAuthenticate
|
||||
} else {
|
||||
return app.authenticate
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
|
||||
@@ -138,6 +142,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID.
|
||||
func (app *appContext) bogusAuthenticate(gc *gin.Context) {
|
||||
gc.Set("jfId", NO_API_AUTH_FORCE_JFID)
|
||||
gc.Set("userId", NO_API_AUTH_FORCE_JFID)
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
func checkToken(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
|
||||
@@ -165,6 +176,31 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
|
||||
// 1. "Allow all" is enabled, so simply being a user implies access.
|
||||
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
|
||||
return true
|
||||
}
|
||||
// 2. You've been made an "accounts admin" from the accounts tab.
|
||||
if emailStore.Admin {
|
||||
return true
|
||||
}
|
||||
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
|
||||
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
|
||||
user, err := app.jf.UserByID(jfID, false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
return app.canAccessAdminPage(user, emailStore)
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, err := app.authJf.Authenticate(username, password)
|
||||
@@ -220,18 +256,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
jfID = user.ID
|
||||
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
|
||||
accountsAdmin := false
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
accountsAdmin = emailStore.Admin
|
||||
}
|
||||
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
|
||||
if !accountsAdmin {
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
accountsAdmin := app.canAccessAdminPage(user, emailStore)
|
||||
if !accountsAdmin {
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
// New users are only added when using jellyfinLogin.
|
||||
userID = shortuuid.New()
|
||||
@@ -247,8 +277,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
// host := gc.Request.URL.Hostname()
|
||||
host := app.ExternalDomain
|
||||
host := app.ExternalDomainNoPort(gc)
|
||||
|
||||
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
@@ -310,7 +339,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
// host := gc.Request.URL.Hostname()
|
||||
host := app.ExternalDomain
|
||||
host := app.ExternalDomainNoPort(gc)
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
||||
10
backups.go
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
BACKUP_PREFIX = "jfa-go-db"
|
||||
BACKUP_PREFIX_OLD = "jfa-go-db-"
|
||||
BACKUP_COMMIT_PREFIX = "-c-"
|
||||
BACKUP_DATE_PREFIX = "-d-"
|
||||
BACKUP_UPLOAD_PREFIX = "upload-"
|
||||
@@ -33,7 +34,7 @@ func (b Backup) Equals(a Backup) bool {
|
||||
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
|
||||
}
|
||||
|
||||
// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
|
||||
// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
|
||||
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
|
||||
|
||||
func (b Backup) String() string {
|
||||
@@ -213,7 +214,6 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
|
||||
count += 1
|
||||
backupsByCommit[b.Commit] = count
|
||||
}
|
||||
fmt.Printf("remaining:%+v\n", backupsByCommit)
|
||||
}
|
||||
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
|
||||
if toDelete > 0 && toDelete <= backups.count {
|
||||
@@ -274,8 +274,10 @@ func (app *appContext) loadPendingBackup() {
|
||||
}
|
||||
app.info.Printf(lm.MoveOldDB, oldPath)
|
||||
|
||||
app.ConnectDB()
|
||||
defer app.storage.db.Close()
|
||||
if err := app.storage.Connect(app.config); err != nil {
|
||||
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
|
||||
}
|
||||
defer app.storage.Close()
|
||||
|
||||
f, err := os.Open(LOADBAK)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupParserOld(t *testing.T) {
|
||||
Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A1 := Backup{}
|
||||
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q1, A1, t)
|
||||
}
|
||||
func TestBackupParserOldUpload(t *testing.T) {
|
||||
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A2 := Backup{
|
||||
Upload: true,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
@@ -155,3 +156,11 @@ func decodeResp(resp *http.Response) (string, error) {
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods.
|
||||
type MustAuthenticateOptions struct {
|
||||
RetryCount int // Number of Retries before failure.
|
||||
RetryGap time.Duration // Duration to wait between tries.
|
||||
LogFailures bool // Whether or not to print failures to the log.
|
||||
Counter int // The current retry count.
|
||||
}
|
||||
|
||||
426
config.go
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,23 +12,31 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
*ini.File
|
||||
proxyTransport *http.Transport
|
||||
proxyConfig *easyproxy.ProxyConfig
|
||||
}
|
||||
|
||||
var emailEnabled = false
|
||||
var messagesEnabled = false
|
||||
var telegramEnabled = false
|
||||
var discordEnabled = false
|
||||
var matrixEnabled = false
|
||||
|
||||
// URL subpaths. Ignore the "Current" field.
|
||||
// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating.
|
||||
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
|
||||
var PAGES = PagePaths{}
|
||||
|
||||
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := app.config.Section(sect).Key(key).MustString("")
|
||||
func (config *Config) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := config.Section(sect).Key(key).MustString("")
|
||||
if strings.HasPrefix(val, "jfa-go:") {
|
||||
return localFS, strings.TrimPrefix(val, "jfa-go:")
|
||||
}
|
||||
@@ -34,182 +44,284 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
return os.DirFS(dir), file
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetValue(section, key, val string) {
|
||||
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
|
||||
func (config *Config) MustSetValue(section, key, val string) {
|
||||
config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetURLPath(section, key, val string) {
|
||||
func (config *Config) MustSetURLPath(section, key, val string) {
|
||||
if !strings.HasPrefix(val, "/") && val != "" {
|
||||
val = "/" + val
|
||||
}
|
||||
app.MustSetValue(section, key, val)
|
||||
config.MustSetValue(section, key, val)
|
||||
}
|
||||
|
||||
func FormatSubpath(path string) string {
|
||||
func FixFullURL(v string) string {
|
||||
// Keep relative paths relative
|
||||
if strings.HasPrefix(v, "/") {
|
||||
return v
|
||||
}
|
||||
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
|
||||
v = "http://" + v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func FormatSubpath(path string, removeSingleSlash bool) string {
|
||||
if path == "/" {
|
||||
return ""
|
||||
if removeSingleSlash {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func (app *appContext) loadConfig() error {
|
||||
var err error
|
||||
app.config, err = ini.ShadowLoad(app.configPath)
|
||||
func (config *Config) MustCorrectURL(section, key, value string) {
|
||||
v := config.Section(section).Key(key).String()
|
||||
if v == "" {
|
||||
v = value
|
||||
}
|
||||
v = FixFullURL(v)
|
||||
config.Section(section).Key(key).SetValue(v)
|
||||
}
|
||||
|
||||
// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
|
||||
func ExternalDomain(gc *gin.Context) string {
|
||||
if !UseProxyHost || gc.Request.Host == "" {
|
||||
return externalDomain
|
||||
}
|
||||
return gc.Request.Host
|
||||
}
|
||||
|
||||
// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
|
||||
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
|
||||
domain := ExternalDomain(gc)
|
||||
host, _, err := net.SplitHostPort(domain)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
|
||||
// When nil is passed, externalURI is returned.
|
||||
func ExternalURI(gc *gin.Context) string {
|
||||
if gc == nil {
|
||||
return externalURI
|
||||
}
|
||||
|
||||
var proto string
|
||||
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
|
||||
proto = "https://"
|
||||
} else {
|
||||
proto = "http://"
|
||||
}
|
||||
|
||||
// app.debug.Printf("Request: %+v\n", gc.Request)
|
||||
if UseProxyHost && gc.Request.Host != "" {
|
||||
return proto + gc.Request.Host + PAGES.Base
|
||||
}
|
||||
return externalURI
|
||||
}
|
||||
|
||||
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return path
|
||||
}
|
||||
|
||||
var proto string
|
||||
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
|
||||
proto = "https://"
|
||||
} else {
|
||||
proto = "http://"
|
||||
}
|
||||
|
||||
return proto + ExternalDomain(gc) + path
|
||||
}
|
||||
|
||||
// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
|
||||
func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
|
||||
var err error
|
||||
config := &Config{}
|
||||
config.File, err = ini.ShadowLoad(configPathOrContents)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
// URLs
|
||||
app.MustSetURLPath("ui", "url_base", "")
|
||||
app.MustSetURLPath("url_paths", "admin", "")
|
||||
app.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
app.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String())
|
||||
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String())
|
||||
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String())
|
||||
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String())
|
||||
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) {
|
||||
config.MustSetURLPath("ui", "url_base", "")
|
||||
config.MustSetURLPath("url_paths", "admin", "")
|
||||
config.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
config.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
|
||||
PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
|
||||
PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
|
||||
PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
|
||||
if !(config.Section("user_page").Key("enabled").MustBool(true)) {
|
||||
PAGES.MyAccount = "disabled"
|
||||
}
|
||||
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
|
||||
app.err.Printf(lm.BadURLBase, PAGES.Base)
|
||||
logs.err.Printf(lm.BadURLBase, PAGES.Base)
|
||||
}
|
||||
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
|
||||
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
|
||||
|
||||
for _, key := range app.config.Section("files").Keys() {
|
||||
config.MustCorrectURL("jellyfin", "server", "")
|
||||
config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
|
||||
config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
for _, key := range config.Section("files").Keys() {
|
||||
if name := key.Name(); name != "html_templates" && name != "lang_files" {
|
||||
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
|
||||
key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
|
||||
}
|
||||
for _, key := range []string{"matrix_sql"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
|
||||
}
|
||||
|
||||
app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(app.ExternalURI, PAGES.Base) {
|
||||
app.err.Println(lm.NoURLSuffix)
|
||||
// If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
|
||||
UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
|
||||
externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(externalURI, PAGES.Base) {
|
||||
logs.err.Println(lm.NoURLSuffix)
|
||||
}
|
||||
if app.ExternalURI == "" {
|
||||
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
|
||||
if externalURI == "" {
|
||||
if UseProxyHost {
|
||||
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
|
||||
} else {
|
||||
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(app.ExternalURI)
|
||||
u, err := url.Parse(externalURI)
|
||||
if err == nil {
|
||||
app.ExternalDomain = u.Hostname()
|
||||
externalDomain = u.Hostname()
|
||||
}
|
||||
|
||||
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
|
||||
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
|
||||
|
||||
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
|
||||
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
|
||||
// FIXME: Remove all these, eventually
|
||||
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
|
||||
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
|
||||
|
||||
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
|
||||
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
|
||||
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
|
||||
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
|
||||
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
app.MustSetValue("smtp", "auth_type", "4")
|
||||
app.MustSetValue("smtp", "port", "465")
|
||||
|
||||
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, "/"), "!"))
|
||||
|
||||
jfUrl := app.config.Section("jellyfin").Key("server").String()
|
||||
if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
|
||||
app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
|
||||
}
|
||||
// config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
// Deletion template is good enough for these as well.
|
||||
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
// config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
// config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
|
||||
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
// config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
// config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
config.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
// config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
// config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
|
||||
app.MustSetValue("email", "collect", "true")
|
||||
// config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
|
||||
// config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
|
||||
|
||||
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "show_on_reg", "true")
|
||||
fnameSettingSuffix := []string{"html", "text"}
|
||||
fnameExtension := []string{"html", "txt"}
|
||||
|
||||
app.MustSetValue("discord", "show_on_reg", "true")
|
||||
for _, cc := range customContent {
|
||||
if cc.SourceFile.DefaultValue == "" {
|
||||
continue
|
||||
}
|
||||
for i := range fnameSettingSuffix {
|
||||
config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
|
||||
}
|
||||
}
|
||||
|
||||
app.MustSetValue("telegram", "show_on_reg", "true")
|
||||
config.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
config.MustSetValue("smtp", "cert_validation", "true")
|
||||
config.MustSetValue("smtp", "auth_type", "4")
|
||||
config.MustSetValue("smtp", "port", "465")
|
||||
|
||||
app.MustSetValue("backups", "every_n_minutes", "1440")
|
||||
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
|
||||
app.MustSetValue("backups", "keep_n_backups", "20")
|
||||
app.MustSetValue("backups", "keep_previous_version_backup", "true")
|
||||
config.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
config.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
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))
|
||||
sc := config.Section("discord").Key("start_command").MustString("start")
|
||||
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
config.MustSetValue("email", "collect", "true")
|
||||
|
||||
app.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
app.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
config.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
app.MustSetValue("ui", "port", "8056")
|
||||
app.MustSetValue("advanced", "tls_port", "8057")
|
||||
config.MustSetValue("discord", "show_on_reg", "true")
|
||||
|
||||
app.MustSetValue("advanced", "value_log_size", "512")
|
||||
config.MustSetValue("telegram", "show_on_reg", "true")
|
||||
|
||||
config.MustSetValue("backups", "every_n_minutes", "1440")
|
||||
config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
|
||||
config.MustSetValue("backups", "keep_n_backups", "20")
|
||||
config.MustSetValue("backups", "keep_previous_version_backup", "true")
|
||||
|
||||
config.Section("jellyfin").Key("version").SetValue(version)
|
||||
config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
|
||||
config.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
|
||||
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
config.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
config.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
|
||||
config.MustSetValue("ui", "port", "8056")
|
||||
config.MustSetValue("advanced", "tls_port", "8057")
|
||||
|
||||
config.MustSetValue("advanced", "value_log_size", "512")
|
||||
|
||||
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) {
|
||||
if config.Section("user_page").Key(v).MustBool(true) {
|
||||
allDisabled = false
|
||||
}
|
||||
}
|
||||
if allDisabled {
|
||||
app.info.Println(lm.EnableAllPWRMethods)
|
||||
logs.info.Println(lm.EnableAllPWRMethods)
|
||||
for _, v := range pwrMethods {
|
||||
app.config.Section("user_page").Key(v).SetValue("true")
|
||||
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)
|
||||
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
|
||||
messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
|
||||
discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
|
||||
matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
|
||||
if !messagesEnabled {
|
||||
emailEnabled = false
|
||||
telegramEnabled = false
|
||||
discordEnabled = false
|
||||
matrixEnabled = false
|
||||
} else if app.config.Section("email").Key("method").MustString("") == "" {
|
||||
} else if config.Section("email").Key("method").MustString("") == "" {
|
||||
emailEnabled = false
|
||||
} else {
|
||||
emailEnabled = true
|
||||
@@ -218,31 +330,64 @@ func (app *appContext) loadConfig() error {
|
||||
messagesEnabled = false
|
||||
}
|
||||
|
||||
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
|
||||
app.proxyConfig = easyproxy.ProxyConfig{}
|
||||
app.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
app.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
|
||||
config.proxyConfig = &easyproxy.ProxyConfig{}
|
||||
config.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
config.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
}
|
||||
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
|
||||
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
|
||||
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
|
||||
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
|
||||
config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
|
||||
config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
|
||||
config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
|
||||
config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
|
||||
logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
|
||||
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
|
||||
// Since we don't crash on this failing.
|
||||
time.Sleep(15 * time.Second)
|
||||
app.proxyEnabled = false
|
||||
config.proxyConfig = nil
|
||||
config.proxyTransport = nil
|
||||
} else {
|
||||
app.proxyEnabled = true
|
||||
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
|
||||
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
app.MustSetValue("updates", "enabled", "true")
|
||||
releaseChannel := app.config.Section("updates").Key("channel").String()
|
||||
if app.config.Section("updates").Key("enabled").MustBool(false) {
|
||||
config.MustSetValue("updates", "enabled", "true")
|
||||
|
||||
substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
}
|
||||
|
||||
datePattern = config.Section("messages").Key("date_format").String()
|
||||
timePattern = `%H:%M`
|
||||
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
|
||||
timePattern = `%I:%M %p`
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
|
||||
func (config *Config) ReloadDependents(app *appContext) {
|
||||
oldFormLang := config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
|
||||
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
releaseChannel := config.Section("updates").Key("channel").String()
|
||||
if config.Section("updates").Key("enabled").MustBool(false) {
|
||||
v := version
|
||||
if releaseChannel == "stable" {
|
||||
if version == "git" {
|
||||
@@ -251,9 +396,9 @@ func (app *appContext) loadConfig() error {
|
||||
} else if releaseChannel == "unstable" {
|
||||
v = "git"
|
||||
}
|
||||
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if app.proxyEnabled {
|
||||
app.updater.SetTransport(app.proxyTransport)
|
||||
app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if config.proxyTransport != nil {
|
||||
app.updater.SetTransport(config.proxyTransport)
|
||||
}
|
||||
}
|
||||
if releaseChannel == "" {
|
||||
@@ -262,32 +407,21 @@ func (app *appContext) loadConfig() error {
|
||||
} else {
|
||||
releaseChannel = "stable"
|
||||
}
|
||||
app.MustSetValue("updates", "channel", releaseChannel)
|
||||
config.MustSetValue("updates", "channel", releaseChannel)
|
||||
}
|
||||
|
||||
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
app.email = NewEmailer(config, app.storage, app.LoggerSet)
|
||||
}
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := app.config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
func (app *appContext) ReloadConfig() {
|
||||
var err error = nil
|
||||
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
|
||||
}
|
||||
|
||||
oldFormLang := app.config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
app.email = NewEmailer(app)
|
||||
|
||||
return nil
|
||||
app.config.ReloadDependents(app)
|
||||
app.info.Printf(lm.LoadConfig, app.configPath)
|
||||
}
|
||||
|
||||
func (app *appContext) PatchConfigBase() {
|
||||
|
||||
@@ -65,6 +65,20 @@ sections:
|
||||
type: number
|
||||
value: 30
|
||||
description: Timeout of user cache in minutes. Set to 0 to disable.
|
||||
- setting: web_cache_async_timeout
|
||||
name: User search cache asynchronous timeout (minutes)
|
||||
requires_restart: true
|
||||
advanced: true
|
||||
type: number
|
||||
value: 1
|
||||
description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
|
||||
- setting: web_cache_sync_timeout
|
||||
name: User search cache synchronous timeout (minutes)
|
||||
requires_restart: true
|
||||
advanced: true
|
||||
type: number
|
||||
value: 10
|
||||
description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
|
||||
- setting: type
|
||||
name: Server type
|
||||
requires_restart: true
|
||||
@@ -201,12 +215,15 @@ sections:
|
||||
- setting: jfa_url
|
||||
name: External jfa-go URL
|
||||
required: true
|
||||
depends_true: enabled
|
||||
type: text
|
||||
value: http://accounts.jellyf.in:8056
|
||||
description: The URL at which the jfa-go root (usually the admin page) is accessible, including
|
||||
the subfolder if you use one. This is necessary because using a reverse proxy
|
||||
means the program has no way of knowing the URL itself.
|
||||
the subfolder if you use one. While your reverse proxy should report this anyway, server-side actions like sending invite messages don't receive such wisdom.
|
||||
- setting: use_proxy_host
|
||||
name: Use reverse-proxy reported "Host" when possible
|
||||
type: bool
|
||||
value: false
|
||||
description: If enabled, the "Host" reported by your reverse proxy will be used in the web app, rather than the "External jfa-go URL" value. Useful if you regularly access jfa-go from more than one host/domain. Also, make sure your proxy passes X-Forwarded-Proto/X-Forwarded-Protocol.
|
||||
- setting: url_base
|
||||
name: Reverse Proxy subfolder
|
||||
requires_restart: true
|
||||
@@ -791,6 +808,7 @@ sections:
|
||||
options:
|
||||
- ["ssl_tls", "SSL/TLS"]
|
||||
- ["starttls", "STARTTLS"]
|
||||
- ["none", "None (only use locally!)"]
|
||||
value: starttls
|
||||
description: Your email provider should provide different ports for each encryption
|
||||
method. Generally 465 for ssl_tls, 587 for starttls.
|
||||
@@ -906,7 +924,7 @@ sections:
|
||||
requires_restart: true
|
||||
depends_true: provide_invite
|
||||
type: text
|
||||
description: Channel to invite new users to.
|
||||
description: Name of channel to invite new users to.
|
||||
- setting: apply_role
|
||||
name: Apply Role on connection
|
||||
requires_restart: true
|
||||
@@ -1123,7 +1141,7 @@ sections:
|
||||
type: note
|
||||
depends_true: link_reset
|
||||
required: false
|
||||
description: Set the "External jfa-go URL" in General so that links to jfa-go can be made.
|
||||
description: Set the "External jfa-go URL" value in General so that links to jfa-go can be made.
|
||||
- setting: language
|
||||
name: Default reset link language
|
||||
requires_restart: true
|
||||
@@ -1152,9 +1170,9 @@ sections:
|
||||
description: Subject of password reset emails.
|
||||
- section: invite_emails
|
||||
meta:
|
||||
name: Invite emails
|
||||
name: Invite Messages
|
||||
description: Settings for sending invites directly to users.
|
||||
depends_true: email|method
|
||||
depends_true: messages|enabled
|
||||
settings:
|
||||
- setting: enabled
|
||||
name: Enabled
|
||||
@@ -1292,6 +1310,7 @@ sections:
|
||||
on account creation, and to automatically link contact methods (email, discord
|
||||
and telegram). A template must be added to a User Profile for accounts to be
|
||||
created.
|
||||
wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/
|
||||
settings:
|
||||
- setting: enabled
|
||||
name: Enabled
|
||||
@@ -1318,7 +1337,7 @@ sections:
|
||||
requires_restart: true
|
||||
type: text
|
||||
depends_true: enabled
|
||||
description: API Key. Get this from the first tab in Jellyseerr's settings.
|
||||
description: API Key. Get this from the first tab in Jellyseerr's settings (NOT the "Jellyfin" tab!)
|
||||
- setting: import_existing
|
||||
name: Import existing users to Jellyseerr
|
||||
requires_restart: true
|
||||
@@ -1450,11 +1469,15 @@ sections:
|
||||
description: When set, user accounts will be deleted this many days after expiring
|
||||
(if "Behaviour" is "Disable user"). Set to 0 to disable.
|
||||
- setting: send_email
|
||||
name: Send email
|
||||
name: Send message
|
||||
type: bool
|
||||
value: true
|
||||
depends_true: messages|enabled
|
||||
description: Send an email when a user's account expires.
|
||||
- setting: send_reminder_n_days_before
|
||||
name: Send message N days before expiry
|
||||
type: list
|
||||
description: Send users a message N days before their account is due to expire. Multiple can be set.
|
||||
- setting: subject
|
||||
name: Email subject
|
||||
depends_true: messages|enabled
|
||||
@@ -1490,6 +1513,23 @@ sections:
|
||||
depends_true: messages|enabled
|
||||
type: text
|
||||
description: Path to custom email in plain text
|
||||
- setting: reminder_subject
|
||||
name: 'Reminder: email subject'
|
||||
depends_true: messages|enabled
|
||||
type: text
|
||||
description: Subject of expiry reminder emails.
|
||||
- setting: reminder_email_html
|
||||
name: 'Reminder: Custom email (HTML)'
|
||||
advanced: true
|
||||
depends_true: messages|enabled
|
||||
type: text
|
||||
description: Path to custom email html
|
||||
- setting: reminder_email_text
|
||||
name: 'Reminder: Custom email (plaintext)'
|
||||
advanced: true
|
||||
depends_true: messages|enabled
|
||||
type: text
|
||||
description: Path to custom email in plain text
|
||||
- section: disable_enable
|
||||
meta:
|
||||
name: Account Disabling/Enabling
|
||||
@@ -1568,30 +1608,36 @@ sections:
|
||||
requires_restart: true
|
||||
type: text
|
||||
description: Location of stored invites (json).
|
||||
deprecated: true
|
||||
- setting: password_resets
|
||||
name: Password Resets
|
||||
requires_restart: true
|
||||
type: text
|
||||
description: Location of stored non-Jellyfin password resets (json).
|
||||
deprecated: true
|
||||
- setting: emails
|
||||
name: Email Addresses
|
||||
requires_restart: true
|
||||
type: text
|
||||
description: Location of stored email addresses (json).
|
||||
deprecated: true
|
||||
- setting: users
|
||||
name: User storage
|
||||
type: text
|
||||
description: Stores users temporarily when a user expiry is set.
|
||||
deprecated: true
|
||||
- setting: ombi_template
|
||||
name: Ombi user template
|
||||
type: text
|
||||
description: Location of stored Ombi user template.
|
||||
deprecated: true
|
||||
- setting: user_profiles
|
||||
name: User Profiles
|
||||
requires_restart: true
|
||||
type: text
|
||||
description: Location of stored user profiles (encompasses template and configuration
|
||||
and displayprefs) (json)
|
||||
deprecated: true
|
||||
- setting: html_templates
|
||||
name: Custom HTML Template Directory
|
||||
requires_restart: true
|
||||
@@ -1609,19 +1655,23 @@ sections:
|
||||
type: text
|
||||
description: JSON file generated by program in settings, different from email_html/email_text.
|
||||
See wiki for more info.
|
||||
deprecated: true
|
||||
- setting: custom_user_page_content
|
||||
name: Custom user page content
|
||||
type: text
|
||||
description: JSON file generated by program in settings, containing user page
|
||||
messages. See wiki for more info.
|
||||
deprecated: true
|
||||
- setting: telegram_users
|
||||
name: Telegram users
|
||||
type: text
|
||||
description: Stores telegram user IDs and language preferences.
|
||||
deprecated: true
|
||||
- setting: matrix_users
|
||||
name: Matrix users
|
||||
type: text
|
||||
description: Stores matrix user IDs and language preferences.
|
||||
deprecated: true
|
||||
- setting: matrix_sql
|
||||
name: Matrix encryption DB
|
||||
type: text
|
||||
@@ -1630,7 +1680,9 @@ sections:
|
||||
name: Discord users
|
||||
type: text
|
||||
description: Stores discord user IDs and language preferences.
|
||||
deprecated: true
|
||||
- setting: announcements
|
||||
name: Announcement templates
|
||||
type: text
|
||||
description: Stores custom announcement templates.
|
||||
deprecated: true
|
||||
|
||||
16
css/base.css
@@ -470,3 +470,19 @@ section.section:not(.\~neutral) {
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* seems to be the sweet spot */
|
||||
--inside-input-base: -2.6rem;
|
||||
|
||||
/* thought --spacing would do the trick but apparently not */
|
||||
--tailwind-spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
|
||||
.gap-1 > .button.inside-input {
|
||||
margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
.gap-2 > .button.inside-input {
|
||||
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
18
css/colors.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const colors = require("tailwindcss/colors");
|
||||
const dark = require("../css/dark");
|
||||
|
||||
export const colorSet = {
|
||||
neutral: colors.slate,
|
||||
positive: colors.green,
|
||||
urge: colors.violet,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue,
|
||||
critical: colors.red,
|
||||
d_neutral: dark.d_neutral,
|
||||
d_positive: dark.d_positive,
|
||||
d_urge: dark.d_urge,
|
||||
d_warning: dark.d_warning,
|
||||
d_info: dark.d_info,
|
||||
d_critical: dark.d_critical,
|
||||
discord: "#5865F2"
|
||||
};
|
||||
@@ -27,6 +27,12 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.above .content {
|
||||
bottom: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
403
customcontent.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func defaultVars(vars ...string) []string {
|
||||
return slices.Concat(vars, []string{
|
||||
"username",
|
||||
})
|
||||
}
|
||||
|
||||
func defaultVals(vals map[string]any) map[string]any {
|
||||
maps.Copy(vals, map[string]any{
|
||||
"username": "Username",
|
||||
})
|
||||
return vals
|
||||
}
|
||||
|
||||
func vendorHeader(config *Config, lang *emailLang) string { return "jfa-go" }
|
||||
func serverHeader(config *Config, lang *emailLang) string {
|
||||
if substituteStrings == "" {
|
||||
return "Jellyfin"
|
||||
} else {
|
||||
return substituteStrings
|
||||
}
|
||||
}
|
||||
func messageFooter(config *Config, lang *emailLang) string {
|
||||
return config.Section("messages").Key("message").String()
|
||||
}
|
||||
|
||||
var customContent = map[string]CustomContentInfo{
|
||||
"EmailConfirmation": {
|
||||
Name: "EmailConfirmation",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].EmailConfirmation["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("email_confirmation").Key("subject").MustString(lang.EmailConfirmation.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"confirmationURL",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"confirmationURL": "https://sub2.test.url/invite/xxxxxx?key=xxxxxx",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "email_confirmation",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "confirmation",
|
||||
},
|
||||
},
|
||||
"ExpiryReminder": {
|
||||
Name: "ExpiryReminder",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].ExpiryReminder["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("reminder_subject").MustString(lang.ExpiryReminder.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"expiresIn",
|
||||
"date",
|
||||
"time",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"expiresIn": "3d 4h 32m",
|
||||
"date": "20/08/25",
|
||||
"time": "14:19",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "reminder_email_",
|
||||
DefaultValue: "expiry-reminder",
|
||||
},
|
||||
},
|
||||
"InviteEmail": {
|
||||
Name: "InviteEmail",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteEmail["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("invite_emails").Key("subject").MustString(lang.InviteEmail.get("title"))
|
||||
},
|
||||
Variables: []string{
|
||||
"date",
|
||||
"time",
|
||||
"expiresInMinutes",
|
||||
"inviteURL",
|
||||
},
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"date": "01/01/01",
|
||||
"time": "00:00",
|
||||
"expiresInMinutes": "16d 13h 19m",
|
||||
"inviteURL": "https://sub2.test.url/invite/xxxxxx",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "invite_emails",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "invite-email",
|
||||
},
|
||||
},
|
||||
"InviteExpiry": {
|
||||
Name: "InviteExpiry",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteExpiry["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return lang.InviteExpiry.get("title")
|
||||
},
|
||||
HeaderText: vendorHeader,
|
||||
FooterText: func(config *Config, lang *emailLang) string {
|
||||
return lang.InviteExpiry.get("notificationNotice")
|
||||
},
|
||||
Variables: []string{
|
||||
"code",
|
||||
"time",
|
||||
},
|
||||
Placeholders: map[string]any{
|
||||
"code": "\"xxxxxx\"",
|
||||
"time": "01/01/01 00:00",
|
||||
},
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "notifications",
|
||||
SettingPrefix: "expiry_",
|
||||
DefaultValue: "expired",
|
||||
},
|
||||
},
|
||||
"PasswordReset": {
|
||||
Name: "PasswordReset",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].PasswordReset["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("password_resets").Key("subject").MustString(lang.PasswordReset.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"date",
|
||||
"time",
|
||||
"expiresInMinutes",
|
||||
"pin",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"date": "01/01/01",
|
||||
"time": "00:00",
|
||||
"expiresInMinutes": "16d 13h 19m",
|
||||
"pin": "12-34-56",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "password_resets",
|
||||
SettingPrefix: "email_",
|
||||
// This was the first email type added, hence the undescriptive filename.
|
||||
DefaultValue: "password-reset",
|
||||
},
|
||||
},
|
||||
"UserCreated": {
|
||||
Name: "UserCreated",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserCreated["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return lang.UserCreated.get("title")
|
||||
},
|
||||
HeaderText: vendorHeader,
|
||||
FooterText: func(config *Config, lang *emailLang) string {
|
||||
return lang.UserCreated.get("notificationNotice")
|
||||
},
|
||||
Variables: []string{
|
||||
"code",
|
||||
"name",
|
||||
"address",
|
||||
"time",
|
||||
},
|
||||
Placeholders: map[string]any{
|
||||
"name": "Subject Username",
|
||||
"code": "\"xxxxxx\"",
|
||||
"address": "Email Address",
|
||||
"time": "01/01/01 00:00",
|
||||
},
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "notifications",
|
||||
SettingPrefix: "created_",
|
||||
DefaultValue: "created",
|
||||
},
|
||||
},
|
||||
"UserDeleted": {
|
||||
Name: "UserDeleted",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDeleted["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("deletion").Key("subject").MustString(lang.UserDeleted.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "deletion",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserDisabled": {
|
||||
Name: "UserDisabled",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDisabled["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("disable_enable").Key("subject_disabled").MustString(lang.UserDisabled.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "disable_enable",
|
||||
SettingPrefix: "disabled_",
|
||||
// Template is shared between deletion enabling and disabling.
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserEnabled": {
|
||||
Name: "UserEnabled",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserEnabled["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("disable_enable").Key("subject_enabled").MustString(lang.UserEnabled.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "disable_enable",
|
||||
SettingPrefix: "enabled_",
|
||||
// Template is shared between deletion enabling and disabling.
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserExpired": {
|
||||
Name: "UserExpired",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpired["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("subject").MustString(lang.UserExpired.get("title"))
|
||||
},
|
||||
Variables: defaultVars(),
|
||||
Placeholders: defaultVals(map[string]any{}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "user-expired",
|
||||
},
|
||||
},
|
||||
"UserExpiryAdjusted": {
|
||||
Name: "UserExpiryAdjusted",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpiryAdjusted["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("adjustment_subject").MustString(lang.UserExpiryAdjusted.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"newExpiry",
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"newExpiry": "01/01/01 00:00",
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "adjustment_email_",
|
||||
DefaultValue: "expiry-adjusted",
|
||||
},
|
||||
},
|
||||
"WelcomeEmail": {
|
||||
Name: "WelcomeEmail",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].WelcomeEmail["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("welcome_email").Key("subject").MustString(lang.WelcomeEmail.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"jellyfinURL",
|
||||
"yourAccountWillExpire",
|
||||
),
|
||||
Conditionals: []string{
|
||||
"yourAccountWillExpire",
|
||||
},
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"jellyfinURL": "https://example.io",
|
||||
"yourAccountWillExpire": "17/08/25 14:19",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "welcome_email",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "welcome",
|
||||
},
|
||||
},
|
||||
"TemplateEmail": {
|
||||
Name: "TemplateEmail",
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
ContentType: CustomTemplate,
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "template_email",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "template",
|
||||
},
|
||||
},
|
||||
"UserLogin": {
|
||||
Name: "UserLogin",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["userPageLogin"]
|
||||
},
|
||||
Variables: []string{},
|
||||
},
|
||||
"UserPage": {
|
||||
Name: "UserPage",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["userPagePage"]
|
||||
},
|
||||
Variables: defaultVars(),
|
||||
Placeholders: defaultVals(map[string]any{}),
|
||||
},
|
||||
"PostSignupCard": {
|
||||
Name: "PostSignupCard",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["postSignupCard"]
|
||||
},
|
||||
Description: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["postSignupCardDescription"]
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"myAccountURL",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"myAccountURL": "https://sub2.test.url/my/account",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
var EmptyCustomContent = CustomContentInfo{
|
||||
Name: "EmptyCustomContent",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
HeaderText: serverHeader,
|
||||
FooterText: messageFooter,
|
||||
Description: nil,
|
||||
Variables: []string{},
|
||||
Placeholders: map[string]any{},
|
||||
}
|
||||
|
||||
var AnnouncementCustomContent = func(subject string) CustomContentInfo {
|
||||
cci := EmptyCustomContent
|
||||
cci.Subject = func(config *Config, lang *emailLang) string { return subject }
|
||||
cci.Variables = defaultVars()
|
||||
cci.Placeholders = defaultVals(map[string]any{})
|
||||
return cci
|
||||
}
|
||||
|
||||
// Validates customContent and sets default fields if needed.
|
||||
var _runtimeValidation = func() bool {
|
||||
for name, cc := range customContent {
|
||||
if name != cc.Name {
|
||||
panic(fmt.Errorf("customContent key and name not matching: %s != %s", name, cc.Name))
|
||||
}
|
||||
if cc.DisplayName == nil {
|
||||
panic(fmt.Errorf("no customContent[%s] DisplayName set", name))
|
||||
}
|
||||
if cc.HeaderText == nil {
|
||||
cc.HeaderText = serverHeader
|
||||
customContent[name] = cc
|
||||
}
|
||||
if cc.FooterText == nil {
|
||||
cc.FooterText = messageFooter
|
||||
customContent[name] = cc
|
||||
}
|
||||
}
|
||||
return true
|
||||
}()
|
||||
167
discord.go
@@ -1,12 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dg "github.com/bwmarrin/discordgo"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
@@ -27,6 +29,7 @@ type DiscordDaemon struct {
|
||||
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
|
||||
commandIDs []string
|
||||
commandDescriptions []*dg.ApplicationCommand
|
||||
retryOpts *common.MustAuthenticateOptions
|
||||
}
|
||||
|
||||
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
@@ -58,6 +61,16 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
dd.users[user.ID] = user
|
||||
}
|
||||
|
||||
dd.retryOpts = &common.MustAuthenticateOptions{
|
||||
RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6),
|
||||
RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
|
||||
LogFailures: true,
|
||||
}
|
||||
|
||||
dd.bot.AddHandler(dd.commandHandler)
|
||||
|
||||
dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
|
||||
|
||||
return dd, nil
|
||||
}
|
||||
|
||||
@@ -98,13 +111,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
|
||||
return d.NewUnknownUser(channelID, userID, discrim, username)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) run() {
|
||||
d.bot.AddHandler(d.commandHandler)
|
||||
func (d *DiscordDaemon) Run() {
|
||||
ro := common.MustAuthenticateOptions{}
|
||||
ro = *d.retryOpts
|
||||
ro.Counter = 0
|
||||
d.run(&ro)
|
||||
}
|
||||
|
||||
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
|
||||
func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) {
|
||||
if err := d.bot.Open(); err != nil {
|
||||
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
|
||||
return
|
||||
if retry == nil || retry.LogFailures {
|
||||
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
|
||||
}
|
||||
if retry != nil {
|
||||
retry.Counter += 1
|
||||
if retry.Counter >= retry.RetryCount {
|
||||
return
|
||||
}
|
||||
time.Sleep(retry.RetryGap)
|
||||
d.run(retry)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Wait for everything to populate, it's slow sometimes.
|
||||
for d.bot.State == nil {
|
||||
@@ -134,15 +161,18 @@ func (d *DiscordDaemon) run() {
|
||||
d.InviteChannel.Name = invChannel
|
||||
}
|
||||
}
|
||||
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
|
||||
d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
|
||||
defer d.deregisterCommands()
|
||||
defer d.bot.Close()
|
||||
|
||||
go d.registerCommands()
|
||||
ro := common.MustAuthenticateOptions{}
|
||||
ro = *(d.retryOpts)
|
||||
ro.Counter = 0
|
||||
|
||||
go d.registerCommands(&ro)
|
||||
|
||||
<-d.ShutdownChannel
|
||||
d.ShutdownChannel <- "Down"
|
||||
return
|
||||
}
|
||||
|
||||
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
|
||||
@@ -332,7 +362,7 @@ func (d *DiscordDaemon) Shutdown() {
|
||||
close(d.ShutdownChannel)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) registerCommands() {
|
||||
func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
|
||||
d.commandDescriptions = []*dg.ApplicationCommand{
|
||||
{
|
||||
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
|
||||
@@ -429,7 +459,27 @@ func (d *DiscordDaemon) registerCommands() {
|
||||
// if err != nil {
|
||||
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
|
||||
// }
|
||||
for i, cmd := range d.commandDescriptions {
|
||||
|
||||
cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions)
|
||||
if err != nil {
|
||||
if retry == nil || retry.LogFailures {
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err)
|
||||
}
|
||||
if retry != nil {
|
||||
retry.Counter += 1
|
||||
if retry.Counter >= retry.RetryCount {
|
||||
return
|
||||
}
|
||||
time.Sleep(retry.RetryGap)
|
||||
d.registerCommands(retry)
|
||||
}
|
||||
} else {
|
||||
for i := range len(d.commandDescriptions) {
|
||||
d.commandIDs[i] = cCommands[i].ID
|
||||
}
|
||||
d.app.debug.Printf(lm.RegisterDiscordCommand, "*")
|
||||
}
|
||||
/* for i, cmd := range d.commandDescriptions {
|
||||
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
|
||||
@@ -437,7 +487,7 @@ func (d *DiscordDaemon) registerCommands() {
|
||||
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
|
||||
d.commandIDs[i] = command.ID
|
||||
}
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) deregisterCommands() {
|
||||
@@ -605,6 +655,21 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
|
||||
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
|
||||
d.users[i.Interaction.Member.User.ID] = requester
|
||||
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
|
||||
|
||||
// We don't reveal much in the message response itself so we can re-use this easily.
|
||||
sendResponse := func(langKey string) {
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get(langKey),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// d.app.debug.Println(invuser)
|
||||
//label := i.ApplicationCommandData().Options[2].StringValue()
|
||||
//profile := i.ApplicationCommandData().Options[3].StringValue()
|
||||
@@ -612,11 +677,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
|
||||
//if mins > 0 {
|
||||
// expmin = mins
|
||||
//}
|
||||
// Check whether requestor is linked to the admin account
|
||||
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
|
||||
if !(ok && requesterEmail.Admin) {
|
||||
// We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
|
||||
if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
|
||||
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
|
||||
// FIXME: add response message
|
||||
sendResponse("noPermission")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -658,54 +722,43 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
|
||||
}
|
||||
}
|
||||
|
||||
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
|
||||
if recipient != nil {
|
||||
err = nil
|
||||
|
||||
var invname *dg.Member = nil
|
||||
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
|
||||
invite.SendTo = invname.User.Username
|
||||
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
d.app.err.Println(invite.SendTo)
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
|
||||
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
|
||||
err = errors.New(lm.InviteMessagesDisabled)
|
||||
}
|
||||
|
||||
var msg *Message
|
||||
if err == nil {
|
||||
msg, err = d.app.email.constructInvite(invite, false)
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
err = d.app.discord.SendDM(msg, recipient.ID)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
|
||||
// Print extra message, ideally we'd just print this, or get rid of it though.
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
d.app.err.Println(invite.SendTo)
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
} else {
|
||||
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = d.app.discord.SendDM(msg, recipient.ID)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
|
||||
sendResponse("sentInvite")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
|
||||
d.app.err.Println(invite.SendTo)
|
||||
sendResponse("sentInviteFailure")
|
||||
}
|
||||
}
|
||||
|
||||
//if profile != "" {
|
||||
d.app.storage.SetInvitesKey(invite.Code, invite)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
module github.com/hrfee/jfa-go/easyproxy
|
||||
|
||||
go 1.18
|
||||
go 1.23.0
|
||||
|
||||
require golang.org/x/net v0.36.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require golang.org/x/net v0.42.0
|
||||
|
||||
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b
|
||||
|
||||
@@ -2,3 +2,5 @@ github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
|
||||
810
email.go
@@ -10,6 +10,7 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -41,6 +42,9 @@ type Emailer struct {
|
||||
fromAddr, fromName string
|
||||
lang emailLang
|
||||
sender EmailClient
|
||||
config *Config
|
||||
storage *Storage
|
||||
LoggerSet
|
||||
}
|
||||
|
||||
// Message stores content.
|
||||
@@ -51,7 +55,7 @@ type Message struct {
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
|
||||
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool) (d, t, expiresIn string) {
|
||||
d = timefmt.Format(expiry, datePattern)
|
||||
t = timefmt.Format(expiry, timePattern)
|
||||
currentTime := time.Now()
|
||||
@@ -73,34 +77,38 @@ func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern
|
||||
}
|
||||
|
||||
// NewEmailer configures and returns a new emailer.
|
||||
func NewEmailer(app *appContext) *Emailer {
|
||||
func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
|
||||
emailer := &Emailer{
|
||||
fromAddr: app.config.Section("email").Key("address").String(),
|
||||
fromName: app.config.Section("email").Key("from").String(),
|
||||
lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
|
||||
fromAddr: config.Section("email").Key("address").String(),
|
||||
fromName: config.Section("email").Key("from").String(),
|
||||
lang: storage.lang.Email[storage.lang.chosenEmailLang],
|
||||
LoggerSet: logs,
|
||||
config: config,
|
||||
storage: storage,
|
||||
}
|
||||
method := app.config.Section("email").Key("method").String()
|
||||
method := emailer.config.Section("email").Key("method").String()
|
||||
if method == "smtp" {
|
||||
sslTLS := false
|
||||
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
|
||||
sslTLS = true
|
||||
enc := sMail.EncryptionSTARTTLS
|
||||
switch emailer.config.Section("smtp").Key("encryption").String() {
|
||||
case "ssl_tls":
|
||||
enc = sMail.EncryptionSSLTLS
|
||||
case "starttls":
|
||||
enc = sMail.EncryptionSTARTTLS
|
||||
case "none":
|
||||
enc = sMail.EncryptionNone
|
||||
}
|
||||
username := app.config.Section("smtp").Key("username").MustString("")
|
||||
password := app.config.Section("smtp").Key("password").String()
|
||||
username := emailer.config.Section("smtp").Key("username").MustString("")
|
||||
password := emailer.config.Section("smtp").Key("password").String()
|
||||
if username == "" && password != "" {
|
||||
username = emailer.fromAddr
|
||||
}
|
||||
var proxyConf *easyproxy.ProxyConfig = nil
|
||||
if app.proxyEnabled {
|
||||
proxyConf = &app.proxyConfig
|
||||
}
|
||||
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)
|
||||
authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
|
||||
err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedInitSMTP, err)
|
||||
emailer.err.Printf(lm.FailedInitSMTP, err)
|
||||
}
|
||||
} else if method == "mailgun" {
|
||||
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
|
||||
emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
|
||||
} else if method == "dummy" {
|
||||
emailer.sender = &DummyClient{}
|
||||
}
|
||||
@@ -121,14 +129,10 @@ 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, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
|
||||
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
|
||||
sender := &SMTP{}
|
||||
sender.Client = sMail.NewSMTPClient()
|
||||
if sslTLS {
|
||||
sender.Client.Encryption = sMail.EncryptionSSLTLS
|
||||
} else {
|
||||
sender.Client.Encryption = sMail.EncryptionSTARTTLS
|
||||
}
|
||||
sender.Client.Encryption = encryption
|
||||
if username != "" || password != "" {
|
||||
sender.Client.Authentication = authType
|
||||
sender.Client.Username = username
|
||||
@@ -160,7 +164,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
|
||||
var cert []byte
|
||||
cert, err = os.ReadFile(certPath)
|
||||
if rootCAs.AppendCertsFromPEM(cert) == false {
|
||||
err = errors.New("Failed to append cert to pool")
|
||||
err = errors.New("failed to append cert to pool")
|
||||
}
|
||||
}
|
||||
sender.Client.TLSConfig = &tls.Config{
|
||||
@@ -242,22 +246,48 @@ type templ interface {
|
||||
Execute(wr io.Writer, data interface{}) error
|
||||
}
|
||||
|
||||
func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) {
|
||||
var tpl templ
|
||||
if substituteStrings == "" {
|
||||
data["jellyfin"] = "Jellyfin"
|
||||
} else {
|
||||
data["jellyfin"] = substituteStrings
|
||||
func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any) (*Message, error) {
|
||||
msg := &Message{
|
||||
Subject: contentInfo.Subject(emailer.config, &emailer.lang),
|
||||
}
|
||||
// Template the subject for bonus points
|
||||
if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil {
|
||||
msg.Subject = subject
|
||||
}
|
||||
if cc.Enabled {
|
||||
// Use template email, rather than the built-in's email file.
|
||||
contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
|
||||
content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data)
|
||||
if err != nil {
|
||||
emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
|
||||
return msg, err
|
||||
}
|
||||
html := markdown.ToHTML([]byte(content), nil, markdownRenderer)
|
||||
text := stripMarkdown(content)
|
||||
templateData := map[string]interface{}{
|
||||
"text": template.HTML(html),
|
||||
"plaintext": text,
|
||||
"md": content,
|
||||
}
|
||||
data = templateData
|
||||
}
|
||||
var err error = nil
|
||||
|
||||
var tpl templ
|
||||
msg.Text = ""
|
||||
msg.Markdown = ""
|
||||
msg.HTML = ""
|
||||
data["header"] = contentInfo.HeaderText(emailer.config, &emailer.lang)
|
||||
data["footer"] = contentInfo.FooterText(emailer.config, &emailer.lang)
|
||||
var keys []string
|
||||
plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
|
||||
plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false)
|
||||
if plaintext {
|
||||
if telegramEnabled || discordEnabled {
|
||||
keys = []string{"text"}
|
||||
text, markdown = "", ""
|
||||
msg.Text, msg.Markdown = "", ""
|
||||
} else {
|
||||
keys = []string{"text"}
|
||||
text = ""
|
||||
msg.Text = ""
|
||||
}
|
||||
} else {
|
||||
if telegramEnabled || discordEnabled {
|
||||
@@ -270,9 +300,9 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
|
||||
var filesystem fs.FS
|
||||
var fpath string
|
||||
if key == "markdown" {
|
||||
filesystem, fpath = app.GetPath(section, keyFragment+"text")
|
||||
filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+"text")
|
||||
} else {
|
||||
filesystem, fpath = app.GetPath(section, keyFragment+key)
|
||||
filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+key)
|
||||
}
|
||||
if key == "html" {
|
||||
tpl, err = template.ParseFS(filesystem, fpath)
|
||||
@@ -280,7 +310,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
|
||||
tpl, err = textTemplate.ParseFS(filesystem, fpath)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
return msg, fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err)
|
||||
}
|
||||
// For constructTemplate, if "md" is found in data it's used in stead of "text".
|
||||
foundMarkdown := false
|
||||
@@ -293,616 +323,284 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, data)
|
||||
if err != nil {
|
||||
return
|
||||
return msg, err
|
||||
}
|
||||
if foundMarkdown {
|
||||
data["plaintext"], data["md"] = data["md"], data["plaintext"]
|
||||
}
|
||||
if key == "html" {
|
||||
html = tplData.String()
|
||||
msg.HTML = tplData.String()
|
||||
} else if key == "text" {
|
||||
text = tplData.String()
|
||||
msg.Text = tplData.String()
|
||||
} else {
|
||||
markdown = tplData.String()
|
||||
msg.Markdown = tplData.String()
|
||||
}
|
||||
}
|
||||
return
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) confirmationValues(code, username, key string, app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any) {
|
||||
contentInfo := customContent[name]
|
||||
template := map[string]any{
|
||||
"username": username,
|
||||
}
|
||||
maps.Copy(template, values)
|
||||
// When generating a version for the user to customise, we'll replace "variable" with "{variable}", so the templater used for custom content understands them.
|
||||
if placeholders {
|
||||
for _, v := range contentInfo.Variables {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
}
|
||||
return contentInfo, template
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructConfirmation(code, username, key string, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
}
|
||||
contentInfo, template := emailer.baseValues("EmailConfirmation", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
|
||||
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
|
||||
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
|
||||
"message": "",
|
||||
"username": username,
|
||||
}
|
||||
if noSub {
|
||||
template["helloUser"] = emailer.lang.Strings.get("helloUser")
|
||||
empty := []string{"confirmationURL"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
inviteLink := app.ExternalURI
|
||||
})
|
||||
if !placeholders {
|
||||
inviteLink := ExternalURI(nil)
|
||||
if code == "" { // Personal email change
|
||||
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
|
||||
} else { // Invite email confirmation
|
||||
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
|
||||
}
|
||||
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
|
||||
template["confirmationURL"] = inviteLink
|
||||
template["message"] = message
|
||||
}
|
||||
return template
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
|
||||
}
|
||||
var err error
|
||||
template := emailer.confirmationValues(code, username, key, app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("EmailConfirmation")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "email_confirmation", "email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// username is optional, but should only be passed once.
|
||||
func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, username ...string) (*Message, error) {
|
||||
if len(username) != 0 {
|
||||
md = templateEmail(md, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
|
||||
subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
|
||||
}
|
||||
email := &Message{Subject: subject}
|
||||
html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
|
||||
text := stripMarkdown(md)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
var err error
|
||||
data := map[string]interface{}{
|
||||
"text": template.HTML(html),
|
||||
"plaintext": text,
|
||||
"message": message,
|
||||
"md": md,
|
||||
}
|
||||
if len(username) != 0 {
|
||||
data["username"] = username[0]
|
||||
}
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
|
||||
func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) {
|
||||
expiry := invite.ValidTill
|
||||
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI, PAGES.Form, code)
|
||||
template := map[string]interface{}{
|
||||
d, t, expiresIn := emailer.formatExpiry(expiry, false)
|
||||
inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
|
||||
contentInfo, template := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{
|
||||
"hello": emailer.lang.InviteEmail.get("hello"),
|
||||
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
|
||||
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
|
||||
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
|
||||
"message": "",
|
||||
"date": d,
|
||||
"time": t,
|
||||
"expiresInMinutes": expiresIn,
|
||||
"inviteURL": inviteLink,
|
||||
"inviteExpiry": emailer.lang.InviteEmail.get("inviteExpiry"),
|
||||
})
|
||||
if !placeholders {
|
||||
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template)
|
||||
}
|
||||
if noSub {
|
||||
template["inviteExpiry"] = emailer.lang.InviteEmail.get("inviteExpiry")
|
||||
empty := []string{"inviteURL"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
|
||||
expiry := formatDatetime(invite.ValidTill)
|
||||
contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
|
||||
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
|
||||
"expiredAt": emailer.lang.InviteExpiry.get("expiredAt"),
|
||||
"code": "\"" + invite.Code + "\"",
|
||||
"time": expiry,
|
||||
})
|
||||
if !placeholders {
|
||||
template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template)
|
||||
}
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) {
|
||||
// NOTE: This was previously invite.Created, not sure why.
|
||||
created := formatDatetime(when)
|
||||
contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{
|
||||
"aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"),
|
||||
"nameString": emailer.lang.Strings.get("name"),
|
||||
"addressString": emailer.lang.Strings.get("emailAddress"),
|
||||
"timeString": emailer.lang.UserCreated.get("time"),
|
||||
"code": "\"" + invite.Code + "\"",
|
||||
"name": username,
|
||||
"time": created,
|
||||
"address": address,
|
||||
})
|
||||
if !placeholders {
|
||||
template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", template)
|
||||
if emailer.config.Section("email").Key("no_username").MustBool(false) {
|
||||
template["address"] = "n/a"
|
||||
}
|
||||
} else {
|
||||
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
|
||||
template["inviteURL"] = inviteLink
|
||||
template["message"] = message
|
||||
}
|
||||
return template
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
|
||||
func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
pwr.Username = "{username}"
|
||||
}
|
||||
template := emailer.inviteValues(code, invite, app, noSub)
|
||||
var err error
|
||||
message := app.storage.MustGetCustomContentKey("InviteEmail")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "invite_emails", "email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
|
||||
expiry := app.formatDatetime(invite.ValidTill)
|
||||
template := map[string]interface{}{
|
||||
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
|
||||
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
|
||||
"code": "\"" + code + "\"",
|
||||
"time": expiry,
|
||||
}
|
||||
if noSub {
|
||||
template["expiredAt"] = emailer.lang.InviteExpiry.get("expiredAt")
|
||||
} else {
|
||||
template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", tmpl{"code": template["code"].(string), "time": template["time"].(string)})
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: emailer.lang.InviteExpiry.get("title"),
|
||||
}
|
||||
var err error
|
||||
template := emailer.expiryValues(code, invite, app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("InviteExpiry")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "expiry_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) createdValues(code, username, address string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
"nameString": emailer.lang.Strings.get("name"),
|
||||
"addressString": emailer.lang.Strings.get("emailAddress"),
|
||||
"timeString": emailer.lang.UserCreated.get("time"),
|
||||
"notificationNotice": "",
|
||||
"code": "\"" + code + "\"",
|
||||
}
|
||||
if noSub {
|
||||
template["aUserWasCreated"] = emailer.lang.UserCreated.get("aUserWasCreated")
|
||||
empty := []string{"name", "address", "time"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
created := app.formatDatetime(invite.Created)
|
||||
var tplAddress string
|
||||
if app.config.Section("email").Key("no_username").MustBool(false) {
|
||||
tplAddress = "n/a"
|
||||
} else {
|
||||
tplAddress = address
|
||||
}
|
||||
template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", tmpl{"code": template["code"].(string)})
|
||||
template["name"] = username
|
||||
template["address"] = tplAddress
|
||||
template["time"] = created
|
||||
template["notificationNotice"] = emailer.lang.UserCreated.get("notificationNotice")
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: emailer.lang.UserCreated.get("title"),
|
||||
}
|
||||
template := emailer.createdValues(code, username, address, invite, app, noSub)
|
||||
var err error
|
||||
message := app.storage.MustGetCustomContentKey("UserCreated")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "created_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
|
||||
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
template := map[string]interface{}{
|
||||
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true)
|
||||
linkResetEnabled := emailer.config.Section("password_resets").Key("link_reset").MustBool(false)
|
||||
contentInfo, template := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}),
|
||||
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
|
||||
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
|
||||
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
|
||||
"pinString": emailer.lang.PasswordReset.get("pin"),
|
||||
"link_reset": false,
|
||||
"message": "",
|
||||
"username": pwr.Username,
|
||||
"codeExpiry": emailer.lang.PasswordReset.get("codeExpiry"),
|
||||
"link_reset": linkResetEnabled && !placeholders,
|
||||
"date": d,
|
||||
"time": t,
|
||||
"expiresInMinutes": expiresIn,
|
||||
}
|
||||
linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false)
|
||||
"pin": pwr.Pin,
|
||||
})
|
||||
if linkResetEnabled {
|
||||
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
|
||||
} else {
|
||||
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
|
||||
}
|
||||
if noSub {
|
||||
template["helloUser"] = emailer.lang.Strings.get("helloUser")
|
||||
template["codeExpiry"] = emailer.lang.PasswordReset.get("codeExpiry")
|
||||
empty := []string{"pin"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
|
||||
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
|
||||
if !placeholders {
|
||||
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", template)
|
||||
if linkResetEnabled {
|
||||
pinLink, err := app.GenResetLink(pwr.Pin)
|
||||
if err == nil {
|
||||
// Strip /invite form end of this URL, ik its ugly.
|
||||
template["link_reset"] = true
|
||||
pinLink, err := GenResetLink(pwr.Pin)
|
||||
if err != nil {
|
||||
template["link_reset"] = false
|
||||
emailer.info.Printf(lm.FailedGeneratePWRLink, err)
|
||||
} else {
|
||||
template["pin"] = pinLink
|
||||
// Only used in html email.
|
||||
template["pin_code"] = pwr.Pin
|
||||
} else {
|
||||
app.info.Printf(lm.FailedGeneratePWRLink, err)
|
||||
template["pin"] = pwr.Pin
|
||||
}
|
||||
} else {
|
||||
template["pin"] = pwr.Pin
|
||||
}
|
||||
template["message"] = message
|
||||
}
|
||||
return template
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
|
||||
func (emailer *Emailer) constructDeleted(username, reason string, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
reason = "{reason}"
|
||||
}
|
||||
template := emailer.resetValues(pwr, app, noSub)
|
||||
var err error
|
||||
message := app.storage.MustGetCustomContentKey("PasswordReset")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "password_resets", "email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
|
||||
"reasonString": emailer.lang.Strings.get("reason"),
|
||||
"message": "",
|
||||
}
|
||||
if noSub {
|
||||
empty := []string{"reason"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
}
|
||||
return template
|
||||
"reason": reason,
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
|
||||
func (emailer *Emailer) constructDisabled(username, reason string, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
reason = "{reason}"
|
||||
}
|
||||
var err error
|
||||
template := emailer.deletedValues(reason, app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("UserDeleted")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "deletion", "email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
|
||||
"reasonString": emailer.lang.Strings.get("reason"),
|
||||
"message": "",
|
||||
}
|
||||
if noSub {
|
||||
empty := []string{"reason"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
}
|
||||
return template
|
||||
"reason": reason,
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
|
||||
func (emailer *Emailer) constructEnabled(username, reason string, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
reason = "{reason}"
|
||||
}
|
||||
var err error
|
||||
template := emailer.disabledValues(reason, app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("UserDisabled")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "disabled_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
|
||||
"reasonString": emailer.lang.Strings.get("reason"),
|
||||
"message": "",
|
||||
}
|
||||
if noSub {
|
||||
empty := []string{"reason"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
}
|
||||
return template
|
||||
"reason": reason,
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
|
||||
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
}
|
||||
var err error
|
||||
template := emailer.enabledValues(reason, app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("UserEnabled")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "enabled_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
exp := formatDatetime(expiry)
|
||||
contentInfo, template := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
|
||||
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
|
||||
"reasonString": emailer.lang.Strings.get("reason"),
|
||||
"newExpiry": "",
|
||||
"message": "",
|
||||
}
|
||||
if noSub {
|
||||
template["helloUser"] = emailer.lang.Strings.get("helloUser")
|
||||
empty := []string{"reason", "newExpiry"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
|
||||
exp := app.formatDatetime(expiry)
|
||||
if !expiry.IsZero() {
|
||||
if custom {
|
||||
template["newExpiry"] = exp
|
||||
} else if !expiry.IsZero() {
|
||||
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
|
||||
"date": exp,
|
||||
})
|
||||
}
|
||||
"reason": reason,
|
||||
"newExpiry": exp,
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey("UserExpiryAdjusted")
|
||||
if !placeholders {
|
||||
if !cc.Enabled {
|
||||
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
|
||||
"date": exp,
|
||||
})
|
||||
}
|
||||
}
|
||||
return template
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
|
||||
func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) {
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
}
|
||||
var err error
|
||||
var template map[string]interface{}
|
||||
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
|
||||
if message.Enabled {
|
||||
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
|
||||
} else {
|
||||
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
|
||||
}
|
||||
if noSub {
|
||||
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
|
||||
"date": "{newExpiry}",
|
||||
})
|
||||
}
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
|
||||
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
|
||||
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
|
||||
"usernameString": emailer.lang.Strings.get("username"),
|
||||
"message": "",
|
||||
"yourAccountWillExpire": "",
|
||||
}
|
||||
if noSub {
|
||||
empty := []string{"jellyfinURL", "username", "yourAccountWillExpire"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
|
||||
template["username"] = username
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
exp := app.formatDatetime(expiry)
|
||||
if !expiry.IsZero() {
|
||||
if custom {
|
||||
template["yourAccountWillExpire"] = exp
|
||||
} else if !expiry.IsZero() {
|
||||
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
|
||||
"date": exp,
|
||||
})
|
||||
}
|
||||
d, t, expiresIn := emailer.formatExpiry(expiry, false)
|
||||
contentInfo, template := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{
|
||||
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
|
||||
"yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"),
|
||||
"expiresIn": expiresIn,
|
||||
"date": d,
|
||||
"time": t,
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
if !placeholders {
|
||||
if !cc.Enabled && !expiry.IsZero() {
|
||||
template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template)
|
||||
}
|
||||
}
|
||||
return template
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
|
||||
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) {
|
||||
var exp any = formatDatetime(expiry)
|
||||
if placeholders {
|
||||
username = "{username}"
|
||||
exp = "{yourAccountWillExpire}"
|
||||
}
|
||||
var err error
|
||||
var template map[string]interface{}
|
||||
message := app.storage.MustGetCustomContentKey("WelcomeEmail")
|
||||
if message.Enabled {
|
||||
template = emailer.welcomeValues(username, expiry, app, noSub, true)
|
||||
} else {
|
||||
template = emailer.welcomeValues(username, expiry, app, noSub, false)
|
||||
}
|
||||
if noSub {
|
||||
contentInfo, template := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{
|
||||
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
|
||||
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
|
||||
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
|
||||
"jellyfinURL": emailer.config.Section("jellyfin").Key("public_server").String(),
|
||||
"usernameString": emailer.lang.Strings.get("username"),
|
||||
})
|
||||
if !expiry.IsZero() || placeholders {
|
||||
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
|
||||
"date": "{yourAccountWillExpire}",
|
||||
"date": exp,
|
||||
})
|
||||
}
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
message.Conditionals,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template)
|
||||
cc := emailer.storage.MustGetCustomContentKey("WelcomeEmail")
|
||||
if !placeholders {
|
||||
if cc.Enabled && !expiry.IsZero() {
|
||||
template["yourAccountWillExpire"] = exp
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
func (emailer *Emailer) constructUserExpired(username string, placeholders bool) (*Message, error) {
|
||||
contentInfo, template := emailer.baseValues("UserExpired", username, placeholders, map[string]any{
|
||||
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
|
||||
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
|
||||
"message": "",
|
||||
}
|
||||
if !noSub {
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
|
||||
}
|
||||
var err error
|
||||
template := emailer.userExpiredValues(app, noSub)
|
||||
message := app.storage.MustGetCustomContentKey("UserExpired")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
})
|
||||
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
|
||||
return emailer.construct(contentInfo, cc, template)
|
||||
}
|
||||
|
||||
// calls the send method in the underlying emailClient.
|
||||
|
||||
491
email_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
var db *badgerhold.Store
|
||||
|
||||
func dbClose(e *Emailer) {
|
||||
e.storage.db.Close()
|
||||
e.storage.db = nil
|
||||
db = nil
|
||||
}
|
||||
|
||||
func Fatal(err any) {
|
||||
fmt.Printf("Fatal log function called: %+v\n", err)
|
||||
}
|
||||
|
||||
// NewTestEmailer initialises most of what the emailer depends on, which happens to be most of the app.
|
||||
func NewTestEmailer() (*Emailer, error) {
|
||||
emailer := &Emailer{
|
||||
fromAddr: "from@addr",
|
||||
fromName: "fromName",
|
||||
LoggerSet: LoggerSet{
|
||||
info: logger.NewLogger(os.Stdout, "[TEST INFO] ", log.Ltime, color.FgHiWhite),
|
||||
err: logger.NewLogger(os.Stdout, "[TEST ERROR] ", log.Ltime|log.Lshortfile, color.FgRed),
|
||||
debug: logger.NewLogger(os.Stdout, "[TEST DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow),
|
||||
},
|
||||
sender: &DummyClient{},
|
||||
}
|
||||
// Assume our working directory is the root of the repo
|
||||
wd, _ := os.Getwd()
|
||||
loadFilesystems(filepath.Join(wd, "build"), logger.NewEmptyLogger())
|
||||
dConfig, err := fs.ReadFile(localFS, "config-default.ini")
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
// Force emailer to construct markdown
|
||||
discordEnabled = true
|
||||
noInfoLS := emailer.LoggerSet
|
||||
noInfoLS.info = logger.NewEmptyLogger()
|
||||
emailer.config, err = NewConfig(dConfig, "/tmp/jfa-go-test", noInfoLS)
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
emailer.storage = NewStorage("/tmp/db", emailer.debug, func(k string) DebugLogAction { return LogAll })
|
||||
emailer.storage.loadLang(langFS)
|
||||
|
||||
emailer.storage.lang.chosenAdminLang = emailer.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
emailer.storage.lang.chosenEmailLang = emailer.config.Section("email").Key("language").MustString("en-us")
|
||||
emailer.storage.lang.chosenPWRLang = emailer.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
emailer.storage.lang.chosenTelegramLang = emailer.config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
opts := badgerhold.DefaultOptions
|
||||
opts.Dir = "/tmp/jfa-go-test-db"
|
||||
opts.ValueDir = opts.Dir
|
||||
opts.SyncWrites = false
|
||||
opts.Logger = nil
|
||||
emailer.storage.db, err = badgerhold.Open(opts)
|
||||
// emailer.info.Printf("DB Opened")
|
||||
db = emailer.storage.db
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
emailer.lang = emailer.storage.lang.Email[emailer.storage.lang.chosenEmailLang]
|
||||
emailer.info.SetFatalFunc(Fatal)
|
||||
emailer.err.SetFatalFunc(Fatal)
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
func testDummyEmailerInit(t *testing.T) *Emailer {
|
||||
e, err := NewTestEmailer()
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestDummyEmailerInit(t *testing.T) {
|
||||
dbClose(testDummyEmailerInit(t))
|
||||
}
|
||||
|
||||
func testContent(e *Emailer, cci CustomContentInfo, t *testing.T, testFunc func(t *testing.T)) {
|
||||
e.storage.DeleteCustomContentKey(cci.Name)
|
||||
t.Run(cci.Name, testFunc)
|
||||
cc := CustomContent{
|
||||
Name: cci.Name,
|
||||
Enabled: true,
|
||||
}
|
||||
cc.Content = "start test content "
|
||||
for _, v := range cci.Variables {
|
||||
cc.Content += "{" + v + "}"
|
||||
}
|
||||
cc.Content += " end test content"
|
||||
e.storage.SetCustomContentKey(cci.Name, cc)
|
||||
t.Run(cci.Name+" Custom", testFunc)
|
||||
e.storage.DeleteCustomContentKey(cci.Name)
|
||||
}
|
||||
|
||||
// constructConfirmation(code, username, key string, placeholders bool)
|
||||
func TestConfirmation(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
// non-blank key, link should therefore not be a /my/confirm one
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["EmailConfirmation"], t, func(t *testing.T) {
|
||||
code := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
key := shortuuid.New()
|
||||
msg, err := e.constructConfirmation(code, username, key, false)
|
||||
t.Run("FromInvite", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if strings.Contains(content, "/my/confirm") {
|
||||
t.Fatalf("/my/confirm link generated instead of invite confirm link: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, key) {
|
||||
t.Fatalf("key not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
code = ""
|
||||
msg, err = e.constructConfirmation(code, username, key, false)
|
||||
t.Run("FromMyAccount", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, "/my/confirm") {
|
||||
t.Fatalf("/my/confirm link not generated: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, key) {
|
||||
t.Fatalf("key not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// constructInvite(invite Invite, placeholders bool)
|
||||
func TestInvite(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["InviteEmail"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Now(),
|
||||
ValidTill: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
msg, err := e.constructInvite(inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "30m") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiry(code string, invite Invite, placeholders bool)
|
||||
func TestExpiry(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["InviteExpiry"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Time{},
|
||||
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
}
|
||||
// So we can easily check is the expiry time is included (which is 0001-01-01).
|
||||
for strings.Contains(inv.Code, "1") {
|
||||
inv.Code = shortuuid.New()
|
||||
}
|
||||
|
||||
msg, err := e.constructExpiry(inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructCreated(code, username, address string, invite Invite, placeholders bool)
|
||||
func TestCreated(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["UserCreated"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Time{},
|
||||
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
}
|
||||
username := shortuuid.New()
|
||||
address := shortuuid.New()
|
||||
|
||||
msg, err := e.constructCreated(username, address, inv.ValidTill, inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, address) {
|
||||
t.Fatalf("address not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructReset(pwr PasswordReset, placeholders bool)
|
||||
func TestReset(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["PasswordReset"], t, func(t *testing.T) {
|
||||
pwr := PasswordReset{
|
||||
Pin: shortuuid.New(),
|
||||
Username: shortuuid.New(),
|
||||
Expiry: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
Internal: false,
|
||||
}
|
||||
|
||||
msg, err := e.constructReset(pwr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, pwr.Pin) {
|
||||
t.Fatalf("pin not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, pwr.Username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructDeleted(reason string, placeholders bool)
|
||||
func TestDeleted(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructDeleted(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructDisabled(reason string, placeholders bool)
|
||||
func TestDisabled(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructDisabled(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructEnabled(reason string, placeholders bool)
|
||||
func TestEnabled(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructEnabled(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool)
|
||||
func TestExpiryAdjusted(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["UserExpiryAdjusted"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
reason := shortuuid.New()
|
||||
msg, err := e.constructExpiryAdjusted(username, expiry, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiryReminder(username string, expiry time.Time, placeholders bool)
|
||||
func TestExpiryReminder(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["ExpiryReminder"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
msg, err := e.constructExpiryReminder(username, expiry, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructWelcome(username string, expiry time.Time, placeholders bool)
|
||||
func TestWelcome(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["WelcomeEmail"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
msg, err := e.constructWelcome(username, expiry, false)
|
||||
t.Run("NoExpiry", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
// time.Time{} is 0001-01-01... so look for a 1 in there at least.
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
username = shortuuid.New()
|
||||
expiry = time.Time{}
|
||||
msg, err = e.constructWelcome(username, expiry, false)
|
||||
t.Run("WithExpiry", func(t *testing.T) {
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if strings.Contains(content, "01/01/01") || strings.Contains(content, "00:00") {
|
||||
t.Fatalf("empty expiry found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
34
external.go
@@ -4,20 +4,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
)
|
||||
|
||||
const binaryType = "external"
|
||||
|
||||
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
|
||||
|
||||
var localFS dirFS
|
||||
var langFS dirFS
|
||||
|
||||
// When using os.DirFS, even on Windows the separator seems to be '/'.
|
||||
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
|
||||
func FSJoin(elem ...string) string {
|
||||
@@ -32,23 +29,12 @@ func FSJoin(elem ...string) string {
|
||||
return strings.TrimSuffix(path, sep)
|
||||
}
|
||||
|
||||
type dirFS string
|
||||
|
||||
func (dir dirFS) Open(name string) (fs.File, error) {
|
||||
return os.Open(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return os.ReadDir(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func loadFilesystems() {
|
||||
log.Println("Using external storage")
|
||||
executable, _ := os.Executable()
|
||||
localFS = dirFS(filepath.Join(filepath.Dir(executable), "data"))
|
||||
langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang"))
|
||||
func loadFilesystems(rootDir string, logger *logger.Logger) {
|
||||
logger.Println("Using external storage")
|
||||
if rootDir == "" {
|
||||
executable, _ := os.Executable()
|
||||
rootDir = filepath.Dir(executable)
|
||||
}
|
||||
localFS = dirFS(filepath.Join(rootDir, "data"))
|
||||
langFS = dirFS(filepath.Join(rootDir, "data", "lang"))
|
||||
}
|
||||
|
||||
29
fs.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type genericFS interface {
|
||||
fs.FS
|
||||
fs.ReadDirFS
|
||||
fs.ReadFileFS
|
||||
}
|
||||
|
||||
var localFS genericFS
|
||||
var langFS genericFS
|
||||
|
||||
type dirFS string
|
||||
|
||||
func (dir dirFS) Open(name string) (fs.File, error) {
|
||||
return os.Open(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return os.ReadDir(string(dir) + "/" + name)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type GenericDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
TriggerChannel chan bool
|
||||
Interval time.Duration
|
||||
period time.Duration
|
||||
jobs []func(app *appContext)
|
||||
@@ -27,6 +28,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
|
||||
d := GenericDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
TriggerChannel: make(chan bool),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
@@ -46,6 +48,8 @@ func (d *GenericDaemon) run() {
|
||||
case <-d.ShutdownChannel:
|
||||
d.ShutdownChannel <- "Down"
|
||||
return
|
||||
case <-d.TriggerChannel:
|
||||
break
|
||||
case <-time.After(d.period):
|
||||
break
|
||||
}
|
||||
@@ -61,6 +65,10 @@ func (d *GenericDaemon) run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) Trigger() {
|
||||
d.TriggerChannel <- true
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) Shutdown() {
|
||||
d.Stopped = true
|
||||
d.ShutdownChannel <- "Down"
|
||||
|
||||
111
go.mod
@@ -23,31 +23,30 @@ replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
|
||||
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.28.1
|
||||
github.com/dgraph-io/badger/v4 v4.3.1
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/dgraph-io/badger/v4 v4.8.0
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/getlantern/systray v1.2.2
|
||||
github.com/gin-contrib/pprof v1.5.0
|
||||
github.com/gin-contrib/static v1.1.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-contrib/pprof v1.5.3
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/mediabrowser v0.3.24
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20250716174732-bcb6346f8115
|
||||
github.com/hrfee/mediabrowser v0.3.29
|
||||
github.com/itchyny/timefmt-go v0.1.6
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/robert-nix/ansihtml v1.0.1
|
||||
github.com/steambap/captcha v1.4.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
@@ -57,64 +56,65 @@ require (
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.21.1
|
||||
maunium.net/go/mautrix v0.24.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.4 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
|
||||
github.com/getlantern/errors v1.0.4 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
|
||||
github.com/mailgun/errors v0.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/swaggo/swag v1.16.4 // indirect
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
@@ -122,23 +122,24 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.mau.fi/util v0.8.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.mau.fi/util v0.8.8 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/otel v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.31.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/image v0.21.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
|
||||
golang.org/x/image v0.29.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
105
go.sum
@@ -14,11 +14,17 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
|
||||
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -28,6 +34,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
@@ -43,9 +51,13 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
|
||||
github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
|
||||
github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
|
||||
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
|
||||
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
|
||||
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
@@ -54,6 +66,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -64,8 +78,12 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
|
||||
@@ -94,22 +112,30 @@ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU=
|
||||
github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
|
||||
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
|
||||
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
|
||||
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
|
||||
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
|
||||
@@ -117,6 +143,8 @@ github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDB
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
|
||||
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
|
||||
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
|
||||
@@ -132,6 +160,8 @@ github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -140,6 +170,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
@@ -149,6 +181,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -162,6 +196,8 @@ github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4er
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -181,10 +217,14 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50=
|
||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -205,8 +245,10 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA=
|
||||
github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.28 h1:KkSgODXxUnZLrkmjSWpma8mXwEVxlOtI51uS2QP/e+c=
|
||||
github.com/hrfee/mediabrowser v0.3.28/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.29 h1:xTqGS9u8HuolZAhouYHxutnE0fF/8aVCInbByKZEzIo=
|
||||
github.com/hrfee/mediabrowser v0.3.29/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
@@ -222,9 +264,13 @@ github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -247,14 +293,20 @@ github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
|
||||
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1 h1:ShNH/wzj7albTF/6le011FF+DGMd3azcSKL4iO9AgeI=
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
|
||||
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
@@ -265,6 +317,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -278,8 +332,12 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwU
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4=
|
||||
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -291,8 +349,11 @@ github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yD
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
@@ -349,6 +410,8 @@ github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZX
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
|
||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
@@ -358,6 +421,8 @@ github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljT
|
||||
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
@@ -371,17 +436,27 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
|
||||
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
|
||||
go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c=
|
||||
go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
|
||||
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
|
||||
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -394,6 +469,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@@ -403,12 +480,18 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@@ -420,6 +503,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -443,6 +527,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -456,6 +542,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -485,6 +572,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -498,6 +587,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -514,6 +605,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -544,6 +637,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -565,4 +660,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
|
||||
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
|
||||
maunium.net/go/mautrix v0.24.2 h1:+AVT5kbcA/QuT5svrJKp4ivwoUmz+RRplMp3DnfpheI=
|
||||
maunium.net/go/mautrix v0.24.2/go.mod h1:1ut900w++eE9by9yqCR2dQdMqwsHwZG5L+1bKB1EvSA=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
@@ -140,7 +140,7 @@ func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaem
|
||||
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
||||
|
||||
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
|
||||
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
d.appendJobs(func(app *appContext) { app.InvalidateJellyfinCache() })
|
||||
}
|
||||
|
||||
if clearEmail {
|
||||
|
||||
168
html/admin.html
@@ -541,7 +541,7 @@
|
||||
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
{{ if .userPageEnabled }}
|
||||
<div class="">
|
||||
<a class="button ~info" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
<a class="button ~info" href="{{ .pages.Base }}{{ .pages.MyAccount }}/"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -551,6 +551,7 @@
|
||||
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</span>
|
||||
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</span>
|
||||
<span id="button-tab-statistics" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.statistics }}</span>
|
||||
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -565,11 +566,11 @@
|
||||
<div class="card ~neutral @low flex flex-col gap-2 flex-1">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
|
||||
<input type="radio" name="radio-duration" class="unfocused" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
|
||||
</label>
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
|
||||
<input type="radio" name="radio-duration" class="unfocused">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -715,16 +716,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
|
||||
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
|
||||
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible flex flex-col gap-2">
|
||||
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
|
||||
<div class="flex flex-col md:flex-row align-middle gap-2">
|
||||
<div class="flex flex-row align-middle justify-between md:justify-normal">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAll }}</span>
|
||||
</button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
@@ -733,13 +742,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="row -mx-2 mb-2">
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<span id="accounts-filter-area"></span>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="supra sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
|
||||
</div>
|
||||
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3 mb-4">
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<div id="accounts-sort-by-field"></div>
|
||||
<span id="accounts-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
|
||||
</div>
|
||||
<div class="supra sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3">
|
||||
<button class="button ~neutral @low center accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
|
||||
@@ -774,7 +787,7 @@
|
||||
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
</div>
|
||||
<div class="card @low accounts-header table-responsive mt-2">
|
||||
<div class="card @low accounts-header table-responsive">
|
||||
<table class="table text-base leading-5">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -802,19 +815,31 @@
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div id="accounts-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-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>
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low accounts-search-clear flex flex-row gap-2">
|
||||
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
<button class="button ~info @low center accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAllRecords }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible flex flex-col gap-2">
|
||||
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
|
||||
<div class="flex flex-col md:flex-row align-middle gap-2">
|
||||
<div class="flex flex-row align-middle justify-between md:justify-normal">
|
||||
@@ -824,10 +849,17 @@
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search 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>
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<button class="button ~info @low center h-full activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAll }}</span>
|
||||
</button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
@@ -836,38 +868,84 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between pt-3 pb-2">
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm flex flex-row gap-2">
|
||||
<span id="activity-total-records"></span>
|
||||
<span id="activity-loaded-records"></span>
|
||||
<span id="activity-shown-records"></span>
|
||||
<div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<span id="activity-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
|
||||
</div>
|
||||
<div class="unfocused h-[100%]" id="activity-not-found">
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low activity-search-clear flex flex-row gap-2">
|
||||
<span>{{ .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="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 id="activity-card-list"></div>
|
||||
<div id="activity-loader"></div>
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button ~neutral @low activity-load-all">{{ .strings.loadAll }}</button>
|
||||
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAllRecords }}</span>
|
||||
</button>
|
||||
</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 id="tab-statistics" class="flex flex-col gap-4 unfocused">
|
||||
<div class="card @low dark:~d_neutral">
|
||||
<div class="card @low dark:~d_neutral flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="w-full">
|
||||
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-accounts" checked>
|
||||
<span class="button ~neutral w-full center @high">{{ .strings.accounts }}</span>
|
||||
</label>
|
||||
<label class="w-full">
|
||||
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-activity">
|
||||
<span class="button ~neutral w-full center @low">{{ .strings.activity }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="statistics-query-tab-accounts">
|
||||
<div class="flex flex-col align-middle gap-2">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<div class="statistics-sort-by-field"></div>
|
||||
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
|
||||
</div>
|
||||
<div class="card ~neutral @low statistics-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</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 id="statistics-query-tab-activity">
|
||||
<div class="flex flex-col align-middle gap-2">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<div class="statistics-sort-by-field"></div>
|
||||
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
|
||||
</div>
|
||||
<div class="card ~neutral @low statistics-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="statistics-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-settings" class="flex flex-col gap-4 unfocused">
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<script>
|
||||
window.pages = {
|
||||
"Base": "{{ .pages.Base }}",
|
||||
"TrueBase": "{{ .pages.TrueBase }}",
|
||||
"ExternalURI": "{{ .pages.ExternalURI }}",
|
||||
"Current": "{{ .pages.Current }}",
|
||||
"Admin": "{{ .pages.Admin }}",
|
||||
"MyAccount": "{{ .pages.MyAccount }}",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{{ template "lang-select.html" . }}
|
||||
</div>
|
||||
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card lg:container sectioned ~neutral @low flex flex-col gap-4 justify-between items-center">
|
||||
<img class="w-[105%] max-w-none" src="banner.svg" alt="jfa-go" />
|
||||
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
|
||||
@@ -106,7 +106,7 @@
|
||||
<p class="support">{{ .lang.General.urlBaseNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.externalURL }}</span>
|
||||
<span>{{ .lang.General.externalURL }} ({{ .lang.Strings.required }})</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
|
||||
<p class="support">{{ .lang.General.externalURLNotice }}</p>
|
||||
</label>
|
||||
@@ -557,12 +557,13 @@
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-center items-center">
|
||||
<span class="heading">{{ .lang.EndPage.finished }}</span>
|
||||
<p class="content text-center">{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}</p>
|
||||
<p class="content text-center">{{ .lang.EndPage.moreFeatures }} {{ .lang.EndPage.restartReload }} {{ .lang.EndPage.ifFailedLoad }}</p>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
|
||||
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-internal"></a>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-external"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 59 KiB |
348
images/src/banner-jakarta.svg
Normal file
|
After Width: | Height: | Size: 73 KiB |
668
images/src/jfa-go-social-jakarta.svg
Normal file
|
After Width: | Height: | Size: 100 KiB |
10
internal.go
@@ -6,7 +6,8 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
)
|
||||
|
||||
const binaryType = "internal"
|
||||
@@ -19,9 +20,6 @@ var loFS embed.FS
|
||||
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
|
||||
var laFS embed.FS
|
||||
|
||||
var langFS rewriteFS
|
||||
var localFS rewriteFS
|
||||
|
||||
type rewriteFS struct {
|
||||
fs embed.FS
|
||||
prefix string
|
||||
@@ -38,8 +36,8 @@ func FSJoin(elem ...string) string {
|
||||
return out[:len(out)-1]
|
||||
}
|
||||
|
||||
func loadFilesystems() {
|
||||
func loadFilesystems(rootDir string, logger *logger.Logger) {
|
||||
langFS = rewriteFS{laFS, "lang/"}
|
||||
localFS = rewriteFS{loFS, "data/"}
|
||||
log.Println("Using internal storage")
|
||||
logger.Println("Using internal storage")
|
||||
}
|
||||
|
||||
11
lang.go
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import "github.com/hrfee/jfa-go/common"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
type langMeta struct {
|
||||
Name string `json:"name"`
|
||||
@@ -108,6 +112,7 @@ type emailLang struct {
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
UserExpired langSection `json:"userExpired"`
|
||||
ExpiryReminder langSection `json:"expiryReminder"`
|
||||
}
|
||||
|
||||
type setupLangs map[string]setupLang
|
||||
@@ -165,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
|
||||
}
|
||||
|
||||
type langSection map[string]string
|
||||
type tmpl map[string]string
|
||||
type tmpl = map[string]any
|
||||
|
||||
func templateString(text string, vals tmpl) string {
|
||||
start, previousEnd := -1, -1
|
||||
@@ -182,7 +187,7 @@ func templateString(text string, vals tmpl) string {
|
||||
start = -1
|
||||
continue
|
||||
}
|
||||
out += text[previousEnd+1:start] + val
|
||||
out += text[previousEnd+1:start] + fmt.Sprint(val)
|
||||
previousEnd = i
|
||||
start = -1
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"statistics": "Stats",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
"inviteHours": "Hours",
|
||||
@@ -56,8 +57,11 @@
|
||||
"unlink": "Unlink Account",
|
||||
"deleted": "Deleted",
|
||||
"disabled": "Disabled",
|
||||
"query": "Query",
|
||||
"run": "Run",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noResultsFound": "No Results Found",
|
||||
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
|
||||
"keepSearching": "Keep Searching",
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
@@ -136,6 +140,8 @@
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
"searchAll": "Search/sort all",
|
||||
"searchAllRecords": "Search/sort all records (on server)",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
@@ -190,6 +196,9 @@
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown",
|
||||
"selectedRecords": "{n} Selected",
|
||||
"allMatchingSelected": "All matching results selected.",
|
||||
"allLoadedSelected": "All loaded matching results selected. Click again to load all.",
|
||||
"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.",
|
||||
|
||||
@@ -136,7 +136,20 @@
|
||||
"disableReferrals": "Deshabilitar referencias",
|
||||
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
|
||||
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
|
||||
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
|
||||
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido.",
|
||||
"settingsHiddenDependency": "Los ajustes que coinciden son escondidos porque dependen del valor de otro ajuste",
|
||||
"actions": "Acciones",
|
||||
"applyConfigurationAndPolicy": "Aplica la póliza/configuración de Jellyfin",
|
||||
"jellyseerrUserDefaultsDescription": "Crea un usuario de Jellyseer y configúralo, después selezionalo abajo. Los ajustes/permisos serán almacenados y aplicados a los usuarios nuevos de jellyseerr creados por jfa-go cuando este perfil está seleccionado.",
|
||||
"postSignupCard": "Tarjeta de ayuda post registro",
|
||||
"loginNotAdmin": "¿No eres un administrador?",
|
||||
"accountLinked": "{Metododecontacto} vinculado a: {usuario}",
|
||||
"applyOmbi": "Aplica el perfil de Ombi(si está disponible)",
|
||||
"applyJellyseerr": "Aplica el perfil de jellyseer(si está disponible)",
|
||||
"jellyseerrProfile": "Perfilé de usuario de Jellyseerr",
|
||||
"referrer": "Referente",
|
||||
"accountUnlinked": "{metododecontacto} removido de: {usuario}",
|
||||
"accountResetPassword": "{usuario} restableció su contraseña"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
"matchText": "Eggyező szöveg",
|
||||
"jellyfinID": "Jellyfin azonosító",
|
||||
"userPageLogin": "Felhasználói oldal: Bejelentkezés",
|
||||
"clickToRemoveFilter": "Szűrő eltávolítása."
|
||||
"clickToRemoveFilter": "Szűrő eltávolítása.",
|
||||
"deleted": "Törölt",
|
||||
"invite": "Meghívás",
|
||||
"activity": "Aktivitás",
|
||||
"userLabel": "Felhasználói címke",
|
||||
"userLabelDescription": "Ezzel a meghívóval létrehozott felhasználókra alkalmazandó címke."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "",
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
"download": "Unduh",
|
||||
"inviteMonths": "Bulan",
|
||||
"inviteDuration": "Durasi undangan",
|
||||
"activity": "Aktivitas"
|
||||
"activity": "Aktivitas",
|
||||
"disabled": "Dihentikan"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Alamat email {n} diubah.",
|
||||
|
||||
314
lang/admin/th-TH.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "คำเชิญ",
|
||||
"invite": "คำเชิญ",
|
||||
"accounts": "บัญชี",
|
||||
"activity": "กิจกรรม",
|
||||
"settings": "ตั้งค่า",
|
||||
"inviteMonths": "เดือน",
|
||||
"inviteDays": "วัน",
|
||||
"inviteHours": "ชั่วโมง",
|
||||
"inviteMinutes": "นาที",
|
||||
"inviteNumberOfUses": "จำนวนผู้ใช้",
|
||||
"inviteDuration": "ระยะเวลาคำเชิญ",
|
||||
"warning": "คำเตือน",
|
||||
"inviteInfiniteUsesWarning": "คำเชิญที่รับผู้ใช้ไม่จำกัดอาจถูกใช้ในทางที่ผิดได้",
|
||||
"inviteSendToEmail": "ส่งไปยัง",
|
||||
"create": "สร้าง",
|
||||
"apply": "ใช้",
|
||||
"select": "เลือก",
|
||||
"name": "ชื่อ",
|
||||
"date": "วันที่",
|
||||
"updates": "อัปเดต",
|
||||
"update": "อัปเดต",
|
||||
"download": "ดาวน์โหลด",
|
||||
"search": "ค้นหา",
|
||||
"advancedSettings": "การตั้งค่าขั้นสูง",
|
||||
"lastActiveTime": "ใช้งานล่าสุด",
|
||||
"from": "จาก",
|
||||
"after": "หลัง",
|
||||
"before": "ก่อน",
|
||||
"user": "ผู้ใช้",
|
||||
"userExpiry": "ผู้ใช้หมดอายุ",
|
||||
"userExpiryDescription": "ระยะเวลาจำนวนหนึ่งหลังจากสมัคร jfa-go จะลบ/ปิดใช้งาน บัญชีให้ คุณสามารถเปลี่ยนวิธีการจัดการได้ในการตั้งค่า",
|
||||
"aboutProgram": "เกี่ยวกับ",
|
||||
"version": "เวอร์ชั่น",
|
||||
"commitNoun": "Commit",
|
||||
"newUser": "ผู้ใช้ใหม่",
|
||||
"profile": "โปรไฟล์",
|
||||
"unknown": "ไม่รู้จัก",
|
||||
"label": "ป้าย",
|
||||
"userLabel": "ป้ายผู้ใช้",
|
||||
"userLabelDescription": "ป้ายจะถูกใช้เมื่อผู้ใช้สมัครผ่านคำเชิญนี้",
|
||||
"logs": "บันทึก",
|
||||
"announce": "ประกาศ",
|
||||
"templates": "แม่แบบ",
|
||||
"subject": "หัวเรื่อง",
|
||||
"message": "ข้อความ",
|
||||
"variables": "ตัวแปร",
|
||||
"conditionals": "เงื่อนไข",
|
||||
"preview": "พรีวิว",
|
||||
"reset": "ตั้งค่าใหม่",
|
||||
"donate": "โดเนท",
|
||||
"unlink": "ปลดลิงค์บัญชี",
|
||||
"deleted": "ลบ",
|
||||
"disabled": "ปิดใช้งาน",
|
||||
"sendPWR": "ส่งคำขอตั้งค่ารหัสผ่าน",
|
||||
"noResultsFound": "ไม่พบผลลัพธ์",
|
||||
"keepSearching": "ค้นหาต่อไป",
|
||||
"keepSearchingDescription": "เฉพาะกิจกรรมที่กำลังโหลดอยู่ถูกค้นหา กดด้านล่างถ้าต้องการค้นหาทั้งหมด",
|
||||
"contactThrough": "ติดต่อผ่าน:",
|
||||
"extendExpiry": "ยืดเวลาหมดอายุ",
|
||||
"setExpiry": "ตั้งเวลาหมดอายุ",
|
||||
"removeExpiry": "ลบเวลาหมดอายุ",
|
||||
"enterExpiry": "กรอกเวลาหมดอายุ",
|
||||
"sendPWRManual": "ผู้ใช้ {n} ไม่มีช่องทางการติดต่อ, กดคัดลอกเพื่อรับลิงค์เพื่อส่งให้เขา",
|
||||
"sendPWRSuccess": "ส่งลิงค์ตั้งรหัสใหม่แล้ว",
|
||||
"sendPWRSuccessManual": "ถ้าผู้ใช้ของคุณยังไม่ได้ลิงค์, กดคัดลอกเพื่อรับส่งไปส่งให้เขาด้วยตนเอง",
|
||||
"sendPWRValidFor": "ลิงค์สามารถใช้ได้ภายใน 30 นาที",
|
||||
"customizeMessages": "ปรับแต่งข้อความ",
|
||||
"customizeMessagesDescription": "ถ้าคุณไม่อยากใช้แบบข้อความของ jfa-go, คุณสามารถทำเองได้โดยใช้ Markdown.",
|
||||
"markdownSupported": "รอบรับ Markdown",
|
||||
"modifySettings": "ปรับแต่งการตั้งค่า",
|
||||
"modifySettingsDescription": "ปรับใช้การตั้งค่าจากโปรไฟล์ที่มีอยู่, หรือใช้แบบข้อมูลจากผู้ใช้",
|
||||
"enableReferrals": "เปิดใช้งานคำเชิญ",
|
||||
"disableReferrals": "ปิดใช้งานคำเชิญ",
|
||||
"enableReferralsDescription": "ให้ลิงค์เชิญส่วนตัวสำหรับผู้ใช้ เสมือนคำเชิญสำหรับส่งให้เพื่อน/ครอบครัว สามารถอ้างอิงจากแบบคำเชิญหน้าโปรไฟล์ หรือจากคำเชิญที่มีอยู่แล้ว",
|
||||
"enableReferralsProfileDescription": "",
|
||||
"useInviteExpiry": "",
|
||||
"useInviteExpiryNote": "",
|
||||
"applyHomescreenLayout": "",
|
||||
"applyConfigurationAndPolicy": "",
|
||||
"applyOmbi": "",
|
||||
"applyJellyseerr": "",
|
||||
"sendDeleteNotificationEmail": "",
|
||||
"sendDeleteNotifiationExample": "",
|
||||
"settingsRestart": "",
|
||||
"settingsRestarting": "",
|
||||
"settingsRestartRequired": "",
|
||||
"settingsRestartRequiredDescription": "",
|
||||
"settingsApplyRestartLater": "",
|
||||
"settingsApplyRestartNow": "",
|
||||
"settingsApplied": "",
|
||||
"settingsRefreshPage": "",
|
||||
"settingsRequiredOrRestartMessage": "",
|
||||
"settingsSave": "",
|
||||
"settingsHiddenDependency": "",
|
||||
"settingsDependsOn": "",
|
||||
"settingsAdvancedMode": "",
|
||||
"settingsMaybeUnderAdvanced": "",
|
||||
"ombiProfile": "",
|
||||
"ombiUserDefaultsDescription": "",
|
||||
"jellyseerrProfile": "",
|
||||
"jellyseerrUserDefaultsDescription": "",
|
||||
"userProfiles": "",
|
||||
"userProfilesDescription": "",
|
||||
"userProfilesIsDefault": "",
|
||||
"userProfilesLibraries": "",
|
||||
"addProfile": "",
|
||||
"addProfileDescription": "",
|
||||
"addProfileNameOf": "",
|
||||
"addProfileStoreHomescreenLayout": "",
|
||||
"inviteNoUsersCreated": "",
|
||||
"inviteUsersCreated": "",
|
||||
"inviteNoProfile": "",
|
||||
"inviteDateCreated": "",
|
||||
"inviteNoInvites": "",
|
||||
"inviteExpiresInTime": "",
|
||||
"notifyEvent": "",
|
||||
"notifyInviteExpiry": "",
|
||||
"notifyUserCreation": "",
|
||||
"sendPIN": "",
|
||||
"searchDiscordUser": "",
|
||||
"findDiscordUser": "",
|
||||
"linkMatrixDescription": "",
|
||||
"matrixHomeServer": "",
|
||||
"saveAsTemplate": "",
|
||||
"deleteTemplate": "",
|
||||
"templateEnterName": "",
|
||||
"accessJFA": "",
|
||||
"accessJFASettings": "",
|
||||
"sortingBy": "",
|
||||
"sortDirection": "",
|
||||
"filters": "",
|
||||
"clickToRemoveFilter": "",
|
||||
"clearSearch": "",
|
||||
"actions": "",
|
||||
"searchOptions": "",
|
||||
"matchText": "",
|
||||
"jellyfinID": "",
|
||||
"userPageLogin": "",
|
||||
"userPagePage": "",
|
||||
"postSignupCard": "",
|
||||
"postSignupCardDescription": "",
|
||||
"buildTime": "",
|
||||
"builtBy": "",
|
||||
"loginNotAdmin": "",
|
||||
"referrer": "",
|
||||
"accountLinked": "",
|
||||
"accountUnlinked": "",
|
||||
"accountResetPassword": "",
|
||||
"accountChangedPassword": "",
|
||||
"accountCreated": "",
|
||||
"accountDeleted": "",
|
||||
"accountDisabled": "",
|
||||
"accountReEnabled": "",
|
||||
"accountExpired": "",
|
||||
"accountWillExpire": "",
|
||||
"expirationBasedOn": "",
|
||||
"userDeleted": "",
|
||||
"userDisabled": "",
|
||||
"inviteCreated": "",
|
||||
"inviteDeleted": "",
|
||||
"inviteExpired": "",
|
||||
"fromInvite": "",
|
||||
"byAdmin": "",
|
||||
"byUser": "",
|
||||
"byJfaGo": "",
|
||||
"activityID": "",
|
||||
"title": "",
|
||||
"usersMentioned": "",
|
||||
"actor": "",
|
||||
"actorDescription": "",
|
||||
"accountCreationFilter": "",
|
||||
"accountDeletionFilter": "",
|
||||
"accountDisabledFilter": "",
|
||||
"accountEnabledFilter": "",
|
||||
"contactLinkedFilter": "",
|
||||
"contactUnlinkedFilter": "",
|
||||
"passwordChangeFilter": "",
|
||||
"passwordResetFilter": "",
|
||||
"inviteCreatedFilter": "",
|
||||
"inviteDeletedFilter": "",
|
||||
"loadMore": "",
|
||||
"loadAll": "",
|
||||
"noMoreResults": "",
|
||||
"totalRecords": "",
|
||||
"loadedRecords": "",
|
||||
"shownRecords": "",
|
||||
"backups": "",
|
||||
"backupsDescription": "",
|
||||
"backupsFormatNote": "",
|
||||
"backupsCopy": "",
|
||||
"backupDownloadRestore": "",
|
||||
"backupUpload": "",
|
||||
"backupDownload": "",
|
||||
"backupRestore": "",
|
||||
"backupNow": "",
|
||||
"backupCreated": "",
|
||||
"backupCanBeFound": "",
|
||||
"backupCanDownload": "",
|
||||
"wikiPage": ""
|
||||
},
|
||||
"notifications": {
|
||||
"pathCopied": "",
|
||||
"changedEmailAddress": "",
|
||||
"userCreated": "",
|
||||
"createProfile": "",
|
||||
"saveSettings": "",
|
||||
"saveEmail": "",
|
||||
"sentAnnouncement": "",
|
||||
"savedAnnouncement": "",
|
||||
"setOmbiProfile": "",
|
||||
"savedProfile": "",
|
||||
"updateApplied": "",
|
||||
"updateAppliedRefresh": "",
|
||||
"telegramVerified": "",
|
||||
"accountConnected": "",
|
||||
"referralsEnabled": "",
|
||||
"activityDeleted": "",
|
||||
"errorInviteNoLongerExists": "",
|
||||
"errorInviteNotFound": "",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "",
|
||||
"errorHomescreenAppliedNoSettings": "",
|
||||
"errorSettingsFailed": "",
|
||||
"errorSaveEmail": "",
|
||||
"errorBlankFields": "",
|
||||
"errorDeleteProfile": "",
|
||||
"errorLoadProfiles": "",
|
||||
"errorCreateProfile": "",
|
||||
"errorSavedProfile": "",
|
||||
"errorSetDefaultProfile": "",
|
||||
"errorLoadUsers": "",
|
||||
"errorLoadSettings": "",
|
||||
"errorSetOmbiProfile": "",
|
||||
"errorLoadOmbiUsers": "",
|
||||
"errorChangedEmailAddress": "",
|
||||
"errorFailureCheckLogs": "",
|
||||
"errorPartialFailureCheckLogs": "",
|
||||
"errorUserCreated": "",
|
||||
"errorSendWelcomeEmail": "",
|
||||
"errorApplyUpdate": "",
|
||||
"errorCheckUpdate": "",
|
||||
"errorNoReferralTemplate": "",
|
||||
"errorLoadActivities": "",
|
||||
"errorInvalidDate": "",
|
||||
"updateAvailable": "",
|
||||
"noUpdatesAvailable": ""
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"disableUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"reEnableUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"disabledUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"enabledUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"extendExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"extendedExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,9 @@
|
||||
"delete": "Delete",
|
||||
"myAccount": "My Account",
|
||||
"referrals": "Referrals",
|
||||
"inviteRemainingUses": "Remaining uses"
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"internal": "Internal",
|
||||
"external": "External"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
|
||||
@@ -18,9 +18,29 @@
|
||||
"theme": "Tema",
|
||||
"login": "Masuk",
|
||||
"logout": "Keluar",
|
||||
"edit": "Edit",
|
||||
"edit": "Ubah",
|
||||
"delete": "Hapus",
|
||||
"inviteRemainingUses": "Penggunaan yang tersisa"
|
||||
"inviteRemainingUses": "Penggunaan yang tersisa",
|
||||
"linkDiscord": "Discord Link",
|
||||
"linkMatrix": "Matrix Link",
|
||||
"contactDiscord": "Hubungi melalui Discord",
|
||||
"linkTelegram": "Telegram Link",
|
||||
"contactEmail": "Hubungi melalui Email",
|
||||
"contactTelegram": "Hubungi melalui Telegram",
|
||||
"refresh": "Segarkan",
|
||||
"required": "Dibutuhkan",
|
||||
"admin": "Admin",
|
||||
"enabled": "Diaktifkan",
|
||||
"disabled": "Dihentikan",
|
||||
"reEnable": "Diaktifkan kembali",
|
||||
"disable": "Matikan",
|
||||
"accountStatus": "Status Akun",
|
||||
"notSet": "Belum ditetapkan",
|
||||
"expiry": "Kedaluwarsa",
|
||||
"add": "Tambah",
|
||||
"myAccount": "Akun Saya",
|
||||
"copied": "Telah disalin",
|
||||
"referrals": "Referensi"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
|
||||
@@ -28,5 +48,19 @@
|
||||
"errorUnknown": "Terjadi kesalahan yang tidak diketahui.",
|
||||
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
|
||||
"errorSaveSettings": "Tidak dapat menyimpan pengaturan."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} Tahun",
|
||||
"plural": "{n} Beberapa tahun"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Bulan",
|
||||
"plural": "{n} Beberapa bulan"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Hari",
|
||||
"plural": "{n} Beberapa hari"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
lang/common/th-TH.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"username": "ชื่อผู้ใช้งาน",
|
||||
"password": "รหัสผ่าน",
|
||||
"emailAddress": "ที่อยู่อีเมล",
|
||||
"name": "ชื่อ",
|
||||
"submit": "ส่ง",
|
||||
"send": "ส่ง",
|
||||
"success": "เสร็จสิ้น",
|
||||
"continue": "ดำเนินการต่อ",
|
||||
"error": "ข้อผิดผลาด",
|
||||
"copy": "คัดลอก",
|
||||
"copied": "คัดลอกแล้ว",
|
||||
"time24h": "เวลา 24 ชม.",
|
||||
"time12h": "เวลา 12 ชม.",
|
||||
"linkTelegram": "ลิงค์ Telegram",
|
||||
"contactEmail": "ติดต่อผ่านอีเมล",
|
||||
"contactTelegram": "ติดต่อผ่าน Telegram",
|
||||
"linkDiscord": "ลิงค์ Discord",
|
||||
"linkMatrix": "ลิงค์ Matrix",
|
||||
"contactDiscord": "ติดต่อผ่าน Discord",
|
||||
"theme": "ธีม",
|
||||
"refresh": "โหลดใหม่",
|
||||
"required": "จำเป็น",
|
||||
"login": "เข้าสู่ระบบ",
|
||||
"logout": "ออกจากระบบ",
|
||||
"admin": "ผู้ดูแล",
|
||||
"enabled": "เปิดใช้งาน",
|
||||
"disabled": "ปิดใช้งาน",
|
||||
"reEnable": "เปิดใช้งานอีกครั้ง",
|
||||
"disable": "ปิดใช้งาน",
|
||||
"contactMethods": "ช่องทางการติดต่อ",
|
||||
"accountStatus": "สถานะบัญชี",
|
||||
"notSet": "ยังไม่ตั้งค่า",
|
||||
"expiry": "หมดอายุ",
|
||||
"add": "เพิ่ม",
|
||||
"edit": "แก้ไข",
|
||||
"delete": "ลบ",
|
||||
"myAccount": "บัญชีของฉัน",
|
||||
"referrals": "คำเชิญ",
|
||||
"inviteRemainingUses": "จำนวนใช้ที่เหลือ"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "ชื่อผู้ใช้ และ/หรือ รหัสผ่านถูกเว้นว่างไว้",
|
||||
"errorConnection": "ไม่สามารถเชื่อต่อไปยัง jfa-go ได้",
|
||||
"errorUnknown": "เกิดข้อผิดผลาดที่ไม่รู้จัก",
|
||||
"error401Unauthorized": "ไม่อนุญาติการเข้าถึง, ลองโหลดหน้านี้อีกครั้ง",
|
||||
"errorSaveSettings": "ไม่สามารถบันทึกการตั้งค่าได้"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} ปี",
|
||||
"plural": "{n} ปี"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} เดือน",
|
||||
"plural": "{n} เดือน"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} วัน",
|
||||
"plural": "{n} วัน"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,12 @@
|
||||
"add": "Thêm",
|
||||
"edit": "Chỉnh sửa",
|
||||
"delete": "Xóa",
|
||||
"inviteRemainingUses": "Số lần sử dụng còn lại"
|
||||
"inviteRemainingUses": "Số lần sử dụng còn lại",
|
||||
"username": "Tài khoản",
|
||||
"password": "Mật khẩu"
|
||||
},
|
||||
"notifications": {
|
||||
"errorConnection": "Không thể kết nối với jfa-go.",
|
||||
"error401Unauthorized": "Không được phép. Hãy thử làm mới trang."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,5 +80,10 @@
|
||||
"title": "Your account has expired - Jellyfin",
|
||||
"yourAccountHasExpired": "Your account has expired.",
|
||||
"contactTheAdmin": "Contact the administrator for more info."
|
||||
},
|
||||
"expiryReminder": {
|
||||
"name": "Expiry reminder",
|
||||
"title": "Reminder: your account will expire soon - Jellyfin",
|
||||
"yourAccountIsDueToExpire": "Your account is due to expire in {expiresIn}, or on {date} at {time}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@
|
||||
"ifItWasYou": "Jika ini adalah benar anda, masukkan pin dibawah ke dalam tempat yang sudah disediakan.",
|
||||
"codeExpiry": "Kode akan kadaluarsa pada {date}, pada waktu {time} UTC, yaitu dalam {expiresInMinutes}.",
|
||||
"pin": "PIN",
|
||||
"name": "Atur ulang kata sandi"
|
||||
"name": "Atur ulang kata sandi",
|
||||
"ifItWasYouLink": "Jika ini kamu yang request, silahkan klik link dibawah ini."
|
||||
},
|
||||
"userDeleted": {
|
||||
"title": "Akun anda telah dihapus - Jellyfin",
|
||||
"yourAccountWasDeleted": "Akun Jellyfin anda telah dihapus.",
|
||||
"name": "Penghapusan pengguna"
|
||||
"name": "Hapus Pengguna"
|
||||
},
|
||||
"inviteEmail": {
|
||||
"title": "Undangan - Jellyfin",
|
||||
@@ -48,12 +49,36 @@
|
||||
"welcome": "Selamat datang di Jellyfin!",
|
||||
"youCanLoginWith": "Anda dapat masuk dengan menggunakan data dibawah ini",
|
||||
"jellyfinURL": "URL",
|
||||
"name": "Email selamat datang"
|
||||
"name": "Selamat Datang",
|
||||
"yourAccountWillExpire": "Akun kamu akan kedaluwarsa pada {date}."
|
||||
},
|
||||
"emailConfirmation": {
|
||||
"title": "Konfirmasi emailmu - Jellyfin",
|
||||
"clickBelow": "Klik link dibawah ini untuk mengkonfirmasikan alamat emailmu untuk mulai menggunakan Jellyfin.",
|
||||
"clickBelow": "Klik link dibawah ini untuk mengkonfirmasikan alamat emailmu dan mulai menggunakan Jellyfin.",
|
||||
"confirmEmail": "Konfirmasi Email",
|
||||
"name": "Email konfirmasi"
|
||||
},
|
||||
"userDisabled": {
|
||||
"title": "Akun anda telah dihentikan - Jellyfin",
|
||||
"yourAccountWasDisabled": "Akun anda telah dihentikan.",
|
||||
"name": "Akun telah dihentikan"
|
||||
},
|
||||
"userEnabled": {
|
||||
"name": "Akun telah diaktifkan",
|
||||
"yourAccountWasEnabled": "Akun anda telah diaktifkan kembali.",
|
||||
"title": "Akun anda telah diaktifkan kembali - Jellyfin"
|
||||
},
|
||||
"userExpired": {
|
||||
"name": "Pengguna kedaluwarsa",
|
||||
"title": "Akun kamu sudah kedaluwarsa - Jellyfin",
|
||||
"yourAccountHasExpired": "Akun kamu sudah kedaluwarsa.",
|
||||
"contactTheAdmin": "Hubungi admin untuk info lebih lanjut."
|
||||
},
|
||||
"userExpiryAdjusted": {
|
||||
"name": "Waktu habis sudah diubah",
|
||||
"yourExpiryWasAdjusted": "Tanggal kedaluwarsa akun kamu sudah disesuaikan.",
|
||||
"title": "Waktu habis akun sudah disesuaikan - Jellyfin",
|
||||
"ifPreviouslyDisabled": "Jika akun kamu sebelumnya dihentikan, akun kamu butuh untuk diaktifkan kembali.",
|
||||
"newExpiry": "Akun kamu akan kedaluwarsa pada {date}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
84
lang/email/th-TH.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "ถ้าหากไม่ใช่คุณ, สามารถมองข้ามได้เลย",
|
||||
"helloUser": "สวัสดี {username},",
|
||||
"reason": "เหตุผล"
|
||||
},
|
||||
"userCreated": {
|
||||
"name": "การสร้างผู้ใช้",
|
||||
"title": "แจ้งเตือน: ผู้ใช้ถูกสร้างแล้ว",
|
||||
"aUserWasCreated": "ผู้ใช้นี้ถูกสร้างโดยใช้รหัส {code}",
|
||||
"time": "เวลา",
|
||||
"notificationNotice": "ปล: การแจ้งเตือนสามารถเปิด/ปิดได้ผ่านหน้าผู้ดูแลระบบ"
|
||||
},
|
||||
"inviteExpiry": {
|
||||
"name": "คำเชิญหมดอายุ",
|
||||
"title": "แจ้งเตือน: คำเชิญหมดอายุ",
|
||||
"inviteExpired": "คำเชิญหมดอายุแล้ว",
|
||||
"expiredAt": "รหัส {code} หมดอายุเมื่อ {time}",
|
||||
"notificationNotice": "ปล: การแจ้งเตือนสามารถเปิด/ปิดได้ผ่านหน้าผู้ดูแลระบบ"
|
||||
},
|
||||
"passwordReset": {
|
||||
"name": "ตั้งค่ารหัสผ่านใหม่",
|
||||
"title": "คำขอตั้งค่ารหัสผ่านใหม่ - Jellyfin",
|
||||
"someoneHasRequestedReset": "บางคนได้ส่งคำขอตั้งค่ารหัสผ่านใหม่บน Jellyfin",
|
||||
"ifItWasYou": "ถ้าเป็นคุณ, ให้กดรหัสด้านล่างไปยังหน้าจอ",
|
||||
"ifItWasYouLink": "ถ้าเป็นคุณ, ให้กดลิงค์ด้านล่าง",
|
||||
"codeExpiry": "รหัสจะหมดอายุภายในวันที่ {date} เวลา {time} (UTC) ซึ่งภายใน {expiresInMinutes}",
|
||||
"pin": "หรัส (PIN)"
|
||||
},
|
||||
"userDeleted": {
|
||||
"name": "ลบผู้ใช้งาน",
|
||||
"title": "บัญชีของคุณถูกลบ - Jellyfin",
|
||||
"yourAccountWasDeleted": "บัญชี Jellyfin ของคุณถูกลบ"
|
||||
},
|
||||
"userDisabled": {
|
||||
"name": "บัญชีถูกปิดใช้งาน",
|
||||
"title": "บัญชีของคุณถูกปิดใช้งาน - Jellyfin",
|
||||
"yourAccountWasDisabled": "บัญชีของคุณถูกปิดใช้งาน"
|
||||
},
|
||||
"userEnabled": {
|
||||
"name": "บัญชีถูกเปิดใช้งาน",
|
||||
"title": "บัญชีของคุณถูกเปิดใช้งานอีกครั้ง - Jellyfin",
|
||||
"yourAccountWasEnabled": "บัญชีของคุณถูกเปิดใช้งานอีกครั้ง"
|
||||
},
|
||||
"userExpiryAdjusted": {
|
||||
"name": "วันหมดอายุถูกปรับ",
|
||||
"title": "วันหมดอายุบัญชีถูกปรับ - Jellyfin",
|
||||
"yourExpiryWasAdjusted": "วันหมดอายุบัญชีของคุณถูกปรับ",
|
||||
"ifPreviouslyDisabled": "ถ้าบัญชีของคุณถูกปิดก่อนหน้า, ตอนนี้อาจถูกเปิดใช้งานอีกครั้งแล้ว",
|
||||
"newExpiry": "วันหมดอายุของคุณตอนนี้กลายเป็น {date}."
|
||||
},
|
||||
"inviteEmail": {
|
||||
"name": "อีเมลเชิญ",
|
||||
"title": "คำเชิญ - Jellyfin",
|
||||
"hello": "สวัสดี",
|
||||
"youHaveBeenInvited": "คุณได้รับคำเชิญเข้าสู่ Jellyfin",
|
||||
"toJoin": "กดลิงค์ด้านล่างเพื่อเข้าร่วม",
|
||||
"inviteExpiry": "ลิงค์คำเชิญจะหมดอายุภายในวันที่ {date} เวลา {time} ซึ่งภายใน {expiresInMinutes}, เพราะฉนั้น รีบซ่ะละ",
|
||||
"linkButton": "ตั้งค่าบัญชีของคุณ"
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"name": "ยินดีต้อนรับ",
|
||||
"title": "ยินดีต้อนรับเข้าสู่ Jellyfin",
|
||||
"welcome": "ยินดีต้อนรับเข้าสู่ Jellyfin!",
|
||||
"youCanLoginWith": "คุณสามารถเข้าสู่ระบบด้วยข้อมูลด้านล่าง",
|
||||
"yourAccountWillExpire": "บัญชีของคุณจะหมดอายุภายใน {date}",
|
||||
"jellyfinURL": "URL"
|
||||
},
|
||||
"emailConfirmation": {
|
||||
"name": "อีเมลยืนยันตัวตน",
|
||||
"title": "ยืนยันอีเมลของคุณ - Jellyfin",
|
||||
"clickBelow": "คลิกลิงค์ด้านล่างเพื่อยืนยันอีเมลของคุณและเริ่มเข้าใช้งาน Jellyfin",
|
||||
"confirmEmail": "ยืนยันอีเมล"
|
||||
},
|
||||
"userExpired": {
|
||||
"name": "บัญชีหมดอายุ",
|
||||
"title": "บัญชีของคุณหมดอายุ - Jellyfin",
|
||||
"yourAccountHasExpired": "บัญชีของคุณหมดอายุแล้ว",
|
||||
"contactTheAdmin": "ติดต่อผู้ดูแลระบบสำหรับข้อมูลเพิ่มเติม"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "کوردی سۆرانی"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "دروستکردنی هەژماری جێڵیفن",
|
||||
"pageTitle": "دروستکردنی هەژماری Jellyfin",
|
||||
"createAccountHeader": "دروستکردنی هەژمار",
|
||||
"accountDetails": "زانیارییەکان",
|
||||
"emailAddress": "ئیمەیل",
|
||||
@@ -23,14 +23,14 @@
|
||||
"sendPINDiscord": "{command} لە چەناڵی {server_channel}ی دیسکۆردەکەت بنوسە، پاشان ئەم ژمارە نهێنییەی خوارەوە بنێرە.",
|
||||
"matrixEnterUser": "",
|
||||
"welcomeUser": "{user}، بەخێربێیت!",
|
||||
"addContactMethod": "",
|
||||
"editContactMethod": "",
|
||||
"joinTheServer": "",
|
||||
"customMessagePlaceholderHeader": "",
|
||||
"addContactMethod": "زیادکردنی ڕێگەی پەیوەندیی پێوە کردنم",
|
||||
"editContactMethod": "گۆڕینی ڕێگەی پەیوەندیی پێوە کردنم",
|
||||
"joinTheServer": "چوونە سێرڤەر:",
|
||||
"customMessagePlaceholderHeader": "دەستکاریکردنی ئەم کارتە",
|
||||
"customMessagePlaceholderContent": "",
|
||||
"userPageSuccessMessage": "",
|
||||
"resetPassword": "",
|
||||
"resetPasswordThroughJellyfin": "",
|
||||
"userPageSuccessMessage": "دواتر دەتوانیت زانیاری لەسەر هەژمارەکەت ببینیت و دەستکاری بکەیت لە بەشی {myAccount}.",
|
||||
"resetPassword": "هێنانەوەی تێپەڕەوشەی نهێنی",
|
||||
"resetPasswordThroughJellyfin": "بۆ هێنانەوەی تێپەڕەوشەکەت، سەردانی {jfLink} بکە و گرتە لە \"تێپەڕەوشەم بیرچووە\" بکە.",
|
||||
"resetPasswordThroughLink": "",
|
||||
"resetPasswordThroughLinkStart": "",
|
||||
"resetPasswordThroughLinkEnd": "",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"resetPasswordEmail": "Su dirección de correo electrónico",
|
||||
"resetPasswordContactMethod": "El nombre de usuario de cualquier método de contacto vinculado a su cuenta",
|
||||
"resetSentDescription": "Si una cuenta con el nombre de usuario o método de contacto suministrados existe, se habrá enviado un enlace de restablecimiento de contraseña a través de todos los métodos de contacto disponibles. El código caducará a los 30 minutos.",
|
||||
"referralsWithExpiryDescription": "Invite a amigos y familia a Jellyfin con este enlace. El enlace se desactivará cuando caduque."
|
||||
"referralsWithExpiryDescription": "Invite a amigos y familia a Jellyfin con este enlace. El enlace se desactivará cuando caduque.",
|
||||
"welcomeUser": "Bienvenido, {user}!"
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "El usuario ya existe.",
|
||||
|
||||
@@ -26,7 +26,22 @@
|
||||
"welcomeUser": "Selamat datang, {user}!",
|
||||
"joinTheServer": "Bergabung ke server:",
|
||||
"changePassword": "Ubah Sandi",
|
||||
"resetPassword": "Atur Ulang Sandi"
|
||||
"resetPassword": "Atur Ulang Sandi",
|
||||
"resetPasswordUsername": "Username Jellyfin kamu",
|
||||
"resetPasswordEmail": "Email kamu",
|
||||
"referralsWithExpiryDescription": "Undang teman & keluarga ke Jellyfin dengan link ini. Link tidak akan bisa digunakan kalau sudah kedaluwarsa.",
|
||||
"addContactMethod": "Tambahkan cara untuk menghubungimu",
|
||||
"editContactMethod": "Ubah informasi kontak",
|
||||
"customMessagePlaceholderHeader": "Sesuaikan kartu ini",
|
||||
"customMessagePlaceholderContent": "Tekan tombol edit User Page di settings untuk sesuaikan kartu ini, atau tampilkan satu dilayar masuk, dan jangan khawatir, pengguna tidak bisa melihat ini.",
|
||||
"resetSent": "Reset dikirim.",
|
||||
"resetPasswordThroughLinkEnd": "Lalu tekan submit. Ada link yang akan dikirim ke email untuk reset password kamu.",
|
||||
"resetPasswordThroughLink": "Untuk reset password kamu, masukkan salah satu dari username, email, atau kontak yang terhubung pada akun, lalu submit. Akan ada link yang dikirim ke email untuk reset password kamu.",
|
||||
"resetPasswordThroughJellyfin": "Untuk mengubah password kamu, kunjungi {ifLink} dan tekan tombol \"Forgot Password\".",
|
||||
"copyReferral": "Salin Link",
|
||||
"referralsDescription": "Undang teman & keluarga untuk bergabung ke Jellyfin dengan link ini. Kembali kesini untuk membuat yang baru jika ini sudah kedaluwarsa.",
|
||||
"invitedBy": "Kamu diundang oleh {user}.",
|
||||
"resetPasswordThroughLinkStart": "Untuk reset password kamu, masukkan salah satu yang ada dibawah:"
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "Pengguna sudah ada.",
|
||||
|
||||
88
lang/form/pt-PT.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Português (PT)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Criar uma conta Jellyfin",
|
||||
"createAccountHeader": "Criar conta",
|
||||
"accountDetails": "Detalhes",
|
||||
"emailAddress": "E-mail",
|
||||
"username": "Nome de utilizador",
|
||||
"oldPassword": "Palavra-passe antiga",
|
||||
"newPassword": "Nova palavra-passe",
|
||||
"password": "Palavra-passe",
|
||||
"reEnterPassword": "Reintroduzir a palavra-passe",
|
||||
"reEnterPasswordInvalid": "As palavras-passe não são iguais.",
|
||||
"createAccountButton": "Criar conta",
|
||||
"passwordRequirementsHeader": "Requisitos da palavra-passe",
|
||||
"successHeader": "Sucesso!",
|
||||
"confirmationRequired": "É necessária uma confirmação por e-mail",
|
||||
"confirmationRequiredMessage": "Verifique a sua caixa de entrada para confirmar o seu e-mail.",
|
||||
"yourAccountIsValidUntil": "A sua conta é válida até {data}.",
|
||||
"sendPIN": "Envie o PIN abaixo para o bot e, em seguida, volte aqui para associar a sua conta.",
|
||||
"sendPINDiscord": "Escreva {command} em {server_channel} no Discord e envie o PIN abaixo.",
|
||||
"matrixEnterUser": "Introduza o seu ID de utilizador, prima enviar e ser-lhe-á enviado um PIN. Introduza-o aqui para continuar.",
|
||||
"welcomeUser": "Bem-vindo(a), {utilizador}!",
|
||||
"addContactMethod": "Adicionar método de contacto",
|
||||
"editContactMethod": "Editar método de contacto",
|
||||
"joinTheServer": "Junte-se ao servidor:",
|
||||
"customMessagePlaceholderHeader": "Personalizar este cartão",
|
||||
"customMessagePlaceholderContent": "Clique no botão de edição da página do utilizador nas definições para personalizar este cartão, ou mostre um no ecrã de início de sessão, e não se preocupe, o utilizador não o pode ver.",
|
||||
"userPageSuccessMessage": "Pode ver e alterar os detalhes da sua conta mais tarde na página {myAccount}.",
|
||||
"resetPassword": "Redefinir palavra-passe",
|
||||
"resetPasswordThroughJellyfin": "Para redefinir a sua palavra-passe, visite {jfLink} e prima o botão “Esqueci-me da palavra-passe”.",
|
||||
"resetPasswordThroughLink": "Para redefinir a sua palavra-passe, introduza o seu nome de utilizador, e-mail ou um nome de utilizador de um método de contacto associado e clique em enviar. Será enviado um link para redefinir a sua palavra-passe.",
|
||||
"resetPasswordThroughLinkStart": "Para redefinir a sua palavra-passe, introduza uma das seguintes opções:",
|
||||
"resetPasswordThroughLinkEnd": "Em seguida, clique em enviar. Será enviado um link para redefinir a sua palavra-passe.",
|
||||
"resetPasswordUsername": "O seu nome de utilizador Jellyfin",
|
||||
"resetPasswordEmail": "O seu endereço de e-mail",
|
||||
"resetPasswordContactMethod": "O nome de utilizador de qualquer método de contacto associado à sua conta",
|
||||
"resetSent": "Link de redefinição enviado.",
|
||||
"resetSentDescription": "Se existir uma conta com o nome de utilizador/método de contacto indicado, será enviado um link de redefinição da palavra-passe através de todos os métodos de contacto disponíveis. O código expirará dentro de 30 minutos.",
|
||||
"changePassword": "Alterar palavra-passe",
|
||||
"referralsDescription": "Convide amigos e familiares para o Jellyfin com este link. Volte aqui para obter um novo link se ele expirar.",
|
||||
"referralsWithExpiryDescription": "Convide amigos e familiares para o Jellyfin com este link. O link será desativado quando expirar.",
|
||||
"copyReferral": "Copiar link",
|
||||
"invitedBy": "Foi convidado pelo utilizador {user}."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "O utilizador já existe.",
|
||||
"errorInvalidCode": "Código de convite inválido.",
|
||||
"errorAccountLinked": "Esta conta já está a ser utilizada.",
|
||||
"errorEmailLinked": "Este e-mail já está a ser utilizado.",
|
||||
"errorTelegramVerification": "É necessária a verificação do Telegram.",
|
||||
"errorDiscordVerification": "É necessária a verificação do Discord.",
|
||||
"errorMatrixVerification": "É necessária a verificação da Matrix.",
|
||||
"errorInvalidPIN": "PIN inválido.",
|
||||
"errorUnknown": "Erro desconhecido.",
|
||||
"errorNoEmail": "E-mail necessário.",
|
||||
"errorCaptcha": "Captcha incorreto.",
|
||||
"errorPassword": "Verifique os requisitos da palavra-passe.",
|
||||
"errorNoMatch": "As palavras-passe não coincidem.",
|
||||
"errorOldPassword": "A palavra-passe antiga está incorreta.",
|
||||
"passwordChanged": "Palavra-passe alterada.",
|
||||
"verified": "Conta verificada."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Deve ter pelo menos {n} caráter",
|
||||
"plural": "Deve ter pelo menos {n} carateres"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Deve ter pelo menos {n} caráter em maiúscula",
|
||||
"plural": "Deve ter pelo menos {n} carateres em maiúsculas"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Deve ter pelo menos {n} caráter minúsculo",
|
||||
"plural": "Deve ter pelo menos {n} carateres minúsculos"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Deve ter pelo menos {n} número",
|
||||
"plural": "Deve ter pelo menos {n} números"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Deve ter pelo menos {n} caráter especial",
|
||||
"plural": "Deve ter pelo menos {n} carateres especiais"
|
||||
}
|
||||
}
|
||||
}
|
||||
88
lang/form/th-TH.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "สมัครบัญชี Jellyfin",
|
||||
"createAccountHeader": "สมัครบัญชี",
|
||||
"accountDetails": "รายละเอียด",
|
||||
"emailAddress": "ที่อยู่อีเมล",
|
||||
"username": "ชื่อผู้ใช้งาน",
|
||||
"oldPassword": "รหัสผ่านเก่า",
|
||||
"newPassword": "รหัสผ่านใหม่",
|
||||
"password": "รหัสผ่าน",
|
||||
"reEnterPassword": "กรอกรหัสผ่านซ้ำ",
|
||||
"reEnterPasswordInvalid": "รหัสผ่านไม่เหมือนกัน",
|
||||
"createAccountButton": "สมัครบัญชี",
|
||||
"passwordRequirementsHeader": "ความต้องการของรหัสผ่าน",
|
||||
"successHeader": "สำเร็จ!",
|
||||
"confirmationRequired": "จำเป็นต้องยืนยันอีเมล",
|
||||
"confirmationRequiredMessage": "โปรดตรวจสอบกล่องข้อความ (Inbox) เพื่อยืนยันที่อยู่ของท่าน",
|
||||
"yourAccountIsValidUntil": "บัญชีของคุณจะใช้ได้ถึงวันที่ {date}",
|
||||
"sendPIN": "ส่งรหัส (PIN) ด้านล่างไปให้บอท, หลังจากนั้นกลับมาเพื่อผูกบัญชีของคุณ",
|
||||
"sendPINDiscord": "พิมพ์ {command} ในห้อง {server_channel} บน Discord, หลังจากนั้นส่งรหัส (PIN) ด้านล่าง",
|
||||
"matrixEnterUser": "กรอกรหัสประจำไอดี, แล้วกดส่ง, รหัส (PIN) จะส่งไปให้คุณ และโปรดกรอกลงตรงนี้เพื่อดำเนินการต่อ",
|
||||
"welcomeUser": "ยินดีต้อนรับ, {user}!",
|
||||
"addContactMethod": "เพิ่มช่องทางการติดต่อ",
|
||||
"editContactMethod": "แก้ไขช่องทางการติดต่อ",
|
||||
"joinTheServer": "เข้าร่วมเซิฟเวอร์:",
|
||||
"customMessagePlaceholderHeader": "ปรับเปลี่ยนการ์ดนี้",
|
||||
"customMessagePlaceholderContent": "กดไปที่ปุ่ม \"แก้ไขหน้าผู้ใช้\" ในการตั้งค่าเพื่อปรับเปลี่ยนการ์ดนี้, หรือแสดงบนหน้าเข้าสู่ระบบ, และไม่ต้องห่วง! ผู้ใช้จะไม่เห็นสิ่งนี้",
|
||||
"userPageSuccessMessage": "คุณสามารถแก้ไขหรือดูข้อมูลเกี่ยวกับบัญชีได้ทีหลัง ผ่านหน้า {myAccount}",
|
||||
"resetPassword": "ตั้งค่ารหัสผ่านใหม่",
|
||||
"resetPasswordThroughJellyfin": "หากต้องการตั้งค่ารหัสผ่านใหม่, เข้าไปที่ {jfLink} และกดปุ่ม \"ตั้งค่ารหัสผ่านใหม่\"",
|
||||
"resetPasswordThroughLink": "หากต้องการตั้งค่ารหัสผ่านใหม่, กรอกชื่อผู้ใช้, บัญชีอีเมล หรือชื่อผู้ใช้ที่เชื่อมไว้, แล้วกดส่ง แล้วลิงค์จะถูกส่งให้ตั้งค่ารหัสผ่านใหม่",
|
||||
"resetPasswordThroughLinkStart": "หากต้องการตั้งค่ารหัสผ่านใหม่, กรอกช่องใดช่องหนึ่งด้านล่าง:",
|
||||
"resetPasswordThroughLinkEnd": "หลังจากนั้นกดส่ง แล้วลิงค์จะถูกส่งให้ตั้งค่ารหัสผ่านใหม่",
|
||||
"resetPasswordUsername": "ชื่อผู้ใช้ Jellyfin ของคุณ",
|
||||
"resetPasswordEmail": "บัญชีอีเมลของคุณ",
|
||||
"resetPasswordContactMethod": "ชื่อผู้ใช้ที่เชื่อมไว้กับบัญชีของคุณอันใดก็ได้",
|
||||
"resetSent": "ส่งอีกครั้ง",
|
||||
"resetSentDescription": "ถ้าหากบัญชี/ข้อมูลที่ให้ไว้ตรงกับในระบบ, ลิงค์เพื่อตั้งค่ารหัสใหม่จะถูกส่งให้ผ่านช่องทางติดต่อทั้งหมด. รหัสจะหมดอายุภายใน 30 นาที",
|
||||
"changePassword": "เปลี่ยนรหัสผ่าน",
|
||||
"referralsDescription": "ชวนเพื่อน & ครอบครัวเข้าสู่ Jellyfin ผ่านลิงค์นี้, กลับมาเอาอันใหม่อีกครั้งหากหมดอายุ",
|
||||
"referralsWithExpiryDescription": "ชวนเพื่อน & ครอบครัวเข้าสู่ Jellyfin ผ่านลิงค์นี้, ลิงค์นี้จะถูกปิดใช้งานหลังจากหมดอายุ",
|
||||
"copyReferral": "คัดลอกลิงค์",
|
||||
"invitedBy": "คุณถูกเชิญโดยผู้ใช้ {user}"
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "ชื่อผู้ใช้นี้มีอยู่แล้ว",
|
||||
"errorInvalidCode": "รหัสเชิญไม่ถูกต้อง",
|
||||
"errorAccountLinked": "บัญชีนี้ถูกใช้ไปแล้ว",
|
||||
"errorEmailLinked": "อีเมลนี้ถูกใช้ไปแล้ว",
|
||||
"errorTelegramVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Telegram",
|
||||
"errorDiscordVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Discord",
|
||||
"errorMatrixVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Matrix",
|
||||
"errorInvalidPIN": "รหัส (PIN) ไม่ถูกต้อง",
|
||||
"errorUnknown": "เกิดข้อผิดผลาดที่ไม่รู้จัก",
|
||||
"errorNoEmail": "จำเป็นต้องกรอกอีเมล",
|
||||
"errorCaptcha": "ยืนยันตัว (Capcha) ไม่ถูกต้อง",
|
||||
"errorPassword": "ตรวจสอบกฎเกณฑ์รหัสผ่าน",
|
||||
"errorNoMatch": "รหัสผ่านไม่ตรงกัน",
|
||||
"errorOldPassword": "รหัสผ่านเก่าไม่ถูกต้อง",
|
||||
"passwordChanged": "รหัสผ่านถูกเปลี่ยนแล้ว",
|
||||
"verified": "ยืนยันบัญชีแล้ว"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "ต้องมีอย่างน้อย {n} ตัว",
|
||||
"plural": "ต้องมีอย่างน้อย {n} ตัว"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "ต้องมีตัวอักษรตัวใหญ่อย่างน้อย {n} ตัว",
|
||||
"plural": "ต้องมีตัวอักษรตัวใหญ่อย่างน้อย {n} ตัว"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "ต้องมีตัวอักษรตัวเล็กอย่างน้อย {n} ตัว",
|
||||
"plural": "ต้องมีตัวอักษรตัวเล็กอย่างน้อย {n} ตัว"
|
||||
},
|
||||
"number": {
|
||||
"singular": "ต้องมีตัวเลขอย่างน้อย {n} ตัว",
|
||||
"plural": "ต้องมีตัวเลขอย่างน้อย {n} ตัว"
|
||||
},
|
||||
"special": {
|
||||
"singular": "ต้องมีอักขระพิเศษอย่างน้อย {n} ตัว",
|
||||
"plural": "ต้องมีอักขระพิเศษอย่างน้อย {n} ตัว"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lang/pwreset/th-TH.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"passwordReset": "ตั้งค่ารหัสผ่านใหม่",
|
||||
"reset": "ตั้งค่าใหม่",
|
||||
"resetFailed": "การตั้งค่ารหัสผ่านใหม่ผิดพลาด",
|
||||
"tryAgain": "โปรดลองอีกครั้ง",
|
||||
"youCanLogin": "คุณสามารถเข้าสู่ระบบด้วยรหัสด้านล่างเป็นรหัสผ่านของคุณ",
|
||||
"youCanLoginOmbi": "คุณสามารถเข้าสู่ระบบ Jellyfin & Ombi ด้วยรหัสด้านล่างเป็นรหัสผ่านของคุณ",
|
||||
"youCanLoginPassword": "คุณสามารถเข้าใช้งานได้ด้วยรหัสผ่านใหม่ กดปุ่มด้านล่างเพื่อเข้าสู่ Jellyfin",
|
||||
"changeYourPassword": "อย่าลืมเปลี่ยนรหัสใหม่หลังจากเข้าสู่ระบบ",
|
||||
"enterYourPassword": "กรอกรหัสผ่านใหม่ด้านล่าง"
|
||||
}
|
||||
}
|
||||
@@ -131,4 +131,4 @@
|
||||
"stable": "Σταθερό",
|
||||
"unstable": "Ασταθές"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,4 +147,4 @@
|
||||
"title": "Invite Messages",
|
||||
"description": "If enabled, you can send invites directly to a user's email address, Discord or Matrix user. Because you might be using a reverse proxy, you need to provide the URL invites are accessed from. Write your URL Base, and append '/invite'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Finished!",
|
||||
"restartMessage": "Features like Discord/Telegram/Matrix bots, custom Markdown messages, and a user-accessible \"My Account\" page can be found in Settings, so make sure to give it a browse. Click below to restart, then refresh the page.",
|
||||
"urlChangedNotice": "If you've changed the host, port, subfolder etc. that jfa-go is hosted on, check the URL is right.",
|
||||
"moreFeatures": "Tons more features like Discord/Telegram/Matrix bots and custom Markdown messages can be found in Settings, so make sure to give it a browse.",
|
||||
"restartReload": "Click below to restart, then access jfa-go at one of the given internal/external URLs.",
|
||||
"ifFailedLoad": "If it doesn't load, check the application's logs for any clues as to why.",
|
||||
"refreshPage": "Refresh"
|
||||
},
|
||||
"language": {
|
||||
|
||||
167
lang/setup/fa-IR.json
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": ""
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "",
|
||||
"next": "",
|
||||
"back": "",
|
||||
"optional": "",
|
||||
"serverType": "",
|
||||
"disabled": "",
|
||||
"enabled": "",
|
||||
"port": "",
|
||||
"message": "",
|
||||
"serverAddress": "",
|
||||
"emailSubject": "",
|
||||
"URL": "",
|
||||
"apiKey": "",
|
||||
"error": "",
|
||||
"errorInvalidUserPass": "",
|
||||
"errorNotAdmin": "",
|
||||
"errorUserDisabled": "",
|
||||
"error404": "",
|
||||
"errorConnectionRefused": "",
|
||||
"errorUnknown": "",
|
||||
"errorProxy": ""
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "",
|
||||
"pressStart": "",
|
||||
"httpsNotice": "",
|
||||
"start": ""
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "",
|
||||
"restartMessage": "",
|
||||
"refreshPage": ""
|
||||
},
|
||||
"language": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"defaultAdminLang": "",
|
||||
"defaultFormLang": "",
|
||||
"defaultEmailLang": ""
|
||||
},
|
||||
"general": {
|
||||
"title": "",
|
||||
"listenAddress": "",
|
||||
"urlBase": "",
|
||||
"urlBaseNotice": "",
|
||||
"lightTheme": "",
|
||||
"darkTheme": "",
|
||||
"useHTTPS": "",
|
||||
"httpsPort": "",
|
||||
"useHTTPSNotice": "",
|
||||
"pathToCertificate": "",
|
||||
"pathToKeyFile": ""
|
||||
},
|
||||
"updates": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"updateChannel": "",
|
||||
"stable": "",
|
||||
"unstable": ""
|
||||
},
|
||||
"proxy": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"protocol": "",
|
||||
"address": ""
|
||||
},
|
||||
"login": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"authorizeWithJellyfin": "",
|
||||
"authorizeManual": "",
|
||||
"adminOnly": "",
|
||||
"allowAll": "",
|
||||
"allowAllDescription": "",
|
||||
"authorizeManualUserPageNotice": "",
|
||||
"emailNotice": ""
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"embyNotice": "",
|
||||
"internal": "",
|
||||
"external": "",
|
||||
"replaceJellyfin": "",
|
||||
"replaceJellyfinNotice": "",
|
||||
"addressExternalNotice": "",
|
||||
"testConnection": ""
|
||||
},
|
||||
"ombi": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"apiKeyNotice": ""
|
||||
},
|
||||
"messages": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
},
|
||||
"email": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"method": "",
|
||||
"useEmailAsUsername": "",
|
||||
"useEmailAsUsernameNotice": "",
|
||||
"fromAddress": "",
|
||||
"senderName": "",
|
||||
"dateFormat": "",
|
||||
"dateFormatNotice": "",
|
||||
"encryption": "",
|
||||
"mailgunApiURL": ""
|
||||
},
|
||||
"notifications": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
},
|
||||
"userPage": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"customizeMessages": "",
|
||||
"requiredSettings": ""
|
||||
},
|
||||
"welcomeEmails": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
},
|
||||
"inviteEmails": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"pathToJellyfin": "",
|
||||
"pathToJellyfinNotice": "",
|
||||
"resetLinks": "",
|
||||
"resetLinksRequiredForUserPage": "",
|
||||
"resetLinksNotice": "",
|
||||
"resetLinksLanguage": "",
|
||||
"setPassword": "",
|
||||
"setPasswordNotice": ""
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"length": "",
|
||||
"uppercase": "",
|
||||
"lowercase": "",
|
||||
"numbers": "",
|
||||
"special": ""
|
||||
},
|
||||
"helpMessages": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"contactMessage": "",
|
||||
"contactMessageNotice": "",
|
||||
"helpMessage": "",
|
||||
"helpMessageNotice": "",
|
||||
"successMessage": "",
|
||||
"successMessageNotice": "",
|
||||
"emailMessage": "",
|
||||
"emailMessageNotice": ""
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"back": "Kembali",
|
||||
"optional": "Pilihan",
|
||||
"serverType": "Tipe Server",
|
||||
"disabled": "Dinonaktifkan",
|
||||
"disabled": "Dihentikan",
|
||||
"enabled": "Diaktifkan",
|
||||
"port": "Port",
|
||||
"message": "Pesan",
|
||||
@@ -124,4 +124,4 @@
|
||||
"emailMessage": "Pesan Email",
|
||||
"emailMessageNotice": "Ditampilkan di bagian bawah email."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,17 +20,20 @@
|
||||
"errorNotAdmin": "Użytkownik nie jest upoważniony do zarządzania serwerem.",
|
||||
"errorUserDisabled": "Użytkownik może być wyłączony.",
|
||||
"error404": "404, nie znaleziono URL.",
|
||||
"errorConnectionRefused": "Brak dostępu."
|
||||
"errorConnectionRefused": "Brak dostępu.",
|
||||
"errorProxy": "Konfiguracja proxy jest nieprawidłowa.",
|
||||
"error": "Błąd",
|
||||
"errorUnknown": "Nieznany błąd, sprawdź logi."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Witaj!",
|
||||
"pressStart": "Musisz wykonać kilka czynności aby skonfigurować jfa-go. Wciśnij start aby kontynuować.",
|
||||
"httpsNotice": "Upewnij się , że masz dostęp do strony przy użyciu HTTPS lub sieci LAN.",
|
||||
"pressStart": "Musisz wykonać kilka czynności, aby skonfigurować jfa-go. Wciśnij start, aby kontynuować.",
|
||||
"httpsNotice": "Upewnij się, że masz dostęp do strony przy użyciu HTTPS lub sieci LAN.",
|
||||
"start": "Start"
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Ukończono!",
|
||||
"restartMessage": "Możesz skonfigurować boty Discord/Telegram/Matrix, dostosować wiadomości i nie tylko w Ustawieniach. Kliknij poniżej, aby ponownie uruchomić, a następnie odśwież stronę.",
|
||||
"restartMessage": "Funkcje takie jak boty Discord/Telegram/Matrix, niestandardowe wiadomości Markdown i dostępna dla użytkownika strona „Moje konto” można znaleźć w Ustawieniach, więc koniecznie je przejrzyj. Kliknij poniżej, aby ponownie uruchomić, a następnie odśwież stronę.",
|
||||
"refreshPage": "Odśwież"
|
||||
},
|
||||
"language": {
|
||||
@@ -42,8 +45,8 @@
|
||||
},
|
||||
"general": {
|
||||
"title": "Ogólne",
|
||||
"listenAddress": "",
|
||||
"urlBase": "",
|
||||
"listenAddress": "Adres nasłuchiwania",
|
||||
"urlBase": "Adres URL",
|
||||
"urlBaseNotice": "Wymagane tylko jeśli używasz reverse proxy na subdomenie np. jellyf.in/accounts.",
|
||||
"lightTheme": "Jasny",
|
||||
"darkTheme": "Ciemny",
|
||||
@@ -55,25 +58,26 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "Aktualizacje",
|
||||
"description": "",
|
||||
"updateChannel": "",
|
||||
"description": "Włącz, aby otrzymywać powiadomienia o dostępności nowych aktualizacji. jfa-go będzie sprawdzać {n} co 30 minut. Nie są zbierane żadne adresy IP ani dane osobowe.",
|
||||
"updateChannel": "Kanał aktualizacji",
|
||||
"stable": "Stabilny",
|
||||
"unstable": "Niestabilne"
|
||||
},
|
||||
"login": {
|
||||
"title": "Zaloguj",
|
||||
"description": "",
|
||||
"authorizeWithJellyfin": "",
|
||||
"authorizeManual": "",
|
||||
"title": "Zaloguj się",
|
||||
"description": "Aby uzyskać dostęp do strony administratora, należy zalogować się za pomocą poniższej metody:",
|
||||
"authorizeWithJellyfin": "Autoryzacja za pomocą Jellyfin/Emby: Dane logowania są współdzielone z Jellyfin, co pozwala na korzystanie z wielu użytkowników.",
|
||||
"authorizeManual": "Nazwa użytkownika i hasło: Ręczne ustawienie nazwy użytkownika i hasła.",
|
||||
"adminOnly": "Tylko administratorzy (zalecane)",
|
||||
"allowAll": "Zezwój wszystkim użytkownikom na logowanie do Jellyfin",
|
||||
"allowAllDescription": "",
|
||||
"emailNotice": ""
|
||||
"allowAllDescription": "Niezalecane, należy zezwolić poszczególnym użytkownikom na logowanie się po skonfigurowaniu.",
|
||||
"emailNotice": "Twój adres e-mail może być używany do otrzymywania powiadomień.",
|
||||
"authorizeManualUserPageNotice": "Użycie tej opcji spowoduje wyłączenie funkcji „Strona użytkownika”."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"embyNotice": "",
|
||||
"title": "Jellyfin/Emby",
|
||||
"description": "Konto administratora jest potrzebne, ponieważ API nie pozwala na tworzenie użytkowników przy użyciu klucza API. Powinieneś utworzyć osobne konto i zaznaczyć opcję „Zezwalaj temu użytkownikowi na zarządzanie serwerem”. Pozostałe opcje można wyłączyć. Po zakończeniu wprowadź dane logowania tutaj.",
|
||||
"embyNotice": "Wsparcie Emby jest ograniczone i nie obsługuje resetowania hasła.",
|
||||
"internal": "",
|
||||
"external": "",
|
||||
"replaceJellyfin": "Nazwa serwera",
|
||||
@@ -146,5 +150,11 @@
|
||||
"successMessageNotice": "",
|
||||
"emailMessage": "",
|
||||
"emailMessageNotice": ""
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Niech jfa-go wykonuje wszystkie połączenia przez proxy HTTP/SOCKS5. Połączenie z Jellyfin będzie testowane za jego pośrednictwem.",
|
||||
"title": "Proxy",
|
||||
"protocol": "Protokół",
|
||||
"address": "Adres (wraz z portem)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +134,4 @@
|
||||
"stable": "Stabil",
|
||||
"unstable": "Ostabil"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
167
lang/setup/th-TH.json
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "ตั้งค่า - jfa-go",
|
||||
"next": "ถัดไป",
|
||||
"back": "กลับ",
|
||||
"optional": "ไม่จำเป็น",
|
||||
"serverType": "ประเภทเซิฟเวอร์",
|
||||
"disabled": "ปิดใช้งาน",
|
||||
"enabled": "เปิดใช้งาน",
|
||||
"port": "Port",
|
||||
"message": "ข้อความ",
|
||||
"serverAddress": "ที่อยู่เซิฟเวอร์",
|
||||
"emailSubject": "ผู้รับอีเมล",
|
||||
"URL": "URL",
|
||||
"apiKey": "API Key",
|
||||
"error": "ข้อผิดผลาด",
|
||||
"errorInvalidUserPass": "ชื่อผู้ใช้/รหัสผ่าน ไม่ถูกต้อง",
|
||||
"errorNotAdmin": "ผู้ใช้นี้ไม่ได้รับอนุญาติในการจัดการเซิฟเวอร์",
|
||||
"errorUserDisabled": "ผู้ใช้นี้อาจถูกปิดใช้งาน",
|
||||
"error404": "404, โปรดตรวจสอบ URL ภายใน",
|
||||
"errorConnectionRefused": "การเชื่อมต่อถูกปฏิเสธ",
|
||||
"errorUnknown": "ข้อผิดพลาดที่ไม่รู้จัก, โปรดตรวจสอบบันทึกแอปฯ",
|
||||
"errorProxy": "การตั้งค่า Proxy ไม่ถูกต้อง"
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "ยินดีต้อนรับ!",
|
||||
"pressStart": "คุณจะต้องทำอะไรเล็กน้อยเพื่อตั้งค่า jfa-go, กดเริ่มเพื่อดำเนินการต่อ",
|
||||
"httpsNotice": "อย่าลืมตรวจสอบว่าเข้าถึงหน้านี้ผ่าน HTTPS หรือเครือข่ายส่วนตัว",
|
||||
"start": "เริ่ม"
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "เสร็จสิ้น!",
|
||||
"restartMessage": "ฟีเจอร์อย่างเช่น บอท Discord/Telegram/Matrix, ข้อความ Markdown แบบกำหนดเอง และหน้า \"บัญชีของฉัน\" ที่ผู้ใช้เข้าถึงได้ สามารถพบได้ที่การตั้งค่า, อย่าลืมส่องดูหล่ะ กดปุ่มข้างล่างเพื่อเริ่มระบบใหม่ หลังจากนั้นโหลดหน้านี้ใหม่อีกครั้ง",
|
||||
"refreshPage": "โหลดใหม่"
|
||||
},
|
||||
"language": {
|
||||
"title": "ภาษา",
|
||||
"description": "แปลภาษาโดยชุมชนมีให้ใช้งานเกือบทุกส่วนใน jfa-go, คุณสามารถเลือกภาษาหลักได้ข้างล่าง, แต่ผู้ใช้สามารถเปลี่ยนเองได้ตามต้องการ ถ้าต้องการช่วยแปลภาษา ลงทะเบียนที่ {n} เพื่อเริ่มแปลภาษาได้เลย!",
|
||||
"defaultAdminLang": "ภาษาหลักสำหรับผู้ดูแล",
|
||||
"defaultFormLang": "ภาษาหลักสำหรับหน้าสร้างบัญชี",
|
||||
"defaultEmailLang": "ภาษาหลักสำหรับอีเมล"
|
||||
},
|
||||
"general": {
|
||||
"title": "ทั่วไป",
|
||||
"listenAddress": "Listen Address",
|
||||
"urlBase": "URL Base",
|
||||
"urlBaseNotice": "จำเป็นเฉพาะเมื่อผ่าน Reverse Proxy บนโดเมนย่อย (ตัวอย่าง 'jellyf.in/accounts')",
|
||||
"lightTheme": "สว่าง",
|
||||
"darkTheme": "มืด",
|
||||
"useHTTPS": "ใช้ HTTPS",
|
||||
"httpsPort": "พอร์ต HTTPS",
|
||||
"useHTTPSNotice": "จำเป็นเฉพาะถ้าเข้าใช้งานไม่ผ่าน Reverse Proxy",
|
||||
"pathToCertificate": "ที่อยู่ใบรับรอง",
|
||||
"pathToKeyFile": "ที่อยู่ไฟล์กุญแจ (รหัสใบรับรอง)"
|
||||
},
|
||||
"updates": {
|
||||
"title": "อัปเดต",
|
||||
"description": "เปิดเพื่อรับการแจ้งเตือนเมื่ออัปเดตพร้อมใช้งาน, jfa-go จะตรวจ {n} ทุก ๆ 30 นาที โดยไม่มีการเก็บ IP หรือข้อมูลที่ระบุถึงบุคคลได้",
|
||||
"updateChannel": "ช่องอัปเดต",
|
||||
"stable": "Stable",
|
||||
"unstable": "Unstable"
|
||||
},
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"description": "ให้ jfa-go เชื่อมต่อทุกอย่างผ่าน HTTP/SOCKS5 proxy, การเชื่อมต่อไปยัง Jellyfin จะถูกเชื่อมต่อผ่านทางนี้",
|
||||
"protocol": "Protocol",
|
||||
"address": "ที่อยู่ (รวมถึง Port)"
|
||||
},
|
||||
"login": {
|
||||
"title": "เข้าสู่ระบบ",
|
||||
"description": "เพื่อเข้าถึงหน้าผู้ดูแลระบบ คุณจำเป็นต้องเข้าสู่ระบบผ่านช่องทางด้านล่างนี้:",
|
||||
"authorizeWithJellyfin": "ยืนยันตัวตนผ่าน Jellyfin/Emby: ข้อมูลเข้าสู่ระบบจะใช้ร่วมกับ Jellyfin, ซึ่งสามารถใช้ร่วมกับผู้ใช้อื่น ๆ ได้",
|
||||
"authorizeManual": "ชื่อผู้ใช้ และ รหัสผ่าน: ตั้งค่าชื่อผู้ใช้ และ รหัสผ่านด้วยตนเอง",
|
||||
"adminOnly": "ผู้ดูและระบบเท่านั้น (แนะนำ)",
|
||||
"allowAll": "อนุญาติให้ผู้ใช้ Jellyfin ทั้งหมดเข้าสู่ระบบได้",
|
||||
"allowAllDescription": "ไม่แนะนำ, คุณควรอนุญาติเป็นรายบุคคลให้เข้าสู่ระบบหลังจากตั้งค่า",
|
||||
"authorizeManualUserPageNotice": "ใช้การตั้งค่านี้ จะปิดการใช้งานฟีเจอร์ \"หน้าผู้ใช้\"",
|
||||
"emailNotice": "อีเมลของคุณสามารถใช้เพื่อรับการแจ้งเตือนได้"
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
"description": "บัญชีแอดมินจำเป็น เพราะ API ไม่อนุญาติให้สร้างผู้ใช้งานโดยไม่มี API key คุณควรสร้างชื่อผู้ใช้เฉพาะ และเลือก \"อนุญาติผู้ใช้นี้จัดการเซิฟเวอร์นี้ (Allow this user to manage the server)\" นอกเหนือจากนั้น สามารถปิดได้เลย หลังจากตั้งค่าเสร็จสิ้นแล้ว กรอกข้อมูลการเข้าสู่ระบบตรงนี้",
|
||||
"embyNotice": "การรอบรับ Emby ยังถูกจำกัด และไม่รอบรับการตั้งค่ารหัสผ่านใหม่",
|
||||
"internal": "ภายใน",
|
||||
"external": "ภายนอก",
|
||||
"replaceJellyfin": "ชื่อเซิฟเวอร์",
|
||||
"replaceJellyfinNotice": "หากกรอก, ชื่อนี้จะถูกเปลี่ยนทุกอย่างที่เกี่ยวกับ 'Jellyfin' ในแอปฯ",
|
||||
"addressExternalNotice": "ปล่อยว่างหากใช้ที่อยู่เดียวกัน",
|
||||
"testConnection": "ทดสอบการเชื่อมต่อ"
|
||||
},
|
||||
"ombi": {
|
||||
"title": "Ombi",
|
||||
"description": "โดยเชื่อมต่อกับ Ombi, ทั้งบัญชี Jellyfin และ Ombi จะถูกสร้างขึ้นเมื่อผู้ใช้เข้าร่วมผ่าน jfa-go, หลังตั้งค่าเสร็จ ไปยังการตั้งค่าเพื่อตั้งค่าโปรไฟล์เริ่มต้นสำหรับผู้ใช้ Ombi ใหม่",
|
||||
"apiKeyNotice": "หาได้จากแท็บแรกจากการตั้งค่าบน Ombi"
|
||||
},
|
||||
"messages": {
|
||||
"title": "ข้อความ",
|
||||
"description": "jfa-go สามารถส่งคำขอตั้งค่ารหัสผ่านใหม่ และข้อความต่าง ๆ ผ่าน Email, Discord, Telegram, และ/หรือ Matrix คุณสามารถตั้งค่าอีเมลข้างล่าง และอื่น ๆ สามารถตั้งค่าได้ผ่านการตั้งค่าในภายหลัง ขั้นตอนสามารถหาได้ใน {n} ถ้าคุณไม่ต้องการ, คุณสามารถปิดการตั้งค่าได้"
|
||||
},
|
||||
"email": {
|
||||
"title": "อีเมล",
|
||||
"description": "jfa-go สามารถส่งคำขอรหัสผ่าน รหัส (PIN) และการแจ้งเตือนอื่น ๆ ผ่านทางอีเมล คุณสามารถเชื่อมต่อไปยัง SMTP server, หรือใช้ {n} API",
|
||||
"method": "วิธีการส่ง",
|
||||
"useEmailAsUsername": "ใช้ที่อยู่อีเมลเป็นชื่อผู้ใช้",
|
||||
"useEmailAsUsernameNotice": "หากเปิด, ผู้ใช้ใหม่จะเข้าสู่ระบบ Jellyfin/Emby ผ่านอีเมลของผู้เขาแทนชื่อผู้ใช้",
|
||||
"fromAddress": "จากที่อยู่",
|
||||
"senderName": "ชื่อผู้ส่ง",
|
||||
"dateFormat": "รูปแบบวันที่",
|
||||
"dateFormatNotice": "วันที่ตามรูปแบบ strftime, สำหรับข้อมูลเพิ่มเติม ไปที่ {n}",
|
||||
"encryption": "การเข้ารหัส",
|
||||
"mailgunApiURL": "ที่อยู่ API"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "การแจ้งเตือนผู้ดูแลระบบ",
|
||||
"description": "ถ้าเปิดใช้งาน, คุณสามารถเลือก (ต่อคำเชิญ) เพื่อรับข้อความแจ้งเมื่อคำเชิญหมดอายุ หรือผู้ใช้ถูกสร้างขึ้น ถ้าคุณไม่ได้เลือกเข้าสู่ระบบผ่าน Jellyfin, อย่าลืมทิ้งอีเมลติดต่อของคุณด้วย หรือเพิ่มช่องทางอื่น ๆ ในภายหลัง"
|
||||
},
|
||||
"userPage": {
|
||||
"title": "หน้าผู้ใช้งาน",
|
||||
"description": "หน้าผู้ใช้ (แสดงเป็น \"บัญชีของฉัน\") เป็นหน้าที่ให้ผู้ใช้เข้าถึงข้อมูลเกี่ยวกับบัญชีของเขา เช่น ข้อมูลติดต่อหรือวันหมดอายุ ผู้ใช้สามารถเปลี่ยนรหัสผ่าน, เริ่มต้นการตั้งรหัสผ่านใหม่ หรือ ลิงค์/เปลี่ยนข้อมูลติดต่อได้โดยไม่ต้องถามคุณ เพิ่มเติม สามารถข้อความ Markdown แบบกำหนดเองให้ผู้ใช้ ก่อนหรือหลังเข้าสู่ระบบได้",
|
||||
"customizeMessages": "กดปุ่มแก้ไขข้าง “บัญชีของฉัน” ในการตั้งค่าเพื่อแก้ไขทีหลัง",
|
||||
"requiredSettings": "จำเป็นต้องตั้งค่าเข้าสู่ระบบ jfa-go ผ่าน Jellyfin และมั่นใจว่าได้เลือก \"ตั้งค่ารหัสผ่านใหม่ผ่านลิงค์\""
|
||||
},
|
||||
"welcomeEmails": {
|
||||
"title": "ข้อความต้อนรับ",
|
||||
"description": "ถ้าเปิดใช้งาน, ข้อความจะถูกส่งไปยังผู้ใช้ใหม่พร้อมลิงค์ Jellyfin/Emby และรวมไปถึงชื่อผู้ใช้ของเขา"
|
||||
},
|
||||
"inviteEmails": {
|
||||
"title": "จดหมายคำเชิญ",
|
||||
"description": "ถ้าเปิดใช้งาน, คุณสามารถส่งคำเชิญไปยังอีเมล, Discord หรือ Matrix ได้โดยตรง เพระาว่าคุณอาจจะใช้ผ่าน Reverse Proxy, คุณจำเป็นต้องกรอกลิงค์ที่สามารถเข้าถึงคำเชิญได้ พิมพ์ URL Base ของคุณต่อท้ายด้วย '/invite'"
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "การตั้งรหัสผ่านใหม่",
|
||||
"description": "เมื่อผู้ใช้พยายามตั้งรหัสผ่านใหม่, Jellyfin จะสร้างไฟล์ชื่อ 'passwordreset-*.json' ซึ่งมีรหัส (PIN) อยู่ jfa-go จะอ่านไฟล์และส่งรหัสไปยังผู้ใช้, ถ้าเปิดใช้งาน \"หน้าผู้ใช้\" ก็สามารถตั้งรหัสผ่านใหม่ผ่านทางนั้นได้, โดยใช้ชื่อผู้ใช้, อีเมล หรือช่องทางการติดต่อ",
|
||||
"pathToJellyfin": "ที่อยู่โฟลเดอร์ไฟล์ตั้งค่า Jellyfin",
|
||||
"pathToJellyfinNotice": "ถ้าคุณไม่รู้ตำแหน่งโฟล์เดอร์ว่าอยู่ที่ไหน ลองตั้งค่ารหัสผ่าน Jellyfin ใหม่, ป๊อปอัพ '<path to jellyfin>/passwordreset-*.json' จะแสดงขึ้นมา ข้อนี้ไม่จำเป็นหากต้องการให้เปลี่ยนรหัสผ่านด้วยตนเองผ่าน \"หน้าผู้ใช้\"",
|
||||
"resetLinks": "ส่งลิงค์แทนรหัส (PIN)",
|
||||
"resetLinksRequiredForUserPage": "จำเป็นสำหรับตั้งค่ารหัสใหม่ด้วยตัวเองผ่านหน้าผู้ใช้",
|
||||
"resetLinksNotice": "ถ้าเปิดการใช้งานร่วมกับ Ombi, เปิดหัวข้อนี้เพื่อซิงค์รหัส Jellyfin กับ Ombi",
|
||||
"resetLinksLanguage": "ภาษาหน้าตั้งค่าฯ หลัก",
|
||||
"setPassword": "ตั้งค่ารหัสผ่านผ่านลิงค์",
|
||||
"setPasswordNotice": "เปิดการตั้งค่านี้ ผู้ใช้ไม่จำเป็นต้องเปลี่ยนรหัสผ่านผ่านรหัส (PIN) หลังจากตั้งค่าใหม่, กฎการยื่นยันรหัสฯ จะถูกใช้"
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "การยืนยันรหัสผ่าน",
|
||||
"description": "หากเปิดใช้งาน, ชุดกฎการตั้งค่ารหัสผ่านจะแสดงบนหน้าสร้างผู้ใช้งาน เช่น ความยาวรหัสผ่าน ตัวอักษรตัวเล็ก/ตัวใหญ่ เป็นต้น",
|
||||
"length": "ความยาว",
|
||||
"uppercase": "ตัวอักษรตัวใหญ่",
|
||||
"lowercase": "ตัวอักษรตัวเล็ก",
|
||||
"numbers": "ตัวเลข",
|
||||
"special": "อักขระพิเศษ (%, *, เป็นต้น)"
|
||||
},
|
||||
"helpMessages": {
|
||||
"title": "ข้อความช่วยเหลือ",
|
||||
"description": "ข้อความเหล่านี้จะแสดงบนหน้าสร้างผู้ใช้และในรายละเอียดอื่น ๆ",
|
||||
"contactMessage": "ข้อความติดต่อ",
|
||||
"contactMessageNotice": "แสดงล่างหน้าทุกหน้ายกเว้นหน้าผู้ดูแลระบบ",
|
||||
"helpMessage": "ข้อความช่วยเหลือ",
|
||||
"helpMessageNotice": "แสดงบนหน้าสร้างบัญชี",
|
||||
"successMessage": "ข้อความสำเร็จ",
|
||||
"successMessageNotice": "แสดงเมื่อผู้ใช้สร้างบัญชีแล้ว",
|
||||
"emailMessage": "ข้อความอีเมล",
|
||||
"emailMessageNotice": "แสดงด้านล่างอีเมล"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"languageSet": "Language set to {language}.",
|
||||
"discordDMs": "Please check your DMs for a response.",
|
||||
"sentInvite": "Sent invite.",
|
||||
"sentInviteFailure": "Failed to send invite, check logs."
|
||||
"sentInviteFailure": "Failed to send invite, check logs.",
|
||||
"noPermission": "You do not have permissions for this action."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"discordStartMessage": "Hola\nIntroduzca su PIN con `/pin <PIN>` para verificar su cuenta.",
|
||||
"languageMessageDiscord": "Nota: configure su idioma con /lang <language name>.",
|
||||
"languageSet": "El idioma esta configurado como {language}.",
|
||||
"discordDMs": "Por favor, compruebe sus DMs para una respuesta."
|
||||
"discordDMs": "Por favor, compruebe sus DMs para una respuesta.",
|
||||
"sentInvite": "Enviar invitación.",
|
||||
"sentInviteFailure": "Error al enviar la invitación, compruebe los logs."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
lang/telegram/pt-PT.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Português (PT)"
|
||||
},
|
||||
"strings": {
|
||||
"startMessage": "Olá!\nIntroduza aqui o seu código PIN Jellyfin para verificar a sua conta.",
|
||||
"discordStartMessage": "Olá!\n Introduza o seu PIN com `/pin <PIN>` para verificar a sua conta.",
|
||||
"matrixStartMessage": "Olá!\nIntroduza o PIN abaixo na página de registo do Jellyfin para verificar a sua conta.",
|
||||
"invalidPIN": "O PIN não é válido, tente novamente.",
|
||||
"pinSuccess": "PIN introduzido com sucesso! Pode agora voltar à página de registo.",
|
||||
"languageMessage": "Nota: Veja os idiomas disponíveis com {command} e defina o idioma com {command} <código do idioma>.",
|
||||
"languageMessageDiscord": "Nota: defina o seu idioma com /lang <nome do idioma>.",
|
||||
"languageSet": "Idioma definido para {language}.",
|
||||
"discordDMs": "Verifique as suas MDs para obter uma resposta.",
|
||||
"sentInvite": "Convite enviado.",
|
||||
"sentInviteFailure": "Falha no envio do convite, verifique os registos."
|
||||
}
|
||||
}
|
||||
18
lang/telegram/th-TH.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "ภาษาไทย (TH)"
|
||||
},
|
||||
"strings": {
|
||||
"startMessage": "สวัสดี!\nโปรดกรอกรหัส (PIN) Jellyfin เพื่อยืนยันบัญชีของคุณ",
|
||||
"discordStartMessage": "สวัสดี!\nโปรดกรอกรหัส (PIN) โดยใช้คำสั่ง `/pin <PIN>` เพื่อยืนยันบัญชีของคุณ",
|
||||
"matrixStartMessage": "สวัสดี!\nโปรดกรอกรหัส (PIN) ด้านล่างไปยังหน้าลงทะเบียน Jellyfin เพื่อยืนยันบัญชีของคุณ",
|
||||
"invalidPIN": "รหัส (PIN) ไม่ถูกต้อง, โปรดลองอีกครั้ง",
|
||||
"pinSuccess": "สำเร็จ! คุณสามารถกลับไปที่หน้าลงทะเบียนได้เลย",
|
||||
"languageMessage": "ปล. คุณสามารถลองดูภาษาที่มีได้ด้วยคำสั่ง {command}, และตั้งภาษาที่ต้องการได้ด้วยคำสั่ง {command} <รหัสภาษา>",
|
||||
"languageMessageDiscord": "ปล. คุณสามารถตั้งภาษาที่ต้องการได้ด้วยคำสั่ง /lang <รหัสภาษา>",
|
||||
"languageSet": "ภาษาถูกตั้งเป็นภาษา {language}",
|
||||
"discordDMs": "โปรดเช็คกล่องข้อความ (DM) สำหรับข้อความ",
|
||||
"sentInvite": "ส่งคำเชิญ",
|
||||
"sentInviteFailure": "เกิดข้อผิดพลาดในการส่งคำเชิญ, โปรดเช็คบันทึก"
|
||||
}
|
||||
}
|
||||
2
log.go
@@ -59,7 +59,7 @@ func logOutput() (closeFunc func(), err error) {
|
||||
|
||||
// Regex that removes ANSI color escape sequences. Used for outputting to log file and log cache.
|
||||
var stripColors = func() *regexp.Regexp {
|
||||
r, err := regexp.Compile("\\x1b\\[[0-9;]*m")
|
||||
r, err := regexp.Compile(`\x1b\[[0-9;]*m`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to compile color escape regexp: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// }
|
||||
|
||||
type Logger struct {
|
||||
empty bool
|
||||
Empty bool
|
||||
logger *log.Logger
|
||||
shortfile bool
|
||||
printer *c.Color
|
||||
@@ -75,13 +75,13 @@ func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Lo
|
||||
|
||||
func NewEmptyLogger() (l *Logger) {
|
||||
l = &Logger{
|
||||
empty: true,
|
||||
Empty: true,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -93,7 +93,7 @@ func (l *Logger) Printf(format string, v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -105,14 +105,14 @@ func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) PrintfNoFile(format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
l.logger.Print(l.printer.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func (l *Logger) Print(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -124,7 +124,7 @@ func (l *Logger) Print(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Println(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -136,7 +136,7 @@ func (l *Logger) Println(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Fatal(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -148,7 +148,7 @@ func (l *Logger) Fatal(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalf(format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
|
||||
@@ -64,12 +64,14 @@ const (
|
||||
TimedOut = "timed out"
|
||||
FailedGenericWithCode = "failed (code %d)"
|
||||
|
||||
InitDiscord = "Initialized Discord daemon"
|
||||
FailedInitDiscord = "Failed to initialize Discord daemon: %v"
|
||||
InitTelegram = "Initialized Telegram daemon"
|
||||
FailedInitTelegram = "Failed to initialize Telegram daemon: %v"
|
||||
InitMatrix = "Initialized Matrix daemon"
|
||||
FailedInitMatrix = "Failed to initialize Matrix daemon: %v"
|
||||
InitDiscord = "Initialized Discord daemon"
|
||||
FailedInitDiscord = "Failed to initialize Discord daemon: %v"
|
||||
InitTelegram = "Initialized Telegram daemon"
|
||||
FailedInitTelegram = "Failed to initialize Telegram daemon: %v"
|
||||
InitMatrix = "Initialized Matrix daemon"
|
||||
FailedInitMatrix = "Failed to initialize Matrix daemon: %v"
|
||||
InitingMatrixCrypto = "Initializing Matrix encryption store"
|
||||
InitMatrixCrypto = "Initialized Matrix encryption store"
|
||||
|
||||
InitRouter = "Initializing router"
|
||||
LoadRoutes = "Loading Routes"
|
||||
@@ -118,8 +120,7 @@ const (
|
||||
SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\""
|
||||
|
||||
// *jellyseerr*.go
|
||||
FailedGetUsers = "Failed to get user(s) from %s: %v"
|
||||
// FIXME: Once done, look back at uses of FailedGetUsers for places where this would make more sense.
|
||||
FailedGetUsers = "Failed to get user(s) from %s: %v"
|
||||
FailedGetUser = "Failed to get user \"%s\" from %s: %v"
|
||||
FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
|
||||
FailedSyncContactMethods = "Failed to sync contact methods with %s: %v"
|
||||
@@ -208,14 +209,16 @@ const (
|
||||
FailedRestoreDB = "Failed to resotre database from \"%s\": %v"
|
||||
|
||||
// config.go
|
||||
EnableAllPWRMethods = "No PWR method preferences set in [user_page], all will be enabled"
|
||||
InitProxy = "Initialized proxy @ \"%s\""
|
||||
FailedInitProxy = "Failed to initialize proxy @ \"%s\": %v\nStartup will pause for a bit to grab your attention."
|
||||
NoURLSuffix = `Warning: Given "jfa_url"/"External jfa-go URL" value does not include "url_base" value!`
|
||||
BadURLBase = `Warning: Given reverse proxy subfolder "%s" may conflict with the applications subpaths.`
|
||||
NoExternalHost = `No "External jfa-go URL" provided, set one in Settings > General.`
|
||||
LoginWontSave = ` Your login won't save until you do.`
|
||||
SubpathBlockMessage = `URLs: Root subfolder = "%s", Admin = "%s", My Account = "%s", Invite forms = "%s"`
|
||||
EnableAllPWRMethods = "No PWR method preferences set in [user_page], all will be enabled"
|
||||
InitProxy = "Initialized proxy @ \"%s\""
|
||||
FailedInitProxy = "Failed to initialize proxy @ \"%s\": %v\nStartup will pause for a bit to grab your attention."
|
||||
NoURLSuffix = `Warning: Given "jfa_url"/"External jfa-go URL" value does not include "url_base" value!`
|
||||
BadURLBase = `Warning: Given reverse proxy subfolder "%s" may conflict with the applications subpaths.`
|
||||
RouteCollision = `Route Collision! Given reverse proxy subfolder "%s" or "URL Paths" settings likely conflict with the applications subpaths. Culprit: %v`
|
||||
NoExternalHost = `No "External jfa-go URL" provided, set one in Settings > General.`
|
||||
LoginWontSave = ` Logins may not save until you do.`
|
||||
SetExternalHostDespiteUseProxyHost = ` This needs to be set even though use_proxy_host is enabled.`
|
||||
SubpathBlockMessage = `URLs: Root subfolder = "%s", Admin = "%s", My Account = "%s", Invite forms = "%s"`
|
||||
|
||||
// discord.go
|
||||
StartDaemon = "Started %s daemon"
|
||||
@@ -281,6 +284,7 @@ const (
|
||||
External = "external"
|
||||
RegisterPprof = "Registered pprof"
|
||||
SwaggerWarning = "Warning: Swagger should not be used on a public instance."
|
||||
NoAPIAuthPrompt = `Disabling API auth is dangerous, only use locally for development. Disable it? [y/n]: `
|
||||
|
||||
// storage.go
|
||||
ConnectDB = "Connected to DB \"%s\""
|
||||
@@ -292,6 +296,8 @@ const (
|
||||
FailedGetUpdateTag = "Failed to get latest tag: %v"
|
||||
FailedGetUpdate = "Failed to get update: %v"
|
||||
UpdateTagDetails = "Update/Tag details: %+v"
|
||||
TagEmpty = "tag was empty"
|
||||
TagAtEmpty = "tag at \"%s\" was empty"
|
||||
|
||||
// user-auth.go
|
||||
UserPage = "userpage"
|
||||
@@ -343,6 +349,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
FailedConstructCustomContent = "Possible error in custom content \"%s\": %v"
|
||||
|
||||
FailedConstructExpiryAdmin = "Failed to construct expiry notification for \"%s\": %v"
|
||||
FailedSendExpiryAdmin = "Failed to send expiry notification for \"%s\" to \"%s\": %v"
|
||||
SentExpiryAdmin = "Sent expiry notification for \"%s\" to \"%s\""
|
||||
@@ -354,6 +362,7 @@ const (
|
||||
FailedConstructInviteMessage = "Failed to construct invite message for \"%s\": %v"
|
||||
FailedSendInviteMessage = "Failed to send invite message for \"%s\" to \"%s\": %v"
|
||||
SentInviteMessage = "Sent invite message for \"%s\" to \"%s\""
|
||||
InviteMessagesDisabled = "invite messages are disabled, check settings"
|
||||
|
||||
FailedConstructConfirmationEmail = "Failed to construct confirmation email for \"%s\": %v"
|
||||
FailedSendConfirmationEmail = "Failed to send confirmation email for \"%s\" to \"%s\": %v"
|
||||
@@ -379,6 +388,10 @@ const (
|
||||
FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v"
|
||||
SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\""
|
||||
|
||||
FailedConstructExpiryReminderMessage = "Failed to construct expiry reminder message for \"%s\": %v"
|
||||
FailedSendExpiryReminderMessage = "Failed to send expiry reminder message for \"%s\" to \"%s\": %v"
|
||||
SentExpiryReminderMessage = "Sent expiry reminder message for \"%s\" to \"%s\""
|
||||
|
||||
FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v"
|
||||
FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v"
|
||||
SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\""
|
||||
|
||||
@@ -1,78 +1,17 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .clickBelow }}</p>
|
||||
<p>{{ .ifItWasNotYou }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold" href="{{ .confirmationURL }}">{{ .confirmEmail }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .clickBelow }}</p>
|
||||
<p>{{ .ifItWasNotYou }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue text-white" href="{{ .confirmationURL }}">{{ .confirmEmail }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
|
||||
{{ .confirmationURL }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
|
||||
@@ -1,86 +1,25 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .aUserWasCreated }}</p>
|
||||
</mj-text>
|
||||
<mj-table mj-class="text" container-background-color="#242424">
|
||||
<tr style="text-align: left;">
|
||||
<th>{{ .nameString }}</th>
|
||||
<th>{{ .addressString }}</th>
|
||||
<th>{{ .timeString }}</th>
|
||||
</tr>
|
||||
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
|
||||
<th>{{ .name }}</th>
|
||||
<th>{{ .address }}</th>
|
||||
<th>{{ .time }}</th>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .notificationNotice }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .aUserWasCreated }}</p>
|
||||
</mj-text>
|
||||
<mj-table css-class="bg-gray" mj-class="bg-gray">
|
||||
<tr style="text-align: left;">
|
||||
<th>{{ .nameString }}</th>
|
||||
<th>{{ .addressString }}</th>
|
||||
<th>{{ .timeString }}</th>
|
||||
</tr>
|
||||
<tr class="text-gray" style="font-style: italic; text-align: left;">
|
||||
<th>{{ .name }}</th>
|
||||
<th>{{ .address }}</th>
|
||||
<th>{{ .time }}</th>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
|
||||
{{ .timeString }}: {{ .time }}
|
||||
|
||||
{{ .notificationNotice }}
|
||||
{{ .footer }}
|
||||
|
||||
@@ -1,76 +1,17 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>{{ .yourAccountWas }}</h3>
|
||||
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .helloUser }}</p>
|
||||
|
||||
<h3>{{ .yourAccountWas }}</h3>
|
||||
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{{ .helloUser }}
|
||||
|
||||
{{ .yourAccountWas }}
|
||||
|
||||
{{ .reasonString }}: {{ .reason }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .someoneHasRequestedReset }}</p>
|
||||
<p>{{ .ifItWasYou }}</p>
|
||||
<p>{{ .codeExpiry }}</p>
|
||||
<p>{{ .ifItWasNotYou }}</p>
|
||||
</mj-text>
|
||||
<mj-raw>{{ if .link_reset }}</mj-raw>
|
||||
<mj-button mj-class="blue bold" href="{{ .pin }}"><mj-raw>{{ .pin_code }}</mj-raw></mj-button>
|
||||
<mj-raw>{{ else }}</mj-raw>
|
||||
<mj-button mj-class="blue bold"><mj-raw>{{ .pin }}</mj-raw></mj-button>
|
||||
<mj-raw>{{ end }}</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
@@ -1,76 +1,15 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>{{ .inviteExpired }}</h3>
|
||||
<p>{{ .expiredAt }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .notificationNotice }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<h3>{{ .inviteExpired }}</h3>
|
||||
<p>{{ .expiredAt }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
{{ .expiredAt }}
|
||||
|
||||
{{ .notificationNotice }}
|
||||
{{ .footer }}
|
||||
|
||||
@@ -1,83 +1,18 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .helloUser }}</p>
|
||||
|
||||
<h3>{{ .yourExpiryWasAdjusted }}</h3>
|
||||
|
||||
<p>{{ .ifPreviouslyDisabled }}</p>
|
||||
|
||||
<h4>{{ .newExpiry }}</h4>
|
||||
|
||||
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .helloUser }}</p>
|
||||
<h3>{{ .yourExpiryWasAdjusted }}</h3>
|
||||
<p>{{ .ifPreviouslyDisabled }}</p>
|
||||
<h4>{{ .newExpiry }}</h4>
|
||||
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
{{ .reasonString }}: {{ .reason }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
|
||||
15
mail/expiry-reminder.mjml
Normal file
@@ -0,0 +1,15 @@
|
||||
<mjml>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .yourAccountIsDueToExpire }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
5
mail/expiry-reminder.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
{{ .helloUser }}
|
||||
|
||||
{{ .yourAccountIsDueToExpire }}
|
||||
|
||||
{{ .footer }}
|
||||
@@ -1,79 +1,18 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>{{ .hello }},</p>
|
||||
<h3>{{ .youHaveBeenInvited }}</h3>
|
||||
<p>{{ .toJoin }}</p>
|
||||
<p>{{ .inviteExpiry }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold" href="{{ .inviteURL }}">{{ .linkButton }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .hello }},</p>
|
||||
<h3>{{ .youHaveBeenInvited }}</h3>
|
||||
<p>{{ .toJoin }}</p>
|
||||
<p>{{ .inviteExpiry }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue text-white" href="{{ .inviteURL }}">{{ .linkButton }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
|
||||
{{ .inviteURL }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
|
||||
5
mail/layout/body-end.mjml
Normal file
@@ -0,0 +1,5 @@
|
||||
<mj-section mj-class="bg-gray">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary text-gray">{{ .footer }}</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
5
mail/layout/body-start.mjml
Normal file
@@ -0,0 +1,5 @@
|
||||
<mj-section mj-class="bg-gray">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text-white" font-size="25px" font-family="Plus Jakarta Sans, Noto Sans, Helvetica, Arial, sans-serif"> {{ .header }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
64
mail/layout/header.mjml
Normal file
@@ -0,0 +1,64 @@
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
body, .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
.text-gray {
|
||||
color: rgb(153,153,153) !important;
|
||||
}
|
||||
.bg-gray {
|
||||
background: #292929 !important;
|
||||
background-color: #292929 !important;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
body, .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
body, .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #101010 !important;
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="body" background-color="#101010" />
|
||||
<mj-class name="bg-gray" background-color="#292929" />
|
||||
<mj-class name="text-white" color="rgb(255,255,255)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
<mj-class name="secondary" font-style="italic" font-size="14px" />
|
||||
<mj-class name="text-gray" color="rgb(153,153,153)" />
|
||||
<mj-all font-family="Hanken Grotesk, Noto Sans, Helvetica, Arial, sans-serif" font-size="16px" color="rgba(255,255,255,0.8)">
|
||||
</mj-attributes>
|
||||
<mj-font name="Plus Jakarta Sans" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,700;1,700&display=swap" />
|
||||
<mj-font name="Hanken Grotesk" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital@0;1&display=swap" />
|
||||
</mj-head>
|
||||
23
mail/password-reset.mjml
Normal file
@@ -0,0 +1,23 @@
|
||||
<mjml>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .someoneHasRequestedReset }}</p>
|
||||
<p>{{ .ifItWasYou }}</p>
|
||||
<p>{{ .codeExpiry }}</p>
|
||||
<p>{{ .ifItWasNotYou }}</p>
|
||||
</mj-text>
|
||||
<mj-raw>{{ if .link_reset }}</mj-raw>
|
||||
<mj-button mj-class="blue text-white" href="{{ .pin }}"><mj-raw>{{ .pin_code }}</mj-raw></mj-button>
|
||||
<mj-raw>{{ else }}</mj-raw>
|
||||
<mj-button mj-class="blue text-white"><mj-raw>{{ .pin }}</mj-raw></mj-button>
|
||||
<mj-raw>{{ end }}</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -10,4 +10,4 @@
|
||||
|
||||
{{ .pinString }}: {{ .pin }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
@@ -1,75 +1,14 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
</mj-raw>
|
||||
<mj-style>
|
||||
:root {
|
||||
Color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
Color-scheme: dark;
|
||||
.body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsc] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
[data-ogsb] .body {
|
||||
background: #242424 !important;
|
||||
background-color: #242424 !important;
|
||||
}
|
||||
}
|
||||
</mj-style>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="#cacaca" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<mj-raw>{{ .text }}</mj-raw>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ .message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
<mj-include path="./layout/header.mjml" />
|
||||
<mj-body>
|
||||
<mj-include path="./layout/body-start.mjml" />
|
||||
<mj-section mj-class="body">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
{{ .text }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./layout/body-end.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{{ .plaintext }}
|
||||
|
||||
{{ .message }}
|
||||
{{ .footer }}
|
||||
|
||||