From 20186f65bff4e574befdbdc295dd0b2916bb311c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 13 Nov 2025 15:50:10 +0000 Subject: [PATCH] Create settings page placeholder, add auth pattern This adds a pattern for creating pages that require authentication. The settings page is currently empty, but it's helpful to figure out how to do this pattern. --- auth.go | 64 ++++++++++++- content_negotiation.go | 156 +++++++++++++++++++++++++++++++ endpoint.go | 4 + html.go | 52 ++++++++--- main.go | 12 ++- templates/components/header.html | 3 +- templates/empty-auth.html | 7 ++ templates/settings.html | 8 ++ 8 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 content_negotiation.go create mode 100644 templates/empty-auth.html create mode 100644 templates/settings.html diff --git a/auth.go b/auth.go index 9adc9cbd..6c8840bb 100644 --- a/auth.go +++ b/auth.go @@ -19,6 +19,10 @@ type NoCredentialsError struct{} func (e NoCredentialsError) Error() string { return "No credentials were present in the request" } +type NoUserError struct{} + +func (e NoUserError) Error() string { return "That user does not exist" } + type InvalidCredentials struct{} func (e InvalidCredentials) Error() string { return "No username with that password exists" } @@ -27,6 +31,49 @@ type InvalidUsername struct{} func (e InvalidUsername) Error() string { return "That username doesn't exist" } +type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *models.User) +type EnsureAuth struct { + handler AuthenticatedHandler +} + +func NewEnsureAuth(handlerToWrap AuthenticatedHandler) *EnsureAuth { + return &EnsureAuth{handlerToWrap} +} + +func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // If this is an API request respond with a more machine-readable error state + accept := r.Header.Values("Accept") + offers := []string{"application/json", "text/html"} + + content_type := NegotiateContent(accept, offers) + user, err := getAuthenticatedUser(r) + if err != nil { + var msg []byte + // Separate return codes for different authentication failures + if _, ok := err.(*NoCredentialsError); ok { + fmt.Println("No credentials present and no session") + w.Header().Set("WWW-Authenticate-Error", "no-credentials") + msg = []byte("Please provide credentials.\n") + } else if _, ok := err.(*NoUserError); ok { + w.Header().Set("WWW-Authenticate-Error", "invalid-credentials") + msg = []byte("Invalid credentials provided.\n") + } else if _, ok := err.(*InvalidCredentials); ok { + w.Header().Set("WWW-Authenticate-Error", "invalid-credentials") + msg = []byte("Invalid credentials provided.\n") + } + + if content_type == "text/html" { + http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) + return + } + w.Header().Set("WWW-Authenticate", `Basic realm="Nidus Sync"`) + w.WriteHeader(401) + w.Write(msg) + return + } + + ea.handler(w, r, user) +} func addUserSession(r *http.Request, user *models.User) { id := strconv.Itoa(int(user.ID)) sessionManager.Put(r.Context(), "user_id", id) @@ -36,6 +83,21 @@ func addUserSession(r *http.Request, user *models.User) { slog.String("user_id", id)) } +// Helper function to translate strings into solid error types for operating on +func findUser(ctx context.Context, user_id int) (*models.User, error) { + user, err := models.FindUser(ctx, PGInstance.BobDB, int32(user_id)) + if err != nil { + if err.Error() == "No such user" { + return nil, &NoUserError{} + } else { + LogErrorTypeInfo(err) + slog.Error("Unrecognized error. This should be updated in the findUser code", slog.String("err", err.Error())) + return nil, err + } + } + return user, err +} + func getAuthenticatedUser(r *http.Request) (*models.User, error) { //user_id := sessionManager.GetInt(r.Context(), "user_id") user_id_str := sessionManager.GetString(r.Context(), "user_id") @@ -49,7 +111,7 @@ func getAuthenticatedUser(r *http.Request) (*models.User, error) { slog.Int("user_id", user_id), slog.String("username", username)) if user_id > 0 && username != "" { - return models.FindUser(r.Context(), PGInstance.BobDB, int32(user_id)) + return findUser(r.Context(), user_id) } } // If we can't get the user from the session try to get from auth headers diff --git a/content_negotiation.go b/content_negotiation.go new file mode 100644 index 00000000..c456d407 --- /dev/null +++ b/content_negotiation.go @@ -0,0 +1,156 @@ +package main + +import ( + "sort" + "strconv" + "strings" +) + +// acceptValue represents a parsed accept header value +type acceptValue struct { + value string // the media type or value + quality float64 // quality factor (0.0 to 1.0) + specificity int // specificity level for sorting + order int // original order in the accept header +} + +// NegotiateContent returns the best content to offer from a set of possible +// values, based on the preferences represented by the accept values. +func NegotiateContent(accepts []string, offers []string) string { + if len(offers) == 0 { + return "" + } + + // If no accept values, return first offer + if len(accepts) == 0 { + return offers[0] + } + + // Parse accept values (limit to first 32 to avoid DOS) + acceptValues := parseAcceptValues(accepts) + if len(acceptValues) > 32 { + acceptValues = acceptValues[:32] + } + + // Find best match + bestMatch := "" + bestScore := -1.0 + bestSpecificity := -1 + bestOrder := len(offers) // use offer order as tiebreaker + + for offerIdx, offer := range offers { + score, specificity := calculateScore(offer, acceptValues) + + // Check if this is a better match + if score > bestScore || + (score == bestScore && specificity > bestSpecificity) || + (score == bestScore && specificity == bestSpecificity && offerIdx < bestOrder) { + bestMatch = offer + bestScore = score + bestSpecificity = specificity + bestOrder = offerIdx + } + } + + if bestScore <= 0 { + return "" + } + + return bestMatch +} + +// parseAcceptValues parses accept header values into structured format +func parseAcceptValues(accepts []string) []acceptValue { + var values []acceptValue + order := 0 + + for _, accept := range accepts { + // Split by comma to handle multiple values in one string + parts := strings.Split(accept, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + value := acceptValue{ + quality: 1.0, // default quality + order: order, + } + + // Split by semicolon to separate value from parameters + sections := strings.Split(part, ";") + value.value = strings.TrimSpace(sections[0]) + + // Parse quality parameter + for i := 1; i < len(sections); i++ { + param := strings.TrimSpace(sections[i]) + if strings.HasPrefix(param, "q=") { + if q, err := strconv.ParseFloat(param[2:], 64); err == nil { + if q >= 0.0 && q <= 1.0 { + value.quality = q + } + } + break + } + } + + // Calculate specificity for media types + value.specificity = calculateSpecificity(value.value) + + values = append(values, value) + order++ + } + } + + // Sort by quality (desc), then specificity (desc), then order (asc) + sort.Slice(values, func(i, j int) bool { + if values[i].quality != values[j].quality { + return values[i].quality > values[j].quality + } + if values[i].specificity != values[j].specificity { + return values[i].specificity > values[j].specificity + } + return values[i].order < values[j].order + }) + + return values +} + +// calculateSpecificity returns specificity level for media type matching +func calculateSpecificity(mediaType string) int { + if mediaType == "*/*" { + return 0 // least specific + } + if strings.HasSuffix(mediaType, "/*") { + return 1 // type wildcard + } + return 2 // exact match (most specific) +} + +// calculateScore returns the quality score and specificity for an offer against accept values +func calculateScore(offer string, acceptValues []acceptValue) (float64, int) { + for _, accept := range acceptValues { + if matches(offer, accept.value) { + return accept.quality, accept.specificity + } + } + return 0.0, 0 +} + +// matches checks if an offer matches an accept value (including wildcards) +func matches(offer, accept string) bool { + if accept == "*/*" { + return true + } + + if strings.HasSuffix(accept, "/*") { + // Type wildcard (e.g., "text/*") + offerType := strings.Split(offer, "/")[0] + acceptType := strings.Split(accept, "/")[0] + return offerType == acceptType + } + + // Exact match + return offer == accept +} diff --git a/endpoint.go b/endpoint.go index 2df4833a..8e4e7cf0 100644 --- a/endpoint.go +++ b/endpoint.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/Gleipnir-Technology/nidus-sync/models" "github.com/go-chi/chi/v5" "github.com/skip2/go-qrcode" ) @@ -209,6 +210,9 @@ func getServiceRequestUpdates(w http.ResponseWriter, r *http.Request) { htmlServiceRequestUpdates(w) } +func getSettings(w http.ResponseWriter, r *http.Request, u *models.User) { + htmlSettings(w, r, u) +} func getSignup(w http.ResponseWriter, r *http.Request) { htmlSignup(w, r.URL.Path) } diff --git a/html.go b/html.go index 8023d9c1..941811ab 100644 --- a/html.go +++ b/html.go @@ -25,9 +25,15 @@ import ( //go:embed templates/* var embeddedFiles embed.FS +// Authenticated pages +var ( + dashboard = newBuiltTemplate("dashboard", "authenticated") + oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated") + settings = newBuiltTemplate("settings", "authenticated") +) + +// Unauthenticated pages var ( - dashboard = newBuiltTemplate("dashboard", "authenticated") - oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated") phoneCall = newBuiltTemplate("phone-call", "base") report = newBuiltTemplate("report", "base") reportConfirmation = newBuiltTemplate("report-confirmation", "base") @@ -59,6 +65,9 @@ type Link struct { Href string Title string } +type ContentAuthenticatedPlaceholder struct { + User User +} type ContentPhoneCall struct { DistrictName string } @@ -129,6 +138,19 @@ func bigNumber(n int) string { return result.String() } +func contentForUser(ctx context.Context, user *models.User) (User, error) { + notifications, err := notificationsForUser(ctx, user) + if err != nil { + return User{}, err + } + return User{ + DisplayName: user.DisplayName, + Initials: extractInitials(user.DisplayName), + Notifications: notifications, + Username: user.Username, + }, nil + +} func extractInitials(name string) string { parts := strings.Fields(name) var initials strings.Builder @@ -187,11 +209,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User Status: "Completed", }) } - notifications, err := notificationsForUser(ctx, user) - if err != nil { - respondError(w, "Failed to get notifications", err, http.StatusInternalServerError) - return - } + userContent, err := contentForUser(ctx, user) data := ContentDashboard{ CountInspections: int(inspectionCount), CountMosquitoSources: int(sourceCount), @@ -199,12 +217,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User LastSync: lastSync, Org: org.Name.MustGet(), RecentRequests: requests, - User: User{ - DisplayName: user.DisplayName, - Initials: extractInitials(user.DisplayName), - Notifications: notifications, - Username: user.Username, - }, + User: userContent, } renderOrError(w, dashboard, data) } @@ -324,6 +337,18 @@ func htmlServiceRequestUpdates(w http.ResponseWriter) { renderOrError(w, serviceRequestUpdates, data) } +func htmlSettings(w http.ResponseWriter, r *http.Request, user *models.User) { + userContent, err := contentForUser(r.Context(), user) + if err != nil { + respondError(w, "Failed to get user content", err, http.StatusInternalServerError) + return + } + data := ContentAuthenticatedPlaceholder{ + User: userContent, + } + renderOrError(w, settings, data) +} + func htmlSignin(w http.ResponseWriter, errorCode string) { data := ContentSignin{ InvalidCredentials: errorCode == "invalid-credentials", @@ -401,6 +426,7 @@ func parseFromDisk(files []string) (*template.Template, error) { } return templ, nil } + func timeElapsed(seconds null.Val[float32]) string { if !seconds.IsValue() { return "none" diff --git a/main.go b/main.go index d8c27d99..bcd665a9 100644 --- a/main.go +++ b/main.go @@ -69,12 +69,18 @@ func main() { r.Use(middleware.Logger) r.Use(sessionManager.LoadAndSave) + // Root is a special endpoint that is neither authenticated nor unauthenticated r.Get("/", getRoot) + + // Unauthenticated endpoints r.Get("/arcgis/oauth/begin", getArcgisOauthBegin) r.Get("/arcgis/oauth/callback", getArcgisOauthCallback) - r.Get("/qr-code/report/{code}", getQRCodeReport) + r.Get("/favicon.ico", getFavicon) + r.Get("/oauth/refresh", getOAuthRefresh) + r.Get("/phone-call", getPhoneCall) + r.Get("/qr-code/report/{code}", getQRCodeReport) r.Get("/report", getReport) r.Get("/report/{code}", getReportDetail) r.Get("/report/{code}/confirm", getReportConfirmation) @@ -93,7 +99,9 @@ func main() { r.Post("/signin", postSignin) r.Get("/signup", getSignup) r.Post("/signup", postSignup) - r.Get("/favicon.ico", getFavicon) + + // Authenticated endpoints + r.Method("GET", "/settings", NewEnsureAuth(getSettings)) localFS := http.Dir("./static") FileServer(r, "/static", localFS, embeddedStaticFS, "static") diff --git a/templates/components/header.html b/templates/components/header.html index 53f796bb..bfe9cb61 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -76,8 +76,7 @@ {{ .DisplayName }} diff --git a/templates/empty-auth.html b/templates/empty-auth.html new file mode 100644 index 00000000..cb20c49e --- /dev/null +++ b/templates/empty-auth.html @@ -0,0 +1,7 @@ +{{template "authenticated.html" .}} + +{{define "title"}}Dash{{end}} +{{define "style"}} +{{end}} +{{define "content"}} +{{end}} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 00000000..37e484aa --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,8 @@ +{{template "authenticated.html" .}} + +{{define "title"}}Dash{{end}} +{{define "style"}} +{{end}} +{{define "content"}} +

Imagine settings here

+{{end}}