mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Add logging to handlers
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/logging"
|
||||
"novamd/internal/models"
|
||||
"novamd/internal/storage"
|
||||
"strconv"
|
||||
@@ -47,6 +48,10 @@ type SystemStats struct {
|
||||
*storage.FileCountStats
|
||||
}
|
||||
|
||||
func getAdminLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("admin")
|
||||
}
|
||||
|
||||
// AdminListUsers godoc
|
||||
// @Summary List all users
|
||||
// @Description Returns the list of all users
|
||||
@@ -58,9 +63,22 @@ type SystemStats struct {
|
||||
// @Failure 500 {object} ErrorResponse "Failed to list users"
|
||||
// @Router /admin/users [get]
|
||||
func (h *Handler) AdminListUsers() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminListUsers",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
users, err := h.DB.GetAllUsers()
|
||||
if err != nil {
|
||||
log.Error("failed to fetch users from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to list users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -89,39 +107,63 @@ func (h *Handler) AdminListUsers() http.HandlerFunc {
|
||||
// @Router /admin/users [post]
|
||||
func (h *Handler) AdminCreateUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminCreateUser",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var req CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
// Validation logging
|
||||
if req.Email == "" || req.Password == "" || req.Role == "" {
|
||||
log.Debug("missing required fields",
|
||||
"hasEmail", req.Email != "",
|
||||
"hasPassword", req.Password != "",
|
||||
"hasRole", req.Role != "",
|
||||
)
|
||||
respondError(w, "Email, password, and role are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
// Email existence check
|
||||
existingUser, err := h.DB.GetUserByEmail(req.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
log.Warn("attempted to create user with existing email",
|
||||
"email", req.Email,
|
||||
)
|
||||
respondError(w, "Email already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if password is long enough
|
||||
if len(req.Password) < 8 {
|
||||
log.Debug("password too short",
|
||||
"passwordLength", len(req.Password),
|
||||
)
|
||||
respondError(w, "Password must be at least 8 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Error("failed to hash password",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
Email: req.Email,
|
||||
DisplayName: req.DisplayName,
|
||||
@@ -131,16 +173,30 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
|
||||
|
||||
insertedUser, err := h.DB.CreateUser(user)
|
||||
if err != nil {
|
||||
log.Error("failed to create user in database",
|
||||
"error", err.Error(),
|
||||
"email", req.Email,
|
||||
"role", req.Role,
|
||||
)
|
||||
respondError(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize user workspace
|
||||
if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
|
||||
log.Error("failed to initialize user workspace",
|
||||
"error", err.Error(),
|
||||
"userID", insertedUser.ID,
|
||||
"workspaceID", insertedUser.LastWorkspaceID,
|
||||
)
|
||||
respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("user created",
|
||||
"newUserID", insertedUser.ID,
|
||||
"email", insertedUser.Email,
|
||||
"role", insertedUser.Role,
|
||||
)
|
||||
respondJSON(w, insertedUser)
|
||||
}
|
||||
}
|
||||
@@ -159,14 +215,32 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
|
||||
// @Router /admin/users/{userId} [get]
|
||||
func (h *Handler) AdminGetUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminGetUser",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
|
||||
if err != nil {
|
||||
log.Debug("invalid user ID format",
|
||||
"userIDParam", chi.URLParam(r, "userId"),
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Debug("user not found",
|
||||
"targetUserID", userID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -194,49 +268,86 @@ func (h *Handler) AdminGetUser() http.HandlerFunc {
|
||||
// @Router /admin/users/{userId} [put]
|
||||
func (h *Handler) AdminUpdateUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminUpdateUser",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
|
||||
if err != nil {
|
||||
log.Debug("invalid user ID format",
|
||||
"userIDParam", chi.URLParam(r, "userId"),
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing user
|
||||
user, err := h.DB.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Debug("user not found",
|
||||
"targetUserID", userID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields if provided
|
||||
// Track what's being updated for logging
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
updates["email"] = req.Email
|
||||
}
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
updates["displayName"] = req.DisplayName
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
if req.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Error("failed to hash password",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
updates["passwordUpdated"] = true
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
log.Error("failed to update user in database",
|
||||
"error", err.Error(),
|
||||
"targetUserID", userID,
|
||||
)
|
||||
respondError(w, "Failed to update user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("user updated",
|
||||
"targetUserID", userID,
|
||||
"updates", updates,
|
||||
)
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
@@ -261,37 +372,61 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminDeleteUser",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
|
||||
if err != nil {
|
||||
log.Debug("invalid user ID format",
|
||||
"userIDParam", chi.URLParam(r, "userId"),
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if userID == ctx.UserID {
|
||||
log.Warn("admin attempted to delete own account")
|
||||
respondError(w, "Cannot delete your own account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user before deletion to check role
|
||||
user, err := h.DB.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Debug("user not found",
|
||||
"targetUserID", userID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent deletion of other admin users
|
||||
if user.Role == models.RoleAdmin && ctx.UserID != userID {
|
||||
log.Warn("attempted to delete another admin user",
|
||||
"targetUserID", userID,
|
||||
"targetUserEmail", user.Email,
|
||||
)
|
||||
respondError(w, "Cannot delete other admin users", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.DeleteUser(userID); err != nil {
|
||||
log.Error("failed to delete user from database",
|
||||
"error", err.Error(),
|
||||
"targetUserID", userID,
|
||||
)
|
||||
respondError(w, "Failed to delete user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("user deleted",
|
||||
"targetUserID", userID,
|
||||
"targetUserEmail", user.Email,
|
||||
"targetUserRole", user.Role,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -309,9 +444,22 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
|
||||
// @Failure 500 {object} ErrorResponse "Failed to get file stats"
|
||||
// @Router /admin/workspaces [get]
|
||||
func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminListWorkspaces",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
workspaces, err := h.DB.GetAllWorkspaces()
|
||||
if err != nil {
|
||||
log.Error("failed to fetch workspaces from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -319,11 +467,15 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
|
||||
workspacesStats := make([]*WorkspaceStats, 0, len(workspaces))
|
||||
|
||||
for _, ws := range workspaces {
|
||||
|
||||
workspaceData := &WorkspaceStats{}
|
||||
|
||||
user, err := h.DB.GetUserByID(ws.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user for workspace",
|
||||
"error", err.Error(),
|
||||
"workspaceID", ws.ID,
|
||||
"userID", ws.UserID,
|
||||
)
|
||||
respondError(w, "Failed to get user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -336,12 +488,16 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
|
||||
|
||||
fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch file stats for workspace",
|
||||
"error", err.Error(),
|
||||
"workspaceID", ws.ID,
|
||||
"userID", ws.UserID,
|
||||
)
|
||||
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceData.FileCountStats = fileStats
|
||||
|
||||
workspacesStats = append(workspacesStats, workspaceData)
|
||||
}
|
||||
|
||||
@@ -361,15 +517,31 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
|
||||
// @Failure 500 {object} ErrorResponse "Failed to get file stats"
|
||||
// @Router /admin/stats [get]
|
||||
func (h *Handler) AdminGetSystemStats() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAdminLogger().With(
|
||||
"handler", "AdminGetSystemStats",
|
||||
"adminID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
userStats, err := h.DB.GetSystemStats()
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user statistics",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get user stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fileStats, err := h.Storage.GetTotalFileStats()
|
||||
if err != nil {
|
||||
log.Error("failed to fetch file statistics",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"novamd/internal/auth"
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/logging"
|
||||
"novamd/internal/models"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +27,10 @@ type LoginResponse struct {
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
func getAuthLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("auth")
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login
|
||||
// @Description Logs in a user and returns a session with access and refresh tokens
|
||||
@@ -43,62 +48,88 @@ type LoginResponse struct {
|
||||
// @Router /auth/login [post]
|
||||
func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log := getAuthLogger().With(
|
||||
"handler", "Login",
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Email == "" || req.Password == "" {
|
||||
log.Debug("missing required fields",
|
||||
"hasEmail", req.Email != "",
|
||||
"hasPassword", req.Password != "",
|
||||
)
|
||||
respondError(w, "Email and password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := h.DB.GetUserByEmail(req.Email)
|
||||
if err != nil {
|
||||
log.Debug("user not found",
|
||||
"email", req.Email,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
|
||||
if err != nil {
|
||||
log.Warn("invalid password attempt",
|
||||
"userID", user.ID,
|
||||
"email", user.Email,
|
||||
)
|
||||
respondError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session and generate tokens
|
||||
session, accessToken, err := authManager.CreateSession(user.ID, string(user.Role))
|
||||
if err != nil {
|
||||
log.Error("failed to create session",
|
||||
"error", err.Error(),
|
||||
"userID", user.ID,
|
||||
)
|
||||
respondError(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate CSRF token
|
||||
csrfToken := make([]byte, 32)
|
||||
if _, err := rand.Read(csrfToken); err != nil {
|
||||
log.Error("failed to generate CSRF token",
|
||||
"error", err.Error(),
|
||||
"userID", user.ID,
|
||||
)
|
||||
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
csrfTokenString := hex.EncodeToString(csrfToken)
|
||||
|
||||
// Set cookies
|
||||
http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken))
|
||||
http.SetCookie(w, cookieService.GenerateRefreshTokenCookie(session.RefreshToken))
|
||||
http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString))
|
||||
|
||||
// Send CSRF token in header for initial setup
|
||||
w.Header().Set("X-CSRF-Token", csrfTokenString)
|
||||
|
||||
// Only send user info in response, not tokens
|
||||
response := LoginResponse{
|
||||
User: user,
|
||||
SessionID: session.ID,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
|
||||
log.Info("user logged in successfully",
|
||||
"userID", user.ID,
|
||||
"email", user.Email,
|
||||
"role", user.Role,
|
||||
"sessionID", session.ID,
|
||||
)
|
||||
respondJSON(w, response)
|
||||
}
|
||||
}
|
||||
@@ -114,24 +145,41 @@ func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.Cook
|
||||
// @Router /auth/logout [post]
|
||||
func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get session ID from cookie
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAuthLogger().With(
|
||||
"handler", "Logout",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
sessionCookie, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
log.Debug("missing access token cookie",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Access token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate the session in the database
|
||||
if err := authManager.InvalidateSession(sessionCookie.Value); err != nil {
|
||||
log.Error("failed to invalidate session",
|
||||
"error", err.Error(),
|
||||
"sessionID", sessionCookie.Value,
|
||||
)
|
||||
respondError(w, "Failed to invalidate session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
http.SetCookie(w, cookieService.InvalidateCookie("access_token"))
|
||||
http.SetCookie(w, cookieService.InvalidateCookie("refresh_token"))
|
||||
http.SetCookie(w, cookieService.InvalidateCookie("csrf_token"))
|
||||
|
||||
log.Info("user logged out successfully",
|
||||
"sessionID", sessionCookie.Value,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -151,22 +199,39 @@ func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.Coo
|
||||
// @Router /auth/refresh [post]
|
||||
func (h *Handler) RefreshToken(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := context.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAuthLogger().With(
|
||||
"handler", "RefreshToken",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
refreshCookie, err := r.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
log.Debug("missing refresh token cookie",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Refresh token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
accessToken, err := authManager.RefreshSession(refreshCookie.Value)
|
||||
if err != nil {
|
||||
log.Error("failed to refresh session",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new CSRF token
|
||||
csrfToken := make([]byte, 32)
|
||||
if _, err := rand.Read(csrfToken); err != nil {
|
||||
log.Error("failed to generate CSRF token",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -196,10 +261,17 @@ func (h *Handler) GetCurrentUser() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getAuthLogger().With(
|
||||
"handler", "GetCurrentUser",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Get user from database
|
||||
user, err := h.DB.GetUserByID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/logging"
|
||||
"novamd/internal/storage"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -35,6 +36,10 @@ type UpdateLastOpenedFileRequest struct {
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
|
||||
func getFilesLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("files")
|
||||
}
|
||||
|
||||
// ListFiles godoc
|
||||
// @Summary List files
|
||||
// @Description Lists all files in the user's workspace
|
||||
@@ -52,13 +57,25 @@ func (h *Handler) ListFiles() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "ListFiles",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to list files in workspace",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to list files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("files listed successfully",
|
||||
"fileCount", len(files),
|
||||
)
|
||||
respondJSON(w, files)
|
||||
}
|
||||
}
|
||||
@@ -82,19 +99,40 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "LookupFileByName",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
if filename == "" {
|
||||
log.Debug("missing filename parameter")
|
||||
respondError(w, "Filename is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Error("failed to lookup file",
|
||||
"filename", filename,
|
||||
"error", err.Error(),
|
||||
)
|
||||
} else {
|
||||
log.Debug("file not found",
|
||||
"filename", filename,
|
||||
)
|
||||
}
|
||||
respondError(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("file lookup successful",
|
||||
"filename", filename,
|
||||
"matchCount", len(filePaths),
|
||||
)
|
||||
respondJSON(w, &LookupResponse{Paths: filePaths})
|
||||
}
|
||||
}
|
||||
@@ -120,21 +158,37 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "GetFileContent",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath)
|
||||
if err != nil {
|
||||
|
||||
if storage.IsPathValidationError(err) {
|
||||
log.Error("invalid file path attempted",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
log.Debug("file not found",
|
||||
"filePath", filePath,
|
||||
)
|
||||
respondError(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("failed to read file content",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to read file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -142,9 +196,18 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, err = w.Write(content)
|
||||
if err != nil {
|
||||
log.Error("failed to write response",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to write response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("file content retrieved",
|
||||
"filePath", filePath,
|
||||
"contentSize", len(content),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +232,20 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "SaveFile",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error("failed to read request body",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -180,10 +253,19 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
||||
err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content)
|
||||
if err != nil {
|
||||
if storage.IsPathValidationError(err) {
|
||||
log.Error("invalid file path attempted",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("failed to save file",
|
||||
"filePath", filePath,
|
||||
"contentSize", len(content),
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -194,7 +276,11 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
log.Debug("file saved",
|
||||
"filePath", filePath,
|
||||
"size", response.Size,
|
||||
"updatedAt", response.UpdatedAt,
|
||||
)
|
||||
respondJSON(w, response)
|
||||
}
|
||||
}
|
||||
@@ -211,7 +297,6 @@ func (h *Handler) SaveFile() http.HandlerFunc {
|
||||
// @Failure 400 {object} ErrorResponse "Invalid file path"
|
||||
// @Failure 404 {object} ErrorResponse "File not found"
|
||||
// @Failure 500 {object} ErrorResponse "Failed to delete file"
|
||||
// @Failure 500 {object} ErrorResponse "Failed to write response"
|
||||
// @Router /workspaces/{workspace_name}/files/{file_path} [delete]
|
||||
func (h *Handler) DeleteFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -219,24 +304,44 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "DeleteFile",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath)
|
||||
if err != nil {
|
||||
if storage.IsPathValidationError(err) {
|
||||
log.Error("invalid file path attempted",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
log.Debug("file not found",
|
||||
"filePath", filePath,
|
||||
)
|
||||
respondError(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("failed to delete file",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to delete file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("file deleted",
|
||||
"filePath", filePath,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -259,18 +364,34 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "GetLastOpenedFile",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to get last opened file from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil {
|
||||
log.Error("invalid file path stored",
|
||||
"filePath", filePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("last opened file retrieved successfully",
|
||||
"filePath", filePath,
|
||||
)
|
||||
respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath})
|
||||
}
|
||||
}
|
||||
@@ -297,10 +418,18 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getFilesLogger().With(
|
||||
"handler", "UpdateLastOpenedFile",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var requestBody UpdateLastOpenedFileRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
log.Error("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -310,25 +439,43 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
|
||||
_, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
|
||||
if err != nil {
|
||||
if storage.IsPathValidationError(err) {
|
||||
log.Error("invalid file path attempted",
|
||||
"filePath", requestBody.FilePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
log.Debug("file not found",
|
||||
"filePath", requestBody.FilePath,
|
||||
)
|
||||
respondError(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("failed to validate file path",
|
||||
"filePath", requestBody.FilePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil {
|
||||
log.Error("failed to update last opened file in database",
|
||||
"filePath", requestBody.FilePath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("last opened file updated successfully",
|
||||
"filePath", requestBody.FilePath,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/logging"
|
||||
)
|
||||
|
||||
// CommitRequest represents a request to commit changes
|
||||
@@ -22,6 +22,10 @@ type PullResponse struct {
|
||||
Message string `json:"message" example:"Pulled changes from remote"`
|
||||
}
|
||||
|
||||
func getGitLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("git")
|
||||
}
|
||||
|
||||
// StageCommitAndPush godoc
|
||||
// @Summary Stage, commit, and push changes
|
||||
// @Description Stages, commits, and pushes changes to the remote repository
|
||||
@@ -42,25 +46,43 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getGitLogger().With(
|
||||
"handler", "StageCommitAndPush",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var requestBody CommitRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
log.Error("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.Message == "" {
|
||||
log.Debug("empty commit message provided")
|
||||
respondError(w, "Commit message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message)
|
||||
if err != nil {
|
||||
log.Error("failed to perform git operations",
|
||||
"error", err.Error(),
|
||||
"commitMessage", requestBody.Message,
|
||||
)
|
||||
respondError(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("git operations completed successfully",
|
||||
"commitHash", hash.String(),
|
||||
"commitMessage", requestBody.Message,
|
||||
)
|
||||
|
||||
respondJSON(w, CommitResponse{CommitHash: hash.String()})
|
||||
}
|
||||
}
|
||||
@@ -82,13 +104,23 @@ func (h *Handler) PullChanges() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getGitLogger().With(
|
||||
"handler", "PullChanges",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to pull changes from remote",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("successfully pulled changes from remote")
|
||||
respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/logging"
|
||||
"novamd/internal/storage"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,15 @@ type Handler struct {
|
||||
Storage storage.Manager
|
||||
}
|
||||
|
||||
var logger logging.Logger
|
||||
|
||||
func getHandlersLogger() logging.Logger {
|
||||
if logger == nil {
|
||||
logger = logging.WithGroup("handlers")
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler with the given dependencies
|
||||
func NewHandler(db db.Database, s storage.Manager) *Handler {
|
||||
return &Handler{
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"novamd/internal/logging"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,8 +20,19 @@ func NewStaticHandler(staticPath string) *StaticHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func getStaticLogger() logging.Logger {
|
||||
return logging.WithGroup("static")
|
||||
}
|
||||
|
||||
// ServeHTTP serves the static files
|
||||
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log := getStaticLogger().With(
|
||||
"handler", "ServeHTTP",
|
||||
"clientIP", r.RemoteAddr,
|
||||
"method", r.Method,
|
||||
"url", r.URL.Path,
|
||||
)
|
||||
|
||||
// Get the requested path
|
||||
requestedPath := r.URL.Path
|
||||
fullPath := filepath.Join(h.staticPath, requestedPath)
|
||||
@@ -28,6 +40,10 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Security check to prevent directory traversal
|
||||
if !strings.HasPrefix(cleanPath, h.staticPath) {
|
||||
log.Warn("directory traversal attempt detected",
|
||||
"requestedPath", requestedPath,
|
||||
"cleanPath", cleanPath,
|
||||
)
|
||||
respondError(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -35,11 +51,29 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Set cache headers for assets
|
||||
if strings.HasPrefix(requestedPath, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year
|
||||
log.Debug("cache headers set for asset",
|
||||
"path", requestedPath,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if file exists (not counting .gz files)
|
||||
stat, err := os.Stat(cleanPath)
|
||||
if err != nil || stat.IsDir() {
|
||||
if os.IsNotExist(err) {
|
||||
log.Debug("file not found, serving index.html",
|
||||
"requestedPath", requestedPath,
|
||||
)
|
||||
} else if stat != nil && stat.IsDir() {
|
||||
log.Debug("directory requested, serving index.html",
|
||||
"requestedPath", requestedPath,
|
||||
)
|
||||
} else {
|
||||
log.Error("error checking file status",
|
||||
"requestedPath", requestedPath,
|
||||
"error", err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
// Serve index.html for SPA routing
|
||||
indexPath := filepath.Join(h.staticPath, "index.html")
|
||||
http.ServeFile(w, r, indexPath)
|
||||
@@ -53,20 +87,32 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
|
||||
// Set proper content type based on original file
|
||||
contentType := "application/octet-stream"
|
||||
switch filepath.Ext(cleanPath) {
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
contentType = "application/javascript"
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
contentType = "text/css"
|
||||
case ".html":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
contentType = "text/html"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
log.Debug("serving gzipped file",
|
||||
"path", requestedPath,
|
||||
"gzPath", gzPath,
|
||||
"contentType", contentType,
|
||||
)
|
||||
http.ServeFile(w, r, gzPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Serve original file
|
||||
log.Debug("serving original file",
|
||||
"path", requestedPath,
|
||||
"size", stat.Size(),
|
||||
"modTime", stat.ModTime(),
|
||||
)
|
||||
http.ServeFile(w, r, cleanPath)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/logging"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -22,6 +23,10 @@ type DeleteAccountRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func getProfileLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("profile")
|
||||
}
|
||||
|
||||
// UpdateProfile godoc
|
||||
// @Summary Update profile
|
||||
// @Description Updates the user's profile
|
||||
@@ -48,9 +53,17 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getProfileLogger().With(
|
||||
"handler", "UpdateProfile",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -58,76 +71,97 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
|
||||
// Get current user
|
||||
user, err := h.DB.GetUserByID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Track what's being updated for logging
|
||||
updates := make(map[string]bool)
|
||||
|
||||
// Handle password update if requested
|
||||
if req.NewPassword != "" {
|
||||
// Current password must be provided to change password
|
||||
if req.CurrentPassword == "" {
|
||||
log.Debug("password change attempted without current password")
|
||||
respondError(w, "Current password is required to change password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
log.Warn("incorrect password provided for password change")
|
||||
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if len(req.NewPassword) < 8 {
|
||||
log.Debug("password change rejected - too short",
|
||||
"passwordLength", len(req.NewPassword),
|
||||
)
|
||||
respondError(w, "New password must be at least 8 characters long", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Error("failed to hash new password",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to process new password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
updates["passwordChanged"] = true
|
||||
}
|
||||
|
||||
// Handle email update if requested
|
||||
if req.Email != "" && req.Email != user.Email {
|
||||
// Check if email change requires password verification
|
||||
if req.CurrentPassword == "" {
|
||||
log.Debug("email change attempted without current password")
|
||||
respondError(w, "Current password is required to change email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password if not already verified for password change
|
||||
if req.NewPassword == "" {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
log.Warn("incorrect password provided for email change")
|
||||
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new email is already in use
|
||||
existingUser, err := h.DB.GetUserByEmail(req.Email)
|
||||
if err == nil && existingUser.ID != user.ID {
|
||||
log.Debug("email change rejected - already in use",
|
||||
"requestedEmail", req.Email,
|
||||
)
|
||||
respondError(w, "Email already in use", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
user.Email = req.Email
|
||||
updates["emailChanged"] = true
|
||||
}
|
||||
|
||||
// Update display name if provided (no password required)
|
||||
// Update display name if provided
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
updates["displayNameChanged"] = true
|
||||
}
|
||||
|
||||
// Update user in database
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
log.Error("failed to update user in database",
|
||||
"error", err.Error(),
|
||||
"updates", updates,
|
||||
)
|
||||
respondError(w, "Failed to update profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
log.Debug("profile updated successfully",
|
||||
"updates", updates,
|
||||
)
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
@@ -155,9 +189,17 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getProfileLogger().With(
|
||||
"handler", "DeleteAccount",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var req DeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug("failed to decode request body",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -165,25 +207,32 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
|
||||
// Get current user
|
||||
user, err := h.DB.GetUserByID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
respondError(w, "Password is incorrect", http.StatusUnauthorized)
|
||||
log.Warn("incorrect password provided for account deletion")
|
||||
respondError(w, "Incorrect password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent admin from deleting their own account if they're the last admin
|
||||
if user.Role == "admin" {
|
||||
// Count number of admin users
|
||||
adminCount, err := h.DB.CountAdminUsers()
|
||||
if err != nil {
|
||||
respondError(w, "Failed to verify admin status", http.StatusInternalServerError)
|
||||
log.Error("failed to count admin users",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get admin count", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
log.Warn("attempted to delete last admin account")
|
||||
respondError(w, "Cannot delete the last admin account", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -192,6 +241,9 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
|
||||
// Get user's workspaces for cleanup
|
||||
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch user workspaces",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get user workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -199,17 +251,31 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
|
||||
// Delete workspace directories
|
||||
for _, workspace := range workspaces {
|
||||
if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil {
|
||||
log.Error("failed to delete workspace directory",
|
||||
"error", err.Error(),
|
||||
"workspaceID", workspace.ID,
|
||||
)
|
||||
respondError(w, "Failed to delete workspace files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debug("workspace deleted",
|
||||
"workspaceID", workspace.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// Delete user from database (this will cascade delete workspaces and sessions)
|
||||
// Delete user from database
|
||||
if err := h.DB.DeleteUser(ctx.UserID); err != nil {
|
||||
log.Error("failed to delete user from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to delete account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("user account deleted",
|
||||
"email", user.Email,
|
||||
"role", user.Role,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/context"
|
||||
"novamd/internal/logging"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,10 @@ type LastWorkspaceNameResponse struct {
|
||||
LastWorkspaceName string `json:"lastWorkspaceName"`
|
||||
}
|
||||
|
||||
func getWorkspaceLogger() logging.Logger {
|
||||
return getHandlersLogger().WithGroup("workspace")
|
||||
}
|
||||
|
||||
// ListWorkspaces godoc
|
||||
// @Summary List workspaces
|
||||
// @Description Lists all workspaces for the current user
|
||||
@@ -35,13 +40,24 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "ListWorkspaces",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch workspaces from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("workspaces retrieved successfully",
|
||||
"count", len(workspaces),
|
||||
)
|
||||
respondJSON(w, workspaces)
|
||||
}
|
||||
}
|
||||
@@ -68,30 +84,54 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "CreateWorkspace",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
log.Debug("invalid request body received",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := workspace.ValidateGitSettings(); err != nil {
|
||||
log.Debug("invalid git settings provided",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid workspace", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspace.UserID = ctx.UserID
|
||||
if err := h.DB.CreateWorkspace(&workspace); err != nil {
|
||||
log.Error("failed to create workspace in database",
|
||||
"error", err.Error(),
|
||||
"workspaceName", workspace.Name,
|
||||
)
|
||||
respondError(w, "Failed to create workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
|
||||
log.Error("failed to initialize workspace directory",
|
||||
"error", err.Error(),
|
||||
"workspaceID", workspace.ID,
|
||||
)
|
||||
respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if workspace.GitEnabled {
|
||||
log.Debug("setting up git repository",
|
||||
"workspaceID", workspace.ID,
|
||||
"gitURL", workspace.GitURL,
|
||||
)
|
||||
|
||||
if err := h.Storage.SetupGitRepo(
|
||||
ctx.UserID,
|
||||
workspace.ID,
|
||||
@@ -101,11 +141,20 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
|
||||
workspace.GitCommitName,
|
||||
workspace.GitCommitEmail,
|
||||
); err != nil {
|
||||
log.Error("failed to setup git repository",
|
||||
"error", err.Error(),
|
||||
"workspaceID", workspace.ID,
|
||||
)
|
||||
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("workspace created",
|
||||
"workspaceID", workspace.ID,
|
||||
"workspaceName", workspace.Name,
|
||||
"gitEnabled", workspace.GitEnabled,
|
||||
)
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
@@ -171,9 +220,18 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "UpdateWorkspace",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
log.Debug("invalid request body received",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -184,13 +242,28 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
|
||||
|
||||
// Validate the workspace
|
||||
if err := workspace.Validate(); err != nil {
|
||||
log.Debug("invalid workspace configuration",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Track what's changed for logging
|
||||
changes := map[string]bool{
|
||||
"gitSettings": gitSettingsChanged(&workspace, ctx.Workspace),
|
||||
"name": workspace.Name != ctx.Workspace.Name,
|
||||
"theme": workspace.Theme != ctx.Workspace.Theme,
|
||||
"autoSave": workspace.AutoSave != ctx.Workspace.AutoSave,
|
||||
}
|
||||
|
||||
// Handle Git repository setup/teardown if Git settings changed
|
||||
if gitSettingsChanged(&workspace, ctx.Workspace) {
|
||||
if changes["gitSettings"] {
|
||||
if workspace.GitEnabled {
|
||||
log.Debug("updating git repository configuration",
|
||||
"gitURL", workspace.GitURL,
|
||||
)
|
||||
|
||||
if err := h.Storage.SetupGitRepo(
|
||||
ctx.UserID,
|
||||
ctx.Workspace.ID,
|
||||
@@ -200,20 +273,29 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
|
||||
workspace.GitCommitName,
|
||||
workspace.GitCommitEmail,
|
||||
); err != nil {
|
||||
log.Error("failed to setup git repository",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Debug("disabling git repository")
|
||||
h.Storage.DisableGitRepo(ctx.UserID, ctx.Workspace.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateWorkspace(&workspace); err != nil {
|
||||
log.Error("failed to update workspace in database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to update workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("workspace updated",
|
||||
"changes", changes,
|
||||
)
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
@@ -241,15 +323,25 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "DeleteWorkspace",
|
||||
"userID", ctx.UserID,
|
||||
"workspaceID", ctx.Workspace.ID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Check if this is the user's last workspace
|
||||
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch workspaces from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) <= 1 {
|
||||
log.Debug("attempted to delete last workspace")
|
||||
respondError(w, "Cannot delete the last workspace", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -265,14 +357,19 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
log.Error("failed to start database transaction",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||
log.Error("failed to rollback transaction",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to rollback transaction", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
@@ -280,6 +377,10 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
|
||||
// Update last workspace ID first
|
||||
err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID)
|
||||
if err != nil {
|
||||
log.Error("failed to update last workspace reference",
|
||||
"error", err.Error(),
|
||||
"nextWorkspaceID", nextWorkspaceID,
|
||||
)
|
||||
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -287,16 +388,27 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
|
||||
// Delete the workspace
|
||||
err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to delete workspace from database",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to delete workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Error("failed to commit transaction",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to commit transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("workspace deleted",
|
||||
"workspaceName", ctx.Workspace.Name,
|
||||
"nextWorkspaceName", nextWorkspaceName,
|
||||
)
|
||||
|
||||
// Return the next workspace ID in the response so frontend knows where to redirect
|
||||
respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName})
|
||||
}
|
||||
@@ -318,13 +430,24 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "GetLastWorkspaceName",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch last workspace name",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Failed to get last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("last workspace name retrieved",
|
||||
"workspaceName", workspaceName,
|
||||
)
|
||||
respondJSON(w, &LastWorkspaceNameResponse{LastWorkspaceName: workspaceName})
|
||||
}
|
||||
}
|
||||
@@ -347,21 +470,36 @@ func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log := getWorkspaceLogger().With(
|
||||
"handler", "UpdateLastWorkspaceName",
|
||||
"userID", ctx.UserID,
|
||||
"clientIP", r.RemoteAddr,
|
||||
)
|
||||
|
||||
var requestBody struct {
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
log.Debug("invalid request body received",
|
||||
"error", err.Error(),
|
||||
)
|
||||
respondError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil {
|
||||
log.Error("failed to update last workspace",
|
||||
"error", err.Error(),
|
||||
"workspaceName", requestBody.WorkspaceName,
|
||||
)
|
||||
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("last workspace name updated",
|
||||
"workspaceName", requestBody.WorkspaceName,
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user