refactored for maintainability

This commit is contained in:
2026-02-08 17:19:45 +11:00
parent 7125683e6a
commit ac38025b77
40 changed files with 1211 additions and 920 deletions

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
@@ -14,23 +13,17 @@ import (
func AdminDashboard(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
var users *db.Users
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
users, err = db.GetUsers(ctx, tx, nil)
if err != nil {
return false, errors.Wrap(err, "db.GetUsers")
}
return true, nil
}); !ok {
return
}
defer func() { _ = tx.Rollback() }()
users, err := db.GetUsers(ctx, tx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetUsers"))
return
}
_ = tx.Commit()
renderSafely(page.AdminDashboard(users), s, r, w)
})
}

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
@@ -15,30 +14,21 @@ import (
// AdminUsersList shows all users
func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "DB Transaction failed", errors.Wrap(err, "conn.BeginTx"))
var users *db.Users
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
return
}
defer func() { _ = tx.Rollback() }()
// Get all users
pageOpts, err := pageOptsFromForm(r)
if err != nil {
throwBadRequest(s, w, r, "invalid form data", err)
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
users, err = db.GetUsers(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetUsers")
}
return true, nil
}); !ok {
return
}
users, err := db.GetUsers(ctx, tx, pageOpts)
if err != nil {
throwInternalServiceError(s, w, r, "Failed to load users", errors.Wrap(err, "db.GetUsers"))
return
}
_ = tx.Commit()
renderSafely(admin.UserList(users), s, r, w)
})
}

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws"
@@ -15,11 +14,12 @@ import (
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/pkg/oauth"
)
func Callback(
server *hws.Server,
s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
cfg *config.Config,
@@ -31,26 +31,9 @@ func Callback(
attempts, exceeded, track := store.TrackRedirect(r, "/callback", 5)
if exceeded {
err := errors.Errorf(
"callback redirect loop detected after %d attempts | ip=%s ua=%s path=%s first_seen=%s",
attempts,
track.IP,
track.UserAgent,
track.Path,
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
)
err := track.Error(attempts)
store.ClearRedirectTrack(r, "/callback")
throwError(
server,
w,
r,
http.StatusBadRequest,
"OAuth callback failed: Too many redirect attempts. Please try logging in again.",
err,
"warn",
)
throw.BadRequest(s, w, r, "Too many redirects. Please try logging in again.", err)
return
}
@@ -64,12 +47,12 @@ func Callback(
if err != nil {
if vsErr, ok := err.(*verifyStateError); ok {
if vsErr.IsCookieError() {
throwUnauthorized(server, w, r, "OAuth session not found or expired", err)
throw.Unauthorized(s, w, r, "OAuth session not found or expired", err)
} else {
throwForbiddenSecurity(server, w, r, "OAuth state verification failed", err)
throw.ForbiddenSecurity(s, w, r, "OAuth state verification failed", err)
}
} else {
throwForbiddenSecurity(server, w, r, "OAuth state verification failed", err)
throw.ForbiddenSecurity(s, w, r, "OAuth state verification failed", err)
}
return
}
@@ -77,20 +60,17 @@ func Callback(
switch data {
case "login":
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "DB Transaction failed to start", err)
var redirect func()
if ok := db.WithWriteTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
redirect, err = login(ctx, auth, tx, cfg, w, r, code, store, discordAPI)
if err != nil {
throw.InternalServiceError(s, w, r, "OAuth login failed", err)
return false, nil
}
return true, nil
}); !ok {
return
}
defer tx.Rollback()
redirect, err := login(ctx, auth, tx, cfg, w, r, code, store, discordAPI)
if err != nil {
throwInternalServiceError(server, w, r, "OAuth login failed", err)
return
}
tx.Commit()
redirect()
return
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/view/page"
)
@@ -21,7 +22,7 @@ func ErrorPage(hwsError hws.HWSError) (hws.ErrorPage, error) {
// Get technical details if applicable
var details string
if showDetails && hwsError.Error != nil {
details = formatErrorDetails(hwsError.Error)
details = notify.FormatErrorDetails(hwsError.Error)
}
// Render appropriate template

View File

@@ -2,152 +2,15 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/a-h/templ"
"github.com/pkg/errors"
)
// throwError is a generic helper that all throw* functions use internally
func throwError(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
statusCode int,
msg string,
err error,
level hws.ErrorLevel,
) {
s.ThrowError(w, r, hws.HWSError{
StatusCode: statusCode,
Message: msg,
Error: err,
Level: level,
RenderErrorPage: true, // throw* family always renders error pages
})
}
// throwInternalServiceError handles 500 errors (server failures)
func throwInternalServiceError(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusInternalServerError, msg, err, hws.ErrorERROR)
}
// throwServiceUnavailable handles 503 errors
func throwServiceUnavailable(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusServiceUnavailable, msg, err, hws.ErrorERROR)
}
// throwBadRequest handles 400 errors (malformed requests)
func throwBadRequest(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusBadRequest, msg, err, hws.ErrorDEBUG)
}
// throwForbidden handles 403 errors (normal permission denials)
func throwForbidden(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusForbidden, msg, err, hws.ErrorDEBUG)
}
// throwForbiddenSecurity handles 403 errors for security events (uses WARN level)
func throwForbiddenSecurity(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusForbidden, msg, err, hws.ErrorWARN)
}
// throwUnauthorized handles 401 errors (not authenticated)
func throwUnauthorized(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusUnauthorized, msg, err, hws.ErrorDEBUG)
}
// throwUnauthorizedSecurity handles 401 errors for security events (uses WARN level)
func throwUnauthorizedSecurity(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
msg string,
err error,
) {
throwError(s, w, r, http.StatusUnauthorized, msg, err, hws.ErrorWARN)
}
// throwNotFound handles 404 errors
func throwNotFound(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
path string,
) {
msg := fmt.Sprintf("The requested resource was not found: %s", path)
err := errors.New("Resource not found")
throwError(s, w, r, http.StatusNotFound, msg, err, hws.ErrorDEBUG)
}
// ErrorDetails contains structured error information for WebSocket error modals
type ErrorDetails struct {
Code int `json:"code"`
Stacktrace string `json:"stacktrace"`
}
// formatErrorDetails extracts and formats error details from wrapped errors
func formatErrorDetails(err error) string {
if err == nil {
return ""
}
// Use %+v format to get stack trace from github.com/pkg/errors
return fmt.Sprintf("%+v", err)
}
// SerializeErrorDetails creates a JSON string with code and stacktrace
// This is exported so it can be used when creating error notifications
func SerializeErrorDetails(code int, err error) string {
details := ErrorDetails{
Code: code,
Stacktrace: formatErrorDetails(err),
}
jsonData, jsonErr := json.Marshal(details)
if jsonErr != nil {
// Fallback if JSON encoding fails
return fmt.Sprintf(`{"code":%d,"stacktrace":"Failed to serialize error"}`, code)
}
return string(jsonData)
}
// parseErrorDetails extracts code and stacktrace from JSON Details field
// Returns (code, stacktrace). If parsing fails, returns (500, original details string)
func parseErrorDetails(details string) (int, string) {
@@ -155,7 +18,7 @@ func parseErrorDetails(details string) (int, string) {
return 500, ""
}
var errDetails ErrorDetails
var errDetails notify.ErrorDetails
err := json.Unmarshal([]byte(details), &errDetails)
if err != nil {
// Not JSON or malformed - treat as plain stacktrace with default code
@@ -168,6 +31,6 @@ func parseErrorDetails(details string) (int, string) {
func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) {
err := page.Render(r.Context(), w)
if err != nil {
throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page."))
throw.InternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page."))
}
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/view/page"
"git.haelnorr.com/h/golib/hws"
@@ -14,7 +15,7 @@ func Index(s *hws.Server) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
throwNotFound(s, w, r, r.URL.Path)
throw.NotFound(s, w, r, r.URL.Path)
}
renderSafely(page.Index(), s, r, w)
},

View File

@@ -0,0 +1,49 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// IsUnique creates a handler that checks field uniqueness
// Returns 200 OK if unique, 409 Conflict if not unique
func IsUnique(
s *hws.Server,
conn *bun.DB,
model any,
field string,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, err := validation.ParseForm(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
value := getter.String(field).TrimSpace().Required().Value
if !getter.Validate() {
w.WriteHeader(http.StatusBadRequest)
return
}
unique := false
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, model, field, value)
if err != nil {
return false, errors.Wrap(err, "db.IsUnique")
}
return true, nil
}); !ok {
return
}
if unique {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusConflict)
}
})
}

View File

@@ -1,45 +0,0 @@
package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/store"
"github.com/uptrace/bun"
)
func IsUsernameUnique(
server *hws.Server,
conn *bun.DB,
cfg *config.Config,
store *store.Store,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "Database transaction failed", err)
return
}
defer tx.Rollback()
unique, err := db.IsUsernameUnique(ctx, tx, username)
if err != nil {
throwInternalServiceError(server, w, r, "Database query failed", err)
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
} else {
w.WriteHeader(http.StatusOK)
}
},
)
}

View File

@@ -11,7 +11,9 @@ import (
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/pkg/oauth"
)
@@ -31,7 +33,7 @@ func Login(
if r.Method == "POST" {
if err != nil {
notifyServiceUnavailable(s, r, "Login currently unavailable", err)
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
w.WriteHeader(http.StatusOK)
return
}
@@ -40,46 +42,29 @@ func Login(
}
if err != nil {
throwServiceUnavailable(s, w, r, "Login currently unavailable", err)
throw.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
return
}
cookies.SetPageFrom(w, r, cfg.HWSAuth.TrustedHost)
attempts, exceeded, track := st.TrackRedirect(r, "/login", 5)
if exceeded {
err := errors.Errorf(
"login redirect loop detected after %d attempts | ip=%s ua=%s path=%s first_seen=%s",
attempts,
track.IP,
track.UserAgent,
track.Path,
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
)
err = track.Error(attempts)
st.ClearRedirectTrack(r, "/login")
throwError(
s,
w,
r,
http.StatusBadRequest,
"Login failed: Too many redirect attempts. Please clear your browser cookies and try again.",
err,
"warn",
)
throw.BadRequest(s, w, r, "Too many redirects. Please clear your browser cookies and try again", err)
return
}
state, uak, err := oauth.GenerateState(cfg.OAuth, "login")
if err != nil {
throwInternalServiceError(s, w, r, "Failed to generate state token", err)
throw.InternalServiceError(s, w, r, "Failed to generate state token", err)
return
}
oauth.SetStateCookie(w, uak, cfg.HWSAuth.SSL)
link, err := discordAPI.GetOAuthLink(state)
if err != nil {
throwInternalServiceError(s, w, r, "An error occurred trying to generate the login link", err)
throw.InternalServiceError(s, w, r, "An error occurred trying to generate the login link", err)
return
}
st.ClearRedirectTrack(r, "/login")

View File

@@ -3,12 +3,12 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
@@ -21,42 +21,31 @@ func Logout(
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
return
}
defer func() { _ = tx.Rollback() }()
user := db.CurrentUser(r.Context())
if user == nil {
// JIC - should be impossible to get here if route is protected by LoginReq
w.Header().Set("HX-Redirect", "/")
return
}
token, err := user.DeleteDiscordTokens(ctx, tx)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "user.DeleteDiscordTokens"))
return
}
if token != nil {
err = discordAPI.RevokeToken(token.Convert())
if ok := db.WithWriteTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
token, err := user.DeleteDiscordTokens(ctx, tx)
if err != nil {
throwInternalServiceError(s, w, r, "Discord API error", errors.Wrap(err, "discordAPI.RevokeToken"))
return
return false, errors.Wrap(err, "user.DeleteDiscordTokens")
}
}
err = auth.Logout(tx, w, r)
if err != nil {
throwInternalServiceError(s, w, r, "Logout failed", errors.Wrap(err, "auth.Logout"))
return
}
err = tx.Commit()
if err != nil {
throwInternalServiceError(s, w, r, "Logout failed", errors.Wrap(err, "tx.Commit"))
if token != nil {
err = discordAPI.RevokeToken(token.Convert())
if err != nil {
throw.InternalServiceError(s, w, r, "Discord API error", errors.Wrap(err, "discordAPI.RevokeToken"))
return false, nil
}
}
err = auth.Logout(tx, w, r)
if err != nil {
throw.InternalServiceError(s, w, r, "Logout failed", errors.Wrap(err, "auth.Logout"))
return false, nil
}
return true, nil
}); !ok {
return
}
w.Header().Set("HX-Redirect", "/")

View File

@@ -4,12 +4,13 @@ import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/page"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
@@ -31,248 +32,62 @@ func NewSeasonSubmit(
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse form data
err := r.ParseForm()
if err != nil {
err = notifyWarn(s, r, "Invalid Form", "Please check your input and try again.", nil)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
name := getter.String("name").
TrimSpace().Required().
MaxLength(20).MinLength(5).Value
shortName := getter.String("short_name").
TrimSpace().ToUpper().Required().
MaxLength(6).MinLength(2).Value
format := timefmt.NewBuilder().
DayNumeric2().Slash().
MonthNumeric2().Slash().
Year4().Build()
startDate := getter.Time("start_date", format).Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
// Get form values
name := strings.TrimSpace(r.FormValue("name"))
shortName := strings.TrimSpace(strings.ToUpper(r.FormValue("short_name")))
startDateStr := r.FormValue("start_date")
// Validate required fields
if name == "" || shortName == "" || startDateStr == "" {
err = notifyWarn(s, r, "Missing Fields", "All fields are required.", nil)
nameUnique := false
shortNameUnique := false
var season *db.Season
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
nameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "name", name)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
return false, errors.Wrap(err, "db.IsSeasonNameUnique")
}
return
}
// Validate field lengths
if len(name) > 20 {
err = notifyWarn(s, r, "Invalid Name", "Season name must be 20 characters or less.", nil)
shortNameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "short_name", shortName)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
return false, errors.Wrap(err, "db.IsSeasonShortNameUnique")
}
return
}
if len(shortName) > 6 {
err = notifyWarn(s, r, "Invalid Short Name", "Short name must be 6 characters or less.", nil)
if !nameUnique || !shortNameUnique {
return true, nil
}
season, err = db.NewSeason(ctx, tx, name, shortName, startDate)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
// Validate short name is alphanumeric only
if !isAlphanumeric(shortName) {
err = notifyWarn(s, r, "Invalid Short Name", "Short name must contain only letters and numbers.", nil)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
// Parse start date (DD/MM/YYYY format)
startDate, err := time.Parse("02/01/2006", startDateStr)
if err != nil {
err = notifyWarn(s, r, "Invalid Date", "Please provide a valid start date in DD/MM/YYYY format.", nil)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
// Begin database transaction
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
defer tx.Rollback()
// Double-check uniqueness (race condition protection)
nameUnique, err := db.IsSeasonNameUnique(ctx, tx, name)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
return false, errors.Wrap(err, "db.NewSeason")
}
return true, nil
}); !ok {
return
}
if !nameUnique {
err = notifyWarn(s, r, "Duplicate Name", "This season name is already taken.", nil)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
shortNameUnique, err := db.IsSeasonShortNameUnique(ctx, tx, shortName)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
notify.Warn(s, w, r, "Duplicate Name", "This season name is already taken.", nil)
return
}
if !shortNameUnique {
err = notifyWarn(s, r, "Duplicate Short Name", "This short name is already taken.", nil)
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
return
}
// Create the season
season, err := db.NewSeason(ctx, tx, name, shortName, startDate)
if err != nil {
err = notifyInternalServiceError(s, r, "Failed to create season", errors.Wrap(err, "db.NewSeason"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
// Commit transaction
err = tx.Commit()
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "tx.Commit"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
// Send success notification
err = notifySuccess(s, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
if err != nil {
// Log but don't fail the request
s.LogError(hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to send success notification",
Error: err,
})
}
// Redirect to the season detail page
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
w.WriteHeader(http.StatusOK)
})
}
// Helper function to validate alphanumeric strings
func isAlphanumeric(s string) bool {
for _, r := range s {
if ((r < 'A') || (r > 'Z')) && ((r < '0') || (r > '9')) {
return false
}
}
return true
}
func IsSeasonNameUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
defer tx.Rollback()
// Trim whitespace for consistency
name := strings.TrimSpace(r.FormValue("name"))
unique, err := db.IsSeasonNameUnique(ctx, tx, name)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
})
}
func IsSeasonShortNameUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
defer tx.Rollback()
// Get short name and convert to uppercase for consistency
shortname := strings.ToUpper(strings.TrimSpace(r.FormValue("short_name")))
unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
})
}

View File

@@ -1,55 +0,0 @@
package handlers
import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/notify"
"github.com/pkg/errors"
)
func notifyClient(
s *hws.Server,
r *http.Request,
level notify.Level,
title, message, details string,
action any,
) error {
subCookie, err := r.Cookie("ws_sub_id")
if err != nil {
return errors.Wrap(err, "r.Cookie")
}
subID := notify.Target(subCookie.Value)
nt := notify.Notification{
Target: subID,
Title: title,
Message: message,
Details: details,
Action: action,
Level: level,
}
s.NotifySub(nt)
return nil
}
func notifyInternalServiceError(s *hws.Server, r *http.Request, msg string, err error) error {
return notifyClient(s, r, notify.LevelError, "Internal Service Error", msg,
SerializeErrorDetails(http.StatusInternalServerError, err), nil)
}
func notifyServiceUnavailable(s *hws.Server, r *http.Request, msg string, err error) error {
return notifyClient(s, r, notify.LevelError, "Service Unavailable", msg,
SerializeErrorDetails(http.StatusServiceUnavailable, err), nil)
}
func notifyWarn(s *hws.Server, r *http.Request, title, msg string, action any) error {
return notifyClient(s, r, notify.LevelWarn, title, msg, "", action)
}
func notifyInfo(s *hws.Server, r *http.Request, title, msg string, action any) error {
return notifyClient(s, r, notify.LevelInfo, title, msg, "", action)
}
func notifySuccess(s *hws.Server, r *http.Request, title, msg string, action any) error {
return notifyClient(s, r, notify.LevelSuccess, title, msg, "", action)
}

View File

@@ -10,6 +10,7 @@ import (
"git.haelnorr.com/h/golib/notify"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/view/component/popup"
"github.com/coder/websocket"
@@ -22,7 +23,7 @@ func NotificationWS(
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") != "websocket" {
throwNotFound(s, w, r, r.URL.Path)
throw.NotFound(s, w, r, r.URL.Path)
return
}
nc, err := setupClient(s, w, r)

View File

@@ -6,6 +6,7 @@ import (
"github.com/pkg/errors"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/view/page"
)
@@ -16,12 +17,6 @@ func NotifyTester(s *hws.Server) http.Handler {
func(w http.ResponseWriter, r *http.Request) {
testErr := errors.New("This is a stack trace. No really i swear. Just pretend ok? Thanks")
if r.Method == "GET" {
// page, _ := ErrorPage(hws.HWSError{
// StatusCode: http.StatusTeapot,
// Message: "This error has been rendered as a test",
// Error: testErr,
// })
// page.Render(r.Context(), w)
renderSafely(page.Test(), s, r, w)
} else {
_ = r.ParseForm()
@@ -30,19 +25,15 @@ func NotifyTester(s *hws.Server) http.Handler {
level := r.Form.Get("type")
message := r.Form.Get("message")
var err error
switch level {
case "success":
err = notifySuccess(s, r, title, message, nil)
notify.Success(s, w, r, title, message, nil)
case "info":
err = notifyInfo(s, r, title, message, nil)
notify.Info(s, w, r, title, message, nil)
case "warn":
err = notifyWarn(s, r, title, message, nil)
notify.Warn(s, w, r, title, message, nil)
case "error":
err = notifyInternalServiceError(s, r, message, testErr)
}
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
notify.InternalServiceError(s, w, r, message, testErr)
}
}
},

View File

@@ -1,68 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func pageOptsFromForm(r *http.Request) (*db.PageOpts, error) {
var pageNum, perPage int
var order bun.Order
var orderBy string
var err error
if pageStr := r.FormValue("page"); pageStr != "" {
pageNum, err = strconv.Atoi(pageStr)
if err != nil {
return nil, errors.Wrap(err, "invalid page number")
}
}
if perPageStr := r.FormValue("per_page"); perPageStr != "" {
perPage, err = strconv.Atoi(perPageStr)
if err != nil {
return nil, errors.Wrap(err, "invalid per_page number")
}
}
order = bun.Order(r.FormValue("order"))
orderBy = r.FormValue("order_by")
pageOpts := &db.PageOpts{
Page: pageNum,
PerPage: perPage,
Order: order,
OrderBy: orderBy,
}
return pageOpts, nil
}
func pageOptsFromQuery(r *http.Request) (*db.PageOpts, error) {
var pageNum, perPage int
var order bun.Order
var orderBy string
var err error
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
pageNum, err = strconv.Atoi(pageStr)
if err != nil {
return nil, errors.Wrap(err, "invalid page number")
}
}
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
perPage, err = strconv.Atoi(perPageStr)
if err != nil {
return nil, errors.Wrap(err, "invalid per_page number")
}
}
order = bun.Order(r.URL.Query().Get("order"))
orderBy = r.URL.Query().Get("order_by")
pageOpts := &db.PageOpts{
Page: pageNum,
PerPage: perPage,
Order: order,
OrderBy: orderBy,
}
return pageOpts, nil
}

View File

@@ -0,0 +1,45 @@
package handlers
import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/uptrace/bun"
)
// pageOptsFromForm calls r.ParseForm and gets the pageOpts from the formdata.
// It renders a Bad Request error page on fail
// PageOpts will be nil on fail
func pageOptsFromForm(s *hws.Server, w http.ResponseWriter, r *http.Request) *db.PageOpts {
getter, ok := validation.ParseFormOrError(s, w, r)
if !ok {
return nil
}
return getPageOpts(s, w, r, getter)
}
// pageOptsFromQuery gets the pageOpts from the request query and renders a Bad Request error page on fail
// PageOpts will be nil on fail
func pageOptsFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) *db.PageOpts {
return getPageOpts(s, w, r, validation.NewQueryGetter(r))
}
func getPageOpts(s *hws.Server, w http.ResponseWriter, r *http.Request, g validation.Getter) *db.PageOpts {
page := g.Int("page").Min(1).Value
perPage := g.Int("per_page").Min(1).Max(100).Value
order := g.String("order").TrimSpace().ToUpper().AllowedValues([]string{"ASC", "DESC"}).Value
orderBy := g.String("order_by").TrimSpace().ToLower().Value
valid := g.ValidateAndError(s, w, r)
if !valid {
return nil
}
pageOpts := &db.PageOpts{
Page: page,
PerPage: perPage,
Order: bun.Order(order),
OrderBy: orderBy,
}
return pageOpts
}

View File

@@ -7,6 +7,7 @@ import (
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/roles"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/uptrace/bun"
)
@@ -17,7 +18,7 @@ func PermTester(s *hws.Server, conn *bun.DB) http.Handler {
isAdmin, err := user.HasRole(r.Context(), tx, roles.Admin)
tx.Rollback()
if err != nil {
throwInternalServiceError(s, w, r, "Error", err)
throw.InternalServiceError(s, w, r, "Error", err)
}
_, _ = w.Write([]byte(strconv.FormatBool(isAdmin)))
})

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws"
@@ -14,6 +13,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/view/page"
)
@@ -29,27 +29,9 @@ func Register(
attempts, exceeded, track := store.TrackRedirect(r, "/register", 3)
if exceeded {
err := errors.Errorf(
"registration redirect loop detected after %d attempts | ip=%s ua=%s path=%s first_seen=%s ssl=%t",
attempts,
track.IP,
track.UserAgent,
track.Path,
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
cfg.HWSAuth.SSL,
)
err := track.Error(attempts)
store.ClearRedirectTrack(r, "/register")
throwError(
s,
w,
r,
http.StatusBadRequest,
"Registration failed: Cookies appear to be blocked or disabled. Please enable cookies in your browser and try again. If this problem persists, try a different browser or contact support.",
err,
"warn",
)
throw.BadRequest(s, w, r, "Cookies appear to be blocked or disabled. Please enable cookies in your browser and try again", err)
return
}
@@ -65,68 +47,45 @@ func Register(
}
store.ClearRedirectTrack(r, "/register")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database transaction failed", err)
return
}
defer tx.Rollback()
method := r.Method
if method == "GET" {
tx.Commit()
if r.Method == "GET" {
renderSafely(page.Register(details.DiscordUser.Username), s, r, w)
return
}
if method == "POST" {
username := r.FormValue("username")
user, err := registerUser(ctx, tx, username, details)
username := r.FormValue("username")
unique := false
var user *db.User
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil {
err = notifyInternalServiceError(s, r, "Registration failed", err)
if err != nil {
throwInternalServiceError(s, w, r, "Registration failed", err)
}
return false, errors.Wrap(err, "db.IsUsernameUnique")
}
if !unique {
return true, nil
}
user, err = db.CreateUser(ctx, tx, username, details.DiscordUser)
if err != nil {
return false, errors.Wrap(err, "db.CreateUser")
}
err = user.UpdateDiscordToken(ctx, tx, details.Token)
if err != nil {
return false, errors.Wrap(err, "db.UpdateDiscordToken")
}
return true, nil
}); !ok {
return
}
if !unique {
w.WriteHeader(http.StatusConflict)
} else {
err = auth.Login(w, r, user, true)
if err != nil {
throw.InternalServiceError(s, w, r, "Login failed", err)
return
}
tx.Commit()
if user == nil {
w.WriteHeader(http.StatusConflict)
} else {
err = auth.Login(w, r, user, true)
if err != nil {
throwInternalServiceError(s, w, r, "Login failed", err)
return
}
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
}
return
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
}
},
)
}
func registerUser(
ctx context.Context,
tx bun.Tx,
username string,
details *store.RegistrationSession,
) (*db.User, error) {
unique, err := db.IsUsernameUnique(ctx, tx, username)
if err != nil {
return nil, errors.Wrap(err, "db.IsUsernameUnique")
}
if !unique {
return nil, nil
}
user, err := db.CreateUser(ctx, tx, username, details.DiscordUser)
if err != nil {
return nil, errors.Wrap(err, "db.CreateUser")
}
err = user.UpdateDiscordToken(ctx, tx, details.Token)
if err != nil {
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
}
return user, nil
}

View File

@@ -3,10 +3,10 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/view/page"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -17,23 +17,20 @@ func SeasonPage(
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
return
}
defer tx.Rollback()
seasonStr := r.PathValue("season_short_name")
season, err := db.GetSeason(ctx, tx, seasonStr)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason"))
var season *db.Season
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
return true, nil
}); !ok {
return
}
tx.Commit()
if season == nil {
throwNotFound(s, w, r, r.URL.Path)
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(page.SeasonPage(season), s, r, w)

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
@@ -17,25 +16,21 @@ func SeasonsPage(
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
pageOpts := pageOptsFromQuery(s, w, r)
if pageOpts == nil {
return
}
defer tx.Rollback()
pageOpts, err := pageOptsFromQuery(r)
if err != nil {
throwBadRequest(s, w, r, "invalid query", err)
var seasons *db.SeasonList
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
seasons, err = db.ListSeasons(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.ListSeasons")
}
return true, nil
}); !ok {
return
}
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
return
}
tx.Commit()
renderSafely(page.SeasonsPage(seasons), s, r, w)
})
}
@@ -45,37 +40,21 @@ func SeasonsList(
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Parse form values
if err := r.ParseForm(); err != nil {
throwBadRequest(s, w, r, "Invalid form data", err)
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
return
}
pageOpts, err := pageOptsFromForm(r)
if err != nil {
throwBadRequest(s, w, r, "invalid form data", err)
var seasons *db.SeasonList
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
seasons, err = db.ListSeasons(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.ListSeasons")
}
return true, nil
}); !ok {
return
}
// Database query
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
return
}
defer tx.Rollback()
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
return
}
tx.Commit()
// Return only the list component (hx-push-url handles URL update client-side)
renderSafely(page.SeasonsList(seasons), s, r, w)
})
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/throw"
)
// StaticFS handles requests for static files, without allowing access to the
@@ -16,7 +17,7 @@ func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
if err != nil {
// If we can't create the file server, return a handler that always errors
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
throwInternalServiceError(server, w, r, "An error occurred trying to load the file system", err)
throw.InternalServiceError(server, w, r, "An error occurred trying to load the file system", err)
})
}