Compare commits

..

2 Commits

17 changed files with 527 additions and 545 deletions

View File

@ -46,6 +46,15 @@ section#game {
margin: 1rem 0;
border-top: 2px solid #000;
& > aside > section:not(:last-child) {
margin-bottom: 1rem;
}
& > aside > section {
display: flex;
flex-direction: column;
}
& > aside#left {
grid-column: 1;
border-right: 2px solid #000;
@ -86,7 +95,7 @@ a {
color: #999999;
}
.title {
div.title {
border: solid 1px black;
background-color: #eeeeee;
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
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
// authentication tools over the store itself
type AuthManager struct {
@ -30,7 +23,7 @@ func Init(sessionsFilePath string) {
// Authenticate checks for the usernaname or email, then verifies the plain password
// 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 err error
@ -51,14 +44,10 @@ func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*Use
return nil, ErrInvalidCredentials
}
return &User{
ID: user.ID,
Username: user.Username,
Email: user.Email,
}, nil
return user, 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)
}

View File

@ -14,6 +14,7 @@ import (
"dk/internal/database"
"dk/internal/password"
"dk/internal/users"
)
const dbPath = "dk.db"
@ -483,15 +484,14 @@ func populateData() error {
}
func createDemoUser() error {
hashedPassword, err := password.Hash("Demo123!")
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user := users.New()
user.Username = "demo"
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)
VALUES (?, ?, ?, 1, 1, 4)`
if err := database.Exec(stmt, "demo", hashedPassword, "demo@demo.com"); err != nil {
if err := user.Insert(); err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}

View File

@ -3,15 +3,11 @@ package middleware
import (
"dk/internal/auth"
"dk/internal/router"
"dk/internal/users"
"github.com/valyala/fasthttp"
)
const (
UserKey = "user"
SessionKey = "session"
)
// Auth creates an authentication middleware
func Auth(authManager *auth.AuthManager) router.Middleware {
return func(next router.Handler) router.Handler {
@ -23,16 +19,16 @@ func Auth(authManager *auth.AuthManager) router.Middleware {
// Update session activity
authManager.UpdateSession(sessionID)
// Store session and user info in context
ctx.SetUserValue(SessionKey, session)
ctx.SetUserValue(UserKey, &auth.User{
ID: session.UserID,
Username: session.Username,
Email: session.Email,
})
// Get the full user object
user, err := users.Find(session.UserID)
if err == nil && user != nil {
// Store session and user info in context
ctx.SetUserValue("session", session)
ctx.SetUserValue("user", user)
// Refresh the cookie
auth.SetSessionCookie(ctx, sessionID)
// Refresh the cookie
auth.SetSessionCookie(ctx, sessionID)
}
}
}
@ -41,12 +37,17 @@ func Auth(authManager *auth.AuthManager) router.Middleware {
}
}
// RequireAuth enforces authentication - redirects to login if not authenticated
func RequireAuth(loginPath string) router.Middleware {
// RequireAuth enforces authentication - redirect defaults to "/login"
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(ctx router.Ctx, params []string) {
if !IsAuthenticated(ctx) {
ctx.Redirect(loginPath, fasthttp.StatusFound)
ctx.Redirect(redirect, fasthttp.StatusFound)
return
}
@ -55,15 +56,19 @@ func RequireAuth(loginPath string) router.Middleware {
}
}
// RequireGuest enforces no authentication - redirects to dashboard if authenticated
func RequireGuest(dashboardPath string) router.Middleware {
// RequireGuest enforces no authentication - redirect defaults to "/"
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(ctx router.Ctx, params []string) {
if IsAuthenticated(ctx) {
ctx.Redirect(dashboardPath, fasthttp.StatusFound)
ctx.Redirect(redirect, fasthttp.StatusFound)
return
}
next(ctx, params)
}
}
@ -71,13 +76,13 @@ func RequireGuest(dashboardPath string) router.Middleware {
// IsAuthenticated checks if the current request has a valid session
func IsAuthenticated(ctx router.Ctx) bool {
_, exists := ctx.UserValue(UserKey).(*auth.User)
_, exists := ctx.UserValue("user").(*users.User)
return exists
}
// GetCurrentUser returns the current authenticated user, or nil if not authenticated
func GetCurrentUser(ctx router.Ctx) *auth.User {
if user, ok := ctx.UserValue(UserKey).(*auth.User); ok {
func GetCurrentUser(ctx router.Ctx) *users.User {
if user, ok := ctx.UserValue("user").(*users.User); ok {
return user
}
return nil
@ -85,20 +90,20 @@ func GetCurrentUser(ctx router.Ctx) *auth.User {
// GetCurrentSession returns the current session, or nil if not authenticated
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 nil
}
// 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)
auth.SetSessionCookie(ctx, session.ID)
// Set in context for immediate use
ctx.SetUserValue(SessionKey, session)
ctx.SetUserValue(UserKey, user)
ctx.SetUserValue("session", session)
ctx.SetUserValue("user", user)
}
// Logout destroys the session and clears the cookie
@ -111,6 +116,6 @@ func Logout(ctx router.Ctx, authManager *auth.AuthManager) {
auth.DeleteSessionCookie(ctx)
// Clear from context
ctx.SetUserValue(SessionKey, nil)
ctx.SetUserValue(UserKey, nil)
ctx.SetUserValue("session", 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
func Hash(password string) (string, error) {
func Hash(password string) string {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
rand.Read(salt)
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)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memory, time, threads, b64Salt, b64Hash)
return encoded, nil
return encoded
}
// Verify checks if a password matches the hash

View File

@ -23,9 +23,9 @@ func RegisterAuthRoutes(r *router.Router) {
guestGroup := r.Group("")
guestGroup.Use(middleware.RequireGuest("/"))
guestGroup.Get("/login", showLogin())
guestGroup.Get("/login", showLogin)
guestGroup.Post("/login", processLogin())
guestGroup.Get("/register", showRegister())
guestGroup.Get("/register", showRegister)
guestGroup.Post("/register", processRegister())
// Authenticated routes
@ -36,60 +36,53 @@ func RegisterAuthRoutes(r *router.Router) {
}
// showLogin displays the login form
func showLogin() router.Handler {
return func(ctx router.Ctx, params []string) {
loginTmpl, err := template.Cache.Load("auth/login.html")
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
func showLogin(ctx router.Ctx, params []string) {
loginTmpl, err := template.Cache.Load("auth/login.html")
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
loginFormData := map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "",
}
loginFormData := map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "",
}
loginContent := loginTmpl.RenderNamed(loginFormData)
loginContent := loginTmpl.RenderNamed(loginFormData)
pageData := components.NewPageData("Login - Dragon Knight", loginContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
pageData := components.NewPageData("Login - Dragon Knight", loginContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
}
// processLogin handles login form submission
func processLogin() router.Handler {
return func(ctx router.Ctx, params []string) {
// Validate CSRF token
if !csrf.ValidateFormToken(ctx, auth.Manager) {
ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.WriteString("CSRF validation failed")
return
}
// Get form values
email := strings.TrimSpace(string(ctx.PostArgs().Peek("email")))
userPassword := string(ctx.PostArgs().Peek("password"))
// Validate input
if email == "" || userPassword == "" {
showLoginError(ctx, "Email and password are required")
return
}
// Authenticate user
user, err := auth.Manager.Authenticate(email, userPassword)
if err != nil {
showLoginError(ctx, "Invalid email or password")
return
}
// Create session and login
middleware.Login(ctx, auth.Manager, user)
// Transfer CSRF token from cookie to session for authenticated user
@ -99,104 +92,78 @@ func processLogin() router.Handler {
}
}
// Redirect to dashboard
ctx.Redirect("/dashboard", fasthttp.StatusFound)
ctx.Redirect("/", fasthttp.StatusFound)
}
}
// showRegister displays the registration form
func showRegister() router.Handler {
return func(ctx router.Ctx, params []string) {
registerTmpl, err := template.Cache.Load("auth/register.html")
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
func showRegister(ctx router.Ctx, _ []string) {
registerTmpl, err := template.Cache.Load("auth/register.html")
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
registerFormData := map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "",
"username": "",
"email": "",
}
registerContent := registerTmpl.RenderNamed(map[string]any{
"csrf_token": csrf.GetToken(ctx, auth.Manager),
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
"error_message": "",
"username": "",
"email": "",
})
registerContent := registerTmpl.RenderNamed(registerFormData)
pageData := components.NewPageData("Register - Dragon Knight", registerContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
pageData := components.NewPageData("Register - Dragon Knight", registerContent)
if err := components.RenderPage(ctx, pageData, nil); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
fmt.Fprintf(ctx, "Template error: %v", err)
return
}
}
// processRegister handles registration form submission
func processRegister() router.Handler {
return func(ctx router.Ctx, params []string) {
// Validate CSRF token
if !csrf.ValidateFormToken(ctx, auth.Manager) {
ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.WriteString("CSRF validation failed")
return
}
// Get form values
username := strings.TrimSpace(string(ctx.PostArgs().Peek("username")))
email := strings.TrimSpace(string(ctx.PostArgs().Peek("email")))
userPassword := string(ctx.PostArgs().Peek("password"))
confirmPassword := string(ctx.PostArgs().Peek("confirm_password"))
// Validate input
if err := validateRegistration(username, email, userPassword, confirmPassword); err != nil {
showRegisterError(ctx, err.Error(), username, email)
return
}
// Check if username already exists
if _, err := users.GetByUsername(username); err == nil {
showRegisterError(ctx, "Username already exists", username, email)
return
}
// Check if email already exists
if _, err := users.GetByEmail(email); err == nil {
showRegisterError(ctx, "Email already registered", username, email)
return
}
// Hash password
hashedPassword, err := password.Hash(userPassword)
if err != nil {
showRegisterError(ctx, "Failed to process password", username, email)
return
}
user := users.New()
user.Username = username
user.Email = email
user.Password = password.Hash(userPassword)
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)
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 {
if err := user.Insert(); err != nil {
showRegisterError(ctx, "Failed to create account", username, email)
return
}
// Auto-login after registration
authUser := &auth.User{
ID: user.ID,
Username: user.Username,
Email: user.Email,
}
middleware.Login(ctx, auth.Manager, authUser)
middleware.Login(ctx, auth.Manager, user)
// Transfer CSRF token from cookie to session for authenticated user
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/routes"
"dk/internal/template"
"dk/internal/template/components"
"github.com/valyala/fasthttp"
)
@ -40,63 +39,9 @@ func Start(port string) error {
r.Use(middleware.Auth(auth.Manager))
r.Use(middleware.CSRF(auth.Manager))
// Setup route handlers
r.Get("/", routes.Index)
routes.RegisterAuthRoutes(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
}
})
routes.RegisterTownRoutes(r)
// Use current working directory for static files
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/router"
"dk/internal/template"
"dk/internal/users"
)
// 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
type PageData struct {
Title string
@ -106,7 +43,7 @@ type PageData struct {
// 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 {
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")
@ -132,10 +69,10 @@ func RenderPage(ctx router.Ctx, pageData PageData, additionalData map[string]any
// Set defaults for empty fields
if data["leftside"] == "" {
data["leftside"] = GenerateLeftSide(ctx)
data["leftside"] = LeftAside(ctx)
}
if data["rightside"] == "" {
data["rightside"] = GenerateRightSide(ctx)
data["rightside"] = RightAside(ctx)
}
if data["numqueries"] == "" {
data["numqueries"] = "0"

View File

@ -158,6 +158,31 @@ func ByMaxTPCost(maxCost int) ([]*Town, error) {
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
func ByDistance(fromX, fromY, maxDistance int) ([]*Town, error) {
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"`
}
// 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]()
// 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)
}
// 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
func (u *User) Delete() error {
if u.ID == 0 {

View File

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

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>