refactor: changed file structure

This commit is contained in:
2025-03-05 20:18:28 +11:00
parent 5c1089e0ce
commit 1d9af44d0a
137 changed files with 4986 additions and 581 deletions

180
internal/handler/account.go Normal file
View File

@@ -0,0 +1,180 @@
package handler
import (
"context"
"net/http"
"time"
"projectreshoot/internal/models"
"projectreshoot/internal/view/component/account"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/contexts"
"projectreshoot/pkg/cookies"
"projectreshoot/pkg/db"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// Renders the account page on the 'General' subpage
func AccountPage() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("subpage")
subpage := "General"
if err == nil {
subpage = cookie.Value
}
page.Account(subpage).Render(r.Context(), w)
},
)
}
// Handles a request to change the subpage for the Accou/accountnt page
func AccountSubpage() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
subpage := r.FormValue("subpage")
cookies.SetCookie(w, "subpage", "/account", subpage, 300)
account.AccountContainer(subpage).Render(r.Context(), w)
},
)
}
// Handles a request to change the users username
func ChangeUsername(
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Error updating username")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r.ParseForm()
newUsername := r.FormValue("username")
unique, err := models.CheckUsernameUnique(ctx, tx, newUsername)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Error updating username")
w.WriteHeader(http.StatusInternalServerError)
return
}
if !unique {
tx.Rollback()
account.ChangeUsername("Username is taken", newUsername).
Render(r.Context(), w)
return
}
user := contexts.GetUser(r.Context())
err = user.ChangeUsername(ctx, tx, newUsername)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Error updating username")
w.WriteHeader(http.StatusInternalServerError)
return
}
tx.Commit()
w.Header().Set("HX-Refresh", "true")
},
)
}
// Handles a request to change the users bio
func ChangeBio(
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Error updating bio")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r.ParseForm()
newBio := r.FormValue("bio")
leng := len([]rune(newBio))
if leng > 128 {
tx.Rollback()
account.ChangeBio("Bio limited to 128 characters", newBio).
Render(r.Context(), w)
return
}
user := contexts.GetUser(r.Context())
err = user.ChangeBio(ctx, tx, newBio)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Error updating bio")
w.WriteHeader(http.StatusInternalServerError)
return
}
tx.Commit()
w.Header().Set("HX-Refresh", "true")
},
)
}
func validateChangePassword(
r *http.Request,
) (string, error) {
r.ParseForm()
formPassword := r.FormValue("password")
formConfirmPassword := r.FormValue("confirm-password")
if formPassword != formConfirmPassword {
return "", errors.New("Passwords do not match")
}
if len(formPassword) > 72 {
return "", errors.New("Password exceeds maximum length of 72 bytes")
}
return formPassword, nil
}
// Handles a request to change the users password
func ChangePassword(
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Error updating password")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
newPass, err := validateChangePassword(r)
if err != nil {
tx.Rollback()
account.ChangePassword(err.Error()).Render(r.Context(), w)
return
}
user := contexts.GetUser(r.Context())
err = user.SetPassword(ctx, tx, newPass)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Error updating password")
w.WriteHeader(http.StatusInternalServerError)
return
}
tx.Commit()
w.Header().Set("HX-Refresh", "true")
},
)
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"net/http"
"projectreshoot/internal/view/page"
)
func ErrorPage(
errorCode int,
w http.ResponseWriter,
r *http.Request,
) {
message := map[int]string{
401: "You need to login to view this page.",
403: "You do not have permission to view this page.",
404: "The page or resource you have requested does not exist.",
500: `An error occured on the server. Please try again, and if this
continues to happen contact an administrator.`,
503: "The server is currently down for maintenance and should be back soon. =)",
}
w.WriteHeader(errorCode)
page.Error(errorCode, http.StatusText(errorCode), message[errorCode]).
Render(r.Context(), w)
}

21
internal/handler/index.go Normal file
View File

@@ -0,0 +1,21 @@
package handler
import (
"net/http"
"projectreshoot/internal/view/page"
)
// Handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func Root() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
ErrorPage(http.StatusNotFound, w, r)
return
}
page.Index().Render(r.Context(), w)
},
)
}

108
internal/handler/login.go Normal file
View File

@@ -0,0 +1,108 @@
package handler
import (
"context"
"net/http"
"time"
"projectreshoot/internal/models"
"projectreshoot/internal/view/component/form"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/cookies"
"projectreshoot/pkg/db"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// Validates the username matches a user in the database and the password
// is correct. Returns the corresponding user
func validateLogin(
ctx context.Context,
tx db.SafeTX,
r *http.Request,
) (*models.User, error) {
formUsername := r.FormValue("username")
formPassword := r.FormValue("password")
user, err := models.GetUserFromUsername(ctx, tx, formUsername)
if err != nil {
return nil, errors.Wrap(err, "db.GetUserFromUsername")
}
err = user.CheckPassword(formPassword)
if err != nil {
return nil, errors.New("Username or password incorrect")
}
return user, nil
}
// Returns result of the "Remember me?" checkbox as a boolean
func checkRememberMe(r *http.Request) bool {
rememberMe := r.FormValue("remember-me")
if rememberMe == "on" {
return true
} else {
return false
}
}
// Handles an attempted login request. On success will return a HTMX redirect
// and on fail will return the login form again, passing the error to the
// template for user feedback
func LoginRequest(
config *config.Config,
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Failed to set token cookies")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r.ParseForm()
user, err := validateLogin(ctx, tx, r)
if err != nil {
tx.Rollback()
if err.Error() != "Username or password incorrect" {
logger.Warn().Caller().Err(err).Msg("Login request failed")
w.WriteHeader(http.StatusInternalServerError)
} else {
form.LoginForm(err.Error()).Render(r.Context(), w)
}
return
}
rememberMe := checkRememberMe(r)
err = cookies.SetTokenCookies(w, r, config, user, true, rememberMe)
if err != nil {
tx.Rollback()
w.WriteHeader(http.StatusInternalServerError)
logger.Warn().Caller().Err(err).Msg("Failed to set token cookies")
return
}
tx.Commit()
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
},
)
}
// Handles a request to view the login page. Will attempt to set "pagefrom"
// cookie so a successful login can redirect the user to the page they came
func LoginPage(trustedHost string) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
cookies.SetPageFrom(w, r, trustedHost)
page.Login().Render(r.Context(), w)
},
)
}

113
internal/handler/logout.go Normal file
View File

@@ -0,0 +1,113 @@
package handler
import (
"context"
"net/http"
"strings"
"time"
"projectreshoot/pkg/config"
"projectreshoot/pkg/cookies"
"projectreshoot/pkg/db"
"projectreshoot/pkg/jwt"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
func revokeAccess(
config *config.Config,
ctx context.Context,
tx *db.SafeWTX,
atStr string,
) error {
aT, err := jwt.ParseAccessToken(config, ctx, tx, atStr)
if err != nil {
if strings.Contains(err.Error(), "Token is expired") ||
strings.Contains(err.Error(), "Token has been revoked") {
return nil // Token is expired, dont need to revoke it
}
return errors.Wrap(err, "jwt.ParseAccessToken")
}
err = jwt.RevokeToken(ctx, tx, aT)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
return nil
}
func revokeRefresh(
config *config.Config,
ctx context.Context,
tx *db.SafeWTX,
rtStr string,
) error {
rT, err := jwt.ParseRefreshToken(config, ctx, tx, rtStr)
if err != nil {
if strings.Contains(err.Error(), "Token is expired") ||
strings.Contains(err.Error(), "Token has been revoked") {
return nil // Token is expired, dont need to revoke it
}
return errors.Wrap(err, "jwt.ParseRefreshToken")
}
err = jwt.RevokeToken(ctx, tx, rT)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
return nil
}
// Retrieve and revoke the user's tokens
func revokeTokens(
config *config.Config,
ctx context.Context,
tx *db.SafeWTX,
r *http.Request,
) error {
// get the tokens from the cookies
atStr, rtStr := cookies.GetTokenStrings(r)
// revoke the refresh token first as the access token expires quicker
// only matters if there is an error revoking the tokens
err := revokeRefresh(config, ctx, tx, rtStr)
if err != nil {
return errors.Wrap(err, "revokeRefresh")
}
err = revokeAccess(config, ctx, tx, atStr)
if err != nil {
return errors.Wrap(err, "revokeAccess")
}
return nil
}
// Handle a logout request
func Logout(
config *config.Config,
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Error occured on user logout")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
err = revokeTokens(config, ctx, tx, r)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Error occured on user logout")
w.WriteHeader(http.StatusInternalServerError)
return
}
tx.Commit()
cookies.DeleteCookie(w, "access", "/")
cookies.DeleteCookie(w, "refresh", "/")
w.Header().Set("HX-Redirect", "/login")
},
)
}

44
internal/handler/movie.go Normal file
View File

@@ -0,0 +1,44 @@
package handler
import (
"net/http"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/tmdb"
"strconv"
"github.com/rs/zerolog"
)
func Movie(
logger *zerolog.Logger,
config *config.Config,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("movie_id")
movie_id, err := strconv.ParseInt(id, 10, 32)
if err != nil {
ErrorPage(http.StatusNotFound, w, r)
logger.Error().Err(err).Str("movie_id", id).
Msg("Error occured getting the movie")
return
}
movie, err := tmdb.GetMovie(int32(movie_id), config.TMDBToken)
if err != nil {
ErrorPage(http.StatusInternalServerError, w, r)
logger.Error().Err(err).Int32("movie_id", int32(movie_id)).
Msg("Error occured getting the movie")
return
}
credits, err := tmdb.GetCredits(int32(movie_id), config.TMDBToken)
if err != nil {
ErrorPage(http.StatusInternalServerError, w, r)
logger.Error().Err(err).Int32("movie_id", int32(movie_id)).
Msg("Error occured getting the movie credits")
return
}
page.Movie(movie, credits, &config.TMDBConfig.Image).Render(r.Context(), w)
},
)
}

View File

@@ -0,0 +1,41 @@
package handler
import (
"net/http"
"projectreshoot/internal/view/component/search"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/tmdb"
"github.com/rs/zerolog"
)
func SearchMovies(
logger *zerolog.Logger,
config *config.Config,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
query := r.FormValue("search")
if query == "" {
w.WriteHeader(http.StatusOK)
return
}
movies, err := tmdb.SearchMovies(config.TMDBToken, query, false, 1)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
search.MovieResults(movies, &config.TMDBConfig.Image).Render(r.Context(), w)
},
)
}
func MoviesPage() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
page.Movies().Render(r.Context(), w)
},
)
}

17
internal/handler/page.go Normal file
View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/a-h/templ"
)
// Handler for static pages. Will render the given templ.Component to the
// http.ResponseWriter
func HandlePage(Page templ.Component) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
Page.Render(r.Context(), w)
},
)
}

View File

@@ -0,0 +1,14 @@
package handler
import (
"net/http"
"projectreshoot/internal/view/page"
)
func ProfilePage() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
page.Profile().Render(r.Context(), w)
},
)
}

View File

@@ -0,0 +1,137 @@
package handler
import (
"context"
"net/http"
"time"
"projectreshoot/internal/view/component/form"
"projectreshoot/pkg/config"
"projectreshoot/pkg/contexts"
"projectreshoot/pkg/cookies"
"projectreshoot/pkg/db"
"projectreshoot/pkg/jwt"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
// Get the tokens from the request
func getTokens(
config *config.Config,
ctx context.Context,
tx db.SafeTX,
r *http.Request,
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
// get the existing tokens from the cookies
atStr, rtStr := cookies.GetTokenStrings(r)
aT, err := jwt.ParseAccessToken(config, ctx, tx, atStr)
if err != nil {
return nil, nil, errors.Wrap(err, "jwt.ParseAccessToken")
}
rT, err := jwt.ParseRefreshToken(config, ctx, tx, rtStr)
if err != nil {
return nil, nil, errors.Wrap(err, "jwt.ParseRefreshToken")
}
return aT, rT, nil
}
// Revoke the given token pair
func revokeTokenPair(
ctx context.Context,
tx *db.SafeWTX,
aT *jwt.AccessToken,
rT *jwt.RefreshToken,
) error {
err := jwt.RevokeToken(ctx, tx, aT)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
err = jwt.RevokeToken(ctx, tx, rT)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
return nil
}
// Issue new tokens for the user, invalidating the old ones
func refreshTokens(
config *config.Config,
ctx context.Context,
tx *db.SafeWTX,
w http.ResponseWriter,
r *http.Request,
) error {
aT, rT, err := getTokens(config, ctx, tx, r)
if err != nil {
return errors.Wrap(err, "getTokens")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[aT.TTL]
// issue new tokens for the user
user := contexts.GetUser(r.Context())
err = cookies.SetTokenCookies(w, r, config, user.User, true, rememberMe)
if err != nil {
return errors.Wrap(err, "cookies.SetTokenCookies")
}
err = revokeTokenPair(ctx, tx, aT, rT)
if err != nil {
return errors.Wrap(err, "revokeTokenPair")
}
return nil
}
// Validate the provided password
func validatePassword(
r *http.Request,
) error {
r.ParseForm()
password := r.FormValue("password")
user := contexts.GetUser(r.Context())
err := user.CheckPassword(password)
if err != nil {
return errors.Wrap(err, "user.CheckPassword")
}
return nil
}
// Handle request to reauthenticate (i.e. make token fresh again)
func Reauthenticate(
logger *zerolog.Logger,
config *config.Config,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Failed to refresh user tokens")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
err = validatePassword(r)
if err != nil {
tx.Rollback()
w.WriteHeader(445)
form.ConfirmPassword("Incorrect password").Render(r.Context(), w)
return
}
err = refreshTokens(config, ctx, tx, w, r)
if err != nil {
tx.Rollback()
logger.Error().Err(err).Msg("Failed to refresh user tokens")
w.WriteHeader(http.StatusInternalServerError)
return
}
tx.Commit()
w.WriteHeader(http.StatusOK)
},
)
}

View File

@@ -0,0 +1,104 @@
package handler
import (
"context"
"net/http"
"time"
"projectreshoot/internal/models"
"projectreshoot/internal/view/component/form"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/cookies"
"projectreshoot/pkg/db"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
func validateRegistration(
ctx context.Context,
tx *db.SafeWTX,
r *http.Request,
) (*models.User, error) {
formUsername := r.FormValue("username")
formPassword := r.FormValue("password")
formConfirmPassword := r.FormValue("confirm-password")
unique, err := models.CheckUsernameUnique(ctx, tx, formUsername)
if err != nil {
return nil, errors.Wrap(err, "db.CheckUsernameUnique")
}
if !unique {
return nil, errors.New("Username is taken")
}
if formPassword != formConfirmPassword {
return nil, errors.New("Passwords do not match")
}
if len(formPassword) > 72 {
return nil, errors.New("Password exceeds maximum length of 72 bytes")
}
user, err := models.CreateNewUser(ctx, tx, formUsername, formPassword)
if err != nil {
return nil, errors.Wrap(err, "db.CreateNewUser")
}
return user, nil
}
func RegisterRequest(
config *config.Config,
logger *zerolog.Logger,
conn *db.SafeConn,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Start the transaction
tx, err := conn.Begin(ctx)
if err != nil {
logger.Warn().Err(err).Msg("Failed to set token cookies")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r.ParseForm()
user, err := validateRegistration(ctx, tx, r)
if err != nil {
tx.Rollback()
if err.Error() != "Username is taken" &&
err.Error() != "Passwords do not match" &&
err.Error() != "Password exceeds maximum length of 72 bytes" {
logger.Warn().Caller().Err(err).Msg("Registration request failed")
w.WriteHeader(http.StatusInternalServerError)
} else {
form.RegisterForm(err.Error()).Render(r.Context(), w)
}
return
}
rememberMe := checkRememberMe(r)
err = cookies.SetTokenCookies(w, r, config, user, true, rememberMe)
if err != nil {
tx.Rollback()
w.WriteHeader(http.StatusInternalServerError)
logger.Warn().Caller().Err(err).Msg("Failed to set token cookies")
return
}
tx.Commit()
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
},
)
}
// Handles a request to view the login page. Will attempt to set "pagefrom"
// cookie so a successful login can redirect the user to the page they came
func RegisterPage(trustedHost string) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
cookies.SetPageFrom(w, r, trustedHost)
page.Register().Render(r.Context(), w)
},
)
}

View File

@@ -0,0 +1,53 @@
package handler
import (
"net/http"
"os"
)
// Wrapper for default FileSystem
type justFilesFilesystem struct {
fs http.FileSystem
}
// Wrapper for default File
type neuteredReaddirFile struct {
http.File
}
// Modifies the behavior of FileSystem.Open to return the neutered version of File
func (fs justFilesFilesystem) Open(name string) (http.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
// Check if the requested path is a directory
// and explicitly return an error to trigger a 404
fileInfo, err := f.Stat()
if err != nil {
return nil, err
}
if fileInfo.IsDir() {
return nil, os.ErrNotExist
}
return neuteredReaddirFile{f}, nil
}
// Overrides the Readdir method of File to always return nil
func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, nil
}
// Handles requests for static files, without allowing access to the
// directory viewer and returning 404 if an exact file is not found
func StaticFS(staticFS *http.FileSystem) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
nfs := justFilesFilesystem{*staticFS}
fs := http.FileServer(nfs)
fs.ServeHTTP(w, r)
},
)
}