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") } }