mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc Covers the cross-cutting surface: operations board reads, app settings, notification producers, activity/audit inserts, builds upsert/cancel, git clone credential resolution, invites, environments, registry auth, project/service variables, HA resolver, boot sweeps, and admin user ops. Dynamic filtered lists and Better Auth table probes stay raw by design. Authored By: TDvorak <[email protected]> * refactor(db): port apwhy gateway handlers and proxy data plane to sqlc Final tranche — every static call site now uses generated queries. Raw SQL remains only where the statement is assembled at runtime (filtered lists, sparse updates, admin count closure) or probes Better Auth's auth_users table, which lives outside the local schema. Authored By: TDvorak <[email protected]>
252 lines
7.7 KiB
Go
252 lines
7.7 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"containr/internal/database"
|
||
"containr/internal/database/sqlcdb"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// Team invites: an admin creates a single-use link; the invitee registers
|
||
// through it even while public signup is closed. Tokens are random 32-byte
|
||
// values shared once — only their sha256 hash is stored.
|
||
|
||
type inviteJSON struct {
|
||
ID string `json:"id"`
|
||
Email *string `json:"email,omitempty"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
}
|
||
|
||
func toInviteJSON(i sqlcdb.UserInvite) inviteJSON {
|
||
out := inviteJSON{ID: i.ID.String(), ExpiresAt: i.ExpiresAt}
|
||
if i.Email.Valid && i.Email.String != "" {
|
||
out.Email = &i.Email.String
|
||
}
|
||
if i.UsedAt.Valid {
|
||
out.UsedAt = &i.UsedAt.Time
|
||
}
|
||
if i.CreatedAt.Valid {
|
||
out.CreatedAt = &i.CreatedAt.Time
|
||
}
|
||
return out
|
||
}
|
||
|
||
func inviteTokenHash(token string) string {
|
||
sum := sha256.Sum256([]byte(token))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func generateInviteToken() (string, error) {
|
||
buf := make([]byte, 32)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// POST /admin/invites — {email?, expires_in_hours?} → {invite, token, url}
|
||
func handleAdminCreateInvite(c *gin.Context) {
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
ExpiresInHours *int `json:"expires_in_hours"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||
return
|
||
}
|
||
ttl := 24 * 7
|
||
if req.ExpiresInHours != nil {
|
||
if *req.ExpiresInHours < 1 || *req.ExpiresInHours > 24*90 {
|
||
respondError(c, http.StatusBadRequest, "VALIDATION", "expires_in_hours must be 1–2160")
|
||
return
|
||
}
|
||
ttl = *req.ExpiresInHours
|
||
}
|
||
token, err := generateInviteToken()
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
|
||
return
|
||
}
|
||
userID, _ := requireAuthenticatedUserUUID(c)
|
||
params := sqlcdb.CreateUserInviteParams{
|
||
TokenHash: inviteTokenHash(token),
|
||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||
ExpiresAt: time.Now().UTC().Add(time.Duration(ttl) * time.Hour),
|
||
}
|
||
if email := strings.ToLower(strings.TrimSpace(req.Email)); email != "" {
|
||
params.Email = sql.NullString{String: email, Valid: true}
|
||
}
|
||
db := c.MustGet("db").(*database.DB)
|
||
invite, err := sqlcdb.New(db.DB).CreateUserInvite(c.Request.Context(), params)
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create invite")
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, gin.H{
|
||
"invite": toInviteJSON(invite),
|
||
"token": token,
|
||
"url": "/auth/accept-invite?token=" + token,
|
||
})
|
||
}
|
||
|
||
// GET /admin/invites
|
||
func handleAdminListInvites(c *gin.Context) {
|
||
db := c.MustGet("db").(*database.DB)
|
||
invites, err := sqlcdb.New(db.DB).ListUserInvites(c.Request.Context())
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list invites")
|
||
return
|
||
}
|
||
out := make([]inviteJSON, 0, len(invites))
|
||
for _, i := range invites {
|
||
out = append(out, toInviteJSON(i))
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"invites": out})
|
||
}
|
||
|
||
// DELETE /admin/invites/:id — revoke an unused invite.
|
||
func handleAdminDeleteInvite(c *gin.Context) {
|
||
id, err := uuid.Parse(c.Param("id"))
|
||
if err != nil {
|
||
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid invite id")
|
||
return
|
||
}
|
||
db := c.MustGet("db").(*database.DB)
|
||
if err := sqlcdb.New(db.DB).DeleteUserInvite(c.Request.Context(), id); err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete invite")
|
||
return
|
||
}
|
||
c.Status(http.StatusNoContent)
|
||
}
|
||
|
||
// GET /auth/invites/:token — public. Lets the accept page validate the link
|
||
// and pre-fill a bound email before the user commits to registering.
|
||
func handleGetInvite(c *gin.Context) {
|
||
db := c.MustGet("db").(*database.DB)
|
||
invite, err := sqlcdb.New(db.DB).GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(c.Param("token")))
|
||
if err != nil {
|
||
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
|
||
return
|
||
}
|
||
expired := time.Now().After(invite.ExpiresAt)
|
||
var email *string
|
||
if invite.Email.Valid && invite.Email.String != "" {
|
||
email = &invite.Email.String
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"valid": !invite.UsedAt.Valid && !expired,
|
||
"used": invite.UsedAt.Valid,
|
||
"expired": expired,
|
||
"email": email,
|
||
"expires_at": invite.ExpiresAt,
|
||
})
|
||
}
|
||
|
||
// POST /auth/accept-invite — {token, name, email, password}
|
||
func handleAcceptInvite(c *gin.Context) {
|
||
var req struct {
|
||
Token string `json:"token" binding:"required"`
|
||
Name string `json:"name" binding:"required"`
|
||
Email string `json:"email" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "token, name, email and password are required")
|
||
return
|
||
}
|
||
if len(req.Password) < 8 {
|
||
respondError(c, http.StatusBadRequest, "VALIDATION", "password must be at least 8 characters")
|
||
return
|
||
}
|
||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||
|
||
db := c.MustGet("db").(*database.DB)
|
||
q := sqlcdb.New(db.DB)
|
||
invite, err := q.GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(req.Token))
|
||
if err != nil {
|
||
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
|
||
return
|
||
}
|
||
if invite.UsedAt.Valid {
|
||
respondError(c, http.StatusGone, "INVITE_USED", "invite already used")
|
||
return
|
||
}
|
||
if time.Now().After(invite.ExpiresAt) {
|
||
respondError(c, http.StatusGone, "INVITE_EXPIRED", "invite expired")
|
||
return
|
||
}
|
||
if invite.Email.Valid && invite.Email.String != "" && invite.Email.String != req.Email {
|
||
respondError(c, http.StatusForbidden, "INVITE_EMAIL_MISMATCH", "invite is bound to a different email")
|
||
return
|
||
}
|
||
|
||
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
|
||
return
|
||
}
|
||
if existing > 0 {
|
||
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
|
||
return
|
||
}
|
||
|
||
if err := createBetterAuthUser(c, strings.TrimSpace(req.Name), req.Email, req.Password); err != nil && !errors.Is(err, errBetterAuthUserExists) {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", err.Error())
|
||
return
|
||
}
|
||
|
||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to hash password")
|
||
return
|
||
}
|
||
|
||
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
|
||
Email: req.Email,
|
||
PasswordHash: string(hashedPassword),
|
||
Name: strings.TrimSpace(req.Name),
|
||
})
|
||
if err != nil {
|
||
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
|
||
return
|
||
}
|
||
user := User{
|
||
ID: row.ID.String(),
|
||
Email: row.Email,
|
||
Name: row.Name,
|
||
AvatarURL: row.AvatarUrl,
|
||
IsAdmin: row.IsAdmin,
|
||
CreatedAt: row.CreatedAt.Time.String(),
|
||
}
|
||
|
||
userUUID, _ := uuid.Parse(user.ID)
|
||
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
|
||
ID: invite.ID,
|
||
UsedBy: uuid.NullUUID{UUID: userUUID, Valid: userUUID != uuid.Nil},
|
||
}); err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to consume invite")
|
||
return
|
||
}
|
||
|
||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||
token, err := generateJWT(user.ID, user.Email, jwtSecret)
|
||
if err != nil {
|
||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
|
||
}
|