From 8a6ed9cef3d4b4bbba6885c99536f69da7f8145e Mon Sep 17 00:00:00 2001 From: poyo Date: Thu, 30 Jul 2026 22:27:05 +0200 Subject: [PATCH] Invite system! --- .gitignore | 3 +- DEVELOPMENT.md | 2 +- app/app.go | 4 ++ app/auth.go | 58 +++++++++++++++--- app/invites.go | 68 ++++++++++++++++++++++ app/repo-new.go | 2 +- app/store.go | 8 ++- config/config.go | 48 ++++++++++----- db/db.go | 2 + db/invites.go | 59 +++++++++++++++++++ db/oauth.go | 2 +- db/types/types.go | 18 ++++++ example.env | 3 +- lib/constants.go | 1 - lib/crypto.go | 14 ++++- lib/oauth2/hackatime.go | 4 +- lib/validation.go | 13 +++++ templating/templating.go | 2 +- templating/views/index.html | 4 +- templating/views/invite/generated.html | 5 ++ templating/views/invite/invalid_token.html | 4 ++ templating/views/invite/new.html | 8 +++ templating/views/register.html | 25 ++++---- 23 files changed, 312 insertions(+), 45 deletions(-) create mode 100644 app/invites.go create mode 100644 db/invites.go create mode 100644 templating/views/invite/generated.html create mode 100644 templating/views/invite/invalid_token.html create mode 100644 templating/views/invite/new.html diff --git a/.gitignore b/.gitignore index 8139211..0374752 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env -farga \ No newline at end of file +farga +data/* \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b17892..d86cae0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -29,7 +29,7 @@ md.ValidateForm[LoginForm](http.HandlerFunc(a.login)) // Use handle, not handleF and retrieve the form using ```go -form, _ := md.Form[LoginForm](r) +form, ok := md.Form[LoginForm](r) ``` Bear in mind forms have that special property because of `contextKey`. diff --git a/app/app.go b/app/app.go index 24a4980..f93844a 100644 --- a/app/app.go +++ b/app/app.go @@ -64,7 +64,11 @@ func New(store Store, config config.Config) *App { auth.Handle("/login", md.ExcludeAuth(md.ValidateForm[LoginForm](a.Login))) auth.Handle("/register", md.ExcludeAuth(md.ValidateForm[RegisterForm](a.Register))) + auth.Handle("/register/{token}", md.ExcludeAuth(md.ValidateForm[RegisterForm](http.HandlerFunc(a.Register)))) auth.Handle("/logout", md.RequireAuth(http.HandlerFunc(a.Logout))) + + auth.Handle("/invite", http.HandlerFunc(a.InviteHandler)).Methods("GET") + auth.Handle("/invite", md.RequireAuth(http.HandlerFunc(a.NewInviteHandler))).Methods("POST") auth.Handle("/hackatime", md.RequireAuth(http.HandlerFunc(a.HackatimeLogin))) auth.Handle("/hackatime/callback", md.RequireAuth(http.HandlerFunc(a.HackatimeCallback))) diff --git a/app/auth.go b/app/auth.go index efe25bf..352fac1 100644 --- a/app/auth.go +++ b/app/auth.go @@ -3,19 +3,22 @@ package app import ( "errors" md "farga/app/middleware" - "farga/templating" db "farga/db" "farga/db/types" "farga/lib" + "farga/templating" "net/http" "strings" + + "github.com/gorilla/mux" ) type RegisterForm struct { - Name string `form:"name" validate:"required,max=40"` - Handle string `form:"handle" validate:"required,min=3,max=30"` - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required,min=8"` + Name string `form:"name" validate:"required,min=2,max=40"` + Handle string `form:"handle" validate:"required,min=3,max=30"` + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required,min=8"` + InviteToken string `form:"invite-token"` } type LoginForm struct { @@ -50,32 +53,64 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) { } func (a *App) Register(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) switch r.Method { case "GET": - if a.Config.AllowSignups { + inviteToken := vars["token"] + + if a.Config.AllowSignups { // if signups are allowed templating.Render(w, r, "base", "register", map[string]any{ "HandleRegex": lib.HandleRegex.String(), }) - } else { + } else if inviteToken != "" { // if signups are disallowed, but the user has an inviteToken + if a.Store.ValidateInviteToken(inviteToken) { + // allow user to register + templating.Render(w, r, "base", "register", map[string]any{ + "HandleRegex": lib.HandleRegex.String(), + "InviteToken": inviteToken, + }) + } else { + // token is invvalid/expired + templating.Render(w, r, "base", "invite/invalid_token") + } + } else { // if neither, just reject them. templating.Render(w, r, "base", "invite_only") } case "POST": - if !a.Config.AllowSignups { + form, ok := md.Form[RegisterForm](r) + if !ok { + http.Error(w, "Internal error.", http.StatusInternalServerError) + return + } + + if !a.Config.AllowSignups && form.InviteToken == "" { http.Redirect(w, r, "/auth/register?err=Forbidden", http.StatusSeeOther) return } - form, _ := md.Form[RegisterForm](r) + + if form.InviteToken != "" { + if !a.Store.ValidateInviteToken(form.InviteToken) { + templating.Render(w, r, "base", "invite/invalid_token") + return + } + } if !lib.ValidateHandle(form.Handle) { http.Redirect(w, r, "?err=UserInvalidHandle", http.StatusSeeOther) return } + if !lib.ValidateUsername(form.Name) { + http.Redirect(w, r, "?err=UserInvalidHandle", http.StatusSeeOther) + return + } + password, err := lib.GenerateHash(form.Password) if err != nil { http.Error(w, "Something went wrong while generating your password's hash. Please try again!", 500) return } + user := types.User{ Name: strings.TrimSpace(form.Name), Handle: strings.TrimSpace(form.Handle), @@ -103,6 +138,11 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) { lib.SetCookie(w, "session", session.ID.String()) + if form.InviteToken != "" { + // delete the token as it has been used. + a.Store.DeleteInviteToken(form.InviteToken) + } + http.Redirect(w, r, "/?msg=AuthSuccess", http.StatusTemporaryRedirect) } } diff --git a/app/invites.go b/app/invites.go new file mode 100644 index 0000000..f02e65f --- /dev/null +++ b/app/invites.go @@ -0,0 +1,68 @@ +package app + +import ( + md "farga/app/middleware" + "farga/db/types" + "farga/lib" + "farga/templating" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +func (a *App) InviteHandler(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(lib.UserKey).(*types.User) + if !ok || user.ID == uuid.Nil { + // render "input invite code" page + templating.Render(w, r, "base", "invite/redeem") + return + } + + if user.Role == types.RoleAdmin { + // generate invite + templating.Render(w, r, "base", "invite/new") + return + } else { + // not allowed to invite + http.Redirect(w, r, "/?err=Forbidden", http.StatusSeeOther) + return + } +} + +// creates an invite, only allowing an admin to create one. +func (a *App) NewInviteHandler(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(lib.UserKey).(*types.User) + if !ok || user.ID == uuid.Nil || user.Role != types.RoleAdmin { + fmt.Println(user) + http.Redirect(w, r, "/?err=Forbidden", http.StatusSeeOther) + return + } + + invite, err := a.Store.NewInvite(user.ID) + if err != nil { + http.Error(w, "We could not generate an invite.", http.StatusInternalServerError) + return + } + + inviteLink := fmt.Sprintf("https://%s/auth/invite/%s", a.Config.Domain, invite.Token) + + templating.Render(w, r, "base", "invite/generated", map[string]any{ + "InviteLink": inviteLink, + "InviteToken": invite.Token, + }) +} + +type RedeemTokenForm struct { + Token string `form:"token" validate:"required"` +} + +func (a *App) RedeemInviteHandler(w http.ResponseWriter, r *http.Request) { + form, ok := md.Form[RedeemTokenForm](r) + if !ok { + http.Error(w, "Invalid form.", http.StatusBadRequest) + return + } + + http.Redirect(w, r, "/auth/register?token="+form.Token, http.StatusSeeOther) +} diff --git a/app/repo-new.go b/app/repo-new.go index c464864..105176d 100644 --- a/app/repo-new.go +++ b/app/repo-new.go @@ -13,7 +13,7 @@ import ( ) type CreateRepoForm struct { - Name string `form:"name" validate:"required,max=40"` + Name string `form:"name" validate:"required,min=1,max=40"` Description string `form:"description" validate:"max=150"` Visibility string `form:"visibility" validate:"required,oneof=public private"` } diff --git a/app/store.go b/app/store.go index 489a054..97a83c8 100644 --- a/app/store.go +++ b/app/store.go @@ -12,7 +12,7 @@ type Store interface { NewSession(userID *uuid.UUID) (types.Session, error) RemoveSession(sessionID string) error Session(token uuid.UUID) (types.Session, error) - + NewUser(user *types.User) error LoginUser(email, password string) (*types.User, error) User(id uuid.UUID) (*types.User, error) @@ -21,6 +21,11 @@ type Store interface { SaveHackatimeToken(userID uuid.UUID, token string) error UpdateUserConfig(userId uuid.UUID, config types.UserConfig) error + NewInvite(userID uuid.UUID) (types.Invite, error) + ValidateInviteToken(token string) bool + DeleteInviteToken(token string) error + RemoveExpiredInviteTokens() + NewRepo(user *types.User, repo *types.Repo) (*git.Repository, error) SetRepoHackatimeName(repoID uuid.UUID, name string) error SetRepoVisibility(repoID uuid.UUID, public bool) error @@ -38,4 +43,5 @@ type Store interface { CreateOAuthState(id uuid.UUID) (*types.OAuthState, error) OAuthState(token string) (types.OAuthState, error) RemoveOAuthState(id uuid.UUID) error + RemoveExpiredOAuthStates() } diff --git a/config/config.go b/config/config.go index 461ff8a..1eb584c 100644 --- a/config/config.go +++ b/config/config.go @@ -4,21 +4,25 @@ import ( "log" "os" "strconv" + "time" "github.com/joho/godotenv" ) type Config struct { SQLitePath string - CSRFKey string + CSRFKey string // random string at least 32 bytes long. - Domain string + Domain string // domain used both for the frontend and SSH Port uint SSHPort uint AllowSignups bool - HACKATIME_CLIENT_ID string - HACKATIME_CLIENT_SECRET string + InviteTokenLength uint // length of token. 6 -> "abc123" + InviteTokenDuration time.Duration // how much an invite should last: 7d or 4h or 2h45m + + HackatimeClientID string + HackatimeClientSecret string } var C Config @@ -37,6 +41,7 @@ func New() Config { ALLOW_SIGNUPS := os.Getenv("ALLOW_SIGNUPS") HACKATIME_CLIENT_ID := os.Getenv("HACKATIME_CLIENT_ID") HACKATIME_CLIENT_SECRET := os.Getenv("HACKATIME_CLIENT_SECRET") + INVITE_TOKEN_LENGTH := os.Getenv("INVITE_TOKEN_LENGTH") if DOMAIN == "" || PORT == "" || CSRF_KEY == "" || SQLITE_PATH == "" { log.Fatal("You're missing one (or more) required env vars: 'SQLITE_PATH, DOMAIN, PORT, CSRF_KEY'.") @@ -48,10 +53,19 @@ func New() Config { } SSHPort, err := strconv.ParseUint(SSH_PORT, 10, 32) - if Port == 0 || err != nil { + if SSHPort == 0 || err != nil { log.Fatal("Invalid SSH_PORT env var.") } + var InviteTokenLength uint64 = 6 + + if INVITE_TOKEN_LENGTH != "" { + InviteTokenLength, err = strconv.ParseUint(INVITE_TOKEN_LENGTH, 10, 32) + if InviteTokenLength == 0 || err != nil { + log.Fatal("Invalid INVITE_TOKEN_LENGTH env var.") + } + } + var AllowSignups bool if ALLOW_SIGNUPS != "" { @@ -61,15 +75,23 @@ func New() Config { } } + // 24 hours * 2 days = 48 hours + InviteTokenDuration, err := time.ParseDuration("48h") // todo: check if not negative + if err != nil { + log.Fatal("Invalid token duration value.") + } + C = Config{ - SQLitePath: SQLITE_PATH, - AllowSignups: AllowSignups, - Port: uint(Port), - SSHPort: uint(SSHPort), - CSRFKey: CSRF_KEY, - Domain: DOMAIN, - HACKATIME_CLIENT_ID: HACKATIME_CLIENT_ID, - HACKATIME_CLIENT_SECRET: HACKATIME_CLIENT_SECRET, + SQLitePath: SQLITE_PATH, + AllowSignups: AllowSignups, + Port: uint(Port), + SSHPort: uint(SSHPort), + CSRFKey: CSRF_KEY, + Domain: DOMAIN, + HackatimeClientID: HACKATIME_CLIENT_ID, + HackatimeClientSecret: HACKATIME_CLIENT_SECRET, + InviteTokenLength: uint(InviteTokenLength), + InviteTokenDuration: InviteTokenDuration, } return C diff --git a/db/db.go b/db/db.go index 6a1da80..a4c9935 100644 --- a/db/db.go +++ b/db/db.go @@ -30,6 +30,7 @@ func New() (*DB, error) { db.AutoMigrate(&types.User{}) db.AutoMigrate(&types.UserConfig{}) + db.AutoMigrate(&types.Invite{}) db.AutoMigrate(&types.Session{}) db.AutoMigrate(&types.Repo{}) db.AutoMigrate(&types.RepoConfig{}) @@ -48,5 +49,6 @@ func (db *DB) CleanupTicker() { for range ticker.C { db.RemoveExpiredSessions() db.RemoveExpiredOAuthStates() + db.RemoveExpiredInviteTokens() } } diff --git a/db/invites.go b/db/invites.go new file mode 100644 index 0000000..ba3dd5f --- /dev/null +++ b/db/invites.go @@ -0,0 +1,59 @@ +package db + +import ( + "farga/config" + "farga/db/types" + "farga/lib" + "time" + + "github.com/google/uuid" +) + +// creates a temporary new invite token. +func (db *DB) NewInvite(userID uuid.UUID) (types.Invite, error) { + var invite = types.Invite{ + UserID: userID, + Token: lib.GenerateToken(config.C.InviteTokenLength), + ExpiresAt: time.Now().Add(config.C.InviteTokenDuration), + } + + err := db.Create(&invite).Error + return invite, err +} + +// validates a given invite token. Returns `true` if it exists and isn't expired. Otherwise false. +// it will ONLY delete the invite token if it expired. +func (db *DB) ValidateInviteToken(token string) bool { + var invite = types.Invite{ + Token: token, + } + err := db.Model(&invite).Where(&invite).Find(&invite).Error + + // if it doesn't exist + if err != nil || invite.ID == uuid.Nil { + return false + } + + // if it expired + if time.Now().After(invite.ExpiresAt) { + db.Delete(&invite) + return false + } + + return true +} + +// deletes a given invite token. +func (db *DB) DeleteInviteToken(token string) error { + var invite = types.Invite{ + Token: token, + } + + err := db.Model(&invite).Where(&invite).Delete(&invite).Error + return err +} + +// checks invite tokens' expiresAt and if they expired, it removes them +func (db *DB) RemoveExpiredInviteTokens() { + db.Where("expires_at < ?", time.Now()).Delete(&types.Invite{}) +} diff --git a/db/oauth.go b/db/oauth.go index 18ff934..a7a85f4 100644 --- a/db/oauth.go +++ b/db/oauth.go @@ -42,7 +42,7 @@ func (db *DB) CreateOAuthState(id uuid.UUID) (*types.OAuthState, error) { return oauthState, nil } -// checks states' expiresAt and they expired, it removes them +// checks states' expiresAt and if they expired, it removes them func (db *DB) RemoveExpiredOAuthStates() { db.Where("expires_at < ?", time.Now()).Delete(&types.OAuthState{}) } diff --git a/db/types/types.go b/db/types/types.go index 1907491..4d3442e 100644 --- a/db/types/types.go +++ b/db/types/types.go @@ -18,6 +18,14 @@ func (u *UUID_ID) BeforeCreate(tx *gorm.DB) (err error) { return } +// admin / regular. DO NOT SET DIRECTLY. Use StatusAdmin and StatusUser. +type Role int + +const ( + RoleUser Role = iota // 0 + RoleAdmin // 1 +) + type User struct { UUID_ID @@ -26,6 +34,8 @@ type User struct { Email string `gorm:"uniqueIndex;not null"` Bio string + Role Role `gorm:"default:0"` // default is RoleUser (0) + PasswordHash string `gorm:"not null"` HackatimeToken string @@ -131,3 +141,11 @@ type OAuthState struct { ExpiresAt time.Time `gorm:"index;not null"` CreatedAt time.Time } + +type Invite struct { + UUID_ID + UserID uuid.UUID `gorm:"not null;"` + Token string `gorm:"size:16;not null"` + ExpiresAt time.Time `gorm:"index;not null"` + CreatedAt time.Time +} diff --git a/example.env b/example.env index 418860a..ed54977 100644 --- a/example.env +++ b/example.env @@ -5,4 +5,5 @@ SSH_PORT=6969 HACKATIME_CLIENT_ID=# https://hackatime.hackclub.com/docs/oauth/oauth-apps HACKATIME_CLIENT_SECRET=# https://hackatime.hackclub.com/docs/oauth/oauth-apps CSRF_KEY=Sup3r-s3cr3t-l0ng-w1th-numbers-key-h3r3 -ALLOW_SIGNUPS=false \ No newline at end of file +ALLOW_SIGNUPS=false +INVITE_TOKEN_LENGTH=8# optional \ No newline at end of file diff --git a/lib/constants.go b/lib/constants.go index 6ff37e6..398f0f5 100644 --- a/lib/constants.go +++ b/lib/constants.go @@ -18,7 +18,6 @@ const GitHashLength = 8 const SSHPort = 6969 const SSHHostKeyPath = "data/host_key" - const IssueTitleMaxLen = 100 const IssueDescriptionMaxLen = 2000 // characters const IssueCommentMaxLen = 3000 diff --git a/lib/crypto.go b/lib/crypto.go index 7639658..dd7b95d 100644 --- a/lib/crypto.go +++ b/lib/crypto.go @@ -1,11 +1,11 @@ package lib import ( - "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" + "math/rand" "strings" "golang.org/x/crypto/argon2" @@ -38,6 +38,7 @@ func GenerateSalt(length int) ([]byte, error) { return secret, nil } +// generates an argon2id hash func GenerateHash(password string) (string, error) { var err error config := &Argon2idConfig{ @@ -80,6 +81,17 @@ func ValidateHash(password string, encoded_hash string) (match bool, err error) return false, nil } +const TokenLetters = "abcdefghijklmnopqrstuvwxyz0123456789" + +// generates an alphanumeric token with a given length. +func GenerateToken(length uint) string { + b := make([]byte, length) + for i := range b { + b[i] = TokenLetters[rand.Intn(len(TokenLetters))] + } + return string(b) +} + func DecodeHash(encodedHash string) (p *Argon2idConfig, salt, hash []byte, err error) { vals := strings.Split(encodedHash, "$") if len(vals) != 6 { diff --git a/lib/oauth2/hackatime.go b/lib/oauth2/hackatime.go index 2405818..4226e87 100644 --- a/lib/oauth2/hackatime.go +++ b/lib/oauth2/hackatime.go @@ -9,8 +9,8 @@ import ( func HackatimeConfig(config config.Config) *oauth2.Config { return &oauth2.Config{ - ClientID: config.HACKATIME_CLIENT_ID, - ClientSecret: config.HACKATIME_CLIENT_SECRET, + ClientID: config.HackatimeClientID, + ClientSecret: config.HackatimeClientSecret, RedirectURL: fmt.Sprintf("http://%s:%d/auth/hackatime/callback", config.Domain, config.Port), Scopes: []string{ diff --git a/lib/validation.go b/lib/validation.go index 8f4da94..268bbbd 100644 --- a/lib/validation.go +++ b/lib/validation.go @@ -7,6 +7,9 @@ var HandleRegex = regexp.MustCompile("^[a-z0-9]+([-_][a-z0-9_]+)*$") const HandleMinLength = 3 const HandleMaxLength = 30 +const UsernameMinLength = 2 +const UsernameMaxLength = 40 + // validates a handle. func ValidateHandle(s string) bool { // check if the format is correct using the regex @@ -22,3 +25,13 @@ func ValidateHandle(s string) bool { return true } + +// validates a handle. +func ValidateUsername(s string) bool { + length := len(s) + if length < UsernameMinLength || length > UsernameMaxLength { + return false + } + + return true +} diff --git a/templating/templating.go b/templating/templating.go index c210ea0..073418d 100644 --- a/templating/templating.go +++ b/templating/templating.go @@ -19,7 +19,7 @@ import ( "github.com/gorilla/csrf" ) -//go:embed views/* +//go:embed views var templatesFS embed.FS var FuncMap = template.FuncMap{ diff --git a/templating/views/index.html b/templating/views/index.html index b130e7e..5c7e125 100644 --- a/templating/views/index.html +++ b/templating/views/index.html @@ -27,8 +27,8 @@

Farga is a work-in-progress Git forge. Maybe try logging in?

Features.