package invite import ( "crypto/rand" "database/sql" "encoding/json" "errors" "math/big" "net/http" "time" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) const codeLength = 8 const codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" type Handler struct { db *sql.DB } func NewHandler(db *sql.DB) *Handler { return &Handler{db: db} } func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/servers/{serverID}/invites", h.Create) r.Get("/invites/{code}", h.Get) r.Post("/invites/{code}/join", h.Join) } type createInviteRequest struct { ExpiresHours *int `json:"expires_hours"` MaxUses *int `json:"max_uses"` } type inviteResponse struct { ID string `json:"id"` Code string `json:"code"` ServerID string `json:"server_id"` ServerName string `json:"server_name"` CreatorID string `json:"creator_id"` MaxUses *int `json:"max_uses"` UseCount int `json:"use_count"` ExpiresAt *string `json:"expires_at"` CreatedAt string `json:"created_at"` } // generateCode generates a cryptographically random alphanumeric code of the given length. func generateCode(length int) (string, error) { result := make([]byte, length) max := big.NewInt(int64(len(codeChars))) for i := 0; i < length; i++ { n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } result[i] = codeChars[n.Int64()] } return string(result), nil } // @Summary Create an invite // @Description Create an invite code for a server // @Tags invites // @Accept json // @Produce json // @Security SessionAuth // @Param serverID path string true "Server ID" // @Param body body createInviteRequest false "Invite options" // @Success 201 {object} inviteResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /servers/{serverID}/invites [post] func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } // Verify user is a member of the server var exists bool err := h.db.QueryRowContext(r.Context(), ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&exists) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !exists { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return } var req createInviteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Allow empty body req = createInviteRequest{} } // Generate a unique code (retry on collision) var code string for i := 0; i < 5; i++ { code, err = generateCode(codeLength) if err != nil { http.Error(w, `{"error":"failed to generate invite code"}`, http.StatusInternalServerError) return } // Check uniqueness var dup bool err = h.db.QueryRowContext(r.Context(), ` SELECT EXISTS(SELECT 1 FROM invites WHERE code = $1) `, code).Scan(&dup) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !dup { break } if i == 4 { http.Error(w, `{"error":"failed to generate unique code"}`, http.StatusInternalServerError) return } } // Compute expiration var expiresAt *time.Time if req.ExpiresHours != nil && *req.ExpiresHours > 0 { t := time.Now().Add(time.Duration(*req.ExpiresHours) * time.Hour) expiresAt = &t } var maxUses *int if req.MaxUses != nil && *req.MaxUses > 0 { maxUses = req.MaxUses } var invite inviteResponse var expiresAtStr sql.NullString var createdAtStr sql.NullString err = h.db.QueryRowContext(r.Context(), ` INSERT INTO invites (code, server_id, creator_id, max_uses, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, code, server_id, creator_id, max_uses, use_count, expires_at::text, created_at::text `, code, serverID, userID, maxUses, expiresAt).Scan( &invite.ID, &invite.Code, &invite.ServerID, &invite.CreatorID, &invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr, ) if err != nil { http.Error(w, `{"error":"failed to create invite"}`, http.StatusInternalServerError) return } if expiresAtStr.Valid { invite.ExpiresAt = &expiresAtStr.String } invite.CreatedAt = createdAtStr.String // Fetch server name err = h.db.QueryRowContext(r.Context(), ` SELECT name FROM servers WHERE id = $1 `, serverID).Scan(&invite.ServerName) if err != nil { // Non-fatal: just leave server_name empty invite.ServerName = "" } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(invite) } // @Summary Get invite info // @Description Get information about an invite by code // @Tags invites // @Produce json // @Param code path string true "Invite code" // @Success 200 {object} inviteResponse // @Failure 404 {object} map[string]string // @Router /invites/{code} [get] func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code") var invite inviteResponse var expiresAtStr sql.NullString var createdAtStr sql.NullString err := h.db.QueryRowContext(r.Context(), ` SELECT i.id, i.code, i.server_id, COALESCE(s.name, ''), i.creator_id, i.max_uses, i.use_count, i.expires_at::text, i.created_at::text FROM invites i LEFT JOIN servers s ON s.id = i.server_id WHERE i.code = $1 `, code).Scan( &invite.ID, &invite.Code, &invite.ServerID, &invite.ServerName, &invite.CreatorID, &invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound) } else { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) } return } if expiresAtStr.Valid { invite.ExpiresAt = &expiresAtStr.String } invite.CreatedAt = createdAtStr.String w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(invite) } // @Summary Join server via invite // @Description Join a server using an invite code // @Tags invites // @Produce json // @Security SessionAuth // @Param code path string true "Invite code" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 409 {object} map[string]string // @Router /invites/{code}/join [post] func (h *Handler) Join(w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } // Fetch the invite var inviteID, serverID string var maxUses *int var useCount int var expiresAt sql.NullTime err := h.db.QueryRowContext(r.Context(), ` SELECT id, server_id, max_uses, use_count, expires_at FROM invites WHERE code = $1 `, code).Scan(&inviteID, &serverID, &maxUses, &useCount, &expiresAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound) } else { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) } return } // Check expiration if expiresAt.Valid && time.Now().After(expiresAt.Time) { http.Error(w, `{"error":"invite has expired"}`, http.StatusBadRequest) return } // Check max uses if maxUses != nil && useCount >= *maxUses { http.Error(w, `{"error":"invite has reached maximum uses"}`, http.StatusBadRequest) return } // Check if already a member var alreadyMember bool err = h.db.QueryRowContext(r.Context(), ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&alreadyMember) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if alreadyMember { http.Error(w, `{"error":"already a member of this server"}`, http.StatusConflict) return } // Use a transaction to insert member and increment use_count atomically tx, err := h.db.BeginTx(r.Context(), nil) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer tx.Rollback() _, err = tx.ExecContext(r.Context(), ` INSERT INTO members (user_id, server_id) VALUES ($1, $2) `, userID, serverID) if err != nil { http.Error(w, `{"error":"failed to join server"}`, http.StatusInternalServerError) return } _, err = tx.ExecContext(r.Context(), ` UPDATE invites SET use_count = use_count + 1 WHERE id = $1 `, inviteID) if err != nil { http.Error(w, `{"error":"failed to update invite"}`, http.StatusInternalServerError) return } if err := tx.Commit(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "server_id": serverID, "message": "successfully joined server", }) }