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:
parent
ae4be91d52
commit
20186f65bf
8 changed files with 288 additions and 18 deletions
64
auth.go
64
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
|
||||
|
|
|
|||
156
content_negotiation.go
Normal file
156
content_negotiation.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
48
html.go
48
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 (
|
||||
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"
|
||||
|
|
|
|||
12
main.go
12
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")
|
||||
|
|
|
|||
|
|
@ -76,8 +76,7 @@
|
|||
<span class="me-2">{{ .DisplayName }}</span>
|
||||
</a>
|
||||
<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.html">Settings</a></li>
|
||||
<li><a class="dropdown-item" href="/settings">Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="login.html">Logout</a></li>
|
||||
</ul>
|
||||
|
|
|
|||
7
templates/empty-auth.html
Normal file
7
templates/empty-auth.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{{template "authenticated.html" .}}
|
||||
|
||||
{{define "title"}}Dash{{end}}
|
||||
{{define "style"}}
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{end}}
|
||||
8
templates/settings.html
Normal file
8
templates/settings.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{{template "authenticated.html" .}}
|
||||
|
||||
{{define "title"}}Dash{{end}}
|
||||
{{define "style"}}
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<p>Imagine settings here</p>
|
||||
{{end}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue