Centralising auth, making pdf retrieval via authenticated request

This commit is contained in:
Finley Ghosh 2025-12-07 12:27:41 +11:00
parent 46cf098dfa
commit 57047e3b32
3 changed files with 173 additions and 57 deletions

View file

@ -0,0 +1,109 @@
package auth
import (
"context"
"net/http"
"strings"
)
// ContextKey is a type for context keys to avoid collisions
type ContextKey string
const (
// ContextKeyUsername is the context key for storing the authenticated username
ContextKeyUsername ContextKey = "username"
// ContextKeyAuthUser is the context key for storing the raw auth username
ContextKeyAuthUser ContextKey = "auth_user"
// ContextKeyAuthPass is the context key for storing the raw auth password
ContextKeyAuthPass ContextKey = "auth_pass"
)
// Credentials holds authentication credentials
type Credentials struct {
Username string
Password string
}
// GetCredentials extracts authentication credentials from the request
// This is the single point where auth mechanism is defined
func GetCredentials(r *http.Request) (*Credentials, bool) {
username, password, ok := r.BasicAuth()
if !ok || username == "" {
return nil, false
}
return &Credentials{
Username: username,
Password: password,
}, true
}
// GetUsername extracts and formats the username for display
func GetUsername(r *http.Request) string {
creds, ok := GetCredentials(r)
if !ok {
return "Guest"
}
// Capitalize the username for display
return strings.Title(creds.Username)
}
// GetUsernameFromContext retrieves the username from the request context
func GetUsernameFromContext(ctx context.Context) string {
if username, ok := ctx.Value(ContextKeyUsername).(string); ok {
return username
}
return "Guest"
}
// GetCredentialsFromContext retrieves credentials from the request context
func GetCredentialsFromContext(ctx context.Context) (*Credentials, bool) {
username, okUser := ctx.Value(ContextKeyAuthUser).(string)
password, okPass := ctx.Value(ContextKeyAuthPass).(string)
if !okUser || !okPass {
return nil, false
}
return &Credentials{
Username: username,
Password: password,
}, true
}
// AddAuthToRequest adds authentication credentials to an HTTP request
// This should be used when making authenticated requests to internal services
func AddAuthToRequest(req *http.Request, creds *Credentials) {
if creds != nil {
req.SetBasicAuth(creds.Username, creds.Password)
}
}
// NewAuthenticatedRequest creates a new HTTP request with authentication from the source request
// This is a convenience method that extracts auth from sourceReq and applies it to the new request
func NewAuthenticatedRequest(method, url string, sourceReq *http.Request) (*http.Request, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
// Copy authentication from source request
if creds, ok := GetCredentials(sourceReq); ok {
AddAuthToRequest(req, creds)
}
return req, nil
}
// Middleware adds authentication information to the request context
// This allows handlers to access auth info without parsing headers repeatedly
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
creds, ok := GetCredentials(r)
if ok {
ctx := r.Context()
ctx = context.WithValue(ctx, ContextKeyUsername, strings.Title(creds.Username))
ctx = context.WithValue(ctx, ContextKeyAuthUser, creds.Username)
ctx = context.WithValue(ctx, ContextKeyAuthPass, creds.Password)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}

View file

@ -7,11 +7,10 @@ import (
"strconv"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/auth"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/gorilla/mux"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type PageHandler struct {
@ -30,12 +29,7 @@ func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager, databa
// Helper function to get the username from the request
func getUsername(r *http.Request) string {
username, _, ok := r.BasicAuth()
if ok && username != "" {
caser := cases.Title(language.English)
return caser.String(username) // Capitalise the username for display
}
return "Guest"
return auth.GetUsername(r)
}
// Home page
@ -839,7 +833,7 @@ func (h *PageHandler) EmailsIndex(w http.ResponseWriter, r *http.Request) {
u.email as user_email, u.first_name, u.last_name
FROM emails e
LEFT JOIN users u ON e.user_id = u.id`
var args []interface{}
var conditions []string
@ -882,16 +876,16 @@ func (h *PageHandler) EmailsIndex(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
type EmailWithUser struct {
ID int32 `json:"id"`
Subject string `json:"subject"`
UserID int32 `json:"user_id"`
Created time.Time `json:"created"`
GmailMessageID *string `json:"gmail_message_id"`
AttachmentCount int32 `json:"attachment_count"`
IsDownloaded *bool `json:"is_downloaded"`
UserEmail *string `json:"user_email"`
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
ID int32 `json:"id"`
Subject string `json:"subject"`
UserID int32 `json:"user_id"`
Created time.Time `json:"created"`
GmailMessageID *string `json:"gmail_message_id"`
AttachmentCount int32 `json:"attachment_count"`
IsDownloaded *bool `json:"is_downloaded"`
UserEmail *string `json:"user_email"`
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
}
var emails []EmailWithUser
@ -1080,7 +1074,7 @@ func (h *PageHandler) EmailsShow(w http.ResponseWriter, r *http.Request) {
var attachments []EmailAttachment
hasStoredAttachments := false
if attachmentRows != nil {
defer attachmentRows.Close()
for attachmentRows.Next() {
@ -1179,7 +1173,7 @@ func (h *PageHandler) EmailsShow(w http.ResponseWriter, r *http.Request) {
func (h *PageHandler) EmailsSearch(w http.ResponseWriter, r *http.Request) {
_ = r.URL.Query().Get("search") // TODO: Implement search functionality
// Empty result for now - would need proper implementation
emails := []interface{}{}

View file

@ -12,19 +12,14 @@ import (
"strings"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/auth"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
)
// Import getUsername from pages.go
// If you move getUsername to a shared utils package, update the import accordingly.
// getUsername is a wrapper around auth.GetUsername for backwards compatibility
func getUsername(r *http.Request) string {
username, _, ok := r.BasicAuth()
if ok && username != "" {
// Capitalise the username for display
return strings.Title(username)
}
return "Guest"
return auth.GetUsername(r)
}
// Helper: returns date string or empty if zero
@ -490,7 +485,9 @@ func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int3
}
// SendQuoteReminderEmailWithAttachment is like SendQuoteReminderEmail but includes a PDF attachment
func (h *QuotesHandler) SendQuoteReminderEmailWithAttachment(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename string) error {
func (h *QuotesHandler) SendQuoteReminderEmailWithAttachment(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename string, sourceReq *http.Request) error {
log.Printf("SendQuoteReminderEmailWithAttachment called for quote %d, recipient: %s, PDF URL: %s", quoteID, recipient, pdfPath)
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
@ -506,43 +503,53 @@ func (h *QuotesHandler) SendQuoteReminderEmailWithAttachment(ctx context.Context
// Download PDF from URL and prepare attachment if available
var attachments []interface{}
resp, err := http.Get(pdfPath)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
// Create temporary file for the PDF
tmpFile, err := os.CreateTemp("", "quote_*.pdf")
if err == nil {
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Create authenticated request for PDF download
req, err := auth.NewAuthenticatedRequest("GET", pdfPath, sourceReq)
if err != nil {
log.Printf("Failed to create PDF download request for quote %d: %v", quoteID, err)
} else {
client := &http.Client{}
resp, err := client.Do(req)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
// Copy PDF content to temp file
if _, err := io.Copy(tmpFile, resp.Body); err == nil {
type Attachment struct {
Filename string
FilePath string
}
attachments = []interface{}{
Attachment{
Filename: pdfFilename,
FilePath: tmpFile.Name(),
},
// Create temporary file for the PDF
tmpFile, err := os.CreateTemp("", "quote_*.pdf")
if err == nil {
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Copy PDF content to temp file
if _, err := io.Copy(tmpFile, resp.Body); err == nil {
type Attachment struct {
Filename string
FilePath string
}
attachments = []interface{}{
Attachment{
Filename: pdfFilename,
FilePath: tmpFile.Name(),
},
}
log.Printf("Successfully downloaded and attached PDF for quote %d: %s", quoteID, pdfFilename)
} else {
log.Printf("Failed to copy PDF content for quote %d: %v", quoteID, err)
}
} else {
log.Printf("Failed to copy PDF content for quote %d: %v", quoteID, err)
log.Printf("Failed to create temporary file for quote %d PDF: %v", quoteID, err)
}
} else {
log.Printf("Failed to create temporary file for quote %d PDF: %v", quoteID, err)
} else if err != nil {
log.Printf("Failed to download PDF from %s for quote %d: %v", pdfPath, quoteID, err)
} else if resp != nil {
defer resp.Body.Close()
log.Printf("PDF download returned status %d for quote %d at %s", resp.StatusCode, quoteID, pdfPath)
}
} else if err != nil {
log.Printf("Failed to download PDF from %s for quote %d: %v", pdfPath, quoteID, err)
} else if resp != nil {
defer resp.Body.Close()
log.Printf("PDF download returned status %d for quote %d at %s", resp.StatusCode, quoteID, pdfPath)
}
// If PDF download fails, just send email without attachment
// Send the email (with or without attachment)
log.Printf("Sending email for quote %d with %d attachment(s)", quoteID, len(attachments))
err = h.emailService.SendTemplateEmailWithAttachments(
recipient,
subject,
@ -577,6 +584,8 @@ func (h *QuotesHandler) SendQuoteReminderEmailWithAttachment(ctx context.Context
// SendManualReminder handles POST requests to manually send a quote reminder
func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Request) {
log.Printf("SendManualReminder handler called")
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
@ -643,6 +652,7 @@ func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Reques
// Attach PDF quote from URL
pdfURL := fmt.Sprintf("https://stg.cmctechnologies.com.au/pdf/%s.pdf", enquiryRef)
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
log.Printf("Manual reminder for quote %d (%s): recipient=%s, pdfURL=%s", quoteID, enquiryRef, customerEmail, pdfURL)
// Get username from request
username := getUsername(r)
@ -661,13 +671,16 @@ func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Reques
usernamePtr,
pdfURL,
pdfFilename,
r,
)
if err != nil {
log.Printf("Failed to send manual reminder for quote %d: %v", quoteID, err)
http.Error(w, fmt.Sprintf("Failed to send reminder: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Manual reminder sent successfully for quote %d (%s) to %s", quoteID, enquiryRef, customerEmail)
// Redirect back to quotes page
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
}