291 lines
9.2 KiB
Go
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")
|
|
}
|
|
}
|