invites: add /invites/send, store more details in new SentTo field

deprecated SendTo string field for SentTo, which holds successful send
addresses, and failures with a reason that isn't plain text. Will soon
add an interface for sending invites after their creation. For #444
(ha).
This commit is contained in:
Harvey Tindall
2025-12-05 12:03:21 +00:00
parent 3635a13682
commit 5fa528fd2d
19 changed files with 327 additions and 144 deletions

View File

@@ -157,6 +157,90 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
return &wait return &wait
} }
// @Summary Send an existing invite to an email address or discord user.
// @Produce json
// @Param SendInviteDTO body SendInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/send [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SendInvite(gc *gin.Context) {
var req SendInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Invite)
if !ok {
app.err.Printf(lm.FailedGetInvite, req.Invite, lm.NotFound)
respond(500, "Invite not found", gc)
return
}
err := app.sendInvite(req.sendInviteDTO, &inv)
// Even if failed, some error info might have been stored in the invite.
app.storage.SetInvitesKey(req.Invite, inv)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, req.Invite, req.SendTo, err)
respond(500, err.Error(), gc)
return
}
app.info.Printf(lm.SentInviteMessage, req.Invite, req.SendTo)
respondBool(200, true, gc)
}
// sendInvite attempts to send an invite to the given email address or discord username.
func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
// app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
err = errors.New(lm.InviteMessagesDisabled)
return err
}
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: NoUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
} else if len(users) > 1 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: MultiUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
}
discord = users[0].User.ID
}
var msg *Message
msg, err = app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
// app.err.Printf(lm.FailedConstructInviteMessage, req.SendTo, err)
return err
}
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
return err
// app.err.Println(invite.SendTo)
}
invite.SentTo.Success = append(invite.SentTo.Success, req.SendTo)
return err
}
// @Summary Create a new invite. // @Summary Create a new invite.
// @Produce json // @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object" // @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@@ -198,48 +282,11 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
} }
invite.ValidTill = validTill invite.ValidTill = validTill
if req.SendTo != "" { if req.SendTo != "" {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) { err := app.sendInvite(req.sendInviteDTO, &invite)
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled)) if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
} else { } else {
addressValid := false app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
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, 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)
} 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)
}
}
}
} }
} }
if req.Profile != "" { if req.Profile != "" {
@@ -357,6 +404,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 { if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses invite.RemainingUses = inv.RemainingUses
} }
if len(inv.SentTo.Success) != 0 || len(inv.SentTo.Failed) != 0 {
invite.SentTo = inv.SentTo
}
if inv.SendTo != "" { if inv.SendTo != "" {
invite.SendTo = inv.SendTo invite.SendTo = inv.SendTo
} }

View File

@@ -158,7 +158,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
case "ExpiryReminder": case "ExpiryReminder":
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true) msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail": case "InviteEmail":
msg, err = app.email.constructInvite(Invite{Code: ""}, true) msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
case "WelcomeEmail": case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true) msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation": case "EmailConfirmation":

View File

@@ -738,7 +738,6 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var invname *dg.Member = nil var invname *dg.Member = nil
invname, err = d.bot.GuildMember(d.guildID, recipient.ID) invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) { if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
err = errors.New(lm.InviteMessagesDisabled) err = errors.New(lm.InviteMessagesDisabled)
@@ -746,11 +745,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message var msg *Message
if err == nil { if err == nil {
msg, err = d.app.email.constructInvite(invite, false) msg, err = d.app.email.constructInvite(&invite, false)
if err != nil { if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though. // Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err) invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
d.app.err.Println(invite.SendTo) Address: invname.User.Username,
Reason: CheckLogs,
})
d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} }
} }
@@ -760,12 +762,12 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
if err == nil { if err == nil {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient)) d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
sendResponse("sentInvite") sendResponse("sentInvite")
} }
if err != nil { if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err) invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
sendResponse("sentInviteFailure") sendResponse("sentInviteFailure")
} }
} }

View File

@@ -379,7 +379,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, placeh
return emailer.construct(contentInfo, cc, template) return emailer.construct(contentInfo, cc, template)
} }
func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) { func (emailer *Emailer) constructInvite(invite *Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false) d, t, expiresIn := emailer.formatExpiry(expiry, false)
inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code) inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)

View File

@@ -266,18 +266,20 @@
<div id="modal-announce" class="modal"> <div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href=""> <form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row gap-4">
<div class="col card ~neutral @low"> <div class="card ~neutral @low w-1/2 grow">
<div id="announce-details"> <div id="announce-details" class="flex flex-col gap-2">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span> <span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="announce-variables"> <div id="announce-variables" class="flex flex-row flex-wrap gap-2">
<span class="button ~urge @low mb-2 mt-4" id="announce-variables-username" style="margin-left: 0.25rem; margin-right: 0.25rem;"><span class="font-mono bg-inherit">{username}</span></span> <span class="button ~urge @low" id="announce-variables-username"><span class="font-mono bg-inherit">{username}</span></span>
</div> </div>
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label> <label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral @low mb-2 mt-4"> <input type="text" id="announce-subject" class="input ~neutral @low">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label> <label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral @low mt-4 font-mono"></textarea> <textarea id="textarea-announce" class="textarea full-width ~neutral @low font-mono"></textarea>
<p class="support mt-4 mb-2">{{ .strings.markdownSupported }}</p> <p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div> </div>
<label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p> <label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p>
<input type="text" class="input ~neutral @low mb-2 mt-4"> <input type="text" class="input ~neutral @low mb-2 mt-4">
@@ -291,9 +293,9 @@
<span class="button ~info @low center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span> <span class="button ~info @low center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span>
</div> </div>
</div> </div>
<div class="col card ~neutral @low"> <div class="card ~neutral @low flex flex-col gap-2 min-w-lg">
<span class="subheading supra">{{ .strings.preview }}</span> <span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-8" id="announce-preview"></div> <div id="announce-preview"></div>
</div> </div>
</div> </div>
</form> </form>
@@ -319,8 +321,8 @@
<div id="modal-editor" class="modal"> <div id="modal-editor" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href=""> <form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href="">
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row"> <div class="flex flex-col md:flex-row gap-4">
<div class="col card ~neutral @low flex flex-col gap-2 justify-between"> <div class="card ~neutral @low w-1/2 grow flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside> <aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside>
<label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label> <label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label>
@@ -329,16 +331,17 @@
<div id="editor-conditionals"></div> <div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label> <label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea> <textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea>
<p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="support">{{ .strings.markdownSupported }}</p>
<label class="w-full"> <label class="w-full">
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span> <span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span>
</label> </label>
</div> </div>
</div> </div>
<div class="col card ~neutral @low flex flex-col gap-2"> <div class="card ~neutral @low flex flex-col gap-2 min-w-lg">
<span class="subheading supra">{{ .strings.preview }}</span> <span class="subheading supra">{{ .strings.preview }}</span>
<div id="editor-preview"></div> <div id="editor-preview"></div>
</div> </div>

View File

@@ -17,6 +17,7 @@
"warning": "Warning", "warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively", "inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to", "inviteSendToEmail": "Send to",
"sentTo": "Sent to",
"create": "Create", "create": "Create",
"apply": "Apply", "apply": "Apply",
"select": "Select", "select": "Select",
@@ -223,7 +224,7 @@
"restartRequired": "Restart required", "restartRequired": "Restart required",
"required": "Required", "required": "Required",
"syntax": "Syntax", "syntax": "Syntax",
"syntaxDescription": "Variables denoted as {varname}. If statements can evaluate truthfulness (e.g. {if messageAddress}Message sent to {messageAddress}{end}) or make basic comparisons (e.g. {if profile == \"Friends\"}Friend{else if profile != \"Admins\"}User{end})" "syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})"
}, },
"notifications": { "notifications": {
"pathCopied": "Full path copied to clipboard.", "pathCopied": "Full path copied to clipboard.",
@@ -261,6 +262,7 @@
"errorLoadOmbiUsers": "Failed to load ombi users.", "errorLoadOmbiUsers": "Failed to load ombi users.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.", "errorChangedEmailAddress": "Couldn't change email address of {n}.",
"errorFailureCheckLogs": "Failed (check console/logs)", "errorFailureCheckLogs": "Failed (check console/logs)",
"errorCheckLogs": "Check console/logs",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)", "errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
"errorUserCreated": "Failed to create user {n}.", "errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)", "errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
@@ -272,7 +274,10 @@
"errorInvalidJSON": "Invalid JSON.", "errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.", "updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available.", "noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task." "runTask": "Triggered task.",
"errorMultiUser": "Multiple matching users found",
"errorNoUser": "No matching user found",
"errorInvalidAddress": "Invalid address/name"
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {

View File

@@ -43,7 +43,8 @@
"referrals": "Referrals", "referrals": "Referrals",
"inviteRemainingUses": "Remaining uses", "inviteRemainingUses": "Remaining uses",
"internal": "Internal", "internal": "Internal",
"external": "External" "external": "External",
"failed": "Failed"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "The username and/or password were left blank.", "errorLoginBlank": "The username and/or password were left blank.",

View File

@@ -109,9 +109,11 @@ const (
GenerateInvite = "Generating new invite" GenerateInvite = "Generating new invite"
FailedGenerateInvite = "Failed to generate new invite: %v" FailedGenerateInvite = "Failed to generate new invite: %v"
InvalidInviteCode = "Invalid invite code \"%s\"" InvalidInviteCode = "Invalid invite code \"%s\""
FailedGetInvite = "Failed to get invite \"%s\": %v"
FailedSendToTooltipNoUser = "Failed: \"%s\" not found" FailedSendToTooltipNoUser = "Failed: \"%s\" not found"
FailedSendToTooltipMultiUser = "Failed: \"%s\" linked to multiple users" FailedSendToTooltipMultiUser = "Failed: \"%s\" linked to multiple users"
InvalidAddress = "invalid address \"%s\""
FailedParseTime = "Failed to parse time value: %v" FailedParseTime = "Failed to parse time value: %v"

View File

@@ -52,16 +52,16 @@ type enableDisableUserDTO struct {
} }
type generateInviteDTO struct { type generateInviteDTO struct {
Months int `json:"months" example:"0"` // Number of months Months int `json:"months" example:"0"` // Number of months
Days int `json:"days" example:"1"` // Number of days Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes Minutes int `json:"minutes" example:"3"` // Number of minutes
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name sendInviteDTO
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
@@ -70,6 +70,15 @@ type generateInviteDTO struct {
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite. UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
} }
type SendInviteDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
sendInviteDTO
}
type sendInviteDTO struct {
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
}
type inviteProfileDTO struct { type inviteProfileDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
Profile string `json:"profile" example:"DefaultProfile"` // Profile to use Profile string `json:"profile" example:"DefaultProfile"` // Profile to use
@@ -106,26 +115,28 @@ type newProfileDTO struct {
} }
type inviteDTO struct { type inviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Months int `json:"months" example:"1"` // Number of months till expiry Months int `json:"months" example:"1"` // Number of months till expiry
Days int `json:"days" example:"1"` // Number of days till expiry Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Created int64 `json:"created" example:"1617737207510"` // Date of creation Created int64 `json:"created" example:"1617737207510"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable) RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
SendTo string `json:"send_to,omitempty"` // Email/Discord username the invite was sent to (if applicable) SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed.
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite. NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
} }
type getInvitesDTO struct { type getInvitesDTO struct {

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14", "@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2", "@webcoder49/code-input": "^2.7.2",
@@ -482,6 +483,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hrfee/simpletemplate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@hrfee/simpletemplate/-/simpletemplate-1.1.0.tgz",
"integrity": "sha512-5H4/y7CegE4twstRPip/ms+OGUb+BPyVt+hriEpY88lTWW4I6jxzHFHmz6NDZmVyRa4RVIEVBxvtwk3RXKJVwA==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",

View File

@@ -18,6 +18,7 @@
"homepage": "https://github.com/hrfee/jfa-go#readme", "homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14", "@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2", "@webcoder49/code-input": "^2.7.2",

View File

@@ -213,6 +213,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/invites/count/used", app.GetInviteUsedCount) api.GET(p+"/invites/count/used", app.GetInviteUsedCount)
api.DELETE(p+"/invites", app.DeleteInvite) api.DELETE(p+"/invites", app.DeleteInvite)
api.POST(p+"/invites/profile", app.SetProfile) api.POST(p+"/invites/profile", app.SetProfile)
api.POST(p+"/invites/send", app.SendInvite)
api.GET(p+"/profiles", app.GetProfiles) api.GET(p+"/profiles", app.GetProfiles)
api.GET(p+"/profiles/names", app.GetProfileNames) api.GET(p+"/profiles/names", app.GetProfileNames)
api.GET(p+"/profiles/raw/:name", app.GetRawProfile) api.GET(p+"/profiles/raw/:name", app.GetRawProfile)

View File

@@ -769,20 +769,39 @@ type JellyseerrTemplate struct {
Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"` Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
} }
type SendFailureReason = string
const (
CheckLogs SendFailureReason = "CheckLogs"
NoUser = "NoUser"
MultiUser = "MultiUser"
InvalidAddress = "InvalidAddress"
)
type SendFailure struct {
Address string `json:"address"`
Reason SendFailureReason `json:"reason"`
}
type SentToList struct {
Success []string `json:"success"`
Failed []SendFailure `json:"failed"`
}
type Invite struct { type Invite struct {
Code string `badgerhold:"key"` Code string `badgerhold:"key"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"` NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"` RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"` ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"` UserExpiry bool `json:"user-duration"`
UserMonths int `json:"user-months,omitempty"` UserMonths int `json:"user-months,omitempty"`
UserDays int `json:"user-days,omitempty"` UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"` UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"` UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"` SendTo string `json:"email"` // deprecated: use SentTo now.
// Used to be stored as formatted time, now as Unix. SentTo SentToList `json:"sent-to,omitempty"`
UsedBy [][]string `json:"used-by"` UsedBy [][]string `json:"used-by"` // Used to be stored as formatted time, now as Unix.
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"` Profile string `json:"profile"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`

View File

@@ -2,7 +2,7 @@ import { ThemeManager } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js"; import { Modal } from "./modules/modal.js";
import { Tabs, Tab } from "./modules/tabs.js"; import { Tabs, Tab } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js"; import { DOMInviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js"; import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js"; import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js"; import { activityList } from "./modules/activity.js";
@@ -105,7 +105,7 @@ var accounts = new accountsList();
var activity = new activityList(); var activity = new activityList();
window.invites = new inviteList(); window.invites = new DOMInviteList();
var settings = new settingsList(); var settings = new settingsList();

View File

@@ -1247,6 +1247,12 @@ export class accountsList extends PaginatedList {
this._search.showHideSearchOptionsHeader(); this._search.showHideSearchOptionsHeader();
this.registerURLListener(); this.registerURLListener();
// Get rid of nasty CSS
window.modals.announce.onclose = () => {
const preview = document.getElementById("announce-preview") as HTMLDivElement;
preview.textContent = ``;
}
} }
reload = (callback?: (resp: paginatedDTO) => void) => { reload = (callback?: (resp: paginatedDTO) => void) => {

View File

@@ -123,20 +123,55 @@ class DOMInvite implements Invite {
} else { } else {
chip.classList.add("button"); chip.classList.add("button");
chip.parentElement.classList.add("h-full"); chip.parentElement.classList.add("h-full");
if (address.includes("Failed")) { if (address.includes(window.lang.strings("failed"))) {
icon.classList.remove("ri-mail-line"); icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line"); icon.classList.add("ri-mail-close-line");
chip.classList.remove("~neutral"); chip.classList.remove("~neutral");
chip.classList.add("~critical"); chip.classList.add("~critical");
} else { } else {
address = "Sent to " + address;
icon.classList.remove("ri-mail-close-line"); icon.classList.remove("ri-mail-close-line");
icon.classList.add("ri-mail-line"); icon.classList.add("ri-mail-line");
chip.classList.remove("~critical"); chip.classList.remove("~critical");
chip.classList.add("~neutral"); chip.classList.add("~neutral");
} }
} }
tooltip.textContent = address; // innerHTML as the newer sent_to re-uses this with HTML.
tooltip.innerHTML = address;
}
private _sent_to: SentToList;
get sent_to(): SentToList { return this._sent_to; }
set sent_to(v: SentToList) {
this._sent_to = v;
if (!v || !(v.success || v.failed)) return;
let text = "";
if (v.success && v.success.length > 0) {
text += window.lang.strings("sentTo") + ": " + v.success.join(", ") + " <br>"
}
if (v.failed && v.failed.length > 0) {
text += window.lang.strings("failed") + ": " + v.failed.map((el: SendFailure) => {
let err: string;
switch (el.reason) {
case "CheckLogs":
err = window.lang.notif("errorCheckLogs");
break;
case "NoUser":
err = window.lang.notif("errorNoUser");
break;
case "MultiUser":
err = window.lang.notif("errorMultiUser");
break;
case "InvalidAddress":
err = window.lang.notif("errorInvalidAddress");
break;
default:
err = el.reason;
break;
}
return el.address + " (" + err + ")";
}).join(", ");
}
if (text.length != 0) this.send_to = text;
} }
private _usedBy: { [name: string]: number }; private _usedBy: { [name: string]: number };
@@ -455,6 +490,7 @@ class DOMInvite implements Invite {
this.code = invite.code; this.code = invite.code;
this.created = invite.created; this.created = invite.created;
this.send_to = invite.send_to; this.send_to = invite.send_to;
this.sent_to = invite.sent_to;
this.expiresIn = invite.expiresIn; this.expiresIn = invite.expiresIn;
if (window.notificationsEnabled) { if (window.notificationsEnabled) {
this.notifyCreation = invite.notifyCreation; this.notifyCreation = invite.notifyCreation;
@@ -477,7 +513,7 @@ class DOMInvite implements Invite {
remove = () => { this._container.remove(); } remove = () => { this._container.remove(); }
} }
export class inviteList implements inviteList { export class DOMInviteList implements InviteList {
private _list: HTMLDivElement; private _list: HTMLDivElement;
private _empty: boolean; private _empty: boolean;
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them. // since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
@@ -493,7 +529,7 @@ export class inviteList implements inviteList {
}; };
public static readonly _inviteURLEvent = "invite-url"; public static readonly _inviteURLEvent = "invite-url";
registerURLListener = () => document.addEventListener(inviteList._inviteURLEvent, (event: CustomEvent) => { registerURLListener = () => document.addEventListener(DOMInviteList._inviteURLEvent, (event: CustomEvent) => {
this.focusInvite(event.detail); this.focusInvite(event.detail);
}) })
@@ -587,12 +623,14 @@ export class inviteList implements inviteList {
})); }));
} }
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) }; export const inviteURLEvent = (id: string) => { return new CustomEvent(DOMInviteList._inviteURLEvent, {"detail": id}) };
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite { // FIXME: Please, i beg you, get rid of this horror!
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean | SentToList }): Invite {
let parsed: Invite = {}; let parsed: Invite = {};
parsed.code = invite["code"] as string; parsed.code = invite["code"] as string;
parsed.send_to = invite["send_to"] as string || ""; parsed.send_to = invite["send_to"] as string || "";
parsed.sent_to = invite["sent_to"] as SentToList || null;
parsed.label = invite["label"] as string || ""; parsed.label = invite["label"] as string || "";
parsed.user_label = invite["user_label"] as string || ""; parsed.user_label = invite["user_label"] as string || "";
let time = ""; let time = "";

View File

@@ -1,4 +1,5 @@
import { _get } from "../modules/common.js"; import { _get } from "../modules/common.js";
import { Template } from "@hrfee/simpletemplate";
interface Meta { interface Meta {
name: string; name: string;
@@ -39,6 +40,15 @@ export class lang implements Lang {
return str; return str;
} }
template = (sect: string, key: string, subs: { [key: string]: any }): string => {
if (sect == "quantityStrings" || sect == "meta") { return ""; }
const map = new Map<string, any>();
for (let key of Object.keys(subs)) { map.set(key, subs[key]); }
const [out, err] = Template(this._lang[sect][key], map);
if (err != null) throw err;
return out;
}
quantity = (key: string, number: number): string => { quantity = (key: string, number: number): string => {
if (number == 1) { if (number == 1) {
return this._lang.quantityStrings[key].singular.replace("{n}", ""+number) return this._lang.quantityStrings[key].singular.replace("{n}", ""+number)

View File

@@ -1626,7 +1626,8 @@ class MessageEditor {
} else { } else {
for (let i = this._templ.conditionals.length-1; i >= 0; i--) { for (let i = this._templ.conditionals.length-1; i >= 0; i--) {
let ci = i % colors.length; let ci = i % colors.length;
innerHTML += '<span class="button ~' + colors[ci] +' @low mb-4" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>' // FIXME: Store full color strings (with ~) so tailwind sees them.
innerHTML += '<span class="button ~' + colors[ci] +' @low"></span>'
} }
this._conditionalsLabel.classList.remove("unfocused"); this._conditionalsLabel.classList.remove("unfocused");
this._conditionals.innerHTML = innerHTML this._conditionals.innerHTML = innerHTML
@@ -1754,6 +1755,20 @@ class MessageEditor {
} }
}); });
}; };
const descriptions = document.getElementsByClassName("editor-syntax-description") as HTMLCollectionOf<HTMLParagraphElement>;
for (let el of descriptions) {
el.innerHTML = window.lang.template("strings", "syntaxDescription", {
"variable": `<span class="font-mono font-bold">{varname}</span>`,
"ifTruth": `<span class="font-mono font-bold">{if address}Message sent to {address}{end}</span>`,
"ifCompare": `<span class="font-mono font-bold">{if profile == "Friends"}Friend{else if profile != "Admins"}User{end}</span>`
});
};
// Get rid of nasty CSS
window.modals.editor.onclose = () => {
this._preview.textContent = ``;
}
} }
} }

View File

@@ -48,7 +48,7 @@ declare interface GlobalWindow extends Window {
transitionEvent: string; transitionEvent: string;
animationEvent: string; animationEvent: string;
tabs: Tabs; tabs: Tabs;
invites: inviteList; invites: InviteList;
notifications: NotificationBox; notifications: NotificationBox;
language: string; language: string;
lang: Lang; lang: Lang;
@@ -61,6 +61,42 @@ declare interface GlobalWindow extends Window {
loginAppearance: string; loginAppearance: string;
} }
declare interface InviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
declare interface Invite {
code?: string;
expiresIn?: string;
remainingUses?: string;
send_to?: string; // DEPRECATED: use sent_to instead.
sent_to?: SentToList;
usedBy?: { [name: string]: number };
created?: number;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
label?: string;
user_label?: string;
userExpiry?: boolean;
userExpiryTime?: string;
}
declare interface SendFailure {
address: string;
reason: "CheckLogs" | "NoUser" | "MultiUser" | "InvalidAddress";
}
declare interface SentToList {
success: string[];
failed: SendFailure[];
}
declare interface Update { declare interface Update {
version: string; version: string;
commit: string; commit: string;
@@ -83,6 +119,7 @@ declare interface Lang {
strings: (key: string) => string; strings: (key: string) => string;
notif: (key: string) => string; notif: (key: string) => string;
var: (sect: string, key: string, ...subs: string[]) => string; var: (sect: string, key: string, ...subs: string[]) => string;
template: (sect: string, key: string, subs: { [key: string]: string }) => string;
quantity: (key: string, number: number) => string; quantity: (key: string, number: number) => string;
} }
@@ -131,31 +168,6 @@ declare interface Modals {
backups?: Modal; backups?: Modal;
} }
interface Invite {
code?: string;
expiresIn?: string;
remainingUses?: string;
send_to?: string;
usedBy?: { [name: string]: number };
created?: number;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
label?: string;
user_label?: string;
userExpiry?: boolean;
userExpiryTime?: string;
}
interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
interface paginatedDTO { interface paginatedDTO {
last_page: boolean; last_page: boolean;
} }