diff --git a/handlers/syndicate.go b/handlers/syndicate.go index 8b21278..9f0a574 100644 --- a/handlers/syndicate.go +++ b/handlers/syndicate.go @@ -56,7 +56,6 @@ func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc { managed := storage.GetSyndicatesByOwner(db, userID) member := storage.GetSyndicatesByMember(db, userID) - // Filter out syndicates where the user is both the owner and a member managedMap := make(map[int]bool) for _, s := range managed { managedMap[s.ID] = true @@ -181,7 +180,6 @@ func SyndicateTicketsHandler(db *sql.DB) http.HandlerFunc { return } - // Check membership if !storage.IsSyndicateMember(db, syndicateID, userID) { helpers.RenderError(w, r, 403) return diff --git a/handlers/syndicate_invites.go b/handlers/syndicate_invites.go index 364b392..d948995 100644 --- a/handlers/syndicate_invites.go +++ b/handlers/syndicate_invites.go @@ -2,8 +2,11 @@ package handlers import ( "database/sql" + "fmt" "net/http" "strconv" + "time" + "synlotto-website/helpers" "synlotto-website/storage" ) @@ -89,3 +92,104 @@ func DeclineInviteHandler(db *sql.DB) http.HandlerFunc { http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther) } } + +func CreateInviteToken(db *sql.DB, syndicateID, invitedByID int, ttlHours int) (string, error) { + token, err := helpers.GenerateSecureToken() + if err != nil { + return "", err + } + + expires := time.Now().Add(time.Duration(ttlHours) * time.Hour) + + _, err = db.Exec(` + INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at) + VALUES (?, ?, ?, ?) + `, syndicateID, token, invitedByID, expires) + + return token, err +} + +func AcceptInviteToken(db *sql.DB, token string, userID int) error { + var syndicateID int + var expiresAt, acceptedAt sql.NullTime + err := db.QueryRow(` + SELECT syndicate_id, expires_at, accepted_at + FROM syndicate_invite_tokens + WHERE token = ? + `, token).Scan(&syndicateID, &expiresAt, &acceptedAt) + if err != nil { + return fmt.Errorf("invalid or expired token") + } + if acceptedAt.Valid || expiresAt.Time.Before(time.Now()) { + return fmt.Errorf("token already used or expired") + } + + _, err = db.Exec(` + INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at) + VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP) + `, syndicateID, userID) + if err != nil { + return err + } + + _, err = db.Exec(` + UPDATE syndicate_invite_tokens + SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP + WHERE token = ? + `, userID, token) + return err +} + +func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := helpers.GetCurrentUserID(r) + if !ok { + helpers.RenderError(w, r, http.StatusForbidden) + return + } + + syndicateID := helpers.Atoi(r.URL.Query().Get("id")) + token, err := CreateInviteToken(db, syndicateID, userID, 48) // token valid for 48 hours + if err != nil { + helpers.SetFlash(w, r, "Failed to generate invite link.") + http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther) + return + } + + origin := r.Host + if r.TLS != nil { + origin = "https://" + origin + } else { + origin = "http://" + origin + } + inviteLink := fmt.Sprintf("%s/syndicate/join?token=%s", origin, token) + + helpers.SetFlash(w, r, "Invite link created: "+inviteLink) + http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther) + } +} + +func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := helpers.GetCurrentUserID(r) + if !ok { + helpers.RenderError(w, r, http.StatusForbidden) + return + } + + token := r.URL.Query().Get("token") + if token == "" { + helpers.SetFlash(w, r, "Invalid or missing invite token.") + http.Redirect(w, r, "/syndicate", http.StatusSeeOther) + return + } + + err := AcceptInviteToken(db, token, userID) + if err != nil { + helpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error()) + } else { + helpers.SetFlash(w, r, "You have joined the syndicate!") + } + http.Redirect(w, r, "/syndicate", http.StatusSeeOther) + } +} diff --git a/helpers/token.go b/helpers/token.go new file mode 100644 index 0000000..5391a84 --- /dev/null +++ b/helpers/token.go @@ -0,0 +1,15 @@ +package helpers + +import ( + "crypto/rand" + "encoding/hex" +) + +func GenerateSecureToken() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/main.go b/main.go index a1857bd..268b186 100644 --- a/main.go +++ b/main.go @@ -92,5 +92,7 @@ func setupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) { mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(handlers.ViewInvitesHandler(db))) mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(handlers.AcceptInviteHandler(db))) mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(handlers.DeclineInviteHandler(db))) + mux.HandleFunc("/syndicate/invite/token", middleware.Auth(true)(handlers.GenerateInviteLinkHandler(db))) + mux.HandleFunc("/syndicate/join", middleware.Auth(true)(handlers.JoinSyndicateWithTokenHandler(db))) } diff --git a/storage/schema.go b/storage/schema.go index ec01fea..4f841f7 100644 --- a/storage/schema.go +++ b/storage/schema.go @@ -209,3 +209,18 @@ CREATE TABLE IF NOT EXISTS syndicate_invites ( FOREIGN KEY(syndicate_id) REFERENCES syndicates(id), FOREIGN KEY(invited_user_id) REFERENCES users(id) );` + +const SchemaSyndicateInviteTokens = ` +CREATE TABLE IF NOT EXISTS syndicate_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + syndicate_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + invited_by_user_id INTEGER NOT NULL, + accepted_by_user_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + accepted_at TIMESTAMP, + expires_at TIMESTAMP, + FOREIGN KEY (syndicate_id) REFERENCES syndicates(id), + FOREIGN KEY (invited_by_user_id) REFERENCES users(id), + FOREIGN KEY (accepted_by_user_id) REFERENCES users(id) +);` diff --git a/templates/syndicate/view.html b/templates/syndicate/view.html index 6519351..7785391 100644 --- a/templates/syndicate/view.html +++ b/templates/syndicate/view.html @@ -20,9 +20,15 @@ Manager Controls
You can add or remove members, and manage tickets. + Invite Members + +
+ {{ .CSRFField }} + +
+ + {{ if .Flash }} +
{{ .Flash }}
+ {{ end }} {{ end }} - - ← Back to Syndicates - -{{ end }}