Files
2026-06-08 13:34:53 -05:00

291 lines
9.2 KiB
Go

package app
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"golang.org/x/crypto/bcrypt"
"net/http"
"strconv"
"strings"
"time"
)
// ---------------- Auth API ----------------
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
if !strings.Contains(in.Email, "@") || len(in.Email) > 255 {
writeError(w, http.StatusBadRequest, "invalid email")
return
}
if !usernameRE.MatchString(in.Username) || isReservedName(in.Username) {
writeError(w, http.StatusBadRequest, "invalid or reserved username")
return
}
if len(in.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "password hash failed")
return
}
var userCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isAdmin := userCount == 0
res, err := s.db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, in.Email, in.Username, string(hash), isAdmin)
if err != nil {
writeError(w, http.StatusConflict, "email or username already exists")
return
}
id, _ := res.LastInsertId()
writeJSON(w, http.StatusCreated, User{ID: id, Email: in.Email, Username: in.Username, IsAdmin: isAdmin})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Login string `json:"login"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &in) {
return
}
login := strings.ToLower(strings.TrimSpace(in.Login))
var user User
var hash string
err := s.db.QueryRow(`SELECT id, email, username, is_admin, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin, &hash)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, expiresAt, err := s.createToken(user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "token creation failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": user, "token": token, "expires_at": expiresAt})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
_, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(token))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) createToken(userID int64) (string, time.Time, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, err
}
token := "go_" + base64.RawURLEncoding.EncodeToString(raw)
expiresAt := time.Now().UTC().Add(tokenTTL)
_, err := s.db.Exec(`INSERT INTO auth_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, userID, hashToken(token), expiresAt)
return token, expiresAt, err
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
func (s *Server) requireBearerUser(w http.ResponseWriter, r *http.Request) (User, bool) {
user, err := s.userFromToken(bearerToken(r), "")
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return User{}, false
}
return user, true
}
func (s *Server) optionalBearerUser(r *http.Request) (User, bool) {
user, err := s.userFromToken(bearerToken(r), "")
return user, err == nil
}
func (s *Server) userFromToken(token, requiredUsername string) (User, error) {
if token == "" {
return User{}, errors.New("missing token")
}
var user User
query := `SELECT u.id, u.email, u.username, u.is_admin
FROM auth_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.revoked_at IS NULL AND t.expires_at > UTC_TIMESTAMP()`
args := []any{hashToken(token)}
if requiredUsername != "" {
query += ` AND u.username = ?`
args = append(args, requiredUsername)
}
err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin)
if err != nil {
return User{}, err
}
return user, nil
}
func (s *Server) userFromBasic(r *http.Request) (User, bool) {
username, token, ok := r.BasicAuth()
if !ok {
return User{}, false
}
user, err := s.userFromToken(token, strings.ToLower(username))
return user, err == nil
}
func (s *Server) handleTokenRoutes(w http.ResponseWriter, r *http.Request, path string) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
if path == "" || path == "/" {
if r.Method == http.MethodGet {
s.handleTokenList(w, user)
return
}
}
if path == "/prune" && r.Method == http.MethodPost {
_, err := s.db.Exec(`DELETE FROM auth_tokens WHERE user_id = ? AND (expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL)`, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"})
return
}
if strings.HasPrefix(path, "/") && r.Method == http.MethodDelete {
id, err := strconv.ParseInt(strings.TrimPrefix(path, "/"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid token id")
return
}
_, err = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE id = ? AND user_id = ?`, id, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
return
}
writeError(w, http.StatusNotFound, "not found")
}
func (s *Server) handleTokenList(w http.ResponseWriter, user User) {
rows, err := s.db.Query(`SELECT id, created_at, expires_at, revoked_at, revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() FROM auth_tokens WHERE user_id = ? ORDER BY created_at DESC`, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
var out []TokenInfo
for rows.Next() {
var t TokenInfo
var revoked sql.NullTime
if err := rows.Scan(&t.ID, &t.CreatedAt, &t.ExpiresAt, &revoked, &t.Active); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if revoked.Valid {
t.RevokedAt = &revoked.Time
}
out = append(out, t)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleAdminRoutes(w http.ResponseWriter, r *http.Request, path string) {
user, ok := s.requireBearerUser(w, r)
if !ok {
return
}
if !user.IsAdmin {
writeError(w, http.StatusForbidden, "admin access required")
return
}
switch {
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/users/"):
username := strings.TrimPrefix(path, "/users/")
if username == user.Username {
writeError(w, http.StatusBadRequest, "cannot delete yourself")
return
}
_, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, strings.ToLower(username))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
case r.Method == http.MethodGet && path == "/users":
rows, err := s.db.Query(`SELECT id, email, username, is_admin FROM users ORDER BY username`)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out = append(out, u)
}
writeJSON(w, http.StatusOK, out)
case r.Method == http.MethodGet && path == "/repos":
rows, err := s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id ORDER BY u.username, r.name`)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
repos, err := scanRepos(rows)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, repos)
case r.Method == http.MethodPost && path == "/tokens/prune":
_, err := s.db.Exec(`DELETE FROM auth_tokens WHERE expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL`)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"})
default:
writeError(w, http.StatusNotFound, "not found")
}
}