Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions api/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"

"github.com/rotisserie/eris"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions api/dto/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions api/handler/auth_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
6 changes: 5 additions & 1 deletion api/handler/auth_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions api/handler/bot_answer_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params); 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 {
Expand Down
13 changes: 12 additions & 1 deletion api/handler/chat_session_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions api/handler/chat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion api/handler/file_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/handler/model_privilege.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 40 additions & 3 deletions api/handler/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions api/provider/llm_openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions api/provider/llm_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}

Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions api/provider/model_openai_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions api/svc/chat_auth_user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package svc
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions api/svc/chat_workspace_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
Expand Down
Loading