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.
This commit is contained in:
Eli Ribble 2025-11-13 15:50:10 +00:00
parent ae4be91d52
commit 20186f65bf
No known key found for this signature in database
8 changed files with 288 additions and 18 deletions

64
auth.go
View file

@ -19,6 +19,10 @@ type NoCredentialsError struct{}
func (e NoCredentialsError) Error() string { return "No credentials were present in the request" } 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{} type InvalidCredentials struct{}
func (e InvalidCredentials) Error() string { return "No username with that password exists" } 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" } 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) { func addUserSession(r *http.Request, user *models.User) {
id := strconv.Itoa(int(user.ID)) id := strconv.Itoa(int(user.ID))
sessionManager.Put(r.Context(), "user_id", 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)) 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) { func getAuthenticatedUser(r *http.Request) (*models.User, error) {
//user_id := sessionManager.GetInt(r.Context(), "user_id") //user_id := sessionManager.GetInt(r.Context(), "user_id")
user_id_str := sessionManager.GetString(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.Int("user_id", user_id),
slog.String("username", username)) slog.String("username", username))
if user_id > 0 && 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 // If we can't get the user from the session try to get from auth headers

156
content_negotiation.go Normal file
View file

@ -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
}

View file

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/Gleipnir-Technology/nidus-sync/models"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
) )
@ -209,6 +210,9 @@ func getServiceRequestUpdates(w http.ResponseWriter, r *http.Request) {
htmlServiceRequestUpdates(w) 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) { func getSignup(w http.ResponseWriter, r *http.Request) {
htmlSignup(w, r.URL.Path) htmlSignup(w, r.URL.Path)
} }

52
html.go
View file

@ -25,9 +25,15 @@ import (
//go:embed templates/* //go:embed templates/*
var embeddedFiles embed.FS var embeddedFiles embed.FS
// Authenticated pages
var (
dashboard = newBuiltTemplate("dashboard", "authenticated")
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
settings = newBuiltTemplate("settings", "authenticated")
)
// Unauthenticated pages
var ( var (
dashboard = newBuiltTemplate("dashboard", "authenticated")
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
phoneCall = newBuiltTemplate("phone-call", "base") phoneCall = newBuiltTemplate("phone-call", "base")
report = newBuiltTemplate("report", "base") report = newBuiltTemplate("report", "base")
reportConfirmation = newBuiltTemplate("report-confirmation", "base") reportConfirmation = newBuiltTemplate("report-confirmation", "base")
@ -59,6 +65,9 @@ type Link struct {
Href string Href string
Title string Title string
} }
type ContentAuthenticatedPlaceholder struct {
User User
}
type ContentPhoneCall struct { type ContentPhoneCall struct {
DistrictName string DistrictName string
} }
@ -129,6 +138,19 @@ func bigNumber(n int) string {
return result.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 { func extractInitials(name string) string {
parts := strings.Fields(name) parts := strings.Fields(name)
var initials strings.Builder var initials strings.Builder
@ -187,11 +209,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
Status: "Completed", Status: "Completed",
}) })
} }
notifications, err := notificationsForUser(ctx, user) userContent, err := contentForUser(ctx, user)
if err != nil {
respondError(w, "Failed to get notifications", err, http.StatusInternalServerError)
return
}
data := ContentDashboard{ data := ContentDashboard{
CountInspections: int(inspectionCount), CountInspections: int(inspectionCount),
CountMosquitoSources: int(sourceCount), CountMosquitoSources: int(sourceCount),
@ -199,12 +217,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
LastSync: lastSync, LastSync: lastSync,
Org: org.Name.MustGet(), Org: org.Name.MustGet(),
RecentRequests: requests, RecentRequests: requests,
User: User{ User: userContent,
DisplayName: user.DisplayName,
Initials: extractInitials(user.DisplayName),
Notifications: notifications,
Username: user.Username,
},
} }
renderOrError(w, dashboard, data) renderOrError(w, dashboard, data)
} }
@ -324,6 +337,18 @@ func htmlServiceRequestUpdates(w http.ResponseWriter) {
renderOrError(w, serviceRequestUpdates, data) 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) { func htmlSignin(w http.ResponseWriter, errorCode string) {
data := ContentSignin{ data := ContentSignin{
InvalidCredentials: errorCode == "invalid-credentials", InvalidCredentials: errorCode == "invalid-credentials",
@ -401,6 +426,7 @@ func parseFromDisk(files []string) (*template.Template, error) {
} }
return templ, nil return templ, nil
} }
func timeElapsed(seconds null.Val[float32]) string { func timeElapsed(seconds null.Val[float32]) string {
if !seconds.IsValue() { if !seconds.IsValue() {
return "none" return "none"

12
main.go
View file

@ -69,12 +69,18 @@ func main() {
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(sessionManager.LoadAndSave) r.Use(sessionManager.LoadAndSave)
// Root is a special endpoint that is neither authenticated nor unauthenticated
r.Get("/", getRoot) r.Get("/", getRoot)
// Unauthenticated endpoints
r.Get("/arcgis/oauth/begin", getArcgisOauthBegin) r.Get("/arcgis/oauth/begin", getArcgisOauthBegin)
r.Get("/arcgis/oauth/callback", getArcgisOauthCallback) 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("/oauth/refresh", getOAuthRefresh)
r.Get("/phone-call", getPhoneCall) r.Get("/phone-call", getPhoneCall)
r.Get("/qr-code/report/{code}", getQRCodeReport)
r.Get("/report", getReport) r.Get("/report", getReport)
r.Get("/report/{code}", getReportDetail) r.Get("/report/{code}", getReportDetail)
r.Get("/report/{code}/confirm", getReportConfirmation) r.Get("/report/{code}/confirm", getReportConfirmation)
@ -93,7 +99,9 @@ func main() {
r.Post("/signin", postSignin) r.Post("/signin", postSignin)
r.Get("/signup", getSignup) r.Get("/signup", getSignup)
r.Post("/signup", postSignup) r.Post("/signup", postSignup)
r.Get("/favicon.ico", getFavicon)
// Authenticated endpoints
r.Method("GET", "/settings", NewEnsureAuth(getSettings))
localFS := http.Dir("./static") localFS := http.Dir("./static")
FileServer(r, "/static", localFS, embeddedStaticFS, "static") FileServer(r, "/static", localFS, embeddedStaticFS, "static")

View file

@ -76,8 +76,7 @@
<span class="me-2">{{ .DisplayName }}</span> <span class="me-2">{{ .DisplayName }}</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="profile.html">Profile</a></li> <li><a class="dropdown-item" href="/settings">Settings</a></li>
<li><a class="dropdown-item" href="settings.html">Settings</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="login.html">Logout</a></li> <li><a class="dropdown-item text-danger" href="login.html">Logout</a></li>
</ul> </ul>

View file

@ -0,0 +1,7 @@
{{template "authenticated.html" .}}
{{define "title"}}Dash{{end}}
{{define "style"}}
{{end}}
{{define "content"}}
{{end}}

8
templates/settings.html Normal file
View file

@ -0,0 +1,8 @@
{{template "authenticated.html" .}}
{{define "title"}}Dash{{end}}
{{define "style"}}
{{end}}
{{define "content"}}
<p>Imagine settings here</p>
{{end}}