From ef004675a58c5cc7b1ff7576226ea15293d6c0d7 Mon Sep 17 00:00:00 2001 From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com> Date: Tue, 2 Apr 2024 21:27:15 +0200 Subject: [PATCH] Create invitations for closed registrations (#233) --- internal/db/db.go | 2 +- internal/db/invitation.go | 87 ++++++++++++++++++++++++++ internal/i18n/locales/en-US.yml | 10 +++ internal/web/admin.go | 57 +++++++++++++++++ internal/web/auth.go | 31 ++++++++- internal/web/gist.go | 16 +---- internal/web/server.go | 19 ++++++ public/admin.ts | 9 +++ public/main.ts | 16 +++++ templates/base/admin_footer.html | 1 + templates/base/admin_header.html | 2 + templates/pages/admin_config.html | 2 - templates/pages/admin_invitations.html | 68 ++++++++++++++++++++ templates/pages/auth_form.html | 2 +- 14 files changed, 301 insertions(+), 21 deletions(-) create mode 100644 internal/db/invitation.go create mode 100644 templates/pages/admin_invitations.html diff --git a/internal/db/db.go b/internal/db/db.go index 0e30628..5cb5709 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -42,7 +42,7 @@ func Setup(dbPath string, sharedCache bool) error { return err } - if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}); err != nil { + if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}); err != nil { return err } diff --git a/internal/db/invitation.go b/internal/db/invitation.go new file mode 100644 index 0000000..f85fc83 --- /dev/null +++ b/internal/db/invitation.go @@ -0,0 +1,87 @@ +package db + +import ( + "math/rand" + "time" +) + +type Invitation struct { + ID uint `gorm:"primaryKey"` + Code string + ExpiresAt int64 + NbUsed uint + NbMax uint +} + +func GetAllInvitations() ([]*Invitation, error) { + var invitations []*Invitation + err := db. + Order("(((expires_at >= strftime('%s', 'now')) AND ((nb_max <= 0) OR (nb_used < nb_max)))) desc"). + Order("id asc"). + Find(&invitations).Error + + return invitations, err +} + +func GetInvitationByID(id uint) (*Invitation, error) { + invitation := new(Invitation) + err := db. + Where("id = ?", id). + First(&invitation).Error + return invitation, err +} + +func GetInvitationByCode(code string) (*Invitation, error) { + invitation := new(Invitation) + err := db. + Where("code = ?", code). + First(&invitation).Error + return invitation, err +} + +func InvitationCodeExists(code string) (bool, error) { + var count int64 + err := db.Model(&Invitation{}).Where("code = ?", code).Count(&count).Error + return count > 0, err +} + +func (i *Invitation) Create() error { + i.Code = generateRandomCode() + return db.Create(&i).Error +} + +func (i *Invitation) Update() error { + return db.Save(&i).Error +} + +func (i *Invitation) Delete() error { + return db.Delete(&i).Error +} + +func (i *Invitation) IsExpired() bool { + return i.ExpiresAt < time.Now().Unix() +} + +func (i *Invitation) IsMaxedOut() bool { + return i.NbMax > 0 && i.NbUsed >= i.NbMax +} + +func (i *Invitation) IsUsable() bool { + return !i.IsExpired() && !i.IsMaxedOut() +} + +func (i *Invitation) Use() error { + i.NbUsed++ + return i.Update() +} + +func generateRandomCode() string { + const charset = "0123456789ABCDEF" + var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) + result := make([]byte, 16) + + for i := range result { + result[i] = charset[seededRand.Intn(len(charset))] + } + return string(result) +} diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index a894a63..f0a54e9 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -162,6 +162,8 @@ admin.general: General admin.users: Users admin.gists: Gists admin.configuration: Configuration +admin.invitations: Invitations +admin.invitations.create: Create invitation admin.versions: Versions admin.ssh_keys: SSH keys admin.stats: Stats @@ -195,3 +197,11 @@ admin.gists.private: Private ? admin.gists.nb-files: Nb. files admin.gists.nb-likes: Nb. likes admin.gists.delete_confirm: Do you want to delete this gist ? + +admin.invitations.help: Invitations can be used to create an account even if signing up is disabled. +admin.invitations.max_uses: Max uses +admin.invitations.expires_at: Expires at +admin.invitations.code: Code +admin.invitations.copy_link: Copy link +admin.invitations.uses: Uses +admin.invitations.expired: Expired \ No newline at end of file diff --git a/internal/web/admin.go b/internal/web/admin.go index a438510..f660ba4 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -8,6 +8,7 @@ import ( "github.com/thomiceli/opengist/internal/git" "runtime" "strconv" + "time" ) func adminIndex(ctx echo.Context) error { @@ -179,3 +180,59 @@ func adminSetConfig(ctx echo.Context) error { "success": true, }) } + +func adminInvitations(ctx echo.Context) error { + setData(ctx, "title", "Invitations") + setData(ctx, "htmlTitle", "Invitations - Admin panel") + setData(ctx, "adminHeaderPage", "invitations") + + var invitations []*db.Invitation + var err error + if invitations, err = db.GetAllInvitations(); err != nil { + return errorRes(500, "Cannot get invites", err) + } + + setData(ctx, "invitations", invitations) + return html(ctx, "admin_invitations.html") +} + +func adminInvitationsCreate(ctx echo.Context) error { + code := ctx.FormValue("code") + nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64) + if err != nil { + nbMax = 10 + } + + expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64) + if err != nil { + expiresAtUnix = time.Now().Unix() + 604800 // 1 week + } + + invitation := &db.Invitation{ + Code: code, + ExpiresAt: expiresAtUnix, + NbMax: uint(nbMax), + } + + if err := invitation.Create(); err != nil { + return errorRes(500, "Cannot create invitation", err) + } + + addFlash(ctx, "Invitation has been created", "success") + return redirect(ctx, "/admin-panel/invitations") +} + +func adminInvitationsDelete(ctx echo.Context) error { + id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64) + invitation, err := db.GetInvitationByID(uint(id)) + if err != nil { + return errorRes(500, "Cannot retrieve invitation", err) + } + + if err := invitation.Delete(); err != nil { + return errorRes(500, "Cannot delete this invitation", err) + } + + addFlash(ctx, "Invitation has been deleted", "success") + return redirect(ctx, "/admin-panel/invitations") +} diff --git a/internal/web/auth.go b/internal/web/auth.go index 05d6edf..82a1a4b 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -36,15 +36,38 @@ const ( var title = cases.Title(language.English) func register(ctx echo.Context) error { + disableSignup := getData(ctx, "DisableSignup") + disableForm := getData(ctx, "DisableLoginForm") + + code := ctx.QueryParam("code") + if code != "" { + if invitation, err := db.GetInvitationByCode(code); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return errorRes(500, "Cannot check for invitation code", err) + } else if invitation != nil && invitation.IsUsable() { + disableSignup = false + } + } + setData(ctx, "title", tr(ctx, "auth.new-account")) setData(ctx, "htmlTitle", "New account") - setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) + setData(ctx, "disableForm", disableForm) + setData(ctx, "disableSignup", disableSignup) setData(ctx, "isLoginPage", false) return html(ctx, "auth_form.html") } func processRegister(ctx echo.Context) error { - if getData(ctx, "DisableSignup") == true { + disableSignup := getData(ctx, "DisableSignup") + + code := ctx.QueryParam("code") + invitation, err := db.GetInvitationByCode(code) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return errorRes(500, "Cannot check for invitation code", err) + } else if invitation != nil && invitation.IsUsable() { + disableSignup = false + } + + if disableSignup == true { return errorRes(403, "Signing up is disabled", nil) } @@ -90,6 +113,10 @@ func processRegister(ctx echo.Context) error { } } + if err := invitation.Use(); err != nil { + return errorRes(500, "Cannot use invitation", err) + } + sess.Values["user"] = user.ID saveSession(sess, ctx) diff --git a/internal/web/gist.go b/internal/web/gist.go index 698a0da..024c200 100644 --- a/internal/web/gist.go +++ b/internal/web/gist.go @@ -74,21 +74,7 @@ func gistInit(next echo.HandlerFunc) echo.HandlerFunc { } } - httpProtocol := "http" - if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" { - httpProtocol = "https" - } - setData(ctx, "httpProtocol", strings.ToUpper(httpProtocol)) - - var baseHttpUrl string - // if a custom external url is set, use it - if config.C.ExternalUrl != "" { - baseHttpUrl = config.C.ExternalUrl - } else { - baseHttpUrl = httpProtocol + "://" + ctx.Request().Host - } - - setData(ctx, "baseHttpUrl", baseHttpUrl) + baseHttpUrl := getData(ctx, "baseHttpUrl").(string) if config.C.HttpGit { setData(ctx, "httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git") diff --git a/internal/web/server.go b/internal/web/server.go index c24f1d9..701da1c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -271,6 +271,9 @@ func NewServer(isDev bool) *Server { g2.POST("/users/:user/delete", adminUserDelete) g2.GET("/gists", adminGists) g2.POST("/gists/:gist/delete", adminGistDelete) + g2.GET("/invitations", adminInvitations) + g2.POST("/invitations", adminInvitationsCreate) + g2.POST("/invitations/:id/delete", adminInvitationsDelete) g2.POST("/sync-fs", adminSyncReposFromFS) g2.POST("/sync-db", adminSyncReposFromDB) g2.POST("/gc-repos", adminGcRepos) @@ -381,6 +384,22 @@ func dataInit(next echo.HandlerFunc) echo.HandlerFunc { setData(ctx, "giteaOauth", config.C.GiteaClientKey != "" && config.C.GiteaSecret != "") setData(ctx, "oidcOauth", config.C.OIDCClientKey != "" && config.C.OIDCSecret != "" && config.C.OIDCDiscoveryUrl != "") + httpProtocol := "http" + if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" { + httpProtocol = "https" + } + setData(ctx, "httpProtocol", strings.ToUpper(httpProtocol)) + + var baseHttpUrl string + // if a custom external url is set, use it + if config.C.ExternalUrl != "" { + baseHttpUrl = config.C.ExternalUrl + } else { + baseHttpUrl = httpProtocol + "://" + ctx.Request().Host + } + + setData(ctx, "baseHttpUrl", baseHttpUrl) + return next(ctx) } } diff --git a/public/admin.ts b/public/admin.ts index 037803c..878c618 100644 --- a/public/admin.ts +++ b/public/admin.ts @@ -5,6 +5,15 @@ document.addEventListener('DOMContentLoaded', () => { registerDomSetting(elem as HTMLElement) }) } + + let copyInviteButtons = Array.from(document.getElementsByClassName("copy-invitation-link")); + for (let button of copyInviteButtons) { + button.addEventListener('click', () => { + navigator.clipboard.writeText((button as HTMLElement).dataset.link).catch((err) => { + console.error('Could not copy text: ', err); + }); + }) + } }); const setSetting = (key: string, value: string) => { diff --git a/public/main.ts b/public/main.ts index a33f78e..ec8de35 100644 --- a/public/main.ts +++ b/public/main.ts @@ -53,6 +53,22 @@ document.addEventListener('DOMContentLoaded', () => { e.innerHTML = dayjs.unix(parseInt(e.innerHTML)).format('DD/MM/YYYY HH:mm'); }); + document.querySelectorAll('form').forEach((form: HTMLFormElement) => { + form.onsubmit = () => { + form.querySelectorAll('input[type=datetime-local]').forEach((input: HTMLInputElement) => { + console.log(dayjs(input.value).unix()); + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'expiredAtUnix' + hiddenInput.value = dayjs(input.value).unix().toString(); + form.appendChild(hiddenInput); + }); + return true; + }; + }) + + + const rev = document.querySelector('.revision-text'); if (rev) { const fullRev = rev.innerHTML; diff --git a/templates/base/admin_footer.html b/templates/base/admin_footer.html index fa67afe..4799fac 100644 --- a/templates/base/admin_footer.html +++ b/templates/base/admin_footer.html @@ -8,6 +8,7 @@ {{ template "_pagination" . }} {{ end }} + {{ end }} diff --git a/templates/base/admin_header.html b/templates/base/admin_header.html index 2a9f0e8..5f09a70 100644 --- a/templates/base/admin_header.html +++ b/templates/base/admin_header.html @@ -15,6 +15,8 @@ {{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.users" }} {{ .locale.Tr "admin.gists" }} + {{ .locale.Tr "admin.invitations" }} {{ .locale.Tr "admin.configuration" }} diff --git a/templates/pages/admin_config.html b/templates/pages/admin_config.html index 248774b..b04d73e 100644 --- a/templates/pages/admin_config.html +++ b/templates/pages/admin_config.html @@ -120,7 +120,5 @@ - - {{ template "admin_footer" .}} {{ template "footer" .}} diff --git a/templates/pages/admin_invitations.html b/templates/pages/admin_invitations.html new file mode 100644 index 0000000..7e28734 --- /dev/null +++ b/templates/pages/admin_invitations.html @@ -0,0 +1,68 @@ +{{ template "header" .}} +{{ template "admin_header" .}} + +

+ {{ .locale.Tr "admin.invitations.help" }} +

+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ {{ .csrfHtml }} +
+
+
+ + + + + + + + + + + + {{ range $invitation := .invitations }} + + + + + + + + {{ end }} + +
{{ .locale.Tr "admin.invitations.code" }}{{ .locale.Tr "admin.invitations.copy_link" }}{{ .locale.Tr "admin.invitations.uses" }}{{ .locale.Tr "admin.invitations.expires_at" }} + {{ .locale.Tr "admin.delete" }} +
{{ $invitation.Code }} + {{ if $invitation.IsUsable }} + + + + + + {{ else }} + {{ $.locale.Tr "admin.invitations.expired" }} + {{ end }} + {{ $invitation.NbUsed }}/{{ $invitation.NbMax }}{{ $invitation.ExpiresAt }} +
+ {{ $.csrfHtml }} + +
+
+
+ +{{ template "admin_footer" .}} +{{ template "footer" .}} diff --git a/templates/pages/auth_form.html b/templates/pages/auth_form.html index d4b403f..c808714 100644 --- a/templates/pages/auth_form.html +++ b/templates/pages/auth_form.html @@ -8,7 +8,7 @@
- {{ if and .DisableSignup (not .isLoginPage) }} + {{ if .disableSignup }}

{{ .locale.Tr "auth.signup-disabled" }}

{{ else }}