Skip to content

Commit

Permalink
Create invitations for closed registrations (thomiceli#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
thomiceli committed Apr 2, 2024
1 parent 3f5f4e0 commit ef00467
Show file tree
Hide file tree
Showing 14 changed files with 301 additions and 21 deletions.
2 changes: 1 addition & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
87 changes: 87 additions & 0 deletions internal/db/invitation.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions internal/i18n/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
57 changes: 57 additions & 0 deletions internal/web/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/thomiceli/opengist/internal/git"
"runtime"
"strconv"
"time"
)

func adminIndex(ctx echo.Context) error {
Expand Down Expand Up @@ -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")
}
31 changes: 29 additions & 2 deletions internal/web/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)

Expand Down
16 changes: 1 addition & 15 deletions internal/web/gist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ":https://" + ctx.Request().Host
}

setData(ctx, "baseHttpUrl", baseHttpUrl)
baseHttpUrl := getData(ctx, "baseHttpUrl").(string)

if config.C.HttpGit {
setData(ctx, "httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git")
Expand Down
19 changes: 19 additions & 0 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 + ":https://" + ctx.Request().Host
}

setData(ctx, "baseHttpUrl", baseHttpUrl)

return next(ctx)
}
}
Expand Down
9 changes: 9 additions & 0 deletions public/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
16 changes: 16 additions & 0 deletions public/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.revision-text');
if (rev) {
const fullRev = rev.innerHTML;
Expand Down
1 change: 1 addition & 0 deletions templates/base/admin_footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{{ template "_pagination" . }}
</div>
{{ end }}
<script src="{{ asset "admin.ts" }}"></script>
</main>
</div>
{{ end }}
2 changes: 2 additions & 0 deletions templates/base/admin_header.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h1 class="text-2xl font-bold leading-tight">{{ .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" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/gists" class="{{ if eq .adminHeaderPage "gists" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ 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.gists" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/invitations" class="{{ if eq .adminHeaderPage "invitations" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ 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.invitations" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/configuration" class="{{ if eq .adminHeaderPage "config" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ 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.configuration" }}</a>
</nav>
Expand Down
2 changes: 0 additions & 2 deletions templates/pages/admin_config.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,5 @@
</div>
</div>

<script type="module" src="{{ asset "admin.ts" }}"></script>

{{ template "admin_footer" .}}
{{ template "footer" .}}
Loading

0 comments on commit ef00467

Please sign in to comment.