From 5aa8f3e9489bd14435c8049c3761c7f9d6c9faf8 Mon Sep 17 00:00:00 2001 From: Thomas Miceli Date: Thu, 7 Mar 2024 18:30:02 +0100 Subject: [PATCH 1/4] invites WIP --- internal/db/db.go | 2 +- internal/db/invitation.go | 87 ++++++++++++++++++++++++++ internal/i18n/locales/en-US.yml | 2 + internal/web/admin.go | 66 +++++++++++++++++++ internal/web/auth.go | 31 ++++++++- internal/web/server.go | 3 + templates/base/admin_header.html | 2 + templates/pages/admin_invitations.html | 56 +++++++++++++++++ templates/pages/auth_form.html | 2 +- 9 files changed, 247 insertions(+), 4 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 0e30628d..5cb57091 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 00000000..821938b8 --- /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.Rand = 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 092dec14..80578db3 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -163,6 +163,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 diff --git a/internal/web/admin.go b/internal/web/admin.go index a4385107..003bbf90 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -1,6 +1,7 @@ package web import ( + "fmt" "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/actions" "github.com/thomiceli/opengist/internal/config" @@ -8,6 +9,7 @@ import ( "github.com/thomiceli/opengist/internal/git" "runtime" "strconv" + "time" ) func adminIndex(ctx echo.Context) error { @@ -179,3 +181,67 @@ 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 + } + expiresAt := ctx.FormValue("expiresAt") + var expiresAtUnix int64 + fmt.Println(expiresAt) + if expiresAt == "" { + expiresAtUnix = time.Now().Add(7 * 24 * time.Hour).Unix() + } else { + parsedDate, err := time.Parse("2006-01-02T15:04", expiresAt) + if err != nil { + return errorRes(400, "Invalid date format", err) + } + parsedDateUTC := time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), parsedDate.Hour(), parsedDate.Minute(), 0, 0, time.Local) + expiresAtUnix = parsedDateUTC.Unix() + } + + 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 ece90678..5cc3872b 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/server.go b/internal/web/server.go index 2aaea44f..5ebc4007 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -260,6 +260,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) diff --git a/templates/base/admin_header.html b/templates/base/admin_header.html index 2a9f0e88..5f09a705 100644 --- a/templates/base/admin_header.html +++ b/templates/base/admin_header.html @@ -15,6 +15,8 @@

{{ .locale.Tr "admin.admin_panel" } {{ 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_invitations.html b/templates/pages/admin_invitations.html new file mode 100644 index 00000000..d1919c52 --- /dev/null +++ b/templates/pages/admin_invitations.html @@ -0,0 +1,56 @@ +{{ template "header" .}} +{{ template "admin_header" .}} + +

+ Invitations can be used to create an account even if signing up is disabled. +

+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ {{ .csrfHtml }} +
+
+
+ + + + + + + + + + + {{ range $invitation := .invitations }} + + + + + + + {{ end }} + +
CodeUsesExpires at + {{ .locale.Tr "admin.delete" }} +
{{ $invitation.Code }}{{ $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 8bc1350d..a0cb2fd2 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 }}
From 0e228d0068a2372140e6dcd23499d29fc950c669 Mon Sep 17 00:00:00 2001 From: Thomas Miceli Date: Wed, 20 Mar 2024 15:36:34 +0100 Subject: [PATCH 2/4] Send unix timestamp --- internal/web/admin.go | 17 ++++------------- public/main.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/internal/web/admin.go b/internal/web/admin.go index 003bbf90..f660ba44 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -1,7 +1,6 @@ package web import ( - "fmt" "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/actions" "github.com/thomiceli/opengist/internal/config" @@ -203,18 +202,10 @@ func adminInvitationsCreate(ctx echo.Context) error { if err != nil { nbMax = 10 } - expiresAt := ctx.FormValue("expiresAt") - var expiresAtUnix int64 - fmt.Println(expiresAt) - if expiresAt == "" { - expiresAtUnix = time.Now().Add(7 * 24 * time.Hour).Unix() - } else { - parsedDate, err := time.Parse("2006-01-02T15:04", expiresAt) - if err != nil { - return errorRes(400, "Invalid date format", err) - } - parsedDateUTC := time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), parsedDate.Hour(), parsedDate.Minute(), 0, 0, time.Local) - expiresAtUnix = parsedDateUTC.Unix() + + expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64) + if err != nil { + expiresAtUnix = time.Now().Unix() + 604800 // 1 week } invitation := &db.Invitation{ diff --git a/public/main.ts b/public/main.ts index a33f78e1..ec8de353 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; From dfb363f45b0c381eab63538e5a1c7d39c9b733c9 Mon Sep 17 00:00:00 2001 From: Thomas Miceli Date: Fri, 29 Mar 2024 15:00:15 +0100 Subject: [PATCH 3/4] Copy link, dark mode --- internal/db/invitation.go | 2 +- internal/i18n/locales/en-US.yml | 8 +++++++ internal/web/gist.go | 16 +------------ internal/web/server.go | 16 +++++++++++++ public/admin.ts | 9 ++++++++ templates/base/admin_footer.html | 1 + templates/pages/admin_config.html | 2 -- templates/pages/admin_invitations.html | 32 ++++++++++++++++++-------- 8 files changed, 58 insertions(+), 28 deletions(-) diff --git a/internal/db/invitation.go b/internal/db/invitation.go index 821938b8..f85fc836 100644 --- a/internal/db/invitation.go +++ b/internal/db/invitation.go @@ -77,7 +77,7 @@ func (i *Invitation) Use() error { func generateRandomCode() string { const charset = "0123456789ABCDEF" - var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) + var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) result := make([]byte, 16) for i := range result { diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 80578db3..daa3f906 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -198,3 +198,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/gist.go b/internal/web/gist.go index 47c36e49..078145c2 100644 --- a/internal/web/gist.go +++ b/internal/web/gist.go @@ -73,21 +73,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 5ebc4007..578e0e61 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -356,6 +356,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 037803c3..878c6188 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/templates/base/admin_footer.html b/templates/base/admin_footer.html index fa67afe7..4799fac9 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/pages/admin_config.html b/templates/pages/admin_config.html index b387c999..9a1e57ce 100644 --- a/templates/pages/admin_config.html +++ b/templates/pages/admin_config.html @@ -118,7 +118,5 @@ - - {{ template "admin_footer" .}} {{ template "footer" .}} diff --git a/templates/pages/admin_invitations.html b/templates/pages/admin_invitations.html index d1919c52..fba6c27f 100644 --- a/templates/pages/admin_invitations.html +++ b/templates/pages/admin_invitations.html @@ -2,17 +2,17 @@ {{ template "admin_header" .}}

- Invitations can be used to create an account even if signing up is disabled. + {{ .locale.Tr "admin.invitations.help" }}

- +
- +
@@ -26,9 +26,10 @@

- - - + + + + @@ -36,10 +37,21 @@

{{ range $invitation := .invitations }} - - - - + + + + +
CodeUsesExpires at{{ .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 }}{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}{{ $invitation.ExpiresAt }}
{{ $invitation.Code }} + {{ if $invitation.IsUsable }} + + + + + + {{ else }} + {{ .locale.Tr "admin.invitations.expired" }} + {{ end }} + {{ $invitation.NbUsed }}/{{ $invitation.NbMax }}{{ $invitation.ExpiresAt }} {{ $.csrfHtml }} From 85066ca95419da0c3a9bfff7065ded5afe98793d Mon Sep 17 00:00:00 2001 From: Thomas Miceli Date: Tue, 2 Apr 2024 17:22:55 +0200 Subject: [PATCH 4/4] Fix tr --- templates/pages/admin_invitations.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/pages/admin_invitations.html b/templates/pages/admin_invitations.html index fba6c27f..7e287348 100644 --- a/templates/pages/admin_invitations.html +++ b/templates/pages/admin_invitations.html @@ -47,7 +47,7 @@

{{ else }} - {{ .locale.Tr "admin.invitations.expired" }} + {{ $.locale.Tr "admin.invitations.expired" }} {{ end }}

{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}