Compare commits

...

2 Commits

17 changed files with 527 additions and 545 deletions

View File

@ -46,6 +46,15 @@ section#game {
margin: 1rem 0; margin: 1rem 0;
border-top: 2px solid #000; border-top: 2px solid #000;
& > aside > section:not(:last-child) {
margin-bottom: 1rem;
}
& > aside > section {
display: flex;
flex-direction: column;
}
& > aside#left { & > aside#left {
grid-column: 1; grid-column: 1;
border-right: 2px solid #000; border-right: 2px solid #000;
@ -86,7 +95,7 @@ a {
color: #999999; color: #999999;
} }
.title { div.title {
border: solid 1px black; border: solid 1px black;
background-color: #eeeeee; background-color: #eeeeee;
font-weight: bold; font-weight: bold;
@ -106,3 +115,51 @@ footer {
} }
} }
ul.unstyled {
list-style: none;
}
form#move-compass {
width: 128px;
height: 128px;
display: flex;
flex-direction: column;
background-image: url('/assets/images/compass.webp');
margin: 0.5rem auto;
& > div.mid {
display: flex;
}
button {
background-color: transparent;
border: none;
color: transparent; /* Hide the text */
background-size: cover; /* Ensure the background image fills the button */
cursor: pointer;
&:hover {
background-color: rgba(225, 16, 16, 0.5);
}
&#north {
width: 128px;
height: 40px;
}
&#west {
width: 63px;
height: 50px;
}
&#east {
width: 63px;
height: 50px;
}
&#south {
width: 128px;
height: 38px;
}
}
}

View File

@ -8,13 +8,6 @@ import (
// Manager is the global singleton instance // Manager is the global singleton instance
var Manager *AuthManager var Manager *AuthManager
// User is a simplified User struct for auth purposes
type User struct {
ID int
Username string
Email string
}
// AuthManager is a wrapper for the session store to add // AuthManager is a wrapper for the session store to add
// authentication tools over the store itself // authentication tools over the store itself
type AuthManager struct { type AuthManager struct {
@ -30,7 +23,7 @@ func Init(sessionsFilePath string) {
// Authenticate checks for the usernaname or email, then verifies the plain password // Authenticate checks for the usernaname or email, then verifies the plain password
// against the stored hash. // against the stored hash.
func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*User, error) { func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*users.User, error) {
var user *users.User var user *users.User
var err error var err error
@ -51,14 +44,10 @@ func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*Use
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
return &User{ return user, nil
ID: user.ID,
Username: user.Username,
Email: user.Email,
}, nil
} }
func (am *AuthManager) CreateSession(user *User) *Session { func (am *AuthManager) CreateSession(user *users.User) *Session {
return am.store.Create(user.ID, user.Username, user.Email) return am.store.Create(user.ID, user.Username, user.Email)
} }

View File

@ -14,6 +14,7 @@ import (
"dk/internal/database" "dk/internal/database"
"dk/internal/password" "dk/internal/password"
"dk/internal/users"
) )
const dbPath = "dk.db" const dbPath = "dk.db"
@ -483,15 +484,14 @@ func populateData() error {
} }
func createDemoUser() error { func createDemoUser() error {
hashedPassword, err := password.Hash("Demo123!") user := users.New()
if err != nil { user.Username = "demo"
return fmt.Errorf("failed to hash password: %w", err) user.Email = "demo@demo.com"
} user.Password = password.Hash("Demo123!")
user.ClassID = 1
user.Auth = 4
stmt := `INSERT INTO users (username, password, email, verified, class_id, auth) if err := user.Insert(); err != nil {
VALUES (?, ?, ?, 1, 1, 4)`
if err := database.Exec(stmt, "demo", hashedPassword, "demo@demo.com"); err != nil {
return fmt.Errorf("failed to create demo user: %w", err) return fmt.Errorf("failed to create demo user: %w", err)
} }

View File

@ -3,67 +3,72 @@ package middleware
import ( import (
"dk/internal/auth" "dk/internal/auth"
"dk/internal/router" "dk/internal/router"
"dk/internal/users"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
const (
UserKey = "user"
SessionKey = "session"
)
// Auth creates an authentication middleware // Auth creates an authentication middleware
func Auth(authManager *auth.AuthManager) router.Middleware { func Auth(authManager *auth.AuthManager) router.Middleware {
return func(next router.Handler) router.Handler { return func(next router.Handler) router.Handler {
return func(ctx router.Ctx, params []string) { return func(ctx router.Ctx, params []string) {
sessionID := auth.GetSessionCookie(ctx) sessionID := auth.GetSessionCookie(ctx)
if sessionID != "" { if sessionID != "" {
if session, exists := authManager.GetSession(sessionID); exists { if session, exists := authManager.GetSession(sessionID); exists {
// Update session activity // Update session activity
authManager.UpdateSession(sessionID) authManager.UpdateSession(sessionID)
// Store session and user info in context // Get the full user object
ctx.SetUserValue(SessionKey, session) user, err := users.Find(session.UserID)
ctx.SetUserValue(UserKey, &auth.User{ if err == nil && user != nil {
ID: session.UserID, // Store session and user info in context
Username: session.Username, ctx.SetUserValue("session", session)
Email: session.Email, ctx.SetUserValue("user", user)
})
// Refresh the cookie
// Refresh the cookie auth.SetSessionCookie(ctx, sessionID)
auth.SetSessionCookie(ctx, sessionID) }
} }
} }
next(ctx, params) next(ctx, params)
} }
} }
} }
// RequireAuth enforces authentication - redirects to login if not authenticated // RequireAuth enforces authentication - redirect defaults to "/login"
func RequireAuth(loginPath string) router.Middleware { func RequireAuth(paths ...string) router.Middleware {
redirect := "/login"
if len(paths) > 0 && paths[0] != "" {
redirect = paths[0]
}
return func(next router.Handler) router.Handler { return func(next router.Handler) router.Handler {
return func(ctx router.Ctx, params []string) { return func(ctx router.Ctx, params []string) {
if !IsAuthenticated(ctx) { if !IsAuthenticated(ctx) {
ctx.Redirect(loginPath, fasthttp.StatusFound) ctx.Redirect(redirect, fasthttp.StatusFound)
return return
} }
next(ctx, params) next(ctx, params)
} }
} }
} }
// RequireGuest enforces no authentication - redirects to dashboard if authenticated // RequireGuest enforces no authentication - redirect defaults to "/"
func RequireGuest(dashboardPath string) router.Middleware { func RequireGuest(paths ...string) router.Middleware {
redirect := "/"
if len(paths) > 0 && paths[0] != "" {
redirect = paths[0]
}
return func(next router.Handler) router.Handler { return func(next router.Handler) router.Handler {
return func(ctx router.Ctx, params []string) { return func(ctx router.Ctx, params []string) {
if IsAuthenticated(ctx) { if IsAuthenticated(ctx) {
ctx.Redirect(dashboardPath, fasthttp.StatusFound) ctx.Redirect(redirect, fasthttp.StatusFound)
return return
} }
next(ctx, params) next(ctx, params)
} }
} }
@ -71,13 +76,13 @@ func RequireGuest(dashboardPath string) router.Middleware {
// IsAuthenticated checks if the current request has a valid session // IsAuthenticated checks if the current request has a valid session
func IsAuthenticated(ctx router.Ctx) bool { func IsAuthenticated(ctx router.Ctx) bool {
_, exists := ctx.UserValue(UserKey).(*auth.User) _, exists := ctx.UserValue("user").(*users.User)
return exists return exists
} }
// GetCurrentUser returns the current authenticated user, or nil if not authenticated // GetCurrentUser returns the current authenticated user, or nil if not authenticated
func GetCurrentUser(ctx router.Ctx) *auth.User { func GetCurrentUser(ctx router.Ctx) *users.User {
if user, ok := ctx.UserValue(UserKey).(*auth.User); ok { if user, ok := ctx.UserValue("user").(*users.User); ok {
return user return user
} }
return nil return nil
@ -85,20 +90,20 @@ func GetCurrentUser(ctx router.Ctx) *auth.User {
// GetCurrentSession returns the current session, or nil if not authenticated // GetCurrentSession returns the current session, or nil if not authenticated
func GetCurrentSession(ctx router.Ctx) *auth.Session { func GetCurrentSession(ctx router.Ctx) *auth.Session {
if session, ok := ctx.UserValue(SessionKey).(*auth.Session); ok { if session, ok := ctx.UserValue("session").(*auth.Session); ok {
return session return session
} }
return nil return nil
} }
// Login creates a session and sets the cookie // Login creates a session and sets the cookie
func Login(ctx router.Ctx, authManager *auth.AuthManager, user *auth.User) { func Login(ctx router.Ctx, authManager *auth.AuthManager, user *users.User) {
session := authManager.CreateSession(user) session := authManager.CreateSession(user)
auth.SetSessionCookie(ctx, session.ID) auth.SetSessionCookie(ctx, session.ID)
// Set in context for immediate use // Set in context for immediate use
ctx.SetUserValue(SessionKey, session) ctx.SetUserValue("session", session)
ctx.SetUserValue(UserKey, user) ctx.SetUserValue("user", user)
} }
// Logout destroys the session and clears the cookie // Logout destroys the session and clears the cookie
@ -107,10 +112,10 @@ func Logout(ctx router.Ctx, authManager *auth.AuthManager) {
if sessionID != "" { if sessionID != "" {
authManager.DeleteSession(sessionID) authManager.DeleteSession(sessionID)
} }
auth.DeleteSessionCookie(ctx) auth.DeleteSessionCookie(ctx)
// Clear from context // Clear from context
ctx.SetUserValue(SessionKey, nil) ctx.SetUserValue("session", nil)
ctx.SetUserValue(UserKey, nil) ctx.SetUserValue("user", nil)
} }

View File

@ -0,0 +1,39 @@
package middleware
import (
"dk/internal/router"
"dk/internal/towns"
"dk/internal/users"
"github.com/valyala/fasthttp"
)
// RequireTown ensures the user is in town at valid coordinates
func RequireTown() router.Middleware {
return func(next router.Handler) router.Handler {
return func(ctx router.Ctx, params []string) {
user, ok := ctx.UserValue("user").(*users.User)
if !ok || user == nil {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
ctx.SetBodyString("Not authenticated")
return
}
if user.Currently != "In Town" {
ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.SetBodyString("You must be in town")
return
}
town, err := towns.ByCoords(user.X, user.Y)
if err != nil || town == nil || town.ID == 0 {
ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.SetBodyString("Invalid town location")
return
}
ctx.SetUserValue("town", town)
next(ctx, params)
}
}
}

View File

@ -18,22 +18,19 @@ const (
) )
// Hash creates an argon2id hash of the password // Hash creates an argon2id hash of the password
func Hash(password string) (string, error) { func Hash(password string) string {
salt := make([]byte, 16) salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil { rand.Read(salt)
return "", err
}
hash := argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen) hash := argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen)
// Encode in the format: $argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>
b64Salt := base64.RawStdEncoding.EncodeToString(salt) b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash) b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memory, time, threads, b64Salt, b64Hash) argon2.Version, memory, time, threads, b64Salt, b64Hash)
return encoded, nil return encoded
} }
// Verify checks if a password matches the hash // Verify checks if a password matches the hash
@ -80,4 +77,4 @@ func Verify(password, encodedHash string) (bool, error) {
} }
return false, nil return false, nil
} }

View File

@ -23,9 +23,9 @@ func RegisterAuthRoutes(r *router.Router) {
guestGroup := r.Group("") guestGroup := r.Group("")
guestGroup.Use(middleware.RequireGuest("/")) guestGroup.Use(middleware.RequireGuest("/"))
guestGroup.Get("/login", showLogin()) guestGroup.Get("/login", showLogin)
guestGroup.Post("/login", processLogin()) guestGroup.Post("/login", processLogin())
guestGroup.Get("/register", showRegister()) guestGroup.Get("/register", showRegister)
guestGroup.Post("/register", processRegister()) guestGroup.Post("/register", processRegister())
// Authenticated routes // Authenticated routes
@ -36,60 +36,53 @@ func RegisterAuthRoutes(r *router.Router) {
} }
// showLogin displays the login form // showLogin displays the login form
func showLogin() router.Handler { func showLogin(ctx router.Ctx, params []string) {
return func(ctx router.Ctx, params []string) { loginTmpl, err := template.Cache.Load("auth/login.html")
loginTmpl, err := template.Cache.Load("auth/login.html") if err != nil {
if err != nil { ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetStatusCode(fasthttp.StatusInternalServerError) fmt.Fprintf(ctx, "Template error: %v", err)
fmt.Fprintf(ctx, "Template error: %v", err) return
return }
}
loginFormData := map[string]any{ loginFormData := map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager), "csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager), "csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "", "error_message": "",
} }
loginContent := loginTmpl.RenderNamed(loginFormData) loginContent := loginTmpl.RenderNamed(loginFormData)
pageData := components.NewPageData("Login - Dragon Knight", loginContent) pageData := components.NewPageData("Login - Dragon Knight", loginContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil { if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError) ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err) fmt.Fprintf(ctx, "Template error: %v", err)
return return
}
} }
} }
// processLogin handles login form submission // processLogin handles login form submission
func processLogin() router.Handler { func processLogin() router.Handler {
return func(ctx router.Ctx, params []string) { return func(ctx router.Ctx, params []string) {
// Validate CSRF token
if !csrf.ValidateFormToken(ctx, auth.Manager) { if !csrf.ValidateFormToken(ctx, auth.Manager) {
ctx.SetStatusCode(fasthttp.StatusForbidden) ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.WriteString("CSRF validation failed") ctx.WriteString("CSRF validation failed")
return return
} }
// Get form values
email := strings.TrimSpace(string(ctx.PostArgs().Peek("email"))) email := strings.TrimSpace(string(ctx.PostArgs().Peek("email")))
userPassword := string(ctx.PostArgs().Peek("password")) userPassword := string(ctx.PostArgs().Peek("password"))
// Validate input
if email == "" || userPassword == "" { if email == "" || userPassword == "" {
showLoginError(ctx, "Email and password are required") showLoginError(ctx, "Email and password are required")
return return
} }
// Authenticate user
user, err := auth.Manager.Authenticate(email, userPassword) user, err := auth.Manager.Authenticate(email, userPassword)
if err != nil { if err != nil {
showLoginError(ctx, "Invalid email or password") showLoginError(ctx, "Invalid email or password")
return return
} }
// Create session and login
middleware.Login(ctx, auth.Manager, user) middleware.Login(ctx, auth.Manager, user)
// Transfer CSRF token from cookie to session for authenticated user // Transfer CSRF token from cookie to session for authenticated user
@ -99,104 +92,78 @@ func processLogin() router.Handler {
} }
} }
// Redirect to dashboard ctx.Redirect("/", fasthttp.StatusFound)
ctx.Redirect("/dashboard", fasthttp.StatusFound)
} }
} }
// showRegister displays the registration form // showRegister displays the registration form
func showRegister() router.Handler { func showRegister(ctx router.Ctx, _ []string) {
return func(ctx router.Ctx, params []string) { registerTmpl, err := template.Cache.Load("auth/register.html")
registerTmpl, err := template.Cache.Load("auth/register.html") if err != nil {
if err != nil { ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetStatusCode(fasthttp.StatusInternalServerError) fmt.Fprintf(ctx, "Template error: %v", err)
fmt.Fprintf(ctx, "Template error: %v", err) return
return }
}
registerFormData := map[string]any{ registerContent := registerTmpl.RenderNamed(map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager), "csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager), "csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "", "error_message": "",
"username": "", "username": "",
"email": "", "email": "",
} })
registerContent := registerTmpl.RenderNamed(registerFormData) pageData := components.NewPageData("Register - Dragon Knight", registerContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
pageData := components.NewPageData("Register - Dragon Knight", registerContent) ctx.SetStatusCode(fasthttp.StatusInternalServerError)
if err := components.RenderPage(ctx, pageData, nil); err != nil { fmt.Fprintf(ctx, "Template error: %v", err)
ctx.SetStatusCode(fasthttp.StatusInternalServerError) return
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
} }
} }
// processRegister handles registration form submission // processRegister handles registration form submission
func processRegister() router.Handler { func processRegister() router.Handler {
return func(ctx router.Ctx, params []string) { return func(ctx router.Ctx, params []string) {
// Validate CSRF token
if !csrf.ValidateFormToken(ctx, auth.Manager) { if !csrf.ValidateFormToken(ctx, auth.Manager) {
ctx.SetStatusCode(fasthttp.StatusForbidden) ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.WriteString("CSRF validation failed") ctx.WriteString("CSRF validation failed")
return return
} }
// Get form values
username := strings.TrimSpace(string(ctx.PostArgs().Peek("username"))) username := strings.TrimSpace(string(ctx.PostArgs().Peek("username")))
email := strings.TrimSpace(string(ctx.PostArgs().Peek("email"))) email := strings.TrimSpace(string(ctx.PostArgs().Peek("email")))
userPassword := string(ctx.PostArgs().Peek("password")) userPassword := string(ctx.PostArgs().Peek("password"))
confirmPassword := string(ctx.PostArgs().Peek("confirm_password")) confirmPassword := string(ctx.PostArgs().Peek("confirm_password"))
// Validate input
if err := validateRegistration(username, email, userPassword, confirmPassword); err != nil { if err := validateRegistration(username, email, userPassword, confirmPassword); err != nil {
showRegisterError(ctx, err.Error(), username, email) showRegisterError(ctx, err.Error(), username, email)
return return
} }
// Check if username already exists
if _, err := users.GetByUsername(username); err == nil { if _, err := users.GetByUsername(username); err == nil {
showRegisterError(ctx, "Username already exists", username, email) showRegisterError(ctx, "Username already exists", username, email)
return return
} }
// Check if email already exists
if _, err := users.GetByEmail(email); err == nil { if _, err := users.GetByEmail(email); err == nil {
showRegisterError(ctx, "Email already registered", username, email) showRegisterError(ctx, "Email already registered", username, email)
return return
} }
// Hash password user := users.New()
hashedPassword, err := password.Hash(userPassword) user.Username = username
if err != nil { user.Email = email
showRegisterError(ctx, "Failed to process password", username, email) user.Password = password.Hash(userPassword)
return user.ClassID = 1
} user.Auth = 1
// Create user (this is a simplified approach - in a real app you'd use a proper user creation function) if err := user.Insert(); err != nil {
user := &users.User{
Username: username,
Email: email,
Password: hashedPassword,
Verified: 1, // Auto-verify for now
Auth: 1, // Enabled
}
// Insert into database
if err := createUser(user); err != nil {
showRegisterError(ctx, "Failed to create account", username, email) showRegisterError(ctx, "Failed to create account", username, email)
return return
} }
// Auto-login after registration // Auto-login after registration
authUser := &auth.User{ middleware.Login(ctx, auth.Manager, user)
ID: user.ID,
Username: user.Username,
Email: user.Email,
}
middleware.Login(ctx, auth.Manager, authUser)
// Transfer CSRF token from cookie to session for authenticated user // Transfer CSRF token from cookie to session for authenticated user
if cookieToken := csrf.GetTokenFromCookie(ctx); cookieToken != "" { if cookieToken := csrf.GetTokenFromCookie(ctx); cookieToken != "" {

37
internal/routes/index.go Normal file
View File

@ -0,0 +1,37 @@
package routes
import (
"dk/internal/middleware"
"dk/internal/router"
"dk/internal/template/components"
"dk/internal/users"
"fmt"
"github.com/valyala/fasthttp"
)
func Index(ctx router.Ctx, _ []string) {
currentUser := middleware.GetCurrentUser(ctx)
var username string
if currentUser != nil {
username = currentUser.Username
user, _ := users.Find(currentUser.ID)
if user.Currently == "In Town" {
ctx.Redirect("/town", 303)
}
} else {
username = "Guest"
}
pageData := components.NewPageData(
"Dragon Knight",
fmt.Sprintf("Hello %s!", username),
)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
}

39
internal/routes/town.go Normal file
View File

@ -0,0 +1,39 @@
package routes
import (
"dk/internal/middleware"
"dk/internal/router"
"dk/internal/template"
"dk/internal/template/components"
"fmt"
"github.com/valyala/fasthttp"
)
func RegisterTownRoutes(r *router.Router) {
group := r.Group("/town")
group.Use(middleware.RequireAuth())
group.Use(middleware.RequireTown())
group.Get("/", showTown)
}
func showTown(ctx router.Ctx, _ []string) {
tmpl, err := template.Cache.Load("town/town.html")
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
content := tmpl.RenderNamed(map[string]any{
"town": ctx.UserValue("town"),
})
pageData := components.NewPageData("Town - Dragon Knight", content)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
}

View File

@ -14,7 +14,6 @@ import (
"dk/internal/router" "dk/internal/router"
"dk/internal/routes" "dk/internal/routes"
"dk/internal/template" "dk/internal/template"
"dk/internal/template/components"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
@ -40,63 +39,9 @@ func Start(port string) error {
r.Use(middleware.Auth(auth.Manager)) r.Use(middleware.Auth(auth.Manager))
r.Use(middleware.CSRF(auth.Manager)) r.Use(middleware.CSRF(auth.Manager))
// Setup route handlers r.Get("/", routes.Index)
routes.RegisterAuthRoutes(r) routes.RegisterAuthRoutes(r)
routes.RegisterTownRoutes(r)
// Dashboard (protected route)
r.WithMiddleware(middleware.RequireAuth("/login")).Get("/dashboard", func(ctx router.Ctx, params []string) {
currentUser := middleware.GetCurrentUser(ctx)
totalSessions, activeSessions := auth.Manager.SessionStats()
pageData := components.NewPageData(
"Dashboard - Dragon Knight",
fmt.Sprintf("Welcome back, %s!", currentUser.Username),
)
additionalData := map[string]any{
"total_sessions": totalSessions,
"active_sessions": activeSessions,
"authenticated": true,
"username": currentUser.Username,
}
if err := components.RenderPage(ctx, pageData, additionalData); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
}
})
// Hello world endpoint (public)
r.Get("/", func(ctx router.Ctx, params []string) {
// Get current user if authenticated
currentUser := middleware.GetCurrentUser(ctx)
var username string
if currentUser != nil {
username = currentUser.Username
} else {
username = "Guest"
}
totalSessions, activeSessions := auth.Manager.SessionStats()
pageData := components.NewPageData(
"Dragon Knight",
fmt.Sprintf("Hello %s!", username),
)
additionalData := map[string]any{
"total_sessions": totalSessions,
"active_sessions": activeSessions,
"authenticated": currentUser != nil,
"username": username,
}
if err := components.RenderPage(ctx, pageData, additionalData); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
})
// Use current working directory for static files // Use current working directory for static files
assetsDir := filepath.Join(cwd, "assets") assetsDir := filepath.Join(cwd, "assets")

View File

@ -0,0 +1,91 @@
package components
import (
"dk/internal/middleware"
"dk/internal/router"
"dk/internal/template"
"dk/internal/towns"
"dk/internal/users"
)
// LeftAside generates the left sidebar content
func LeftAside(ctx router.Ctx) string {
if !middleware.IsAuthenticated(ctx) {
return ""
}
leftSideTmpl, err := template.Cache.Load("leftside.html")
if err != nil {
return "leftside failed to load?"
}
currentUser := middleware.GetCurrentUser(ctx)
if currentUser == nil {
return "no currentUser?"
}
user, err := users.Find(currentUser.ID)
if err != nil {
return "user not found?"
}
cardinalX := "E"
if user.X < 0 {
cardinalX = "W"
}
cardinalY := "S"
if user.Y < 0 {
cardinalY = "N"
}
townname := ""
if user.Currently == "In Town" {
town, err := towns.ByCoords(user.X, user.Y)
if err != nil {
townname = "error finding town"
}
townname = "<b>In " + town.Name + "</b>"
}
leftSideData := map[string]any{
"user": user.ToMap(),
"cardinalX": cardinalX,
"cardinalY": cardinalY,
"townname": townname,
"townlist": "@TODO",
}
return leftSideTmpl.RenderNamed(leftSideData)
}
// RightAside generates the right sidebar content
func RightAside(ctx router.Ctx) string {
if !middleware.IsAuthenticated(ctx) {
return ""
}
// Load and render the rightside template with user data
rightSideTmpl, err := template.Cache.Load("rightside.html")
if err != nil {
return "" // Silently fail - sidebar is optional
}
// Get the current user from session
currentUser := middleware.GetCurrentUser(ctx)
if currentUser == nil {
return ""
}
user, err := users.Find(currentUser.ID)
if err != nil {
return ""
}
// Pass the user object directly to the template
rightSideData := map[string]any{
"user": user.ToMap(),
}
return rightSideTmpl.RenderNamed(rightSideData)
}

View File

@ -9,7 +9,6 @@ import (
"dk/internal/middleware" "dk/internal/middleware"
"dk/internal/router" "dk/internal/router"
"dk/internal/template" "dk/internal/template"
"dk/internal/users"
) )
// GenerateTopNav generates the top navigation HTML based on authentication status // GenerateTopNav generates the top navigation HTML based on authentication status
@ -28,68 +27,6 @@ func GenerateTopNav(ctx router.Ctx) string {
} }
} }
// GenerateLeftSide generates the left sidebar content for authenticated users
func GenerateLeftSide(ctx router.Ctx) string {
if !middleware.IsAuthenticated(ctx) {
return ""
}
// Load and render the leftside template with user data
leftSideTmpl, err := template.Cache.Load("leftside.html")
if err != nil {
return "" // Silently fail - sidebar is optional
}
// Get the current user from session
currentUser := middleware.GetCurrentUser(ctx)
if currentUser == nil {
return ""
}
user, err := users.Find(currentUser.ID)
if err != nil {
return ""
}
// Pass the user object directly to the template
leftSideData := map[string]any{
"user": user.ToMap(),
}
return leftSideTmpl.RenderNamed(leftSideData)
}
// GenerateRightSide generates the right sidebar content for authenticated users
func GenerateRightSide(ctx router.Ctx) string {
if !middleware.IsAuthenticated(ctx) {
return ""
}
// Load and render the rightside template with user data
rightSideTmpl, err := template.Cache.Load("rightside.html")
if err != nil {
return "" // Silently fail - sidebar is optional
}
// Get the current user from session
currentUser := middleware.GetCurrentUser(ctx)
if currentUser == nil {
return ""
}
user, err := users.Find(currentUser.ID)
if err != nil {
return ""
}
// Pass the user object directly to the template
rightSideData := map[string]any{
"user": user.ToMap(),
}
return rightSideTmpl.RenderNamed(rightSideData)
}
// PageData holds common page template data // PageData holds common page template data
type PageData struct { type PageData struct {
Title string Title string
@ -106,7 +43,7 @@ type PageData struct {
// RenderPage renders a page using the layout template with common data and additional custom data // RenderPage renders a page using the layout template with common data and additional custom data
func RenderPage(ctx router.Ctx, pageData PageData, additionalData map[string]any) error { func RenderPage(ctx router.Ctx, pageData PageData, additionalData map[string]any) error {
if template.Cache == nil || auth.Manager == nil { if template.Cache == nil || auth.Manager == nil {
return fmt.Errorf("singleton template.Cache or auth.Manager not initialized") return fmt.Errorf("template.Cache or auth.Manager not initialized")
} }
layoutTmpl, err := template.Cache.Load("layout.html") layoutTmpl, err := template.Cache.Load("layout.html")
@ -132,10 +69,10 @@ func RenderPage(ctx router.Ctx, pageData PageData, additionalData map[string]any
// Set defaults for empty fields // Set defaults for empty fields
if data["leftside"] == "" { if data["leftside"] == "" {
data["leftside"] = GenerateLeftSide(ctx) data["leftside"] = LeftAside(ctx)
} }
if data["rightside"] == "" { if data["rightside"] == "" {
data["rightside"] = GenerateRightSide(ctx) data["rightside"] = RightAside(ctx)
} }
if data["numqueries"] == "" { if data["numqueries"] == "" {
data["numqueries"] = "0" data["numqueries"] = "0"

View File

@ -158,6 +158,31 @@ func ByMaxTPCost(maxCost int) ([]*Town, error) {
return towns, nil return towns, nil
} }
// ByCoords retrieves a town by its x, y coordinates
func ByCoords(x, y int) (*Town, error) {
town := &Town{}
query := `SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list
FROM towns WHERE x = ? AND y = ? LIMIT 1`
err := database.Query(query, func(stmt *sqlite.Stmt) error {
town.ID = stmt.ColumnInt(0)
town.Name = stmt.ColumnText(1)
town.X = stmt.ColumnInt(2)
town.Y = stmt.ColumnInt(3)
town.InnCost = stmt.ColumnInt(4)
town.MapCost = stmt.ColumnInt(5)
town.TPCost = stmt.ColumnInt(6)
town.ShopList = stmt.ColumnText(7)
return nil
}, x, y)
if err != nil {
return nil, fmt.Errorf("failed to retrieve towns by distance: %w", err)
}
return town, nil
}
// ByDistance retrieves towns within a certain distance from a point // ByDistance retrieves towns within a certain distance from a point
func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) { func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
var towns []*Town var towns []*Town

View File

@ -1,232 +0,0 @@
package users
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating users
type Builder struct {
user *User
}
// NewBuilder creates a new user builder with default values
func NewBuilder() *Builder {
now := time.Now().Unix()
return &Builder{
user: &User{
Verified: 0, // Default unverified
Token: "", // Empty verification token
Registered: now, // Current time
LastOnline: now, // Current time
Auth: 0, // Default no special permissions
X: 0, // Default starting position
Y: 0, // Default starting position
ClassID: 1, // Default to class 1
Currently: "In Town", // Default status
Fighting: 0, // Default not fighting
HP: 15, // Default starting HP
MP: 0, // Default starting MP
TP: 10, // Default starting TP
MaxHP: 15, // Default starting max HP
MaxMP: 0, // Default starting max MP
MaxTP: 10, // Default starting max TP
Level: 1, // Default starting level
Gold: 100, // Default starting gold
Exp: 0, // Default starting exp
Strength: 5, // Default starting strength
Dexterity: 5, // Default starting dexterity
Attack: 5, // Default starting attack
Defense: 5, // Default starting defense
Spells: "", // No spells initially
Towns: "", // No towns visited initially
},
}
}
// WithUsername sets the username
func (b *Builder) WithUsername(username string) *Builder {
b.user.Username = username
return b
}
// WithPassword sets the password
func (b *Builder) WithPassword(password string) *Builder {
b.user.Password = password
return b
}
// WithEmail sets the email address
func (b *Builder) WithEmail(email string) *Builder {
b.user.Email = email
return b
}
// WithVerified sets the verification status
func (b *Builder) WithVerified(verified bool) *Builder {
if verified {
b.user.Verified = 1
} else {
b.user.Verified = 0
}
return b
}
// WithToken sets the verification token
func (b *Builder) WithToken(token string) *Builder {
b.user.Token = token
return b
}
// WithAuth sets the authorization level
func (b *Builder) WithAuth(auth int) *Builder {
b.user.Auth = auth
return b
}
// AsAdmin sets the user as admin (auth = 4)
func (b *Builder) AsAdmin() *Builder {
b.user.Auth = 4
return b
}
// AsModerator sets the user as moderator (auth = 2)
func (b *Builder) AsModerator() *Builder {
b.user.Auth = 2
return b
}
// WithClassID sets the character class ID
func (b *Builder) WithClassID(classID int) *Builder {
b.user.ClassID = classID
return b
}
// WithPosition sets the starting coordinates
func (b *Builder) WithPosition(x, y int) *Builder {
b.user.X = x
b.user.Y = y
return b
}
// WithLevel sets the starting level
func (b *Builder) WithLevel(level int) *Builder {
b.user.Level = level
return b
}
// WithGold sets the starting gold amount
func (b *Builder) WithGold(gold int) *Builder {
b.user.Gold = gold
return b
}
// WithStats sets the core character stats
func (b *Builder) WithStats(strength, dexterity, attack, defense int) *Builder {
b.user.Strength = strength
b.user.Dexterity = dexterity
b.user.Attack = attack
b.user.Defense = defense
return b
}
// WithHP sets current and maximum HP
func (b *Builder) WithHP(hp, maxHP int) *Builder {
b.user.HP = hp
b.user.MaxHP = maxHP
return b
}
// WithMP sets current and maximum MP
func (b *Builder) WithMP(mp, maxMP int) *Builder {
b.user.MP = mp
b.user.MaxMP = maxMP
return b
}
// WithTP sets current and maximum TP
func (b *Builder) WithTP(tp, maxTP int) *Builder {
b.user.TP = tp
b.user.MaxTP = maxTP
return b
}
// WithCurrently sets the current status message
func (b *Builder) WithCurrently(currently string) *Builder {
b.user.Currently = currently
return b
}
// WithRegistered sets the registration timestamp
func (b *Builder) WithRegistered(registered int64) *Builder {
b.user.Registered = registered
return b
}
// WithRegisteredTime sets the registration timestamp from time.Time
func (b *Builder) WithRegisteredTime(t time.Time) *Builder {
b.user.Registered = t.Unix()
return b
}
// WithSpells sets the user's known spells
func (b *Builder) WithSpells(spells []string) *Builder {
b.user.SetSpellIDs(spells)
return b
}
// WithTowns sets the user's visited towns
func (b *Builder) WithTowns(towns []string) *Builder {
b.user.SetTownIDs(towns)
return b
}
// Create saves the user to the database and returns the created user with ID
func (b *Builder) Create() (*User, error) {
// Use a transaction to ensure we can get the ID
var user *User
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO users (username, password, email, verified, token, registered, last_online, auth,
x, y, class_id, currently, fighting, monster_id, monster_hp, monster_sleep, monster_immune,
uber_damage, uber_defense, hp, mp, tp, max_hp, max_mp, max_tp, level, gold, exp,
gold_bonus, exp_bonus, strength, dexterity, attack, defense, weapon_id, armor_id, shield_id,
slot_1_id, slot_2_id, slot_3_id, weapon_name, armor_name, shield_name,
slot_1_name, slot_2_name, slot_3_name, drop_code, spells, towns)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, b.user.Username, b.user.Password, b.user.Email, b.user.Verified, b.user.Token,
b.user.Registered, b.user.LastOnline, b.user.Auth, b.user.X, b.user.Y, b.user.ClassID, b.user.Currently,
b.user.Fighting, b.user.MonsterID, b.user.MonsterHP, b.user.MonsterSleep, b.user.MonsterImmune,
b.user.UberDamage, b.user.UberDefense, b.user.HP, b.user.MP, b.user.TP, b.user.MaxHP, b.user.MaxMP, b.user.MaxTP,
b.user.Level, b.user.Gold, b.user.Exp, b.user.GoldBonus, b.user.ExpBonus, b.user.Strength, b.user.Dexterity,
b.user.Attack, b.user.Defense, b.user.WeaponID, b.user.ArmorID, b.user.ShieldID, b.user.Slot1ID,
b.user.Slot2ID, b.user.Slot3ID, b.user.WeaponName, b.user.ArmorName, b.user.ShieldName,
b.user.Slot1Name, b.user.Slot2Name, b.user.Slot3Name, b.user.DropCode, b.user.Spells, b.user.Towns); err != nil {
return fmt.Errorf("failed to insert user: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
b.user.ID = id
user = b.user
return nil
})
if err != nil {
return nil, err
}
return user, nil
}

View File

@ -65,6 +65,38 @@ type User struct {
Towns string `db:"towns" json:"towns"` Towns string `db:"towns" json:"towns"`
} }
// New creates a new User with sensible defaults
func New() *User {
now := time.Now().Unix()
return &User{
Verified: 0, // Default unverified
Token: "", // Empty verification token
Registered: now, // Current time
LastOnline: now, // Current time
Auth: 0, // Default no special permissions
X: 0, // Default starting position
Y: 0, // Default starting position
ClassID: 1, // Default to class 1
Currently: "In Town", // Default status
Fighting: 0, // Default not fighting
HP: 15, // Default starting HP
MP: 0, // Default starting MP
TP: 10, // Default starting TP
MaxHP: 15, // Default starting max HP
MaxMP: 0, // Default starting max MP
MaxTP: 10, // Default starting max TP
Level: 1, // Default starting level
Gold: 100, // Default starting gold
Exp: 0, // Default starting exp
Strength: 5, // Default starting strength
Dexterity: 5, // Default starting dexterity
Attack: 5, // Default starting attack
Defense: 5, // Default starting defense
Spells: "", // No spells initially
Towns: "", // No towns visited initially
}
}
var userScanner = scanner.New[User]() var userScanner = scanner.New[User]()
// userColumns returns the column list for user queries // userColumns returns the column list for user queries
@ -229,6 +261,50 @@ func (u *User) Save() error {
u.Slot1Name, u.Slot2Name, u.Slot3Name, u.DropCode, u.Spells, u.Towns, u.ID) u.Slot1Name, u.Slot2Name, u.Slot3Name, u.DropCode, u.Spells, u.Towns, u.ID)
} }
// Insert saves a new user to the database and sets the ID
func (u *User) Insert() error {
if u.ID != 0 {
return fmt.Errorf("user already has ID %d, use Save() to update", u.ID)
}
// Use a transaction to ensure we can get the ID
err := database.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO users (username, password, email, verified, token, registered, last_online, auth,
x, y, class_id, currently, fighting, monster_id, monster_hp, monster_sleep, monster_immune,
uber_damage, uber_defense, hp, mp, tp, max_hp, max_mp, max_tp, level, gold, exp,
gold_bonus, exp_bonus, strength, dexterity, attack, defense, weapon_id, armor_id, shield_id,
slot_1_id, slot_2_id, slot_3_id, weapon_name, armor_name, shield_name,
slot_1_name, slot_2_name, slot_3_name, drop_code, spells, towns)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, u.Username, u.Password, u.Email, u.Verified, u.Token,
u.Registered, u.LastOnline, u.Auth, u.X, u.Y, u.ClassID, u.Currently,
u.Fighting, u.MonsterID, u.MonsterHP, u.MonsterSleep, u.MonsterImmune,
u.UberDamage, u.UberDefense, u.HP, u.MP, u.TP, u.MaxHP, u.MaxMP, u.MaxTP,
u.Level, u.Gold, u.Exp, u.GoldBonus, u.ExpBonus, u.Strength, u.Dexterity,
u.Attack, u.Defense, u.WeaponID, u.ArmorID, u.ShieldID, u.Slot1ID,
u.Slot2ID, u.Slot3ID, u.WeaponName, u.ArmorName, u.ShieldName,
u.Slot1Name, u.Slot2Name, u.Slot3Name, u.DropCode, u.Spells, u.Towns); err != nil {
return fmt.Errorf("failed to insert user: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
u.ID = id
return nil
})
return err
}
// Delete removes the user from the database // Delete removes the user from the database
func (u *User) Delete() error { func (u *User) Delete() error {
if u.ID == 0 { if u.ID == 0 {

View File

@ -1,46 +1,32 @@
<table width="100%"> <section>
<tr> <div class="title"><img src="/assets/images/button_location.gif" alt="Location" title="Location"></div>
<td class="title"><img src="/assets/images/button_location.gif" alt="Location" title="Location"></td>
</tr>
<tr>
<td>
Currently: {user.Currently}<br>
Longitude: {user.X}<br>
Latitude: {user.Y}<br>
<a href="javascript:openmappopup()">View Map</a>
<br><br>
<form action="/move" method="post">
<center>
<input name="north" type="submit" value="North"><br>
<input name="west" type="submit" value="West"><input name="east" type="submit" value="East"><br>
<input name="south" type="submit" value="South">
</center>
</form>
</td>
</tr>
</table>
<br> <div><b>{user.Currently}</b></div>
<div>{user.X}{cardinalX}, {user.Y}{cardinalY}</div>
<a href="javascript:openmappopup()">View Map</a>
<table width="100%"> <form id="move-compass" action="/move" method="post" >
<tr><td class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></td></tr> <button id="north" name="direction" value="north">North</button>
<tr> <div class="mid">
<td> <button id="west" name="direction" value="west">West</button>
{townname} <button id="east" name="direction" value="east">East</button>
{townlist} </div>
</td> <button id="south" name="direction" value="south">South</button>
</tr> </form>
</table> </section>
<br> <section>
<div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div>
<div>{townname}</div>
<div>{townlist}</div>
</section>
<table width="100%"> <section id="functions">
<tr><td class="title"><img src="/assets/images/button_functions.gif" alt="Functions" title="Functions"></td></tr> <div class="title"><img src="/assets/images/button_functions.gif" alt="Functions" title="Functions"></div>
<tr><td> <a href="/">Home</a>
<a href="/">Home</a><br>
<a href="/forum">Forum</a> <a href="/forum">Forum</a>
<a href="/change-password">Change Password</a><br> <a href="/change-password">Change Password</a>
<a href="#">Log Out</a><br> <a href="#">Log Out</a>
<a href="/help">Help</a> <a href="/help">Help</a>
</td></tr> </section>
</table>

24
templates/town/town.html Normal file
View File

@ -0,0 +1,24 @@
<div class="town">
<div class="options">
<div class="title"><img src="/assets/images/town_{town.ID}.gif" alt="Welcome to {town.Name}" title="Welcome to {town.Name}"></div>
<b>Town Options:</b><br>
<ul hx-boost="true" hx-target="#middle">
<li><a href="/inn">Rest at the Inn</a></li>
<li><a href="/shop">Browse the Shop</a></li>
<li><a href="/maps">Buy Maps</a></li>
</ul>
</div>
<div class="news">
{news}
</div>
<div class="whos-online">
{whosonline}
</div>
<div class="babblebox">
<div class="title">Babblebox</div>
@TODO
</div>
</div>