package main import ( "fmt" "mime/multipart" "path/filepath" "slices" "strconv" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" mmodels "github.com/abhinavxd/libredesk/internal/media/models" notifier "github.com/abhinavxd/libredesk/internal/notification" "github.com/abhinavxd/libredesk/internal/stringutil" tmpl "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user/models" realip "github.com/ferluci/fast-realip" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" ) const ( maxAvatarSizeMB = 2 ) type updateAvailabilityRequest struct { Status string `json:"status"` } type resetPasswordRequest struct { Email string `json:"email"` } type setPasswordRequest struct { Token string `json:"token"` Password string `json:"password"` } type availabilityRequest struct { Status string `json:"status"` } type agentReq struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` SendWelcomeEmail bool `json:"send_welcome_email"` Teams []string `json:"teams"` Roles []string `json:"roles"` Enabled bool `json:"enabled"` AvailabilityStatus string `json:"availability_status"` NewPassword string `json:"new_password,omitempty"` } // handleGetAgents returns all agents. func handleGetAgents(r *fastglue.Request) error { var app = r.Context.(*App) agents, err := app.user.GetAgents() if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agents) } // handleGetAgentsCompact returns all agents in a compact format. func handleGetAgentsCompact(r *fastglue.Request) error { var app = r.Context.(*App) agents, err := app.user.GetAgentsCompact() if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agents) } // handleGetAgent returns an agent. func handleGetAgent(r *fastglue.Request) error { var app = r.Context.(*App) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil || id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } agent, err := app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agent) } // handleUpdateAgentAvailability updates the current agent availability. func handleUpdateAgentAvailability(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ip = realip.FromRequest(r.RequestCtx) availReq availabilityRequest ) // Decode JSON request if err := r.Decode(&availReq, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } // Fetch entire agent agent, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } // Same status? if agent.AvailabilityStatus == availReq.Status { return r.SendEnvelope(agent) } // Update availability status if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { return sendErrorEnvelope(r, err) } // Skip activity log if agent returns online from away (to avoid spam). if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil { app.lo.Error("error creating activity log", "error", err) } } // Fetch updated agent and return agent, err = app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agent) } // handleGetCurrentAgentTeams returns the teams of current agent. func handleGetCurrentAgentTeams(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) teams, err := app.team.GetUserTeams(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(teams) } // handleUpdateCurrentAgent updates the current agent. func handleUpdateCurrentAgent(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) form, err := r.RequestCtx.MultipartForm() if err != nil { app.lo.Error("error parsing form data", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) } files, ok := form.File["files"] // Upload avatar? if ok && len(files) > 0 { agent, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } if err := uploadUserAvatar(r, agent, files); err != nil { return sendErrorEnvelope(r, err) } } // Fetch updated agent and return. agent, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agent) } // handleCreateAgent creates a new agent. func handleCreateAgent(r *fastglue.Request) error { var ( app = r.Context.(*App) req = agentReq{} ) if err := r.Decode(&req, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } // Validate agent request if err := validateAgentRequest(r, &req); err != nil { return err } agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles) if err != nil { return sendErrorEnvelope(r, err) } // Upsert user teams. if len(req.Teams) > 0 { app.team.UpsertUserTeams(agent.ID, req.Teams) } if req.SendWelcomeEmail { // Generate reset token. resetToken, err := app.user.SetResetPasswordToken(agent.ID) if err != nil { return sendErrorEnvelope(r, err) } // Render template and send email. content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{ "ResetToken": resetToken, "Email": req.Email, }) if err != nil { app.lo.Error("error rendering template", "error", err) } if err := app.notifier.Send(notifier.Message{ RecipientEmails: []string{req.Email}, Subject: app.i18n.T("globals.messages.welcomeToLibredesk"), Content: content, Provider: notifier.ProviderEmail, }); err != nil { app.lo.Error("error sending notification message", "error", err) } } // Refetch agent as other details might've changed. agent, err = app.user.GetAgent(agent.ID, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agent) } // handleUpdateAgent updates an agent. func handleUpdateAgent(r *fastglue.Request) error { var ( app = r.Context.(*App) req = agentReq{} auser = r.RequestCtx.UserValue("user").(amodels.User) ip = realip.FromRequest(r.RequestCtx) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) if id == 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) } if err := r.Decode(&req, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } // Validate agent request if err := validateAgentRequest(r, &req); err != nil { return err } agent, err := app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } oldAvailabilityStatus := agent.AvailabilityStatus // Update agent with individual fields if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil { return sendErrorEnvelope(r, err) } // Invalidate authz cache. defer app.authz.InvalidateUserCache(id) // Create activity log if user availability status changed. if oldAvailabilityStatus != req.AvailabilityStatus { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil { app.lo.Error("error creating activity log", "error", err) } } // Upsert agent teams. if err := app.team.UpsertUserTeams(id, req.Teams); err != nil { return sendErrorEnvelope(r, err) } // Refetch agent and return. agent, err = app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(agent) } // handleDeleteAgent soft deletes an agent. func handleDeleteAgent(r *fastglue.Request) error { var ( app = r.Context.(*App) id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) auser = r.RequestCtx.UserValue("user").(amodels.User) ) if err != nil || id == 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError) } // Disallow if self-deleting. if id == auser.ID { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userCannotDeleteSelf"), nil, envelope.InputError) } // Soft delete user. if err = app.user.SoftDeleteAgent(id); err != nil { return sendErrorEnvelope(r, err) } // Unassign all open conversations assigned to the user. if err := app.conversation.UnassignOpen(id); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) } // handleGetCurrentAgent returns the current logged in agent. func handleGetCurrentAgent(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) u, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(u) } // handleDeleteCurrentAgentAvatar deletes the current agent's avatar. func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) // Get user agent, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) } // Valid str? if agent.AvatarURL.String == "" { return r.SendEnvelope(true) } fileName := filepath.Base(agent.AvatarURL.String) // Delete file from the store. if err := app.media.Delete(fileName); err != nil { return sendErrorEnvelope(r, err) } if err = app.user.UpdateAvatar(agent.ID, ""); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) } // handleResetPassword generates a reset password token and sends an email to the agent. func handleResetPassword(r *fastglue.Request) error { var ( app = r.Context.(*App) auser, ok = r.RequestCtx.UserValue("user").(amodels.User) resetReq resetPasswordRequest ) if ok && auser.ID > 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) } // Decode JSON request if err := r.Decode(&resetReq, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } if resetReq.Email == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) } agent, err := app.user.GetAgent(0, resetReq.Email) if err != nil { // Send 200 even if user not found, to prevent email enumeration. return r.SendEnvelope(true) } token, err := app.user.SetResetPasswordToken(agent.ID) if err != nil { return sendErrorEnvelope(r, err) } // Send email. content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{ "ResetToken": token, }) if err != nil { app.lo.Error("error rendering template", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError) } if err := app.notifier.Send(notifier.Message{ RecipientEmails: []string{agent.Email.String}, Subject: "Reset Password", Content: content, Provider: notifier.ProviderEmail, }); err != nil { app.lo.Error("error sending password reset email", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError) } return r.SendEnvelope(true) } // handleSetPassword resets the password with the provided token. func handleSetPassword(r *fastglue.Request) error { var ( app = r.Context.(*App) agent, ok = r.RequestCtx.UserValue("user").(amodels.User) req setPasswordRequest ) if ok && agent.ID > 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) } if err := r.Decode(&req, "json"); err != nil { return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) } if req.Password == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError) } if err := app.user.ResetPassword(req.Token, req.Password); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) } // uploadUserAvatar uploads the user avatar. func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error { var app = r.Context.(*App) fileHeader := files[0] file, err := fileHeader.Open() if err != nil { app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil) } defer file.Close() // Sanitize filename. srcFileName := stringutil.SanitizeFilename(fileHeader.Filename) srcContentType := fileHeader.Header.Get("Content-Type") srcFileSize := fileHeader.Size srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") if !slices.Contains(image.Exts, srcExt) { return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil) } // Check file size if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB { app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB) return envelope.NewError( envelope.InputError, app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), nil, ) } // Reset ptr. file.Seek(0, 0) linkedModel := null.StringFrom(mmodels.ModelUser) linkedID := null.IntFrom(user.ID) disposition := null.NewString("", false) contentID := "" meta := []byte("{}") media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta) if err != nil { app.lo.Error("error uploading file", "user_id", user.ID, "error", err) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) } // Delete current avatar. if user.AvatarURL.Valid { fileName := filepath.Base(user.AvatarURL.String) if err := app.media.Delete(fileName); err != nil { app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err) } } // Save file path. path, err := stringutil.GetPathFromURL(media.URL) if err != nil { app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) } if err := app.user.UpdateAvatar(user.ID, path); err != nil { return sendErrorEnvelope(r, err) } return nil } // handleGenerateAPIKey generates a new API key for a user func handleGenerateAPIKey(r *fastglue.Request) error { var ( app = r.Context.(*App) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) if id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } // Check if user exists user, err := app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } // Generate API key and secret apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID) if err != nil { return sendErrorEnvelope(r, err) } // Return the API key and secret (only shown once) response := struct { APIKey string `json:"api_key"` APISecret string `json:"api_secret"` }{ APIKey: apiKey, APISecret: apiSecret, } return r.SendEnvelope(response) } // handleRevokeAPIKey revokes a user's API key func handleRevokeAPIKey(r *fastglue.Request) error { var ( app = r.Context.(*App) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) if id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) } // Check if user exists _, err := app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } // Revoke API key if err := app.user.RevokeAPIKey(id); err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(true) } // validateAgentRequest validates common agent request fields and normalizes the email func validateAgentRequest(r *fastglue.Request, req *agentReq) error { var app = r.Context.(*App) // Normalize email req.Email = strings.TrimSpace(strings.ToLower(req.Email)) if req.Email == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) } if !stringutil.ValidEmail(req.Email) { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError) } if req.Roles == nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) } if req.FirstName == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) } return nil }