From 51f604d061985279f6639f4c1055ef4ab6a024d9 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 6 Dec 2025 13:59:34 +0000 Subject: [PATCH] ivnites: use actual inviteDTO for DOMInvite no intermediary parsing step. Also, moved the date -> duration (3mo6d3h sorta thing) to the web, there's now a ValidTill field with a unix timestamp. Used the new Temporal api with a polyfill. Bumped api version (although it still isn't semver). --- api-invites.go | 17 ++-- email_test.go | 2 +- main.go | 2 +- models.go | 25 +++--- package-lock.json | 16 ++++ package.json | 1 + ts/modules/common.ts | 31 ++++++++ ts/modules/invites.ts | 181 +++++++++++++++++++++++------------------- ts/typings/d.ts | 33 ++++---- 9 files changed, 188 insertions(+), 120 deletions(-) diff --git a/api-invites.go b/api-invites.go index 9ce9ac8..e8b424c 100644 --- a/api-invites.go +++ b/api-invites.go @@ -359,21 +359,22 @@ func (app *appContext) GetInviteUsedCount(gc *gin.Context) { // @Security Bearer // @tags Invites,Statistics func (app *appContext) GetInvites(gc *gin.Context) { - currentTime := time.Now() + // currentTime := time.Now() app.checkInvites() var invites []inviteDTO for _, inv := range app.storage.GetInvites() { if inv.IsReferral { continue } - years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) - months += years * 12 + // years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) + // months += years * 12 invite := inviteDTO{ - Code: inv.Code, - Months: months, - Days: days, - Hours: hours, - Minutes: minutes, + Code: inv.Code, + ValidTill: inv.ValidTill.Unix(), + // Months: months, + // Days: days, + // Hours: hours, + // Minutes: minutes, UserExpiry: inv.UserExpiry, UserMonths: inv.UserMonths, UserDays: inv.UserDays, diff --git a/email_test.go b/email_test.go index 25e15fb..6729d31 100644 --- a/email_test.go +++ b/email_test.go @@ -180,7 +180,7 @@ func TestInvite(t *testing.T) { Created: time.Now(), ValidTill: time.Now().Add(30 * time.Minute), } - msg, err := e.constructInvite(inv, false) + msg, err := e.constructInvite(&inv, false) if err != nil { t.Fatalf("failed construct: %+v", err) } diff --git a/main.go b/main.go index 7f3c1b6..c18f31c 100644 --- a/main.go +++ b/main.go @@ -715,7 +715,7 @@ func flagPassed(name string) (found bool) { } // @title jfa-go internal API -// @version 0.6.0 +// @version 0.6.1 // @description API for the jfa-go frontend // @contact.name Harvey Tindall // @contact.email hrfee@hrfee.dev diff --git a/models.go b/models.go index 5f81f42..55eb2c9 100644 --- a/models.go +++ b/models.go @@ -116,25 +116,22 @@ type newProfileDTO struct { type inviteDTO struct { Code string `json:"code" example:"sajdlj23423j23"` // Invite code - Months int `json:"months" example:"1"` // Number of months till expiry - Days int `json:"days" example:"1"` // Number of days till expiry - Hours int `json:"hours" example:"2"` // Number of hours 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 - 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 - 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 + ValidTill int64 `json:"valid_till" example:"1617737207510"` // Unix timestamp of expiry + 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 + 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 + UserMinutes int `json:"user_minutes,omitempty" example:"3"` // Number of minutes till user expiry Created int64 `json:"created" example:"1617737207510"` // Date of creation 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 - 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) + 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"` // If true, invite can be used any number of times + RemainingUses int `json:"remaining_uses,omitempty"` // Remaining number of uses (if applicable) SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable) SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed. - NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not - NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not + NotifyExpiry bool `json:"notify_expiry,omitempty"` // Whether to notify the requesting user of expiry or not + 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. } diff --git a/package-lock.json b/package-lock.json index 0b261b7..3c7524a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "remixicon": "^4.3.0", "remove-markdown": "^0.5.0", "tailwindcss": "^3.3.2", + "temporal-polyfill": "^0.3.0", "typescript": "^5.1.3", "uncss": "^0.17.3" }, @@ -6755,6 +6756,21 @@ "node": ">=8.0" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz", + "integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz", + "integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==", + "license": "ISC" + }, "node_modules/terser": { "version": "5.43.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", diff --git a/package.json b/package.json index 3fbb61d..4e68e62 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "remixicon": "^4.3.0", "remove-markdown": "^0.5.0", "tailwindcss": "^3.3.2", + "temporal-polyfill": "^0.3.0", "typescript": "^5.1.3", "uncss": "^0.17.3" }, diff --git a/ts/modules/common.ts b/ts/modules/common.ts index ede5be1..56fbdb1 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -1,5 +1,6 @@ declare var window: GlobalWindow; import dateParser from "any-date-parser"; +import { Temporal } from 'temporal-polyfill'; export function toDateString(date: Date): string { const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language; @@ -41,6 +42,36 @@ export const parseDateString = (value: string): ParsedDate => { return out; } +// DateCountdown sets the given el's textContent to the time till the given date (unixSeconds), updating +// every minute. It returns the timeout, so it can be later removed with clearTimeout if desired. +export function DateCountdown(el: HTMLElement, unixSeconds: number): ReturnType { + let then = Temporal.Instant.fromEpochMilliseconds(unixSeconds * 1000); + const toString = (): string => { + let out = ""; + let now = Temporal.Now.instant(); + let nowPlain = Temporal.Now.plainDateTimeISO(); + let diff = now.until(then).round({ + largestUnit: "years", + smallestUnit: "minutes", + relativeTo: nowPlain + }); + // FIXME: I'd really like this to be localized, but don't know of any nice solutions. + const fields = [diff.years, diff.months, diff.days, diff.hours, diff.minutes]; + const abbrevs = ["y", "mo", "d", "h", "m"]; + for (let i = 0; i < fields.length; i++) { + if (fields[i]) { + out += ""+fields[i] + abbrevs[i] + " "; + } + } + return out.slice(0, -1); + }; + const update = () => { + el.textContent = toString(); + }; + update(); + return setTimeout(update, 60000); +} + export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => { let req = new XMLHttpRequest(); if (window.pages) { url = window.pages.Base + url; } diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index b44b6a2..1a0f75c 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -1,9 +1,11 @@ -import { _get, _post, _delete, toClipboard, toggleLoader, toDateString, SetupCopyButton, addLoader, removeLoader } from "../modules/common.js"; +import { _get, _post, _delete, toClipboard, toggleLoader, toDateString, SetupCopyButton, addLoader, removeLoader, DateCountdown } from "../modules/common.js"; import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { reloadProfileNames } from "../modules/profiles.js"; declare var window: GlobalWindow; +const INF = "∞"; + export const generateCodeLink = (code: string): string => { // let codeLink = window.pages.Base + window.pages.Form + "/" + code; let codeLink = window.pages.ExternalURI + window.pages.Form + "/" + code; @@ -15,11 +17,11 @@ class DOMInvite implements Invite { let state: { [code: string]: { [type: string]: boolean } } = {}; let revertChanges: () => void; if (checkbox.classList.contains("inv-notify-expiry")) { - revertChanges = () => { this.notifyExpiry = !this.notifyExpiry }; - state[this.code] = { "notify-expiry": this.notifyExpiry }; + revertChanges = () => { this.notify_expiry = !this.notify_expiry }; + state[this.code] = { "notify-expiry": this.notify_expiry }; } else { - revertChanges = () => { this.notifyCreation = !this.notifyCreation }; - state[this.code] = { "notify-creation": this.notifyCreation }; + revertChanges = () => { this.notify_creation = !this.notify_creation }; + state[this.code] = { "notify-creation": this.notify_creation }; } _post("/invites/notify", state, (req: XMLHttpRequest) => { if (req.readyState == 4 && !(req.status == 200 || req.status == 204)) { @@ -78,44 +80,92 @@ class DOMInvite implements Invite { } private _codeLink: string; - private _expiresIn: string; - get expiresIn(): string { return this._expiresIn } - set expiresIn(expiry: string) { - this._expiresIn = expiry; - this._codeArea.querySelector("span.inv-duration").textContent = expiry; + private _validTill: number; + private _validTillUpdater: ReturnType = null; + get valid_till(): number { return this._validTill; } + set valid_till(v: number) { + this._validTill = v; + if (this._validTillUpdater) clearTimeout(this._validTillUpdater); + this._validTillUpdater = DateCountdown(this._codeArea.querySelector("span.inv-duration"), v); } - private _userExpiry: string; - get userExpiryTime(): string { return this._userExpiry; } - set userExpiryTime(d: string) { + private _userExpiryEnabled: boolean; + get user_expiry(): boolean { return this._userExpiryEnabled; } + set user_expiry(v: boolean) { this._userExpiryEnabled = v; } + private _userExpiry = { months: 0, days: 0, hours: 0, minutes: 0 }; + private _userExpiryString: string; + get user_months(): number { return this._userExpiry.months; } + get user_days(): number { return this._userExpiry.days; } + get user_hours(): number { return this._userExpiry.hours; } + get user_minutes(): number { return this._userExpiry.minutes; } + set user_months(v: number) { + this._userExpiry.months = v; + this._updateUserExpiry(); + } + set user_days(v: number) { + this._userExpiry.days = v; + this._updateUserExpiry(); + } + set user_hours(v: number) { + this._userExpiry.hours = v; + this._updateUserExpiry(); + } + set user_minutes(v: number) { + this._userExpiry.minutes = v; + this._updateUserExpiry(); + } + set user_expiry_time(v: { months: number, days: number, hours: number, minutes: number }) { + this._userExpiry = v; + this._updateUserExpiry() + } + private _updateUserExpiry() { const expiry = this._middle.querySelector("span.user-expiry") as HTMLSpanElement; - if (!d) { + this._userExpiryString = ""; + if (!(this._userExpiry.months || this._userExpiry.days || this._userExpiry.hours || this._userExpiry.minutes)) { expiry.textContent = ""; expiry.parentElement.classList.add("unfocused"); } else { expiry.textContent = window.lang.strings("userExpiry"); expiry.parentElement.classList.remove("unfocused"); + const fields = ["months", "days", "hours", "minutes"].map((v) => this._userExpiry[v]); + const abbrevs = ["mo", "d", "h", "m"]; + for (let i = 0; i < fields.length; i++) { + if (fields[i]) { + this._userExpiryString += ""+fields[i] + abbrevs[i] + " "; + } + } + this._userExpiryString = this._userExpiryString.slice(0, -1); } - this._userExpiry = d; - this._middle.querySelector("strong.user-expiry-time").textContent = d; + this._middle.querySelector("strong.user-expiry-time").textContent = this._userExpiryString; + } + + private _noLimit: boolean = false; + get no_limit(): boolean { return this._noLimit; } + set no_limit(v: boolean) { + this._noLimit = v; + const remaining = this._middle.querySelector("strong.inv-remaining") as HTMLElement; + if (!this.no_limit) remaining.textContent = ""+this._remainingUses; + else remaining.textContent = INF; } - private _remainingUses: string = "1"; - get remainingUses(): string { return this._remainingUses; } - set remainingUses(remaining: string) { - this._remainingUses = remaining; - this._middle.querySelector("strong.inv-remaining").textContent = remaining; + private _remainingUses: number = 1; + get remaining_uses(): number { return this._remainingUses; } + set remaining_uses(v: number) { + this._remainingUses = v; + const remaining = this._middle.querySelector("strong.inv-remaining") as HTMLElement; + if (!this.no_limit) remaining.textContent = ""+this._remainingUses; + else remaining.textContent = INF; } private _send_to: string = ""; get send_to(): string { return this._send_to }; - set send_to(address: string) { + set send_to(address: string | null) { this._send_to = address; const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement; const icon = container.querySelector("i"); const chip = container.querySelector("span.inv-email-chip"); const tooltip = container.querySelector("span.content") as HTMLSpanElement; - if (address == "") { + if (!address) { icon.classList.remove("ri-mail-line"); icon.classList.remove("ri-mail-close-line"); chip.classList.remove("~neutral"); @@ -177,10 +227,10 @@ class DOMInvite implements Invite { } private _usedBy: { [name: string]: number }; - get usedBy(): { [name: string]: number } { return this._usedBy; } - set usedBy(uB: { [name: string]: number }) { + get used_by(): { [name: string]: number } { return this._usedBy; } + set used_by(uB: { [name: string]: number } | null) { this._usedBy = uB; - if (Object.keys(uB).length == 0) { + if (!uB || Object.keys(uB).length == 0) { this._right.classList.add("empty"); this._userTable.innerHTML = `

${window.lang.strings("inviteNoUsersCreated")}

`; return; @@ -226,15 +276,15 @@ class DOMInvite implements Invite { } private _notifyExpiry: boolean = false; - get notifyExpiry(): boolean { return this._notifyExpiry } - set notifyExpiry(state: boolean) { + get notify_expiry(): boolean { return this._notifyExpiry } + set notify_expiry(state: boolean) { this._notifyExpiry = state; (this._left.querySelector("input.inv-notify-expiry") as HTMLInputElement).checked = state; } private _notifyCreation: boolean = false; - get notifyCreation(): boolean { return this._notifyCreation } - set notifyCreation(state: boolean) { + get notify_creation(): boolean { return this._notifyCreation } + set notify_creation(state: boolean) { this._notifyCreation = state; (this._left.querySelector("input.inv-notify-creation") as HTMLInputElement).checked = state; } @@ -366,7 +416,7 @@ class DOMInvite implements Invite { - + ${window.lang.var("strings", "inviteExpiresInTime", "")} `; const copyButton = this._codeArea.getElementsByClassName("invite-copy-button")[0] as HTMLButtonElement; SetupCopyButton(copyButton, this._codeLink); @@ -476,30 +526,39 @@ class DOMInvite implements Invite { document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false); document.addEventListener("timefmt-change", () => { this.created = this.created; - this.usedBy = this.usedBy; + this.used_by = this.used_by; }); } update = (invite: Invite) => { this.code = invite.code; + this.valid_till = invite.valid_till; + if (invite.user_expiry) { + this.user_expiry = invite.user_expiry; + this.user_expiry_time = { + months: invite.user_months, + days: invite.user_days, + hours: invite.user_hours, + minutes: invite.user_minutes + }; + } this.created = invite.created; + this.profile = invite.profile; + this.used_by = invite.used_by; + this.no_limit = invite.no_limit ? invite.no_limit : false; + this.remaining_uses = invite.remaining_uses; this.send_to = invite.send_to; this.sent_to = invite.sent_to; - this.expiresIn = invite.expiresIn; if (window.notificationsEnabled) { - this.notifyCreation = invite.notifyCreation; - this.notifyExpiry = invite.notifyExpiry; + this.notify_creation = invite.notify_creation; + this.notify_expiry = invite.notify_expiry; } - this.profile = invite.profile; - this.remainingUses = invite.remainingUses; - this.usedBy = invite.usedBy; if (invite.label) { this.label = invite.label; } if (invite.user_label) { this.user_label = invite.user_label; } - this.userExpiryTime = invite.userExpiryTime || ""; this._sendToDialog = new SendToDialog(this._middle.getElementsByClassName("invite-send-to-dialog")[0] as HTMLElement, invite, () => { const needsUpdatingEvent = new CustomEvent("inviteNeedsUpdating", { detail: this.code }); document.dispatchEvent(needsUpdatingEvent); @@ -605,8 +664,7 @@ export class DOMInviteList implements InviteList { // at end delete all remaining in list from dom let invitesOnDOM: { [code: string]: boolean } = {}; for (let code in this.invites) { invitesOnDOM[code] = true; } - for (let inv of (data["invites"] as Array)) { - const invite = parseInvite(inv); + for (let invite of (data["invites"] as Array)) { if (invite.code in this.invites) { this.invites[invite.code].update(invite); delete invitesOnDOM[invite.code]; @@ -626,47 +684,6 @@ export class DOMInviteList implements InviteList { export const inviteURLEvent = (id: string) => { return new CustomEvent(DOMInviteList._inviteURLEvent, {"detail": id}) }; -// 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 = {}; - parsed.code = invite["code"] 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.user_label = invite["user_label"] as string || ""; - let time = ""; - let userExpiryTime = ""; - const fields = ["months", "days", "hours", "minutes"]; - let prefixes = [""]; - if (invite["user-expiry"] as boolean) { prefixes.push("user-"); } - for (let i = 0; i < fields.length; i++) { - for (let j = 0; j < prefixes.length; j++) { - if (invite[prefixes[j]+fields[i]]) { - let abbreviation = fields[i][0]; - if (fields[i] == "months") { - abbreviation += fields[i][1]; - } - let text = `${invite[prefixes[j]+fields[i]]}${abbreviation} `; - if (prefixes[j] == "user-") { - userExpiryTime += text; - } else { - time += text; - } - } - } - } - parsed.expiresIn = window.lang.var("strings", "inviteExpiresInTime", time.slice(0, -1)); - parsed.userExpiry = invite["user-expiry"] as boolean; - parsed.userExpiryTime = userExpiryTime.slice(0, -1); - parsed.remainingUses = invite["no-limit"] ? "∞" : String(invite["remaining-uses"]) - parsed.usedBy = invite["used-by"] as { [name: string]: number } || {} ; - parsed.created = invite["created"] as number || 0; - parsed.profile = invite["profile"] as string || ""; - parsed.notifyExpiry = invite["notify-expiry"] as boolean || false; - parsed.notifyCreation = invite["notify-creation"] as boolean || false; - return parsed; -} - export class createInvite { private _sendTo: SendToDialog; private _userExpiryToggle = document.getElementById("create-user-expiry-enabled") as HTMLInputElement; diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 77327ce..9e0426d 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -71,20 +71,25 @@ declare interface InviteList { } 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; + code: string; // Invite code + valid_till: number; // Unix timestamp of expiry + user_expiry: boolean; // Whether or not user expiry is enabled + user_months?: number; // Number of months till user expiry + user_days?: number; // Number of days till user expiry + user_hours?: number; // Number of hours till user expiry + user_minutes?: number; // Number of minutes till user expiry + created: number; // Date of creation (unix timestamp) + profile: string; // Profile used on this invite + used_by?: { [user: string]: number }; // Users who have used this invite mapped to their creation time in Epoch/Unix time + no_limit: boolean; // If true, invite can be used any number of times + remaining_uses?: number; // Remaining number of uses (if applicable) + send_to?: string; // DEPRECATED Email/Discord username the invite was sent to (if applicable) + sent_to?: SentToList; // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed. + + notify_expiry?: boolean; // Whether to notify the requesting user of expiry or not + notify_creation?: boolean; // Whether to notify the requesting user of account creation or not + label?: string; // Optional label for the invite + user_label?: string; // Label to apply to users created w/ this invite. } declare interface SendFailure {