diff --git a/api/auth/auth.go b/api/auth/auth.go index 80090769..e8ba0e70 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "strconv" "strings" "github.com/rotisserie/eris" @@ -41,9 +42,16 @@ func GeneratePasswordHash(password string) (string, error) { func ValidatePassword(password, hash string) bool { fields := strings.Split(hash, "$") - if len(fields) != 4 || fields[0] != "pbkdf2_sha256" || fields[1] != fmt.Sprintf("%d", iterations) { + if len(fields) != 4 || fields[0] != "pbkdf2_sha256" { return false } + + // Parse iteration count from the hash itself to support future upgrades + hashIterations, err := strconv.Atoi(fields[1]) + if err != nil || hashIterations <= 0 { + return false + } + encodedSalt := fields[2] decodedSalt, err := base64.StdEncoding.DecodeString(encodedSalt) if err != nil { @@ -55,7 +63,7 @@ func ValidatePassword(password, hash string) bool { if err != nil { return false } - computedHash := pbkdf2.Key([]byte(password), decodedSalt, iterations, keySize, sha256.New) + computedHash := pbkdf2.Key([]byte(password), decodedSalt, hashIterations, keySize, sha256.New) return subtle.ConstantTimeCompare(decodedHash, computedHash) == 1 } diff --git a/api/dto/errors.go b/api/dto/errors.go index 5ec82240..f45d608d 100644 --- a/api/dto/errors.go +++ b/api/dto/errors.go @@ -293,11 +293,11 @@ func RespondWithAPIError(w http.ResponseWriter, err APIError) { }{ Code: err.Code, Message: err.Message, - Detail: err.Detail + " " + err.DebugInfo, + Detail: err.Detail, } if err.DebugInfo != "" { - slog.Error("api error", "code", err.Code, "message", err.Message, "debug", err.DebugInfo) + slog.Error("api error", "code", err.Code, "message", err.Message, "detail", err.Detail, "debug", err.DebugInfo) } if err := json.NewEncoder(w).Encode(response); err != nil { diff --git a/api/handler/auth_password.go b/api/handler/auth_password.go index 5715678f..de6dd5e2 100644 --- a/api/handler/auth_password.go +++ b/api/handler/auth_password.go @@ -78,6 +78,23 @@ func (h *AuthUserHandler) ChangePasswordHandler(w http.ResponseWriter, r *http.R return } + // Verify the authenticated user owns the email being changed + userID, err := getUserID(r.Context()) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrAuthInvalidCredentials.WithDebugInfo(err.Error())) + return + } + + user, err := h.service.GetAuthUserByID(r.Context(), userID) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrResourceNotFound("user").WithDebugInfo(err.Error())) + return + } + if user.Email != req.Email { + dto.RespondWithAPIError(w, dto.ErrAuthAccessDenied.WithMessage("Cannot change password for another user")) + return + } + hashedPassword, err := auth.GeneratePasswordHash(req.NewPassword) if err != nil { dto.RespondWithAPIError(w, dto.ErrInternalUnexpected.WithMessage("Failed to hash password").WithDebugInfo(err.Error())) diff --git a/api/handler/auth_user.go b/api/handler/auth_user.go index 70acb26b..e64e1045 100644 --- a/api/handler/auth_user.go +++ b/api/handler/auth_user.go @@ -264,7 +264,11 @@ func (h *AuthUserHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthUserHandler) ForeverToken(w http.ResponseWriter, r *http.Request) { lifetime := time.Duration(10*365*24) * time.Hour - userId, _ := getUserID(r.Context()) + userId, err := getUserID(r.Context()) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrAuthInvalidCredentials.WithDebugInfo(err.Error())) + return + } userRole, _ := r.Context().Value(middleware.RoleContextKey).(string) token, err := auth.GenerateToken(userId, userRole, h.jwtSecret, h.audience, lifetime, auth.TokenTypeAccess) diff --git a/api/handler/bot_answer_history.go b/api/handler/bot_answer_history.go index 237859a1..ae6b0e5f 100644 --- a/api/handler/bot_answer_history.go +++ b/api/handler/bot_answer_history.go @@ -127,17 +127,24 @@ func (h *BotAnswerHistoryHandler) GetBotAnswerHistoryByUserID(w http.ResponseWri } func (h *BotAnswerHistoryHandler) UpdateBotAnswerHistory(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - if id == "" { + idStr := mux.Vars(r)["id"] + if idStr == "" { dto.RespondWithAPIError(w, dto.ErrValidationInvalidInput("ID is required")) return } + idInt, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrValidationInvalidInput("Invalid ID format")) + return + } + var params sqlc_queries.UpdateBotAnswerHistoryParams if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { dto.RespondWithAPIError(w, dto.ErrValidationInvalidInput("Invalid request body").WithDebugInfo(err.Error())) return } + params.ID = int32(idInt) history, err := h.service.UpdateBotAnswerHistory(r.Context(), params.ID, params.Answer, params.TokensUsed) if err != nil { diff --git a/api/handler/chat_session_helpers.go b/api/handler/chat_session_helpers.go index 58a65685..11b24419 100644 --- a/api/handler/chat_session_helpers.go +++ b/api/handler/chat_session_helpers.go @@ -18,6 +18,9 @@ import ( "github.com/swuecho/chat_backend/sqlc_queries" ) +// titleGenSemaphore limits concurrent title generation goroutines to prevent unbounded resource usage. +var titleGenSemaphore = make(chan struct{}, 5) + // validateChatSession validates the session UUID and retrieves session + model info. func (h *ChatHandler) validateChatSession(ctx context.Context, w http.ResponseWriter, chatSessionUuid string) (*sqlc_queries.ChatSession, *sqlc_queries.ChatModel, string, bool) { chatSession, err := h.sessionSvc.GetChatSessionByUUID(ctx, chatSessionUuid) @@ -125,7 +128,12 @@ func (h *ChatHandler) generateAndSaveAnswer(ctx context.Context, w http.Response h.sendSuggestedQuestionsStream(w, LLMAnswer.AnswerId, chatMessage.SuggestedQuestions) } - go h.generateSessionTitle(chatSession, userID) + // Launch title generation with bounded concurrency + go func() { + titleGenSemaphore <- struct{}{} + defer func() { <-titleGenSemaphore }() + h.generateSessionTitle(chatSession, userID) + }() return true } @@ -160,6 +168,9 @@ func streamFromModel(model provider.ChatModel, ctx context.Context, w http.Respo }) } } + // Send the [DONE] termination marker + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() } else { for chunk := range ch { if chunk.Err != nil { diff --git a/api/handler/chat_stream.go b/api/handler/chat_stream.go index 6ae8e278..f4db1c50 100644 --- a/api/handler/chat_stream.go +++ b/api/handler/chat_stream.go @@ -87,7 +87,7 @@ func (h *ChatHandler) ChatBotCompletionHandler(w http.ResponseWriter, r *http.Re return } - genBotAnswer(h, w, session, messages, req.SnapshotUuid, req.Message, userID, req.Stream) + genBotAnswer(ctx, h, w, session, messages, req.SnapshotUuid, req.Message, userID, req.Stream) } // ChatCompletionHandler handles regular chat completion with streaming support. @@ -130,8 +130,7 @@ func genAnswer(h *ChatHandler, w http.ResponseWriter, ctx context.Context, sessi } // genBotAnswer generates a bot answer from a snapshot conversation. -func genBotAnswer(h *ChatHandler, w http.ResponseWriter, session sqlc_queries.ChatSession, messages []dto.SimpleChatMessage, snapshotUuid, question string, userID int32, streamOutput bool) { - ctx := context.Background() +func genBotAnswer(ctx context.Context, h *ChatHandler, w http.ResponseWriter, session sqlc_queries.ChatSession, messages []dto.SimpleChatMessage, snapshotUuid, question string, userID int32, streamOutput bool) { if _, err := h.sessionSvc.ChatModelByName(ctx, session.Model); err != nil { dto.RespondWithAPIError(w, dto.ErrResourceNotFound("Chat model: "+session.Model).WithDebugInfo(err.Error())) return diff --git a/api/handler/file_upload.go b/api/handler/file_upload.go index d06653a5..4d7da352 100644 --- a/api/handler/file_upload.go +++ b/api/handler/file_upload.go @@ -122,7 +122,29 @@ func (h *ChatFileHandler) DownloadFile(w http.ResponseWriter, r *http.Request) { func (h *ChatFileHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { fileID := mux.Vars(r)["id"] - fileIdInt, _ := strconv.ParseInt(fileID, 10, 32) + fileIdInt, err := strconv.ParseInt(fileID, 10, 32) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrValidationInvalidInput("invalid file ID")) + return + } + + // Verify file ownership before deletion + file, err := h.service.GetChatFile(r.Context(), int32(fileIdInt)) + if err != nil { + dto.RespondWithAPIError(w, dto.WrapError(err, "failed to get chat file")) + return + } + + userID, err := getUserID(r.Context()) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrAuthInvalidCredentials.WithDebugInfo(err.Error())) + return + } + if file.UserID != userID { + dto.RespondWithAPIError(w, dto.ErrAuthAccessDenied.WithMessage("You do not own this file")) + return + } + if err := h.service.DeleteChatFile(r.Context(), int32(fileIdInt)); err != nil { dto.RespondWithAPIError(w, dto.WrapError(err, "failed to delete chat file")) return diff --git a/api/handler/model_privilege.go b/api/handler/model_privilege.go index 3dffd2da..ca3f7765 100644 --- a/api/handler/model_privilege.go +++ b/api/handler/model_privilege.go @@ -80,7 +80,7 @@ func (h *UserChatModelPrivilegeHandler) CreateUserChatModelPrivilege(w http.Resp return } - slog.Info("Creating chat model privilege for user %s with model %s", input.UserEmail, input.ChatModelName) + slog.Info("Creating chat model privilege", "userEmail", input.UserEmail, "chatModelName", input.ChatModelName) user, err := h.db.GetAuthUserByEmail(r.Context(), input.UserEmail) if err != nil { diff --git a/api/handler/session.go b/api/handler/session.go index 6853e0a0..2cd7eeb7 100644 --- a/api/handler/session.go +++ b/api/handler/session.go @@ -170,6 +170,24 @@ func (h *ChatSessionHandler) createOrUpdateChatSessionByUUID(w http.ResponseWrit func (h *ChatSessionHandler) deleteChatSessionByUUID(w http.ResponseWriter, r *http.Request) { uuid := mux.Vars(r)["uuid"] + + userID, err := getUserID(r.Context()) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrAuthInvalidCredentials.WithDebugInfo(err.Error())) + return + } + + // Verify session ownership before deletion + session, err := h.service.GetChatSessionByUUID(r.Context(), uuid) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrResourceNotFound("Chat session").WithDebugInfo(err.Error())) + return + } + if session.UserID != userID { + dto.RespondWithAPIError(w, dto.ErrAuthAccessDenied.WithMessage("You do not own this session")) + return + } + if err := h.service.DeleteChatSessionByUUID(r.Context(), uuid); err != nil { dto.RespondWithAPIError(w, dto.ErrInternalUnexpected.WithDetail("Failed to delete chat session").WithDebugInfo(err.Error())) return @@ -224,12 +242,28 @@ func (h *ChatSessionHandler) updateSessionMaxLength(w http.ResponseWriter, r *ht } params.Uuid = uuid - session, err := h.service.UpdateSessionMaxLength(r.Context(), params) + // Verify session ownership + userID, err := getUserID(r.Context()) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrAuthInvalidCredentials.WithDebugInfo(err.Error())) + return + } + session, err := h.service.GetChatSessionByUUID(r.Context(), uuid) + if err != nil { + dto.RespondWithAPIError(w, dto.ErrResourceNotFound("Chat session").WithDebugInfo(err.Error())) + return + } + if session.UserID != userID { + dto.RespondWithAPIError(w, dto.ErrAuthAccessDenied.WithMessage("You do not own this session")) + return + } + + updatedSession, err := h.service.UpdateSessionMaxLength(r.Context(), params) if err != nil { dto.RespondWithAPIError(w, dto.ErrInternalUnexpected.WithDetail("Failed to update session max length").WithDebugInfo(err.Error())) return } - json.NewEncoder(w).Encode(session) + json.NewEncoder(w).Encode(updatedSession) } func (h *ChatSessionHandler) createChatSessionFromSnapshot(w http.ResponseWriter, r *http.Request) { @@ -248,7 +282,10 @@ func (h *ChatSessionHandler) createChatSessionFromSnapshot(w http.ResponseWriter } var messages []dto.SimpleChatMessage - json.Unmarshal(snapshot.Conversation, &messages) + if err := json.Unmarshal(snapshot.Conversation, &messages); err != nil || len(messages) == 0 { + dto.RespondWithAPIError(w, dto.ErrValidationInvalidInput("Snapshot has no messages")) + return + } promptMsg := messages[0] chatPrompt, err := h.service.GetChatPromptByUUID(r.Context(), promptMsg.Uuid) diff --git a/api/provider/llm_openai.go b/api/provider/llm_openai.go index 08e5b36b..6eeb8405 100644 --- a/api/provider/llm_openai.go +++ b/api/provider/llm_openai.go @@ -69,13 +69,13 @@ func messagesToOpenAIMesages(messages []models.Message, chatFiles []sqlc_queries firstUserMessage, idx, found := lo.FindIndexOf(open_ai_msgs, func(msg openai.ChatCompletionMessage) bool { return msg.Role == "user" }) if found { - slog.Info("firstUserMessage before attach", "msg", firstUserMessage) + slog.Debug("firstUserMessage before attach", "msg", firstUserMessage) open_ai_msgs[idx].MultiContent = append( []openai.ChatMessagePart{ {Type: openai.ChatMessagePartTypeText, Text: firstUserMessage.Content}, }, parts...) open_ai_msgs[idx].Content = "" - slog.Info("firstUserMessage after attach", "msg", firstUserMessage) + slog.Debug("firstUserMessage after attach", "msg", firstUserMessage) } return open_ai_msgs @@ -117,7 +117,7 @@ func NormalizeOpenAIModelName(chatModel sqlc_queries.ChatModel, modelName string if strings.Contains(chatModel.Url, "open.bigmodel.cn") { normalized := strings.ToLower(modelName) if normalized != modelName { - slog.Info("Normalizing BigModel model name from %q to %q", modelName, normalized) + slog.Info("Normalizing BigModel model name", "from", modelName, "to", normalized) } return normalized } diff --git a/api/provider/llm_summary.go b/api/provider/llm_summary.go index d3797a7b..492228ed 100644 --- a/api/provider/llm_summary.go +++ b/api/provider/llm_summary.go @@ -31,7 +31,7 @@ func llm_summarize(ctx context.Context, apiToken, baseURL string, doc string) st openai.WithBaseURL(baseURL), ) if err != nil { - slog.Info("failed to create openai client %s: %v", baseURL, err) + slog.Info("failed to create openai client", "baseURL", baseURL, "error", err) return "" } @@ -41,7 +41,7 @@ func llm_summarize(ctx context.Context, apiToken, baseURL string, doc string) st ) outputValues, err := chains.Call(ctx, llmSummarizationChain, map[string]any{"input_documents": docs}) if err != nil { - slog.Info("failed to call chain: %s, %v", baseURL, err) + slog.Info("failed to call chain", "baseURL", baseURL, "error", err) return "" } out, _ := outputValues["text"].(string) diff --git a/api/provider/model_openai_service.go b/api/provider/model_openai_service.go index eaaa3879..ff6560ae 100644 --- a/api/provider/model_openai_service.go +++ b/api/provider/model_openai_service.go @@ -75,7 +75,7 @@ func handleRegularResponse(ctx context.Context, ch chan<- StreamChunk, client *o completion, err := client.CreateChatCompletion(ctx, req) if err != nil { - slog.Info("OpenAI request failed - Model: %s, ConfiguredURL: %s, BaseURL: %s, Error: %+v", req.Model, configuredURL, baseURL, err) + slog.Info("OpenAI request failed", "model", req.Model, "configuredURL", configuredURL, "baseURL", baseURL, "error", err) ch <- StreamChunk{Err: dto.ErrOpenAIRequestFailed.WithMessage("Failed to create chat completion").WithDebugInfo(err.Error())} return } @@ -100,7 +100,7 @@ func doChatStream(ctx context.Context, ch chan<- StreamChunk, client *openai.Cli slog.Info("Creating OpenAI stream") stream, err := client.CreateChatCompletionStream(ctx, req) if err != nil { - slog.Info("OpenAI stream setup failed - Model: %s, ConfiguredURL: %s, BaseURL: %s, Error: %+v", req.Model, configuredURL, baseURL, err) + slog.Info("OpenAI stream setup failed", "model", req.Model, "configuredURL", configuredURL, "baseURL", baseURL, "error", err) ch <- StreamChunk{Err: dto.ErrOpenAIStreamFailed.WithMessage("Failed to create chat completion stream").WithDebugInfo(err.Error())} return } @@ -139,7 +139,7 @@ func doChatStream(ctx context.Context, ch chan<- StreamChunk, client *openai.Cli rawLine, err := stream.RecvRaw() if err != nil { - slog.Info("OpenAI stream receive error - Model: %s, ConfiguredURL: %s, BaseURL: %s, Error: %+v", req.Model, configuredURL, baseURL, err) + slog.Info("OpenAI stream receive error", "model", req.Model, "configuredURL", configuredURL, "baseURL", baseURL, "error", err) if errors.Is(err, io.EOF) { if TextBuffer.String("\n") == "" && reasonBuffer.String("\n") == "" { errMsg := fmt.Sprintf("stream closed without content; verify configured URL %q resolves to a valid OpenAI-compatible base URL %q and that model %q is valid", configuredURL, baseURL, req.Model) diff --git a/api/svc/chat_auth_user_service.go b/api/svc/chat_auth_user_service.go index d414b064..7b7133ff 100644 --- a/api/svc/chat_auth_user_service.go +++ b/api/svc/chat_auth_user_service.go @@ -3,7 +3,7 @@ package svc import ( "context" "errors" - "fmt" + "log/slog" "net/http" "strconv" "time" @@ -40,7 +40,7 @@ func (s *AuthUserService) CreateAuthUser(ctx context.Context, auth_user_params s } if totalUserCount == 0 { auth_user_params.IsSuperuser = true - fmt.Println("First user is superuser.") + slog.Info("First user is superuser") } auth_user, err := s.q.CreateAuthUser(ctx, auth_user_params) if err != nil { diff --git a/api/svc/chat_workspace_service.go b/api/svc/chat_workspace_service.go index 0eed66a5..19c29a37 100644 --- a/api/svc/chat_workspace_service.go +++ b/api/svc/chat_workspace_service.go @@ -116,7 +116,7 @@ func (s *ChatWorkspaceService) SetWorkspaceAsDefaultForUser(ctx context.Context, // --- Permission --- func (s *ChatWorkspaceService) HasWorkspacePermission(ctx context.Context, uuid string, userID int32) (bool, error) { - slog.Info("Checking permission for workspace=%s, user=%d", uuid, userID) + slog.Info("Checking workspace permission", "workspace", uuid, "user", userID) result, err := s.q.HasWorkspacePermission(ctx, sqlc_queries.HasWorkspacePermissionParams{ Uuid: uuid, UserID: userID, }) @@ -210,7 +210,7 @@ func (s *ChatWorkspaceService) MigrateLegacyActiveSessions(ctx context.Context, ChatSessionUuid: session.ChatSessionUuid, }) if err != nil { - slog.Warn("failed to migrate active session %s: %v", session.ChatSessionUuid, err) + slog.Warn("failed to migrate active session", "sessionUuid", session.ChatSessionUuid, "error", err) continue } // Delete old global active session