refactored for maintainability
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
49
internal/handlers/isunique.go
Normal file
49
internal/handlers/isunique.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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", "/")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
45
internal/handlers/pageopt_helpers.go
Normal file
45
internal/handlers/pageopt_helpers.go
Normal 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
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user