summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-29 22:52:46 +0530
committerBobby <[email protected]>2026-03-29 22:52:46 +0530
commit9eb9b7f4bd552a641235764f66483e1f940fcfd9 (patch)
treeda520b923b5e6758d5457b6233dd6671fc640914
parent65a143a0871c35989b7c7ea6723d39a0585c089e (diff)
downloadechoes-of-vaelun-9eb9b7f4bd552a641235764f66483e1f940fcfd9.tar.xz
echoes-of-vaelun-9eb9b7f4bd552a641235764f66483e1f940fcfd9.zip
feat: nexus account manager scaffold with auth, characters, realmsHEADmain
-rw-r--r--Makefile40
-rw-r--r--nexus/.air.toml13
-rw-r--r--nexus/.air.windows.toml13
-rw-r--r--nexus/.gitignore43
-rw-r--r--nexus/Makefile47
-rw-r--r--nexus/api/account/account.go20
-rw-r--r--nexus/api/auth/auth.go69
-rw-r--r--nexus/api/characters/characters.go66
-rw-r--r--nexus/api/characters/messages.go3
-rw-r--r--nexus/api/realms/realms.go23
-rw-r--r--nexus/config/config.go38
-rw-r--r--nexus/config/defaults.go3
-rw-r--r--nexus/config/env.go37
-rw-r--r--nexus/config/messages.go10
-rw-r--r--nexus/controllers/auth/defaults.go3
-rw-r--r--nexus/controllers/auth/login.go39
-rw-r--r--nexus/controllers/auth/logout.go20
-rw-r--r--nexus/controllers/auth/messages.go6
-rw-r--r--nexus/controllers/auth/register.go48
-rw-r--r--nexus/database/database.go49
-rw-r--r--nexus/database/defaults.go10
-rw-r--r--nexus/database/logger.go14
-rw-r--r--nexus/database/messages.go9
-rw-r--r--nexus/database/migration.go7
-rw-r--r--nexus/go.mod41
-rw-r--r--nexus/go.sum179
-rw-r--r--nexus/middleware/account.go30
-rw-r--r--nexus/middleware/middleware.go9
-rw-r--r--nexus/middleware/request.go12
-rw-r--r--nexus/middleware/session.go17
-rw-r--r--nexus/models/account.go77
-rw-r--r--nexus/models/character.go51
-rw-r--r--nexus/models/defaults.go7
-rw-r--r--nexus/models/realm.go36
-rw-r--r--nexus/models/session.go36
-rw-r--r--nexus/nexus/defaults.go3
-rw-r--r--nexus/nexus/main.go56
-rw-r--r--nexus/nexus/messages.go9
-rw-r--r--nexus/pages/account/account.go13
-rw-r--r--nexus/pages/auth/login.go13
-rw-r--r--nexus/pages/auth/register.go13
-rw-r--r--nexus/pages/characters/characters.go52
-rw-r--r--nexus/repositories/account/account.go42
-rw-r--r--nexus/repositories/character/character.go38
-rw-r--r--nexus/repositories/realm/realm.go28
-rw-r--r--nexus/repositories/session/session.go38
-rw-r--r--nexus/router/api.go29
-rw-r--r--nexus/router/router.go21
-rw-r--r--nexus/router/web.go25
-rw-r--r--nexus/services/account/account.go46
-rw-r--r--nexus/services/account/defaults.go3
-rw-r--r--nexus/services/account/messages.go7
-rw-r--r--nexus/services/auth/auth.go129
-rw-r--r--nexus/services/auth/defaults.go3
-rw-r--r--nexus/services/auth/messages.go17
-rw-r--r--nexus/services/character/character.go79
-rw-r--r--nexus/services/character/defaults.go3
-rw-r--r--nexus/services/character/messages.go11
-rw-r--r--nexus/sessions/defaults.go11
-rw-r--r--nexus/sessions/functions.go55
-rw-r--r--nexus/sessions/kv.go17
-rw-r--r--nexus/sessions/messages.go5
-rw-r--r--nexus/sessions/sessions.go36
-rw-r--r--nexus/static/css/main.css18
-rw-r--r--nexus/tags/active.go20
-rw-r--r--nexus/tags/defaults.go6
-rw-r--r--nexus/tags/messages.go12
-rw-r--r--nexus/tags/static.go32
-rw-r--r--nexus/tags/tags.go40
-rw-r--r--nexus/tags/url.go97
-rw-r--r--nexus/templates/account/index.django8
-rw-r--r--nexus/templates/auth/login.django17
-rw-r--r--nexus/templates/auth/register.django19
-rw-r--r--nexus/templates/characters/create.django20
-rw-r--r--nexus/templates/characters/index.django7
-rw-r--r--nexus/templates/errors/error.django8
-rw-r--r--nexus/templates/layouts/base.django12
-rw-r--r--nexus/types/account/response.go15
-rw-r--r--nexus/types/auth/request.go16
-rw-r--r--nexus/types/auth/response.go6
-rw-r--r--nexus/types/character/context.go5
-rw-r--r--nexus/types/character/request.go10
-rw-r--r--nexus/types/character/response.go16
-rw-r--r--nexus/types/realm/response.go10
-rw-r--r--nexus/utils/auth/auth.go54
-rw-r--r--nexus/utils/collections/maps.go37
-rw-r--r--nexus/utils/collections/record.go3
-rw-r--r--nexus/utils/env/defaults.go6
-rw-r--r--nexus/utils/env/extract.go33
-rw-r--r--nexus/utils/env/getenv.go75
-rw-r--r--nexus/utils/env/messages.go5
-rw-r--r--nexus/utils/env/parse.go27
-rw-r--r--nexus/utils/env/setenv.go62
-rw-r--r--nexus/utils/logger/defaults.go25
-rw-r--r--nexus/utils/logger/format.go63
-rw-r--r--nexus/utils/logger/logger.go81
-rw-r--r--nexus/utils/logger/messages.go5
-rw-r--r--nexus/utils/meta/account.go15
-rw-r--r--nexus/utils/meta/body.go11
-rw-r--r--nexus/utils/meta/defaults.go8
-rw-r--r--nexus/utils/meta/messages.go5
-rw-r--r--nexus/utils/meta/request.go108
-rw-r--r--nexus/utils/meta/session.go16
-rw-r--r--nexus/utils/meta/title.go7
-rw-r--r--nexus/utils/shortcuts/errors.go20
-rw-r--r--nexus/utils/shortcuts/flash.go36
-rw-r--r--nexus/utils/shortcuts/json.go12
-rw-r--r--nexus/utils/shortcuts/messages.go5
-rw-r--r--nexus/utils/shortcuts/redirect.go11
-rw-r--r--nexus/utils/shortcuts/render.go103
-rw-r--r--nexus/utils/shortcuts/token.go16
-rw-r--r--nexus/utils/token/token.go14
-rw-r--r--nexus/utils/urls/attach.go12
-rw-r--r--nexus/utils/urls/path.go114
-rw-r--r--nexus/utils/urls/registry.go34
-rw-r--r--nexus/utils/validate/email.go9
-rw-r--r--nexus/utils/validate/messages.go5
-rw-r--r--toolchain/docker-compose.nakama.yml (renamed from toolchain/docker-compose.yml)1
-rw-r--r--toolchain/docker-compose.nexus.yml18
119 files changed, 3376 insertions, 1 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..42cf880
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,40 @@
+NAKAMA_COMPOSE = toolchain/docker-compose.nakama.yml
+NEXUS_COMPOSE = toolchain/docker-compose.nexus.yml
+
+.PHONY: up down clean logs-nakama logs-nexus nexus
+
+up:
+ @echo "Starting all services..."
+ @docker compose -f $(NAKAMA_COMPOSE) up -d
+ @docker compose -f $(NEXUS_COMPOSE) up -d
+ @echo "All services started."
+
+down:
+ @echo "Stopping all services..."
+ @docker compose -f $(NAKAMA_COMPOSE) down
+ @docker compose -f $(NEXUS_COMPOSE) down
+ @echo "All services stopped."
+
+clean:
+ @echo "Stopping and removing all data..."
+ @docker compose -f $(NAKAMA_COMPOSE) down -v
+ @docker compose -f $(NEXUS_COMPOSE) down -v
+ @rm -rf data/cockroach data/nakama data/postgres
+ @mkdir -p data/cockroach data/nakama data/postgres
+ @echo "Clean complete."
+
+logs-nakama:
+ @docker compose -f $(NAKAMA_COMPOSE) logs -f
+
+logs-nexus:
+ @docker compose -f $(NEXUS_COMPOSE) logs -f
+
+nexus:
+ @echo "Starting Nexus..."
+ @$(MAKE) -C nexus dev
+
+nexus-build:
+ @echo "Building Nexus..."
+ @$(MAKE) -C nexus build
+
+.SILENT: \ No newline at end of file
diff --git a/nexus/.air.toml b/nexus/.air.toml
new file mode 100644
index 0000000..50d6b88
--- /dev/null
+++ b/nexus/.air.toml
@@ -0,0 +1,13 @@
+#:schema https://raw.githubusercontent.com/air-verse/air/master/air_example.toml
+root = "."
+tmp_dir = "bin"
+
+[build]
+bin = "./bin/nexus"
+cmd = "make build"
+include_ext = ["go", "django", "css", "js"]
+exclude_dir = ["tmp", "bin", "toolchain", "node_modules", ".git"]
+delay = 500
+
+[misc]
+clean_on_exit = true \ No newline at end of file
diff --git a/nexus/.air.windows.toml b/nexus/.air.windows.toml
new file mode 100644
index 0000000..b47add8
--- /dev/null
+++ b/nexus/.air.windows.toml
@@ -0,0 +1,13 @@
+#:schema https://raw.githubusercontent.com/air-verse/air/master/air_example.toml
+root = "."
+tmp_dir = "bin"
+
+[build]
+bin = "bin/nexus.exe"
+cmd = "make build"
+include_ext = ["go", "django", "css", "js"]
+exclude_dir = ["tmp", "bin", "toolchain", "node_modules", ".git"]
+delay = 500
+
+[misc]
+clean_on_exit = true \ No newline at end of file
diff --git a/nexus/.gitignore b/nexus/.gitignore
new file mode 100644
index 0000000..e5a45c1
--- /dev/null
+++ b/nexus/.gitignore
@@ -0,0 +1,43 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Code coverage profiles and other test artifacts
+*.out
+coverage.*
+*.coverprofile
+profile.cov
+
+# Dependency directories
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# env file
+.env
+
+# Editor/IDE
+.idea/
+.vscode/
+.claude/
+
+# Binaries
+bin/
+
+# Database files
+*.db
+
+# Data
+data/
+
+# OS generated files
+.DS_Store
+Thumbs.db \ No newline at end of file
diff --git a/nexus/Makefile b/nexus/Makefile
new file mode 100644
index 0000000..db75a79
--- /dev/null
+++ b/nexus/Makefile
@@ -0,0 +1,47 @@
+BINARY_NAME = nexus
+MAIN_PATH = ./$(BINARY_NAME)
+
+ifeq ($(OS),Windows_NT)
+ BUILD_PATH = bin/$(BINARY_NAME).exe
+ AIR_CONFIG = .air.windows.toml
+else
+ BUILD_PATH = bin/$(BINARY_NAME)
+ AIR_CONFIG = .air.toml
+endif
+
+.PHONY: setup clean tidy build run dev all
+
+setup:
+ @echo "Setting up environment..."
+ @go mod download
+ @go mod tidy
+ @which air > /dev/null 2>&1 || go install github.com/air-verse/air@latest
+ @echo "Environment setup complete."
+
+clean:
+ @echo "Cleaning up..."
+ @rm -rf bin
+ @echo "Cleanup complete."
+
+tidy:
+ @echo "Tidying modules..."
+ @go mod tidy
+ @echo "Modules tidied."
+
+build:
+ @echo "Building..."
+ @go build -o $(BUILD_PATH) $(MAIN_PATH)
+ @echo "Build complete."
+
+run:
+ @if [ ! -f $(BUILD_PATH) ]; then echo "Binary not found. Building..."; $(MAKE) -s build; fi
+ @echo "Running..."
+ @$(BUILD_PATH)
+
+dev:
+ @echo "Running in development mode..."
+ @air -c $(AIR_CONFIG)
+
+all: setup clean build run
+
+.SILENT: \ No newline at end of file
diff --git a/nexus/api/account/account.go b/nexus/api/account/account.go
new file mode 100644
index 0000000..42f8836
--- /dev/null
+++ b/nexus/api/account/account.go
@@ -0,0 +1,20 @@
+package account
+
+import (
+ "nexus/services/account"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Show(context *fiber.Ctx) error {
+ return shortcuts.JSON(context, meta.Account(context).ToResponse())
+}
+
+func Delete(context *fiber.Ctx) error {
+ if serviceErr := account.Delete(meta.Account(context).ID); serviceErr != nil {
+ return serviceErr
+ }
+ return shortcuts.NoContent(context)
+}
diff --git a/nexus/api/auth/auth.go b/nexus/api/auth/auth.go
new file mode 100644
index 0000000..36f2e5f
--- /dev/null
+++ b/nexus/api/auth/auth.go
@@ -0,0 +1,69 @@
+package auth
+
+import (
+ "nexus/services/auth"
+ authTypes "nexus/types/auth"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Register(context *fiber.Ctx) error {
+ body, err := meta.Body[authTypes.RegisterRequest](context)
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, err.Error())
+ }
+
+ account, serviceErr := auth.Register(body)
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.Created(context, account.ToResponse())
+}
+
+func Login(context *fiber.Ctx) error {
+ body, err := meta.Body[authTypes.LoginRequest](context)
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, err.Error())
+ }
+
+ req := meta.Request(context)
+
+ session, serviceErr := auth.Login(body, req.IP, req.Header("User-Agent"))
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.JSON(context, authTypes.TokenResponse{
+ AuthToken: session.AuthToken,
+ RefreshToken: session.RefreshToken,
+ })
+}
+
+func Refresh(context *fiber.Ctx) error {
+ body, err := meta.Body[authTypes.RefreshRequest](context)
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, err.Error())
+ }
+
+ req := meta.Request(context)
+
+ session, serviceErr := auth.Refresh(body.RefreshToken, req.IP, req.Header("User-Agent"))
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.JSON(context, authTypes.TokenResponse{
+ AuthToken: session.AuthToken,
+ RefreshToken: session.RefreshToken,
+ })
+}
+
+func Logout(context *fiber.Ctx) error {
+ if serviceErr := auth.Logout(shortcuts.BearerToken(context)); serviceErr != nil {
+ return serviceErr
+ }
+ return shortcuts.NoContent(context)
+}
diff --git a/nexus/api/characters/characters.go b/nexus/api/characters/characters.go
new file mode 100644
index 0000000..4443212
--- /dev/null
+++ b/nexus/api/characters/characters.go
@@ -0,0 +1,66 @@
+package characters
+
+import (
+ "nexus/services/character"
+ characterTypes "nexus/types/character"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+func Index(context *fiber.Ctx) error {
+ characters, serviceErr := character.GetAllForAccount(meta.Account(context).ID)
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ response := make([]characterTypes.Response, len(characters))
+ for i, c := range characters {
+ response[i] = c.ToResponse()
+ }
+
+ return shortcuts.JSON(context, response)
+}
+
+func Show(context *fiber.Ctx) error {
+ id, err := uuid.Parse(meta.Request(context).Param("id"))
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, InvalidCharacterID)
+ }
+
+ c, serviceErr := character.GetByID(id, meta.Account(context).ID)
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.JSON(context, c.ToResponse())
+}
+
+func Create(context *fiber.Ctx) error {
+ body, err := meta.Body[characterTypes.CreateRequest](context)
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, err.Error())
+ }
+
+ c, serviceErr := character.Create(meta.Account(context).ID, body)
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.Created(context, c.ToResponse())
+}
+
+func Delete(context *fiber.Ctx) error {
+ id, err := uuid.Parse(meta.Request(context).Param("id"))
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusBadRequest, InvalidCharacterID)
+ }
+
+ if serviceErr := character.Delete(id, meta.Account(context).ID); serviceErr != nil {
+ return serviceErr
+ }
+
+ return shortcuts.NoContent(context)
+}
diff --git a/nexus/api/characters/messages.go b/nexus/api/characters/messages.go
new file mode 100644
index 0000000..f8bdb02
--- /dev/null
+++ b/nexus/api/characters/messages.go
@@ -0,0 +1,3 @@
+package characters
+
+const InvalidCharacterID = "invalid character id"
diff --git a/nexus/api/realms/realms.go b/nexus/api/realms/realms.go
new file mode 100644
index 0000000..025949f
--- /dev/null
+++ b/nexus/api/realms/realms.go
@@ -0,0 +1,23 @@
+package realms
+
+import (
+ "nexus/repositories/realm"
+ realmTypes "nexus/types/realm"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Index(context *fiber.Ctx) error {
+ realms, err := realm.FindAll()
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusInternalServerError, err.Error())
+ }
+
+ response := make([]realmTypes.Response, len(realms))
+ for i, r := range realms {
+ response[i] = r.ToResponse()
+ }
+
+ return shortcuts.JSON(context, response)
+}
diff --git a/nexus/config/config.go b/nexus/config/config.go
new file mode 100644
index 0000000..bbc42f6
--- /dev/null
+++ b/nexus/config/config.go
@@ -0,0 +1,38 @@
+package config
+
+import (
+ "nexus/utils/env"
+ "nexus/utils/logger"
+)
+
+var (
+ Server server
+ Database database
+ Session session
+ Token token
+ Game game
+)
+
+func init() {
+ if err := env.Parse(&Server); err != nil {
+ logger.Fatalf(LogPrefix, ServerConfigFailed, err)
+ }
+ if err := env.Parse(&Database); err != nil {
+ logger.Fatalf(LogPrefix, DatabaseConfigFailed, err)
+ }
+ if err := env.Parse(&Session); err != nil {
+ logger.Fatalf(LogPrefix, SessionConfigFailed, err)
+ }
+ if err := env.Parse(&Token); err != nil {
+ logger.Fatalf(LogPrefix, TokenConfigFailed, err)
+ }
+ if err := env.Parse(&Game); err != nil {
+ logger.Fatalf(LogPrefix, GameConfigFailed, err)
+ }
+
+ if Server.Debug {
+ logger.SetDebug(true)
+ }
+
+ logger.Successf(LogPrefix, ConfigLoaded)
+}
diff --git a/nexus/config/defaults.go b/nexus/config/defaults.go
new file mode 100644
index 0000000..b3bb012
--- /dev/null
+++ b/nexus/config/defaults.go
@@ -0,0 +1,3 @@
+package config
+
+const LogPrefix = "Config"
diff --git a/nexus/config/env.go b/nexus/config/env.go
new file mode 100644
index 0000000..3b3b65b
--- /dev/null
+++ b/nexus/config/env.go
@@ -0,0 +1,37 @@
+package config
+
+import "time"
+
+type server struct {
+ Host string `env:"HOST" default:"0.0.0.0"`
+ Port int `env:"PORT" default:"8080"`
+ Debug bool `env:"DEBUG" default:"false"`
+}
+
+type database struct {
+ Host string `env:"DB_HOST" default:"localhost"`
+ Port int `env:"DB_PORT" default:"5432"`
+ User string `env:"DB_USER" default:"postgres"`
+ Password string `env:"DB_PASSWORD" default:"postgres"`
+ Name string `env:"DB_NAME" default:"nexus"`
+ SSLMode string `env:"DB_SSL_MODE" default:"disable"`
+}
+
+type session struct {
+ CookieName string `env:"SESSION_COOKIE_NAME" default:"eov_session"`
+ CookieDomain string `env:"SESSION_COOKIE_DOMAIN" default:"localhost"`
+ CookiePath string `env:"SESSION_COOKIE_PATH" default:"/"`
+ CookieSecure bool `env:"SESSION_COOKIE_SECURE" default:"false"`
+ Timeout time.Duration `env:"SESSION_TIMEOUT" default:"24h"`
+}
+
+type token struct {
+ AuthExpiry int `env:"AUTH_TOKEN_EXPIRY_MINUTES" default:"30"`
+ RefreshExpiry int `env:"REFRESH_TOKEN_EXPIRY_DAYS" default:"30"`
+}
+
+type game struct {
+ NakamaHost string `env:"NAKAMA_HOST" default:"localhost"`
+ NakamaPort int `env:"NAKAMA_PORT" default:"7350"`
+ ServerKey string `env:"NAKAMA_SERVER_KEY" default:"defaultkey"`
+}
diff --git a/nexus/config/messages.go b/nexus/config/messages.go
new file mode 100644
index 0000000..55f6a6a
--- /dev/null
+++ b/nexus/config/messages.go
@@ -0,0 +1,10 @@
+package config
+
+const (
+ ServerConfigFailed = "Failed to parse server config: %v"
+ DatabaseConfigFailed = "Failed to parse database config: %v"
+ SessionConfigFailed = "Failed to parse session config: %v"
+ TokenConfigFailed = "Failed to parse token config: %v"
+ GameConfigFailed = "Failed to parse game config: %v"
+ ConfigLoaded = "Configuration loaded successfully"
+)
diff --git a/nexus/controllers/auth/defaults.go b/nexus/controllers/auth/defaults.go
new file mode 100644
index 0000000..8998b77
--- /dev/null
+++ b/nexus/controllers/auth/defaults.go
@@ -0,0 +1,3 @@
+package auth
+
+const LogPrefix = "AuthController"
diff --git a/nexus/controllers/auth/login.go b/nexus/controllers/auth/login.go
new file mode 100644
index 0000000..4fd7ffd
--- /dev/null
+++ b/nexus/controllers/auth/login.go
@@ -0,0 +1,39 @@
+package auth
+
+import (
+ "nexus/services/auth"
+ "nexus/sessions"
+ authTypes "nexus/types/auth"
+ "nexus/utils/collections"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Login(context *fiber.Ctx) error {
+ body, err := meta.Body[authTypes.LoginRequest](context)
+ if err != nil {
+ return shortcuts.RedirectWithFlash(context, "login", collections.Record[string, any]{
+ "Error": err.Error(),
+ })
+ }
+
+ req := meta.Request(context)
+
+ session, serviceErr := auth.Login(body, req.IP, req.Header("User-Agent"))
+ if serviceErr != nil {
+ return shortcuts.RedirectWithFlash(context, "login", collections.Record[string, any]{
+ "Error": serviceErr.Message,
+ })
+ }
+
+ sess := meta.Session(context)
+ if err := sessions.CreateSession(sess, session.AccountID); err != nil {
+ return shortcuts.RedirectWithFlash(context, "login", collections.Record[string, any]{
+ "Error": ErrSessionCreate,
+ })
+ }
+
+ return shortcuts.Redirect(context, "account")
+}
diff --git a/nexus/controllers/auth/logout.go b/nexus/controllers/auth/logout.go
new file mode 100644
index 0000000..f4c7014
--- /dev/null
+++ b/nexus/controllers/auth/logout.go
@@ -0,0 +1,20 @@
+package auth
+
+import (
+ "nexus/sessions"
+ "nexus/utils/collections"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Logout(context *fiber.Ctx) error {
+ sess := meta.Session(context)
+ if err := sessions.DestroySession(sess); err != nil {
+ return shortcuts.RedirectWithFlash(context, "account", collections.Record[string, any]{
+ "Error": ErrSessionDestroy,
+ })
+ }
+ return shortcuts.Redirect(context, "login")
+}
diff --git a/nexus/controllers/auth/messages.go b/nexus/controllers/auth/messages.go
new file mode 100644
index 0000000..de8f0bd
--- /dev/null
+++ b/nexus/controllers/auth/messages.go
@@ -0,0 +1,6 @@
+package auth
+
+const (
+ ErrSessionCreate = "Failed to create session. Please try again."
+ ErrSessionDestroy = "Failed to logout. Please try again."
+)
diff --git a/nexus/controllers/auth/register.go b/nexus/controllers/auth/register.go
new file mode 100644
index 0000000..6436073
--- /dev/null
+++ b/nexus/controllers/auth/register.go
@@ -0,0 +1,48 @@
+package auth
+
+import (
+ "nexus/services/auth"
+ "nexus/sessions"
+ authTypes "nexus/types/auth"
+ "nexus/utils/collections"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Register(context *fiber.Ctx) error {
+ body, err := meta.Body[authTypes.RegisterRequest](context)
+ if err != nil {
+ return shortcuts.RedirectWithFlash(context, "register", collections.Record[string, any]{
+ "Error": err.Error(),
+ })
+ }
+
+ req := meta.Request(context)
+
+ account, serviceErr := auth.Register(body)
+ if serviceErr != nil {
+ return shortcuts.RedirectWithFlash(context, "register", collections.Record[string, any]{
+ "Error": serviceErr.Message,
+ })
+ }
+
+ session, serviceErr := auth.Login(authTypes.LoginRequest{
+ Email: body.Email,
+ Password: body.Password,
+ }, req.IP, req.Header("User-Agent"))
+ if serviceErr != nil {
+ return shortcuts.Redirect(context, "login")
+ }
+
+ sess := meta.Session(context)
+ if err := sessions.CreateSession(sess, account.ID); err != nil {
+ return shortcuts.RedirectWithFlash(context, "login", collections.Record[string, any]{
+ "Error": ErrSessionCreate,
+ })
+ }
+
+ _ = session
+ return shortcuts.Redirect(context, "account")
+}
diff --git a/nexus/database/database.go b/nexus/database/database.go
new file mode 100644
index 0000000..e00c494
--- /dev/null
+++ b/nexus/database/database.go
@@ -0,0 +1,49 @@
+package database
+
+import (
+ "fmt"
+ "nexus/config"
+ "nexus/utils/logger"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+)
+
+var DB *gorm.DB
+
+func init() {
+ dsn := fmt.Sprintf(
+ "host=%s user=%s dbname=%s port=%d sslmode=%s",
+ config.Database.Host,
+ config.Database.User,
+ config.Database.Name,
+ config.Database.Port,
+ config.Database.SSLMode,
+ )
+
+ if config.Database.Password != "" {
+ dsn += fmt.Sprintf(" password=%s", config.Database.Password)
+ }
+
+ var connectionError error
+ DB, connectionError = gorm.Open(postgres.Open(dsn), &gorm.Config{
+ Logger: resolveGORMLogLevel(),
+ })
+
+ if connectionError != nil {
+ logger.Fatalf(LogPrefix, ConnectionFailed, connectionError)
+ }
+
+ sqlDB, poolError := DB.DB()
+ if poolError != nil {
+ logger.Fatalf(LogPrefix, PoolConfigFailed, poolError)
+ }
+
+ sqlDB.SetMaxOpenConns(MaxOpenConnections)
+ sqlDB.SetMaxIdleConns(MaxIdleConnections)
+ sqlDB.SetConnMaxLifetime(MaxConnectionLifetime)
+
+ logger.Successf(LogPrefix, Connected, config.Database.Name)
+
+ migrate()
+}
diff --git a/nexus/database/defaults.go b/nexus/database/defaults.go
new file mode 100644
index 0000000..222493f
--- /dev/null
+++ b/nexus/database/defaults.go
@@ -0,0 +1,10 @@
+package database
+
+import "time"
+
+const (
+ LogPrefix = "Database"
+ MaxOpenConnections = 25
+ MaxIdleConnections = 5
+ MaxConnectionLifetime = 5 * time.Minute
+)
diff --git a/nexus/database/logger.go b/nexus/database/logger.go
new file mode 100644
index 0000000..a5eb704
--- /dev/null
+++ b/nexus/database/logger.go
@@ -0,0 +1,14 @@
+package database
+
+import (
+ "nexus/config"
+
+ "gorm.io/gorm/logger"
+)
+
+func resolveGORMLogLevel() logger.Interface {
+ if config.Server.Debug {
+ return logger.Default.LogMode(logger.Info)
+ }
+ return logger.Default.LogMode(logger.Silent)
+}
diff --git a/nexus/database/messages.go b/nexus/database/messages.go
new file mode 100644
index 0000000..b2cb8e4
--- /dev/null
+++ b/nexus/database/messages.go
@@ -0,0 +1,9 @@
+package database
+
+const (
+ ConnectionFailed = "Failed to connect to database: %v"
+ PoolConfigFailed = "Failed to configure connection pool: %v"
+ Connected = "Connected to database: %s"
+ MigrationFailed = "Migration failed: %v"
+ MigrationDone = "Migrations complete"
+)
diff --git a/nexus/database/migration.go b/nexus/database/migration.go
new file mode 100644
index 0000000..246dc39
--- /dev/null
+++ b/nexus/database/migration.go
@@ -0,0 +1,7 @@
+package database
+
+import "nexus/utils/logger"
+
+func migrate() {
+ logger.Successf(LogPrefix, MigrationDone)
+}
diff --git a/nexus/go.mod b/nexus/go.mod
new file mode 100644
index 0000000..e278432
--- /dev/null
+++ b/nexus/go.mod
@@ -0,0 +1,41 @@
+module nexus
+
+go 1.25.0
+
+require (
+ github.com/flosch/pongo2/v6 v6.0.0
+ github.com/gofiber/fiber/v2 v2.52.12
+ github.com/gofiber/storage/postgres/v3 v3.3.2
+ github.com/gofiber/template/django/v3 v3.1.14
+ github.com/google/uuid v1.6.0
+ github.com/joho/godotenv v1.5.1
+ go.uber.org/zap v1.27.1
+ golang.org/x/crypto v0.47.0
+ gorm.io/driver/postgres v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/gofiber/template v1.8.3 // indirect
+ github.com/gofiber/utils v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.8.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/klauspost/compress v1.18.3 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+)
diff --git a/nexus/go.sum b/nexus/go.sum
new file mode 100644
index 0000000..facfcb9
--- /dev/null
+++ b/nexus/go.sum
@@ -0,0 +1,179 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
+github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
+github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
+github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/gofiber/storage/postgres/v3 v3.3.2 h1:ayI0SOgx6FflNowFn+WuQCQVJOJQbNrRySJ8ZP3GXEY=
+github.com/gofiber/storage/postgres/v3 v3.3.2/go.mod h1:X6EzXVhJBVY179LVoMGVZNn1kLtqbmNT5z4jy9V8WHY=
+github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
+github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
+github.com/gofiber/template/django/v3 v3.1.14 h1:SvTvs+u5vTZuu1Y2pMUD2NhaGIjBj9FmDA3XD50QBvw=
+github.com/gofiber/template/django/v3 v3.1.14/go.mod h1:gP4vH+T1ajZw7yaejqG1dZVdHQkMC/jPoQbmlG812I0=
+github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
+github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
+github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
+github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
+github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
+github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
+github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/nexus/middleware/account.go b/nexus/middleware/account.go
new file mode 100644
index 0000000..5d2d5c1
--- /dev/null
+++ b/nexus/middleware/account.go
@@ -0,0 +1,30 @@
+package middleware
+
+import (
+ "nexus/repositories/account"
+ "nexus/sessions"
+ "nexus/utils/meta"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+func resolveAccount(context *fiber.Ctx) error {
+ sess, ok := context.Locals(sessions.SessionLocalKey).(*session.Session)
+ if !ok {
+ return context.Next()
+ }
+
+ accountID, ok := sessions.GetSessionAccountID(sess)
+ if !ok {
+ return context.Next()
+ }
+
+ a, err := account.FindByID(accountID)
+ if err != nil {
+ return context.Next()
+ }
+
+ context.Locals(meta.AccountKey, a)
+ return context.Next()
+}
diff --git a/nexus/middleware/middleware.go b/nexus/middleware/middleware.go
new file mode 100644
index 0000000..4d4b7d5
--- /dev/null
+++ b/nexus/middleware/middleware.go
@@ -0,0 +1,9 @@
+package middleware
+
+import "github.com/gofiber/fiber/v2"
+
+func Initialize(application *fiber.App) {
+ application.Use(request)
+ application.Use(webSession)
+ application.Use(resolveAccount)
+}
diff --git a/nexus/middleware/request.go b/nexus/middleware/request.go
new file mode 100644
index 0000000..5a3b772
--- /dev/null
+++ b/nexus/middleware/request.go
@@ -0,0 +1,12 @@
+package middleware
+
+import (
+ "nexus/utils/meta"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func request(context *fiber.Ctx) error {
+ context.Locals(meta.RequestKey, meta.BuildRequest(context))
+ return context.Next()
+}
diff --git a/nexus/middleware/session.go b/nexus/middleware/session.go
new file mode 100644
index 0000000..0ca93ce
--- /dev/null
+++ b/nexus/middleware/session.go
@@ -0,0 +1,17 @@
+package middleware
+
+import (
+ "nexus/sessions"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func webSession(context *fiber.Ctx) error {
+ sess, err := sessions.Store.Get(context)
+ if err != nil {
+ return context.Next()
+ }
+
+ context.Locals(sessions.SessionLocalKey, sess)
+ return context.Next()
+}
diff --git a/nexus/models/account.go b/nexus/models/account.go
new file mode 100644
index 0000000..d6e8b5a
--- /dev/null
+++ b/nexus/models/account.go
@@ -0,0 +1,77 @@
+package models
+
+import (
+ "errors"
+ "nexus/types/account"
+ "nexus/utils/validate"
+ "strings"
+
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+)
+
+type Account struct {
+ gorm.Model
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
+ Username string `gorm:"uniqueIndex;not null"`
+ Email string `gorm:"uniqueIndex;not null"`
+ PasswordHash string `gorm:"not null"`
+ IsActive bool `gorm:"default:true"`
+ IsVerified bool `gorm:"default:false"`
+ Characters []Character `gorm:"foreignKey:AccountID"`
+ Sessions []Session `gorm:"foreignKey:AccountID"`
+}
+
+func (self *Account) BeforeCreate(tx *gorm.DB) error {
+ if self.ID == uuid.Nil {
+ self.ID = uuid.New()
+ }
+
+ self.Email = strings.TrimSpace(strings.ToLower(self.Email))
+ self.Username = strings.TrimSpace(self.Username)
+
+ if !validate.Email(self.Email) {
+ return errors.New(validate.InvalidEmail)
+ }
+
+ if self.Username == "" {
+ return errors.New("username is required")
+ }
+
+ return nil
+}
+
+func (self *Account) BeforeUpdate(tx *gorm.DB) error {
+ self.Email = strings.TrimSpace(strings.ToLower(self.Email))
+ self.Username = strings.TrimSpace(self.Username)
+
+ if !validate.Email(self.Email) {
+ return errors.New(validate.InvalidEmail)
+ }
+
+ return nil
+}
+
+func (self *Account) SetPassword(password string) error {
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ self.PasswordHash = string(hash)
+ return nil
+}
+
+func (self *Account) CheckPassword(password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(self.PasswordHash), []byte(password)) == nil
+}
+
+func (self *Account) ToResponse() account.Response {
+ return account.Response{
+ ID: self.ID,
+ Username: self.Username,
+ Email: self.Email,
+ IsVerified: self.IsVerified,
+ CreatedAt: self.CreatedAt,
+ }
+}
diff --git a/nexus/models/character.go b/nexus/models/character.go
new file mode 100644
index 0000000..b44cf87
--- /dev/null
+++ b/nexus/models/character.go
@@ -0,0 +1,51 @@
+package models
+
+import (
+ "nexus/types/character"
+ "strings"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Character struct {
+ gorm.Model
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
+ AccountID uuid.UUID `gorm:"type:uuid;not null;index"`
+ RealmID uuid.UUID `gorm:"type:uuid;not null;index"`
+ Name string `gorm:"not null"`
+ Race string `gorm:"not null"`
+ StartingKingdom string `gorm:"not null"`
+ Account Account `gorm:"foreignKey:AccountID"`
+ Realm Realm `gorm:"foreignKey:RealmID"`
+}
+
+func (self *Character) BeforeCreate(tx *gorm.DB) error {
+ if self.ID == uuid.Nil {
+ self.ID = uuid.New()
+ }
+ self.Name = strings.TrimSpace(self.Name)
+ if self.Name == "" {
+ return ErrCharacterNameRequired
+ }
+ return nil
+}
+
+func (self *Character) BeforeUpdate(tx *gorm.DB) error {
+ self.Name = strings.TrimSpace(self.Name)
+ if self.Name == "" {
+ return ErrCharacterNameRequired
+ }
+ return nil
+}
+
+func (self *Character) ToResponse() character.Response {
+ return character.Response{
+ ID: self.ID,
+ Name: self.Name,
+ Race: self.Race,
+ StartingKingdom: self.StartingKingdom,
+ RealmID: self.RealmID,
+ CreatedAt: self.CreatedAt,
+ }
+}
diff --git a/nexus/models/defaults.go b/nexus/models/defaults.go
new file mode 100644
index 0000000..9dd830e
--- /dev/null
+++ b/nexus/models/defaults.go
@@ -0,0 +1,7 @@
+package models
+
+import "errors"
+
+var (
+ ErrCharacterNameRequired = errors.New("character name is required")
+)
diff --git a/nexus/models/realm.go b/nexus/models/realm.go
new file mode 100644
index 0000000..044db71
--- /dev/null
+++ b/nexus/models/realm.go
@@ -0,0 +1,36 @@
+package models
+
+import (
+ "nexus/types/realm"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Realm struct {
+ gorm.Model
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
+ Name string `gorm:"uniqueIndex;not null"`
+ Region string `gorm:"not null"`
+ Host string `gorm:"not null"`
+ Port int `gorm:"not null"`
+ IsOnline bool `gorm:"default:true"`
+ ServerKey string `gorm:"not null"`
+ Characters []Character `gorm:"foreignKey:RealmID"`
+}
+
+func (self *Realm) BeforeCreate(tx *gorm.DB) error {
+ if self.ID == uuid.Nil {
+ self.ID = uuid.New()
+ }
+ return nil
+}
+
+func (self *Realm) ToResponse() realm.Response {
+ return realm.Response{
+ ID: self.ID,
+ Name: self.Name,
+ Region: self.Region,
+ IsOnline: self.IsOnline,
+ }
+}
diff --git a/nexus/models/session.go b/nexus/models/session.go
new file mode 100644
index 0000000..0adbba2
--- /dev/null
+++ b/nexus/models/session.go
@@ -0,0 +1,36 @@
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Session struct {
+ gorm.Model
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
+ AccountID uuid.UUID `gorm:"type:uuid;not null;index"`
+ AuthToken string `gorm:"uniqueIndex;not null"`
+ RefreshToken string `gorm:"uniqueIndex;not null"`
+ AuthExpiry time.Time `gorm:"not null"`
+ RefreshExpiry time.Time `gorm:"not null"`
+ IPAddress string
+ UserAgent string
+ Account Account `gorm:"foreignKey:AccountID"`
+}
+
+func (self *Session) BeforeCreate(tx *gorm.DB) error {
+ if self.ID == uuid.Nil {
+ self.ID = uuid.New()
+ }
+ return nil
+}
+
+func (self *Session) IsAuthExpired() bool {
+ return time.Now().After(self.AuthExpiry)
+}
+
+func (self *Session) IsRefreshExpired() bool {
+ return time.Now().After(self.RefreshExpiry)
+}
diff --git a/nexus/nexus/defaults.go b/nexus/nexus/defaults.go
new file mode 100644
index 0000000..3bca2e5
--- /dev/null
+++ b/nexus/nexus/defaults.go
@@ -0,0 +1,3 @@
+package main
+
+const LogPrefix = "Nexus"
diff --git a/nexus/nexus/main.go b/nexus/nexus/main.go
new file mode 100644
index 0000000..506233d
--- /dev/null
+++ b/nexus/nexus/main.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "fmt"
+ "nexus/config"
+ "nexus/middleware"
+ "nexus/router"
+ "nexus/tags"
+ "nexus/utils/logger"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/recover"
+ "github.com/gofiber/template/django/v3"
+)
+
+func main() {
+ tags.Initialize()
+ engine := django.New("./templates", ".django")
+ engine.Reload(config.Server.Debug)
+
+ application := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ BodyLimit: 32 * 1024 * 1024,
+ Views: engine,
+ ErrorHandler: router.ErrorHandler,
+ })
+
+ application.Use(recover.New())
+
+ middleware.Initialize(application)
+ router.Initialize(application)
+
+ shutdownSignal := make(chan os.Signal, 1)
+ signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ address := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
+ logger.Successf(LogPrefix, ServerStarting, address)
+
+ if listenError := application.Listen(address); listenError != nil {
+ logger.Fatalf(LogPrefix, ServerListenFailed, listenError)
+ }
+ }()
+
+ <-shutdownSignal
+ logger.Infof(LogPrefix, ServerShuttingDown)
+
+ if shutdownError := application.Shutdown(); shutdownError != nil {
+ logger.Errorf(LogPrefix, ServerShutdownFailed, shutdownError)
+ }
+
+ logger.Successf(LogPrefix, ServerShutdownComplete)
+}
diff --git a/nexus/nexus/messages.go b/nexus/nexus/messages.go
new file mode 100644
index 0000000..90a48d6
--- /dev/null
+++ b/nexus/nexus/messages.go
@@ -0,0 +1,9 @@
+package main
+
+const (
+ ServerStarting = "Server starting on %s"
+ ServerListenFailed = "Server failed to listen: %v"
+ ServerShuttingDown = "Server shutting down..."
+ ServerShutdownFailed = "Server shutdown failed: %v"
+ ServerShutdownComplete = "Server shutdown complete"
+)
diff --git a/nexus/pages/account/account.go b/nexus/pages/account/account.go
new file mode 100644
index 0000000..ed3abf1
--- /dev/null
+++ b/nexus/pages/account/account.go
@@ -0,0 +1,13 @@
+package account
+
+import (
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Index(context *fiber.Ctx) error {
+ meta.SetPageTitle(context, "My Account")
+ return shortcuts.Render(context, "account/index", nil)
+}
diff --git a/nexus/pages/auth/login.go b/nexus/pages/auth/login.go
new file mode 100644
index 0000000..acc3a2f
--- /dev/null
+++ b/nexus/pages/auth/login.go
@@ -0,0 +1,13 @@
+package auth
+
+import (
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Login(context *fiber.Ctx) error {
+ meta.SetPageTitle(context, "Sign In")
+ return shortcuts.Render(context, "auth/login", nil)
+}
diff --git a/nexus/pages/auth/register.go b/nexus/pages/auth/register.go
new file mode 100644
index 0000000..1fd5315
--- /dev/null
+++ b/nexus/pages/auth/register.go
@@ -0,0 +1,13 @@
+package auth
+
+import (
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Register(context *fiber.Ctx) error {
+ meta.SetPageTitle(context, "Create Account")
+ return shortcuts.Render(context, "auth/register", nil)
+}
diff --git a/nexus/pages/characters/characters.go b/nexus/pages/characters/characters.go
new file mode 100644
index 0000000..ab49049
--- /dev/null
+++ b/nexus/pages/characters/characters.go
@@ -0,0 +1,52 @@
+package characters
+
+import (
+ characterService "nexus/services/character"
+ characterTypes "nexus/types/character"
+ "nexus/utils/collections"
+ "nexus/utils/meta"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Index(context *fiber.Ctx) error {
+ meta.SetPageTitle(context, "Characters")
+
+ characters, serviceErr := characterService.GetAllForAccount(meta.Account(context).ID)
+ if serviceErr != nil {
+ return serviceErr
+ }
+
+ response := make([]characterTypes.Response, len(characters))
+ for i, c := range characters {
+ response[i] = c.ToResponse()
+ }
+
+ return shortcuts.Render(context, "characters/index", characterTypes.IndexContext{
+ Characters: response,
+ })
+}
+
+func Create(context *fiber.Ctx) error {
+ meta.SetPageTitle(context, "Create Character")
+ return shortcuts.Render(context, "characters/create", nil)
+}
+
+func Store(context *fiber.Ctx) error {
+ body, err := meta.Body[characterTypes.CreateRequest](context)
+ if err != nil {
+ return shortcuts.RedirectWithFlash(context, "characters.create", collections.Record[string, any]{
+ "Error": err.Error(),
+ })
+ }
+
+ _, serviceErr := characterService.Create(meta.Account(context).ID, body)
+ if serviceErr != nil {
+ return shortcuts.RedirectWithFlash(context, "characters.create", collections.Record[string, any]{
+ "Error": serviceErr.Message,
+ })
+ }
+
+ return shortcuts.Redirect(context, "characters")
+}
diff --git a/nexus/repositories/account/account.go b/nexus/repositories/account/account.go
new file mode 100644
index 0000000..1c532b2
--- /dev/null
+++ b/nexus/repositories/account/account.go
@@ -0,0 +1,42 @@
+package account
+
+import (
+ "nexus/database"
+ "nexus/models"
+
+ "github.com/google/uuid"
+)
+
+func FindByID(id uuid.UUID) (*models.Account, error) {
+ var account models.Account
+ result := database.DB.Where("id = ?", id).First(&account)
+ return &account, result.Error
+}
+
+func FindByEmail(email string) (*models.Account, error) {
+ var account models.Account
+ result := database.DB.Unscoped().Where("email = ?", email).First(&account)
+ return &account, result.Error
+}
+
+func FindByUsername(username string) (*models.Account, error) {
+ var account models.Account
+ result := database.DB.Unscoped().Where("username = ?", username).First(&account)
+ return &account, result.Error
+}
+
+func Create(account *models.Account) error {
+ return database.DB.Create(account).Error
+}
+
+func Update(account *models.Account) error {
+ return database.DB.Save(account).Error
+}
+
+func Disable(id uuid.UUID) error {
+ return database.DB.Model(&models.Account{}).Where("id = ?", id).Update("is_active", false).Error
+}
+
+func Delete(id uuid.UUID) error {
+ return database.DB.Delete(&models.Account{}, id).Error
+}
diff --git a/nexus/repositories/character/character.go b/nexus/repositories/character/character.go
new file mode 100644
index 0000000..1cc8f90
--- /dev/null
+++ b/nexus/repositories/character/character.go
@@ -0,0 +1,38 @@
+package character
+
+import (
+ "nexus/database"
+ "nexus/models"
+
+ "github.com/google/uuid"
+)
+
+func FindByID(id uuid.UUID) (*models.Character, error) {
+ var character models.Character
+ result := database.DB.Where("id = ?", id).First(&character)
+ return &character, result.Error
+}
+
+func FindByAccountID(accountID uuid.UUID) ([]models.Character, error) {
+ var characters []models.Character
+ result := database.DB.Where("account_id = ?", accountID).Find(&characters)
+ return characters, result.Error
+}
+
+func FindByAccountAndRealm(accountID uuid.UUID, realmID uuid.UUID) ([]models.Character, error) {
+ var characters []models.Character
+ result := database.DB.Where("account_id = ? AND realm_id = ?", accountID, realmID).Find(&characters)
+ return characters, result.Error
+}
+
+func Create(character *models.Character) error {
+ return database.DB.Create(character).Error
+}
+
+func Update(character *models.Character) error {
+ return database.DB.Save(character).Error
+}
+
+func Delete(id uuid.UUID) error {
+ return database.DB.Delete(&models.Character{}, id).Error
+}
diff --git a/nexus/repositories/realm/realm.go b/nexus/repositories/realm/realm.go
new file mode 100644
index 0000000..d1acb0e
--- /dev/null
+++ b/nexus/repositories/realm/realm.go
@@ -0,0 +1,28 @@
+package realm
+
+import (
+ "nexus/database"
+ "nexus/models"
+
+ "github.com/google/uuid"
+)
+
+func FindAll() ([]models.Realm, error) {
+ var realms []models.Realm
+ result := database.DB.Where("is_online = ?", true).Find(&realms)
+ return realms, result.Error
+}
+
+func FindByID(id uuid.UUID) (*models.Realm, error) {
+ var realm models.Realm
+ result := database.DB.Where("id = ?", id).First(&realm)
+ return &realm, result.Error
+}
+
+func Create(realm *models.Realm) error {
+ return database.DB.Create(realm).Error
+}
+
+func Update(realm *models.Realm) error {
+ return database.DB.Save(realm).Error
+}
diff --git a/nexus/repositories/session/session.go b/nexus/repositories/session/session.go
new file mode 100644
index 0000000..1c26391
--- /dev/null
+++ b/nexus/repositories/session/session.go
@@ -0,0 +1,38 @@
+package session
+
+import (
+ "nexus/database"
+ "nexus/models"
+
+ "github.com/google/uuid"
+)
+
+func FindByAuthToken(token string) (*models.Session, error) {
+ var session models.Session
+ result := database.DB.Where("auth_token = ?", token).First(&session)
+ return &session, result.Error
+}
+
+func FindByRefreshToken(token string) (*models.Session, error) {
+ var session models.Session
+ result := database.DB.Where("refresh_token = ?", token).First(&session)
+ return &session, result.Error
+}
+
+func FindByAccountID(accountID uuid.UUID) ([]models.Session, error) {
+ var sessions []models.Session
+ result := database.DB.Where("account_id = ?", accountID).Find(&sessions)
+ return sessions, result.Error
+}
+
+func Create(session *models.Session) error {
+ return database.DB.Create(session).Error
+}
+
+func Delete(id uuid.UUID) error {
+ return database.DB.Delete(&models.Session{}, id).Error
+}
+
+func DeleteAllForAccount(accountID uuid.UUID) error {
+ return database.DB.Where("account_id = ?", accountID).Delete(&models.Session{}).Error
+}
diff --git a/nexus/router/api.go b/nexus/router/api.go
new file mode 100644
index 0000000..6f8314e
--- /dev/null
+++ b/nexus/router/api.go
@@ -0,0 +1,29 @@
+package router
+
+import (
+ "nexus/api/account"
+ "nexus/api/auth"
+ "nexus/api/characters"
+ "nexus/api/realms"
+ nexusAuth "nexus/utils/auth"
+ "nexus/utils/urls"
+)
+
+func init() {
+ urls.SetNamespace("api")
+
+ urls.Path(urls.Post, "/auth/register", auth.Register, "auth.register")
+ urls.Path(urls.Post, "/auth/login", auth.Login, "auth.login")
+ urls.Path(urls.Post, "/auth/refresh", auth.Refresh, "auth.refresh")
+ urls.Path(urls.Post, "/auth/logout", nexusAuth.APIAuth(auth.Logout), "auth.logout")
+
+ urls.Path(urls.Get, "/account", nexusAuth.APIAuth(account.Show), "account.show")
+ urls.Path(urls.Delete, "/account", nexusAuth.APIAuth(account.Delete), "account.delete")
+
+ urls.Path(urls.Get, "/characters", nexusAuth.APIAuth(characters.Index), "characters.index")
+ urls.Path(urls.Get, "/characters/:id", nexusAuth.APIAuth(characters.Show), "characters.show")
+ urls.Path(urls.Post, "/characters", nexusAuth.APIAuth(characters.Create), "characters.create")
+ urls.Path(urls.Delete, "/characters/:id", nexusAuth.APIAuth(characters.Delete), "characters.delete")
+
+ urls.Path(urls.Get, "/realms", nexusAuth.APIAuth(realms.Index), "realms.index")
+}
diff --git a/nexus/router/router.go b/nexus/router/router.go
new file mode 100644
index 0000000..4f66ff6
--- /dev/null
+++ b/nexus/router/router.go
@@ -0,0 +1,21 @@
+package router
+
+import (
+ "nexus/utils/shortcuts"
+ "nexus/utils/urls"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Initialize(application *fiber.App) {
+ application.Static("/static", "./static")
+ urls.Attach(application)
+}
+
+func ErrorHandler(context *fiber.Ctx, err error) error {
+ fiberErr, ok := err.(*fiber.Error)
+ if !ok {
+ fiberErr = fiber.NewError(fiber.StatusInternalServerError, err.Error())
+ }
+ return shortcuts.RouteError(context, fiberErr)
+}
diff --git a/nexus/router/web.go b/nexus/router/web.go
new file mode 100644
index 0000000..640a0bd
--- /dev/null
+++ b/nexus/router/web.go
@@ -0,0 +1,25 @@
+package router
+
+import (
+ controller "nexus/controllers/auth"
+ accountPage "nexus/pages/account"
+ authPage "nexus/pages/auth"
+ characterPage "nexus/pages/characters"
+ "nexus/utils/auth"
+ "nexus/utils/urls"
+)
+
+func init() {
+ urls.SetNamespace("")
+
+ urls.Path(urls.Get, "/login", authPage.Login, "login")
+ urls.Path(urls.Post, "/login", controller.Login, "login.submit")
+ urls.Path(urls.Get, "/register", authPage.Register, "register")
+ urls.Path(urls.Post, "/register", controller.Register, "register.submit")
+ urls.Path(urls.Get, "/logout", controller.Logout, "logout")
+
+ urls.Path(urls.Get, "/account", auth.WebAuth(accountPage.Index), "account")
+ urls.Path(urls.Get, "/characters", auth.WebAuth(characterPage.Index), "characters")
+ urls.Path(urls.Get, "/characters/create", auth.WebAuth(characterPage.Create), "characters.create")
+ urls.Path(urls.Post, "/characters/create", auth.WebAuth(characterPage.Store), "characters.store")
+}
diff --git a/nexus/services/account/account.go b/nexus/services/account/account.go
new file mode 100644
index 0000000..a990659
--- /dev/null
+++ b/nexus/services/account/account.go
@@ -0,0 +1,46 @@
+package account
+
+import (
+ "fmt"
+ "nexus/models"
+ "nexus/repositories/account"
+ "nexus/utils/logger"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+func GetByID(id uuid.UUID) (*models.Account, *fiber.Error) {
+ a, err := account.FindByID(id)
+ if err != nil {
+ return nil, shortcuts.ServiceError(fiber.StatusNotFound, ErrAccountNotFound)
+ }
+ return a, nil
+}
+
+func Disable(id uuid.UUID) *fiber.Error {
+ if _, err := account.FindByID(id); err != nil {
+ return shortcuts.ServiceError(fiber.StatusNotFound, ErrAccountNotFound)
+ }
+
+ if err := account.Disable(id); err != nil {
+ logger.Errorf(LogPrefix, AccountDisableFailed, err)
+ return shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(AccountDisableFailed, err))
+ }
+
+ return nil
+}
+
+func Delete(id uuid.UUID) *fiber.Error {
+ if _, err := account.FindByID(id); err != nil {
+ return shortcuts.ServiceError(fiber.StatusNotFound, ErrAccountNotFound)
+ }
+
+ if err := account.Delete(id); err != nil {
+ logger.Errorf(LogPrefix, AccountDeleteFailed, err)
+ return shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(AccountDeleteFailed, err))
+ }
+
+ return nil
+}
diff --git a/nexus/services/account/defaults.go b/nexus/services/account/defaults.go
new file mode 100644
index 0000000..ec8fedb
--- /dev/null
+++ b/nexus/services/account/defaults.go
@@ -0,0 +1,3 @@
+package account
+
+const LogPrefix = "Account"
diff --git a/nexus/services/account/messages.go b/nexus/services/account/messages.go
new file mode 100644
index 0000000..6bf6024
--- /dev/null
+++ b/nexus/services/account/messages.go
@@ -0,0 +1,7 @@
+package account
+
+const (
+ ErrAccountNotFound = "account not found"
+ AccountDisableFailed = "failed to disable account: %v"
+ AccountDeleteFailed = "failed to delete account: %v"
+)
diff --git a/nexus/services/auth/auth.go b/nexus/services/auth/auth.go
new file mode 100644
index 0000000..c9c97c6
--- /dev/null
+++ b/nexus/services/auth/auth.go
@@ -0,0 +1,129 @@
+package auth
+
+import (
+ "fmt"
+ "nexus/config"
+ "nexus/models"
+ "nexus/repositories/account"
+ "nexus/repositories/session"
+ "nexus/types/auth"
+ "nexus/utils/logger"
+ "nexus/utils/shortcuts"
+ "nexus/utils/token"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+func Register(req auth.RegisterRequest) (*models.Account, *fiber.Error) {
+ if existing, _ := account.FindByEmail(req.Email); existing != nil {
+ return nil, shortcuts.ServiceError(fiber.StatusConflict, ErrEmailTaken)
+ }
+
+ if existing, _ := account.FindByUsername(req.Username); existing != nil {
+ return nil, shortcuts.ServiceError(fiber.StatusConflict, ErrUsernameTaken)
+ }
+
+ a := &models.Account{
+ Username: req.Username,
+ Email: req.Email,
+ }
+
+ if err := a.SetPassword(req.Password); err != nil {
+ logger.Errorf(LogPrefix, PasswordHashFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(PasswordHashFailed, err))
+ }
+
+ if err := account.Create(a); err != nil {
+ logger.Errorf(LogPrefix, AccountCreateFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(AccountCreateFailed, err))
+ }
+
+ logger.Successf(LogPrefix, AccountCreated, a.Username)
+ return a, nil
+}
+
+func Login(req auth.LoginRequest, ip string, userAgent string) (*models.Session, *fiber.Error) {
+ a, err := account.FindByEmail(req.Email)
+ if err != nil || a == nil {
+ return nil, shortcuts.ServiceError(fiber.StatusUnauthorized, ErrInvalidCredentials)
+ }
+
+ if !a.IsActive {
+ return nil, shortcuts.ServiceError(fiber.StatusForbidden, ErrAccountDisabled)
+ }
+
+ if !a.CheckPassword(req.Password) {
+ return nil, shortcuts.ServiceError(fiber.StatusUnauthorized, ErrInvalidCredentials)
+ }
+
+ s, sessionErr := createSession(a.ID, ip, userAgent)
+ if sessionErr != nil {
+ return nil, sessionErr
+ }
+
+ logger.Successf(LogPrefix, LoginSuccess, a.Username)
+ return s, nil
+}
+
+func Refresh(refreshToken string, ip string, userAgent string) (*models.Session, *fiber.Error) {
+ s, err := session.FindByRefreshToken(refreshToken)
+ if err != nil || s == nil {
+ return nil, shortcuts.ServiceError(fiber.StatusUnauthorized, ErrInvalidToken)
+ }
+
+ if s.IsRefreshExpired() {
+ _ = session.Delete(s.ID)
+ return nil, shortcuts.ServiceError(fiber.StatusUnauthorized, ErrTokenExpired)
+ }
+
+ _ = session.Delete(s.ID)
+
+ return createSession(s.AccountID, ip, userAgent)
+}
+
+func Logout(authToken string) *fiber.Error {
+ s, err := session.FindByAuthToken(authToken)
+ if err != nil || s == nil {
+ return shortcuts.ServiceError(fiber.StatusUnauthorized, ErrInvalidToken)
+ }
+
+ if err := session.Delete(s.ID); err != nil {
+ logger.Errorf(LogPrefix, LogoutFailed, err)
+ return shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(LogoutFailed, err))
+ }
+
+ return nil
+}
+
+func createSession(accountID uuid.UUID, ip string, userAgent string) (*models.Session, *fiber.Error) {
+ authToken, err := token.Generate()
+ if err != nil {
+ logger.Errorf(LogPrefix, TokenGenerateFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(TokenGenerateFailed, err))
+ }
+
+ refreshToken, err := token.Generate()
+ if err != nil {
+ logger.Errorf(LogPrefix, TokenGenerateFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(TokenGenerateFailed, err))
+ }
+
+ s := &models.Session{
+ AccountID: accountID,
+ AuthToken: authToken,
+ RefreshToken: refreshToken,
+ AuthExpiry: time.Now().Add(time.Duration(config.Token.AuthExpiry) * time.Minute),
+ RefreshExpiry: time.Now().Add(time.Duration(config.Token.RefreshExpiry) * 24 * time.Hour),
+ IPAddress: ip,
+ UserAgent: userAgent,
+ }
+
+ if err := session.Create(s); err != nil {
+ logger.Errorf(LogPrefix, SessionCreateFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(SessionCreateFailed, err))
+ }
+
+ return s, nil
+}
diff --git a/nexus/services/auth/defaults.go b/nexus/services/auth/defaults.go
new file mode 100644
index 0000000..3a18c38
--- /dev/null
+++ b/nexus/services/auth/defaults.go
@@ -0,0 +1,3 @@
+package auth
+
+const LogPrefix = "Auth"
diff --git a/nexus/services/auth/messages.go b/nexus/services/auth/messages.go
new file mode 100644
index 0000000..b13dfe6
--- /dev/null
+++ b/nexus/services/auth/messages.go
@@ -0,0 +1,17 @@
+package auth
+
+const (
+ ErrEmailTaken = "email is already taken"
+ ErrUsernameTaken = "username is already taken"
+ ErrInvalidCredentials = "invalid email or password"
+ ErrAccountDisabled = "account is disabled"
+ ErrInvalidToken = "invalid token"
+ ErrTokenExpired = "token has expired"
+ PasswordHashFailed = "failed to hash password: %v"
+ AccountCreateFailed = "failed to create account: %v"
+ AccountCreated = "account created: %s"
+ LoginSuccess = "login successful: %s"
+ LogoutFailed = "failed to logout: %v"
+ TokenGenerateFailed = "failed to generate token: %v"
+ SessionCreateFailed = "failed to create session: %v"
+)
diff --git a/nexus/services/character/character.go b/nexus/services/character/character.go
new file mode 100644
index 0000000..c607d66
--- /dev/null
+++ b/nexus/services/character/character.go
@@ -0,0 +1,79 @@
+package character
+
+import (
+ "fmt"
+ "nexus/models"
+ "nexus/repositories/character"
+ "nexus/repositories/realm"
+ characterTypes "nexus/types/character"
+ "nexus/utils/logger"
+ "nexus/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+func GetAllForAccount(accountID uuid.UUID) ([]models.Character, *fiber.Error) {
+ characters, err := character.FindByAccountID(accountID)
+ if err != nil {
+ logger.Errorf(LogPrefix, CharactersFetchFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(CharactersFetchFailed, err))
+ }
+ return characters, nil
+}
+
+func GetByID(id uuid.UUID, accountID uuid.UUID) (*models.Character, *fiber.Error) {
+ c, err := character.FindByID(id)
+ if err != nil {
+ return nil, shortcuts.ServiceError(fiber.StatusNotFound, ErrCharacterNotFound)
+ }
+ if c.AccountID != accountID {
+ return nil, shortcuts.ServiceError(fiber.StatusNotFound, ErrCharacterNotFound)
+ }
+ return c, nil
+}
+
+func Create(accountID uuid.UUID, req characterTypes.CreateRequest) (*models.Character, *fiber.Error) {
+ r, err := realm.FindByID(req.RealmID)
+ if err != nil || r == nil {
+ return nil, shortcuts.ServiceError(fiber.StatusNotFound, ErrRealmNotFound)
+ }
+
+ if !r.IsOnline {
+ return nil, shortcuts.ServiceError(fiber.StatusServiceUnavailable, ErrRealmOffline)
+ }
+
+ c := &models.Character{
+ AccountID: accountID,
+ RealmID: req.RealmID,
+ Name: req.Name,
+ Race: req.Race,
+ StartingKingdom: req.StartingKingdom,
+ }
+
+ if err := character.Create(c); err != nil {
+ logger.Errorf(LogPrefix, CharacterCreateFailed, err)
+ return nil, shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(CharacterCreateFailed, err))
+ }
+
+ logger.Successf(LogPrefix, CharacterCreated, c.Name)
+ return c, nil
+}
+
+func Delete(id uuid.UUID, accountID uuid.UUID) *fiber.Error {
+ c, err := character.FindByID(id)
+ if err != nil {
+ return shortcuts.ServiceError(fiber.StatusNotFound, ErrCharacterNotFound)
+ }
+
+ if c.AccountID != accountID {
+ return shortcuts.ServiceError(fiber.StatusNotFound, ErrCharacterNotFound)
+ }
+
+ if err := character.Delete(id); err != nil {
+ logger.Errorf(LogPrefix, CharacterDeleteFailed, err)
+ return shortcuts.ServiceError(fiber.StatusInternalServerError, fmt.Sprintf(CharacterDeleteFailed, err))
+ }
+
+ return nil
+}
diff --git a/nexus/services/character/defaults.go b/nexus/services/character/defaults.go
new file mode 100644
index 0000000..f79094b
--- /dev/null
+++ b/nexus/services/character/defaults.go
@@ -0,0 +1,3 @@
+package character
+
+const LogPrefix = "Character"
diff --git a/nexus/services/character/messages.go b/nexus/services/character/messages.go
new file mode 100644
index 0000000..ba7ee07
--- /dev/null
+++ b/nexus/services/character/messages.go
@@ -0,0 +1,11 @@
+package character
+
+const (
+ ErrCharacterNotFound = "character not found"
+ ErrRealmNotFound = "realm not found"
+ ErrRealmOffline = "realm is currently offline"
+ CharactersFetchFailed = "failed to fetch characters: %v"
+ CharacterCreateFailed = "failed to create character: %v"
+ CharacterDeleteFailed = "failed to delete character: %v"
+ CharacterCreated = "character created: %s"
+)
diff --git a/nexus/sessions/defaults.go b/nexus/sessions/defaults.go
new file mode 100644
index 0000000..b52f977
--- /dev/null
+++ b/nexus/sessions/defaults.go
@@ -0,0 +1,11 @@
+package sessions
+
+import "time"
+
+const (
+ LogPrefix = "Session"
+ SessionInterval = 10 * time.Second
+ SessionAccountIDKey = "account_id"
+ SessionFlashKey = "flash"
+ SessionLocalKey = "Session"
+)
diff --git a/nexus/sessions/functions.go b/nexus/sessions/functions.go
new file mode 100644
index 0000000..4baedb1
--- /dev/null
+++ b/nexus/sessions/functions.go
@@ -0,0 +1,55 @@
+package sessions
+
+import (
+ "nexus/utils/collections"
+
+ "github.com/gofiber/fiber/v2/middleware/session"
+ "github.com/google/uuid"
+)
+
+func CreateSession(sess *session.Session, accountID uuid.UUID) error {
+ return Set(sess, SessionAccountIDKey, accountID.String())
+}
+
+func DestroySession(sess *session.Session) error {
+ return Delete(sess, SessionAccountIDKey)
+}
+
+func GetSessionAccountID(sess *session.Session) (uuid.UUID, bool) {
+ value := Get(sess, SessionAccountIDKey)
+ if value == nil {
+ return uuid.Nil, false
+ }
+
+ str, ok := value.(string)
+ if !ok {
+ return uuid.Nil, false
+ }
+
+ id, err := uuid.Parse(str)
+ if err != nil {
+ return uuid.Nil, false
+ }
+
+ return id, true
+}
+
+func SetFlash(sess *session.Session, data collections.Record[string, any]) error {
+ return Set(sess, SessionFlashKey, data)
+}
+
+func GetFlash(sess *session.Session) collections.Record[string, any] {
+ value := Get(sess, SessionFlashKey)
+ if value == nil {
+ return nil
+ }
+ m, ok := value.(collections.Record[string, any])
+ if !ok {
+ return nil
+ }
+ return m
+}
+
+func ClearFlash(sess *session.Session) error {
+ return Delete(sess, SessionFlashKey)
+}
diff --git a/nexus/sessions/kv.go b/nexus/sessions/kv.go
new file mode 100644
index 0000000..cddc05f
--- /dev/null
+++ b/nexus/sessions/kv.go
@@ -0,0 +1,17 @@
+package sessions
+
+import "github.com/gofiber/fiber/v2/middleware/session"
+
+func Set(sess *session.Session, key string, value any) error {
+ sess.Set(key, value)
+ return sess.Save()
+}
+
+func Get(sess *session.Session, key string) any {
+ return sess.Get(key)
+}
+
+func Delete(sess *session.Session, key string) error {
+ sess.Delete(key)
+ return sess.Save()
+}
diff --git a/nexus/sessions/messages.go b/nexus/sessions/messages.go
new file mode 100644
index 0000000..47c0a24
--- /dev/null
+++ b/nexus/sessions/messages.go
@@ -0,0 +1,5 @@
+package sessions
+
+const (
+ SessionStoreInitialized = "Session store initialized with PostgreSQL"
+)
diff --git a/nexus/sessions/sessions.go b/nexus/sessions/sessions.go
new file mode 100644
index 0000000..be2e272
--- /dev/null
+++ b/nexus/sessions/sessions.go
@@ -0,0 +1,36 @@
+package sessions
+
+import (
+ "fmt"
+ "nexus/config"
+ "nexus/utils/logger"
+
+ "github.com/gofiber/fiber/v2/middleware/session"
+ "github.com/gofiber/storage/postgres/v3"
+)
+
+var Store *session.Store
+
+func init() {
+ storage := postgres.New(postgres.Config{
+ Host: config.Database.Host,
+ Port: config.Database.Port,
+ Username: config.Database.User,
+ Password: config.Database.Password,
+ Database: config.Database.Name,
+ Table: config.Session.CookieName,
+ Reset: false,
+ })
+
+ Store = session.New(session.Config{
+ Storage: storage,
+ KeyLookup: fmt.Sprintf("cookie:%s", config.Session.CookieName),
+ CookieDomain: config.Session.CookieDomain,
+ CookiePath: config.Session.CookiePath,
+ CookieSecure: config.Session.CookieSecure,
+ CookieHTTPOnly: true,
+ Expiration: config.Session.Timeout,
+ })
+
+ logger.Successf(LogPrefix, SessionStoreInitialized)
+}
diff --git a/nexus/static/css/main.css b/nexus/static/css/main.css
new file mode 100644
index 0000000..069bafe
--- /dev/null
+++ b/nexus/static/css/main.css
@@ -0,0 +1,18 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+body {
+ font-family: monospace;
+ padding: 2rem;
+}
+.error {
+ color: red;
+ margin-bottom: 1rem;
+}
+input,
+button {
+ display: block;
+ margin-bottom: 1rem;
+}
diff --git a/nexus/tags/active.go b/nexus/tags/active.go
new file mode 100644
index 0000000..c762222
--- /dev/null
+++ b/nexus/tags/active.go
@@ -0,0 +1,20 @@
+package tags
+
+import (
+ "nexus/utils/urls"
+
+ "github.com/flosch/pongo2/v6"
+)
+
+func active(value *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
+ routeName := value.String()
+
+ routePath, exists := urls.GetFullPath(routeName)
+ if !exists {
+ return pongo2.AsValue(false), nil
+ }
+
+ requestPath := param.String()
+
+ return pongo2.AsValue(requestPath == routePath), nil
+}
diff --git a/nexus/tags/defaults.go b/nexus/tags/defaults.go
new file mode 100644
index 0000000..b040c18
--- /dev/null
+++ b/nexus/tags/defaults.go
@@ -0,0 +1,6 @@
+package tags
+
+const (
+ LogPrefix = "Tags"
+ StaticPrefix = "/static"
+)
diff --git a/nexus/tags/messages.go b/nexus/tags/messages.go
new file mode 100644
index 0000000..87644ae
--- /dev/null
+++ b/nexus/tags/messages.go
@@ -0,0 +1,12 @@
+package tags
+
+const (
+ ExpectedEquals = "Expected '=' after parameter key."
+ ExpectedParamKey = "Expected parameter key identifier."
+ ExpectedRouteName = "Expected route name string."
+ ExpectedStaticPath = "Expected static file path string."
+ ExpectedVariableName = "Expected variable name after 'as'."
+ RegistrationFailed = "Failed to register tag: %s."
+ RouteNotFound = "Route not found: %s."
+ TemplateWriteFailed = "Failed to write template output."
+)
diff --git a/nexus/tags/static.go b/nexus/tags/static.go
new file mode 100644
index 0000000..ba29807
--- /dev/null
+++ b/nexus/tags/static.go
@@ -0,0 +1,32 @@
+package tags
+
+import (
+ "fmt"
+
+ "github.com/flosch/pongo2/v6"
+)
+
+type StaticNode struct {
+ Path string
+}
+
+func static(document *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, *pongo2.Error) {
+ pathToken := arguments.MatchType(pongo2.TokenString)
+ if pathToken == nil {
+ return nil, arguments.Error(ExpectedStaticPath, nil)
+ }
+
+ return &StaticNode{Path: pathToken.Val}, nil
+}
+
+func (self *StaticNode) Execute(executionContext *pongo2.ExecutionContext, writer pongo2.TemplateWriter) *pongo2.Error {
+ _, writeError := writer.WriteString(fmt.Sprintf("%s/%s", StaticPrefix, self.Path))
+ if writeError != nil {
+ return &pongo2.Error{
+ Sender: "tag:static",
+ OrigError: fmt.Errorf(TemplateWriteFailed),
+ }
+ }
+
+ return nil
+}
diff --git a/nexus/tags/tags.go b/nexus/tags/tags.go
new file mode 100644
index 0000000..165172d
--- /dev/null
+++ b/nexus/tags/tags.go
@@ -0,0 +1,40 @@
+package tags
+
+import (
+ "nexus/utils/logger"
+
+ "github.com/flosch/pongo2/v6"
+)
+
+type TemplateTag struct {
+ Name string
+ Parser pongo2.TagParser
+}
+
+type TemplateFilter struct {
+ Name string
+ Filter pongo2.FilterFunction
+}
+
+func Initialize() {
+ tags := []TemplateTag{
+ {Name: "static", Parser: static},
+ {Name: "url", Parser: url},
+ }
+
+ filters := []TemplateFilter{
+ {Name: "active", Filter: active},
+ }
+
+ for _, tag := range tags {
+ if registrationError := pongo2.RegisterTag(tag.Name, tag.Parser); registrationError != nil {
+ logger.Errorf(LogPrefix, RegistrationFailed, tag.Name)
+ }
+ }
+
+ for _, filter := range filters {
+ if registrationError := pongo2.RegisterFilter(filter.Name, filter.Filter); registrationError != nil {
+ logger.Errorf(LogPrefix, RegistrationFailed, filter.Name)
+ }
+ }
+}
diff --git a/nexus/tags/url.go b/nexus/tags/url.go
new file mode 100644
index 0000000..85f7923
--- /dev/null
+++ b/nexus/tags/url.go
@@ -0,0 +1,97 @@
+package tags
+
+import (
+ "fmt"
+ "strings"
+
+ "nexus/utils/collections"
+ "nexus/utils/urls"
+
+ "github.com/flosch/pongo2/v6"
+)
+
+type UrlNode struct {
+ RouteName string
+ Params collections.Record[string, pongo2.IEvaluator]
+ VariableName string
+}
+
+func url(document *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, *pongo2.Error) {
+ routeNameToken := arguments.MatchType(pongo2.TokenString)
+ if routeNameToken == nil {
+ return nil, arguments.Error(ExpectedRouteName, nil)
+ }
+
+ params := make(collections.Record[string, pongo2.IEvaluator])
+
+ var variableName string
+
+ for arguments.Remaining() > 0 {
+ if arguments.Match(pongo2.TokenKeyword, "as") != nil {
+ nameToken := arguments.MatchType(pongo2.TokenIdentifier)
+ if nameToken == nil {
+ return nil, arguments.Error(ExpectedVariableName, nil)
+ }
+ variableName = nameToken.Val
+ break
+ }
+
+ keyToken := arguments.MatchType(pongo2.TokenIdentifier)
+ if keyToken == nil {
+ return nil, arguments.Error(ExpectedParamKey, nil)
+ }
+
+ if arguments.Match(pongo2.TokenSymbol, "=") == nil {
+ return nil, arguments.Error(ExpectedEquals, nil)
+ }
+
+ valueExpression, parseError := arguments.ParseExpression()
+ if parseError != nil {
+ return nil, parseError
+ }
+
+ params[keyToken.Val] = valueExpression
+ }
+
+ return &UrlNode{
+ RouteName: routeNameToken.Val,
+ Params: params,
+ VariableName: variableName,
+ }, nil
+}
+
+func (self *UrlNode) Execute(executionContext *pongo2.ExecutionContext, writer pongo2.TemplateWriter) *pongo2.Error {
+ path, exists := urls.GetFullPath(self.RouteName)
+ if !exists {
+ return &pongo2.Error{
+ Sender: "tag:url",
+ OrigError: fmt.Errorf(RouteNotFound, self.RouteName),
+ }
+ }
+
+ for key, expression := range self.Params {
+ evaluatedValue, evaluationError := expression.Evaluate(executionContext)
+ if evaluationError != nil {
+ return evaluationError
+ }
+
+ placeholder := fmt.Sprintf(":%s", key)
+ replacement := fmt.Sprintf("%v", evaluatedValue.Interface())
+ path = strings.ReplaceAll(path, placeholder, replacement)
+ }
+
+ if self.VariableName != "" {
+ executionContext.Public[self.VariableName] = path
+ return nil
+ }
+
+ _, writeError := writer.WriteString(path)
+ if writeError != nil {
+ return &pongo2.Error{
+ Sender: "tag:url",
+ OrigError: fmt.Errorf(TemplateWriteFailed),
+ }
+ }
+
+ return nil
+}
diff --git a/nexus/templates/account/index.django b/nexus/templates/account/index.django
new file mode 100644
index 0000000..50dcf79
--- /dev/null
+++ b/nexus/templates/account/index.django
@@ -0,0 +1,8 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="account-container">
+ <h1>My Account</h1>
+ <p><a href="{% url 'characters' %}">My Characters</a></p>
+ <p><a href="{% url 'logout' %}">Sign Out</a></p>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/auth/login.django b/nexus/templates/auth/login.django
new file mode 100644
index 0000000..210fc06
--- /dev/null
+++ b/nexus/templates/auth/login.django
@@ -0,0 +1,17 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="auth-container">
+ <h1>Sign In</h1>
+ {% if Error %}
+ <p class="error">{{ Error }}</p>
+ {% endif %}
+ <form method="POST" action="{% url 'login.submit' %}">
+ <label>Email</label>
+ <input type="email" name="email" required>
+ <label>Password</label>
+ <input type="password" name="password" required>
+ <button type="submit">Sign In</button>
+ </form>
+ <p>No account? <a href="{% url 'register' %}">Create one</a></p>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/auth/register.django b/nexus/templates/auth/register.django
new file mode 100644
index 0000000..82feacf
--- /dev/null
+++ b/nexus/templates/auth/register.django
@@ -0,0 +1,19 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="auth-container">
+ <h1>Create Account</h1>
+ {% if Error %}
+ <p class="error">{{ Error }}</p>
+ {% endif %}
+ <form method="POST" action="{% url 'register.submit' %}">
+ <label>Username</label>
+ <input type="text" name="username" required>
+ <label>Email</label>
+ <input type="email" name="email" required>
+ <label>Password</label>
+ <input type="password" name="password" required>
+ <button type="submit">Create Account</button>
+ </form>
+ <p>Have an account? <a href="{% url 'login' %}">Sign in</a></p>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/characters/create.django b/nexus/templates/characters/create.django
new file mode 100644
index 0000000..f2f08d1
--- /dev/null
+++ b/nexus/templates/characters/create.django
@@ -0,0 +1,20 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="characters-container">
+ <h1>Create Character</h1>
+ {% if Error %}
+ <p class="error">{{ Error }}</p>
+ {% endif %}
+ <form method="POST" action="{% url 'characters.store' %}">
+ <label>Name</label>
+ <input type="text" name="name" required>
+ <label>Race</label>
+ <input type="text" name="race" required>
+ <label>Starting Kingdom</label>
+ <input type="text" name="starting_kingdom" required>
+ <label>Realm</label>
+ <input type="text" name="realm_id" required>
+ <button type="submit">Create</button>
+ </form>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/characters/index.django b/nexus/templates/characters/index.django
new file mode 100644
index 0000000..a1c4186
--- /dev/null
+++ b/nexus/templates/characters/index.django
@@ -0,0 +1,7 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="characters-container">
+ <h1>Characters</h1>
+ <a href="{% url 'characters.create' %}">Create Character</a>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/errors/error.django b/nexus/templates/errors/error.django
new file mode 100644
index 0000000..90e4fe9
--- /dev/null
+++ b/nexus/templates/errors/error.django
@@ -0,0 +1,8 @@
+{% extends "layouts/base.django" %}
+{% block content %}
+<div class="error-container">
+ <h1>{{ Code }}</h1>
+ <p>{{ Message }}</p>
+ <a href="{% url 'login' %}">Go Home</a>
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/nexus/templates/layouts/base.django b/nexus/templates/layouts/base.django
new file mode 100644
index 0000000..4219ce3
--- /dev/null
+++ b/nexus/templates/layouts/base.django
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{ Title }} — Echoes of Vaelun</title>
+ <link rel="stylesheet" href="{% static 'css/main.css' %}">
+</head>
+<body>
+ {% block content %}{% endblock %}
+</body>
+</html> \ No newline at end of file
diff --git a/nexus/types/account/response.go b/nexus/types/account/response.go
new file mode 100644
index 0000000..bb413ed
--- /dev/null
+++ b/nexus/types/account/response.go
@@ -0,0 +1,15 @@
+package account
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Response struct {
+ ID uuid.UUID `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ IsVerified bool `json:"is_verified"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/nexus/types/auth/request.go b/nexus/types/auth/request.go
new file mode 100644
index 0000000..ce821cc
--- /dev/null
+++ b/nexus/types/auth/request.go
@@ -0,0 +1,16 @@
+package auth
+
+type RegisterRequest struct {
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type LoginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type RefreshRequest struct {
+ RefreshToken string `json:"refresh_token"`
+}
diff --git a/nexus/types/auth/response.go b/nexus/types/auth/response.go
new file mode 100644
index 0000000..718fb97
--- /dev/null
+++ b/nexus/types/auth/response.go
@@ -0,0 +1,6 @@
+package auth
+
+type TokenResponse struct {
+ AuthToken string `json:"auth_token"`
+ RefreshToken string `json:"refresh_token"`
+}
diff --git a/nexus/types/character/context.go b/nexus/types/character/context.go
new file mode 100644
index 0000000..ac05332
--- /dev/null
+++ b/nexus/types/character/context.go
@@ -0,0 +1,5 @@
+package character
+
+type IndexContext struct {
+ Characters []Response
+}
diff --git a/nexus/types/character/request.go b/nexus/types/character/request.go
new file mode 100644
index 0000000..1c62190
--- /dev/null
+++ b/nexus/types/character/request.go
@@ -0,0 +1,10 @@
+package character
+
+import "github.com/google/uuid"
+
+type CreateRequest struct {
+ Name string `json:"name"`
+ Race string `json:"race"`
+ StartingKingdom string `json:"starting_kingdom"`
+ RealmID uuid.UUID `json:"realm_id"`
+}
diff --git a/nexus/types/character/response.go b/nexus/types/character/response.go
new file mode 100644
index 0000000..5806cb7
--- /dev/null
+++ b/nexus/types/character/response.go
@@ -0,0 +1,16 @@
+package character
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Response struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Race string `json:"race"`
+ StartingKingdom string `json:"starting_kingdom"`
+ RealmID uuid.UUID `json:"realm_id"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/nexus/types/realm/response.go b/nexus/types/realm/response.go
new file mode 100644
index 0000000..e477bda
--- /dev/null
+++ b/nexus/types/realm/response.go
@@ -0,0 +1,10 @@
+package realm
+
+import "github.com/google/uuid"
+
+type Response struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Region string `json:"region"`
+ IsOnline bool `json:"is_online"`
+}
diff --git a/nexus/utils/auth/auth.go b/nexus/utils/auth/auth.go
new file mode 100644
index 0000000..4e44d5e
--- /dev/null
+++ b/nexus/utils/auth/auth.go
@@ -0,0 +1,54 @@
+package auth
+
+import (
+ "nexus/repositories/account"
+ "nexus/repositories/session"
+ "nexus/utils/meta"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func APIAuth(handler fiber.Handler) fiber.Handler {
+ return func(context *fiber.Ctx) error {
+ authHeader := context.Get("Authorization")
+ if authHeader == "" {
+ return fiber.ErrUnauthorized
+ }
+
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return fiber.ErrUnauthorized
+ }
+
+ s, err := session.FindByAuthToken(parts[1])
+ if err != nil || s == nil {
+ return fiber.ErrUnauthorized
+ }
+
+ if s.IsAuthExpired() {
+ return fiber.ErrUnauthorized
+ }
+
+ a, err := account.FindByID(s.AccountID)
+ if err != nil || a == nil {
+ return fiber.ErrUnauthorized
+ }
+
+ if !a.IsActive {
+ return fiber.ErrUnauthorized
+ }
+
+ context.Locals(meta.AccountKey, a)
+ return handler(context)
+ }
+}
+
+func WebAuth(handler fiber.Handler) fiber.Handler {
+ return func(context *fiber.Ctx) error {
+ if meta.Account(context) == nil {
+ return context.Redirect("/login")
+ }
+ return handler(context)
+ }
+}
diff --git a/nexus/utils/collections/maps.go b/nexus/utils/collections/maps.go
new file mode 100644
index 0000000..4536515
--- /dev/null
+++ b/nexus/utils/collections/maps.go
@@ -0,0 +1,37 @@
+package collections
+
+type OrderedMap[K comparable, V any] struct {
+ keys []K
+ values map[K]V
+}
+
+func OrderedMapOf[K comparable, V any]() OrderedMap[K, V] {
+ return OrderedMap[K, V]{
+ keys: make([]K, 0),
+ values: make(map[K]V),
+ }
+}
+
+func (self *OrderedMap[K, V]) Set(key K, value V) {
+ if _, exists := self.values[key]; !exists {
+ self.keys = append(self.keys, key)
+ }
+ self.values[key] = value
+}
+
+func (self *OrderedMap[K, V]) Get(key K) (V, bool) {
+ value, exists := self.values[key]
+ return value, exists
+}
+
+func (self *OrderedMap[K, V]) All() []V {
+ result := make([]V, 0, len(self.keys))
+ for _, key := range self.keys {
+ result = append(result, self.values[key])
+ }
+ return result
+}
+
+func (self *OrderedMap[K, V]) Len() int {
+ return len(self.keys)
+}
diff --git a/nexus/utils/collections/record.go b/nexus/utils/collections/record.go
new file mode 100644
index 0000000..18dbc2d
--- /dev/null
+++ b/nexus/utils/collections/record.go
@@ -0,0 +1,3 @@
+package collections
+
+type Record[K comparable, V any] = map[K]V
diff --git a/nexus/utils/env/defaults.go b/nexus/utils/env/defaults.go
new file mode 100644
index 0000000..70c3b41
--- /dev/null
+++ b/nexus/utils/env/defaults.go
@@ -0,0 +1,6 @@
+package env
+
+const (
+ keyEnv = "env"
+ keyDefault = "default"
+)
diff --git a/nexus/utils/env/extract.go b/nexus/utils/env/extract.go
new file mode 100644
index 0000000..72db69f
--- /dev/null
+++ b/nexus/utils/env/extract.go
@@ -0,0 +1,33 @@
+package env
+
+import (
+ "errors"
+ "reflect"
+)
+
+func extractConfig(config any) (reflect.Value, reflect.Type, error) {
+ v := reflect.ValueOf(config)
+ if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
+ return reflect.Value{}, nil, errors.New(ConfigMustBePointer)
+ }
+ elem := v.Elem()
+ return elem, elem.Type(), nil
+}
+
+func extractFieldEnvInfo(element reflect.Value, elementType reflect.Type, index int) (*reflect.Value, string, string, bool) {
+ field := element.Field(index)
+ fieldType := elementType.Field(index)
+
+ if !field.CanSet() {
+ return nil, "", "", false
+ }
+
+ envKey := fieldType.Tag.Get(keyEnv)
+ defaultVal := fieldType.Tag.Get(keyDefault)
+
+ if envKey == "" {
+ return nil, "", "", false
+ }
+
+ return &field, envKey, defaultVal, true
+}
diff --git a/nexus/utils/env/getenv.go b/nexus/utils/env/getenv.go
new file mode 100644
index 0000000..fde01ae
--- /dev/null
+++ b/nexus/utils/env/getenv.go
@@ -0,0 +1,75 @@
+package env
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func getEnv(key, defaultVal string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultVal
+}
+
+func getEnvBool(key string, defaultVal bool) bool {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseBool(value); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := time.ParseDuration(value); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvInt(key string, defaultVal int64) int64 {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvFloat(key string, defaultVal float64) float64 {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseFloat(value, 64); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvStringSlice(key string, defaultVal []string) []string {
+ if value := os.Getenv(key); value != "" {
+ parts := strings.Split(value, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+ }
+ return defaultVal
+}
+
+func getEnvUint(key string, defaultVal uint64) uint64 {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseUint(value, 10, 64); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
diff --git a/nexus/utils/env/messages.go b/nexus/utils/env/messages.go
new file mode 100644
index 0000000..f5c7c91
--- /dev/null
+++ b/nexus/utils/env/messages.go
@@ -0,0 +1,5 @@
+package env
+
+const (
+ ConfigMustBePointer = "config must be a pointer to struct"
+)
diff --git a/nexus/utils/env/parse.go b/nexus/utils/env/parse.go
new file mode 100644
index 0000000..2c494b5
--- /dev/null
+++ b/nexus/utils/env/parse.go
@@ -0,0 +1,27 @@
+package env
+
+import (
+ "github.com/joho/godotenv"
+)
+
+func init() {
+ godotenv.Load()
+}
+
+func Parse(config any) error {
+ element, elementType, err := extractConfig(config)
+ if err != nil {
+ return err
+ }
+
+ for index := range element.NumField() {
+ field, envKey, defaultVal, ok := extractFieldEnvInfo(element, elementType, index)
+ if !ok {
+ continue
+ }
+
+ setFieldFromEnv(*field, envKey, defaultVal)
+ }
+
+ return nil
+}
diff --git a/nexus/utils/env/setenv.go b/nexus/utils/env/setenv.go
new file mode 100644
index 0000000..9001e68
--- /dev/null
+++ b/nexus/utils/env/setenv.go
@@ -0,0 +1,62 @@
+package env
+
+import (
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func setFieldFromEnv(field reflect.Value, envKey, defaultVal string) {
+ if field.Type() == reflect.TypeFor[time.Duration]() {
+ setDurationField(field, envKey, defaultVal)
+ return
+ }
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString(getEnv(envKey, defaultVal))
+ case reflect.Bool:
+ defaultBool, _ := strconv.ParseBool(defaultVal)
+ field.SetBool(getEnvBool(envKey, defaultBool))
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ defaultInt, _ := strconv.ParseInt(defaultVal, 10, 64)
+ field.SetInt(getEnvInt(envKey, defaultInt))
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ defaultUint, _ := strconv.ParseUint(defaultVal, 10, 64)
+ setUintField(field, envKey, defaultUint)
+ case reflect.Float32, reflect.Float64:
+ defaultFloat, _ := strconv.ParseFloat(defaultVal, 64)
+ field.SetFloat(getEnvFloat(envKey, defaultFloat))
+ case reflect.Slice:
+ setSliceField(field, envKey, defaultVal)
+ }
+}
+
+func setUintField(field reflect.Value, envKey string, defaultVal uint64) {
+ field.SetUint(getEnvUint(envKey, defaultVal))
+}
+
+func setDurationField(field reflect.Value, envKey, defaultVal string) {
+ if field.Type() == reflect.TypeFor[time.Duration]() {
+ defaultDuration, _ := time.ParseDuration(defaultVal)
+ field.Set(reflect.ValueOf(getEnvDuration(envKey, defaultDuration)))
+ }
+}
+
+func setSliceField(field reflect.Value, envKey, defaultVal string) {
+ if field.Type().Elem().Kind() == reflect.String {
+ var defaultSlice []string
+ if defaultVal != "" {
+ parts := strings.Split(defaultVal, ",")
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ defaultSlice = append(defaultSlice, trimmed)
+ }
+ }
+ }
+ result := getEnvStringSlice(envKey, defaultSlice)
+ field.Set(reflect.ValueOf(result))
+ }
+}
diff --git a/nexus/utils/logger/defaults.go b/nexus/utils/logger/defaults.go
new file mode 100644
index 0000000..3b00803
--- /dev/null
+++ b/nexus/utils/logger/defaults.go
@@ -0,0 +1,25 @@
+package logger
+
+const (
+ AnsiReset = "\033[0m"
+)
+
+const (
+ LevelColorDebug = "\033[35mDEBUG \033[0m"
+ LevelColorError = "\033[31mERROR \033[0m"
+ LevelColorInfo = "\033[34mINFO \033[0m"
+ LevelColorWarn = "\033[33mWARN \033[0m"
+)
+
+const (
+ MessageColorDebug = "\033[90m"
+ MessageColorError = "\033[31m"
+ MessageColorInfo = "\033[97m"
+ MessageColorSuccess = "\033[32m"
+ MessageColorWarn = "\033[33m"
+)
+
+const (
+ PrefixColor = "\033[36m"
+ PrefixWidth = 18
+)
diff --git a/nexus/utils/logger/format.go b/nexus/utils/logger/format.go
new file mode 100644
index 0000000..f3503df
--- /dev/null
+++ b/nexus/utils/logger/format.go
@@ -0,0 +1,63 @@
+package logger
+
+import (
+ "fmt"
+ "strings"
+
+ "go.uber.org/zap/zapcore"
+)
+
+type LogLevel string
+
+const (
+ LevelDebug LogLevel = "debug"
+ LevelInfo LogLevel = "info"
+ LevelWarn LogLevel = "warn"
+ LevelError LogLevel = "error"
+ LevelSuccess LogLevel = "success"
+)
+
+func formatLevel(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
+ switch level {
+ case zapcore.DebugLevel:
+ encoder.AppendString(LevelColorDebug)
+ case zapcore.WarnLevel:
+ encoder.AppendString(LevelColorWarn)
+ case zapcore.ErrorLevel:
+ encoder.AppendString(LevelColorError)
+ default:
+ encoder.AppendString(LevelColorInfo)
+ }
+}
+
+func formatPrefix(prefix string) string {
+ if prefix == "" {
+ return ""
+ }
+
+ padding := ""
+ if len(prefix) < PrefixWidth {
+ padding = strings.Repeat(" ", PrefixWidth-len(prefix))
+ }
+
+ return PrefixColor + "[" + prefix + "]" + AnsiReset + padding
+}
+
+func colorizeMessage(level LogLevel, message string) string {
+ switch level {
+ case LevelDebug:
+ return MessageColorDebug + message + AnsiReset
+ case LevelWarn:
+ return MessageColorWarn + message + AnsiReset
+ case LevelError:
+ return MessageColorError + message + AnsiReset
+ case LevelSuccess:
+ return MessageColorSuccess + message + AnsiReset
+ default:
+ return MessageColorInfo + message + AnsiReset
+ }
+}
+
+func buildFullMessage(level LogLevel, prefix string, message any) string {
+ return formatPrefix(prefix) + colorizeMessage(level, fmt.Sprint(message))
+}
diff --git a/nexus/utils/logger/logger.go b/nexus/utils/logger/logger.go
new file mode 100644
index 0000000..f1cbbd1
--- /dev/null
+++ b/nexus/utils/logger/logger.go
@@ -0,0 +1,81 @@
+package logger
+
+import (
+ "fmt"
+ "os"
+
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+var (
+ instance *zap.Logger
+ atomicLevel zap.AtomicLevel
+)
+
+func init() {
+ atomicLevel = zap.NewAtomicLevelAt(zapcore.InfoLevel)
+
+ encoderConfig := zapcore.EncoderConfig{
+ LevelKey: "level",
+ MessageKey: "msg",
+ LineEnding: "\n",
+ EncodeLevel: formatLevel,
+ }
+
+ encoder := zapcore.NewConsoleEncoder(encoderConfig)
+ stdoutSink := zapcore.AddSync(os.Stdout)
+ stderrSink := zapcore.AddSync(os.Stderr)
+
+ core := zapcore.NewTee(
+ zapcore.NewCore(encoder, stdoutSink, zap.LevelEnablerFunc(func(level zapcore.Level) bool {
+ return level < zapcore.WarnLevel && atomicLevel.Enabled(level)
+ })),
+ zapcore.NewCore(encoder, stderrSink, zap.LevelEnablerFunc(func(level zapcore.Level) bool {
+ return level >= zapcore.WarnLevel && atomicLevel.Enabled(level)
+ })),
+ )
+
+ instance = zap.New(core, zap.AddCaller())
+}
+
+func SetDebug(enabled bool) {
+ if enabled {
+ atomicLevel.SetLevel(zapcore.DebugLevel)
+ } else {
+ atomicLevel.SetLevel(zapcore.InfoLevel)
+ }
+}
+
+func Debugf(prefix string, format string, arguments ...any) {
+ emit(LevelDebug, zapcore.DebugLevel, prefix, fmt.Sprintf(format, arguments...))
+}
+
+func Infof(prefix string, format string, arguments ...any) {
+ emit(LevelInfo, zapcore.InfoLevel, prefix, fmt.Sprintf(format, arguments...))
+}
+
+func Successf(prefix string, format string, arguments ...any) {
+ emit(LevelSuccess, zapcore.InfoLevel, prefix, fmt.Sprintf(format, arguments...))
+}
+
+func Warnf(prefix string, format string, arguments ...any) {
+ emit(LevelWarn, zapcore.WarnLevel, prefix, fmt.Sprintf(format, arguments...))
+}
+
+func Errorf(prefix string, format string, arguments ...any) {
+ emit(LevelError, zapcore.ErrorLevel, prefix, fmt.Sprintf(format, arguments...))
+}
+
+func Fatalf(prefix string, format string, arguments ...any) {
+ emit(LevelError, zapcore.ErrorLevel, prefix, fmt.Sprintf(format, arguments...))
+ os.Exit(1)
+}
+
+func emit(levelLabel LogLevel, zapLevel zapcore.Level, prefix string, message any) {
+ if instance == nil {
+ panic(NotInitialized)
+ }
+
+ instance.Log(zapLevel, buildFullMessage(levelLabel, prefix, message))
+}
diff --git a/nexus/utils/logger/messages.go b/nexus/utils/logger/messages.go
new file mode 100644
index 0000000..784cc43
--- /dev/null
+++ b/nexus/utils/logger/messages.go
@@ -0,0 +1,5 @@
+package logger
+
+const (
+ NotInitialized = "Logger was not initialized."
+)
diff --git a/nexus/utils/meta/account.go b/nexus/utils/meta/account.go
new file mode 100644
index 0000000..a881b36
--- /dev/null
+++ b/nexus/utils/meta/account.go
@@ -0,0 +1,15 @@
+package meta
+
+import (
+ "nexus/models"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Account(context *fiber.Ctx) *models.Account {
+ account, ok := context.Locals(AccountKey).(*models.Account)
+ if !ok {
+ return nil
+ }
+ return account
+}
diff --git a/nexus/utils/meta/body.go b/nexus/utils/meta/body.go
new file mode 100644
index 0000000..bc63546
--- /dev/null
+++ b/nexus/utils/meta/body.go
@@ -0,0 +1,11 @@
+package meta
+
+import "github.com/gofiber/fiber/v2"
+
+func Body[T any](context *fiber.Ctx) (T, error) {
+ var body T
+ if err := context.BodyParser(&body); err != nil {
+ return body, err
+ }
+ return body, nil
+}
diff --git a/nexus/utils/meta/defaults.go b/nexus/utils/meta/defaults.go
new file mode 100644
index 0000000..8f0e27a
--- /dev/null
+++ b/nexus/utils/meta/defaults.go
@@ -0,0 +1,8 @@
+package meta
+
+const (
+ LogPrefix = "Meta"
+ RequestKey = "Request"
+ AccountKey = "Account"
+ SessionKey = "Session"
+)
diff --git a/nexus/utils/meta/messages.go b/nexus/utils/meta/messages.go
new file mode 100644
index 0000000..a188b46
--- /dev/null
+++ b/nexus/utils/meta/messages.go
@@ -0,0 +1,5 @@
+package meta
+
+const (
+ RequestContextMissing = "Request context missing in fiber locals."
+)
diff --git a/nexus/utils/meta/request.go b/nexus/utils/meta/request.go
new file mode 100644
index 0000000..e9c649d
--- /dev/null
+++ b/nexus/utils/meta/request.go
@@ -0,0 +1,108 @@
+package meta
+
+import (
+ "nexus/utils/logger"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type Param struct {
+ Key string
+ Value string
+}
+
+type RequestInfo struct {
+ Path string
+ Method string
+ Query []Param
+ Params []Param
+ Headers []Param
+ QueryString string
+ IP string
+ URL string
+}
+
+type RequestData struct {
+ RequestInfo
+ Context *fiber.Ctx
+}
+
+func BuildRequest(context *fiber.Ctx) RequestInfo {
+ return RequestInfo{
+ Path: context.Path(),
+ Method: context.Method(),
+ Query: buildQueryParams(context),
+ Params: buildRouteParams(context),
+ Headers: buildHeaders(context),
+ QueryString: string(context.Request().URI().QueryString()),
+ IP: context.IP(),
+ URL: context.OriginalURL(),
+ }
+}
+
+func Request(context *fiber.Ctx) *RequestData {
+ data, ok := context.Locals(RequestKey).(RequestInfo)
+ if !ok {
+ logger.Errorf(LogPrefix, RequestContextMissing)
+ return nil
+ }
+
+ return &RequestData{
+ RequestInfo: data,
+ Context: context,
+ }
+}
+
+func (self *RequestData) Param(key string) string {
+ if self == nil || self.Context == nil {
+ return ""
+ }
+ return self.Context.Params(key)
+}
+
+func (self *RequestData) Query(key string) string {
+ if self == nil {
+ return ""
+ }
+ return findParam(self.RequestInfo.Query, key)
+}
+
+func (self *RequestData) Header(key string) string {
+ if self == nil {
+ return ""
+ }
+ return findParam(self.RequestInfo.Headers, key)
+}
+
+func buildQueryParams(context *fiber.Ctx) []Param {
+ params := make([]Param, 0)
+ context.Request().URI().QueryArgs().VisitAll(func(name []byte, value []byte) {
+ params = append(params, Param{Key: string(name), Value: string(value)})
+ })
+ return params
+}
+
+func buildRouteParams(context *fiber.Ctx) []Param {
+ params := make([]Param, 0)
+ for name, value := range context.AllParams() {
+ params = append(params, Param{Key: name, Value: value})
+ }
+ return params
+}
+
+func buildHeaders(context *fiber.Ctx) []Param {
+ params := make([]Param, 0)
+ context.Request().Header.VisitAll(func(name []byte, value []byte) {
+ params = append(params, Param{Key: string(name), Value: string(value)})
+ })
+ return params
+}
+
+func findParam(params []Param, key string) string {
+ for _, param := range params {
+ if param.Key == key {
+ return param.Value
+ }
+ }
+ return ""
+}
diff --git a/nexus/utils/meta/session.go b/nexus/utils/meta/session.go
new file mode 100644
index 0000000..ccc7adf
--- /dev/null
+++ b/nexus/utils/meta/session.go
@@ -0,0 +1,16 @@
+package meta
+
+import (
+ "nexus/sessions"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+func Session(context *fiber.Ctx) *session.Session {
+ sess, ok := context.Locals(sessions.SessionLocalKey).(*session.Session)
+ if !ok {
+ return nil
+ }
+ return sess
+}
diff --git a/nexus/utils/meta/title.go b/nexus/utils/meta/title.go
new file mode 100644
index 0000000..3e6506f
--- /dev/null
+++ b/nexus/utils/meta/title.go
@@ -0,0 +1,7 @@
+package meta
+
+import "github.com/gofiber/fiber/v2"
+
+func SetPageTitle(context *fiber.Ctx, title string) {
+ context.Locals("Title", title)
+}
diff --git a/nexus/utils/shortcuts/errors.go b/nexus/utils/shortcuts/errors.go
new file mode 100644
index 0000000..cdb2ad2
--- /dev/null
+++ b/nexus/utils/shortcuts/errors.go
@@ -0,0 +1,20 @@
+package shortcuts
+
+import "github.com/gofiber/fiber/v2"
+
+func RouteError(context *fiber.Ctx, err *fiber.Error) error {
+ if isAPIRequest(context) {
+ return context.Status(err.Code).JSON(fiber.Map{
+ "error": err.Message,
+ })
+ }
+ return RenderWithStatus(context, "errors/error", err, err.Code)
+}
+
+func ServiceError(code int, message string) *fiber.Error {
+ return fiber.NewError(code, message)
+}
+
+func isAPIRequest(context *fiber.Ctx) bool {
+ return len(context.Path()) >= 4 && context.Path()[:4] == "/api"
+}
diff --git a/nexus/utils/shortcuts/flash.go b/nexus/utils/shortcuts/flash.go
new file mode 100644
index 0000000..c82b37d
--- /dev/null
+++ b/nexus/utils/shortcuts/flash.go
@@ -0,0 +1,36 @@
+package shortcuts
+
+import (
+ "nexus/sessions"
+ "nexus/utils/collections"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+func Flash(context *fiber.Ctx, data collections.Record[string, any]) error {
+ sess, ok := context.Locals(sessions.SessionLocalKey).(*session.Session)
+ if !ok {
+ return nil
+ }
+ return sessions.SetFlash(sess, data)
+}
+
+func ConsumeFlash(context *fiber.Ctx) collections.Record[string, any] {
+ sess, ok := context.Locals(sessions.SessionLocalKey).(*session.Session)
+ if !ok {
+ return nil
+ }
+ data := sessions.GetFlash(sess)
+ if data != nil {
+ _ = sessions.ClearFlash(sess)
+ }
+ return data
+}
+
+func RedirectWithFlash(context *fiber.Ctx, routeName string, data collections.Record[string, any]) error {
+ if err := Flash(context, data); err != nil {
+ return err
+ }
+ return Redirect(context, routeName)
+}
diff --git a/nexus/utils/shortcuts/json.go b/nexus/utils/shortcuts/json.go
new file mode 100644
index 0000000..210d1a9
--- /dev/null
+++ b/nexus/utils/shortcuts/json.go
@@ -0,0 +1,12 @@
+package shortcuts
+
+import "github.com/gofiber/fiber/v2"
+
+func JSON(context *fiber.Ctx, data any) error {
+ return context.JSON(data)
+}
+
+func Created(context *fiber.Ctx, data any) error {
+ context.Status(fiber.StatusCreated)
+ return context.JSON(data)
+}
diff --git a/nexus/utils/shortcuts/messages.go b/nexus/utils/shortcuts/messages.go
new file mode 100644
index 0000000..819e536
--- /dev/null
+++ b/nexus/utils/shortcuts/messages.go
@@ -0,0 +1,5 @@
+package shortcuts
+
+const (
+ UnsupportedBindType = "Unsupported data type for binding. Only struct, collections.Record[string, any] are supported."
+)
diff --git a/nexus/utils/shortcuts/redirect.go b/nexus/utils/shortcuts/redirect.go
new file mode 100644
index 0000000..8999b7c
--- /dev/null
+++ b/nexus/utils/shortcuts/redirect.go
@@ -0,0 +1,11 @@
+package shortcuts
+
+import "github.com/gofiber/fiber/v2"
+
+func Redirect(context *fiber.Ctx, path string) error {
+ return context.Redirect(path)
+}
+
+func RedirectWithStatus(context *fiber.Ctx, path string, statusCode int) error {
+ return context.Redirect(path, statusCode)
+}
diff --git a/nexus/utils/shortcuts/render.go b/nexus/utils/shortcuts/render.go
new file mode 100644
index 0000000..5f3678f
--- /dev/null
+++ b/nexus/utils/shortcuts/render.go
@@ -0,0 +1,103 @@
+package shortcuts
+
+import (
+ "maps"
+ "nexus/utils/collections"
+ "reflect"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Render(context *fiber.Ctx, templateName string, data any) error {
+ templateData := make(collections.Record[string, any])
+
+ if flash := ConsumeFlash(context); flash != nil {
+ maps.Copy(templateData, flash)
+ }
+
+ mergeContextValues(context, templateData)
+
+ if data != nil {
+ if mergeError := mergeBindData(templateData, data); mergeError != nil {
+ return mergeError
+ }
+ }
+
+ return context.Render(templateName, templateData)
+}
+
+func RenderWithStatus(context *fiber.Ctx, templateName string, data any, statusCode int) error {
+ context.Status(statusCode)
+ return Render(context, templateName, data)
+}
+
+func NoContent(context *fiber.Ctx) error {
+ return context.SendStatus(fiber.StatusNoContent)
+}
+
+func mergeContextValues(context *fiber.Ctx, target collections.Record[string, any]) {
+ context.Context().VisitUserValuesAll(func(key any, value any) {
+ switch typedKey := key.(type) {
+ case string:
+ if typedKey != "" {
+ target[typedKey] = value
+ }
+ case []byte:
+ if len(typedKey) > 0 {
+ target[string(typedKey)] = value
+ }
+ }
+ })
+}
+
+func mergeBindData(target collections.Record[string, any], data any) error {
+ normalized, err := normalizeToMap(data)
+ if err != nil {
+ return err
+ }
+ maps.Copy(target, normalized)
+ return nil
+}
+
+func normalizeToMap(data any) (collections.Record[string, any], error) {
+ switch v := data.(type) {
+ case collections.Record[string, any]:
+ return v, nil
+ default:
+ return convertStructToMap(data)
+ }
+}
+
+func convertStructToMap(data any) (collections.Record[string, any], error) {
+ v := reflect.ValueOf(data)
+ if v.Kind() == reflect.Pointer {
+ v = v.Elem()
+ }
+ if v.Kind() != reflect.Struct {
+ return nil, fiber.NewError(fiber.StatusInternalServerError, UnsupportedBindType)
+ }
+
+ t := v.Type()
+ result := make(collections.Record[string, any], t.NumField())
+
+ for i := range t.NumField() {
+ field := t.Field(i)
+ if !field.IsExported() {
+ continue
+ }
+
+ key := field.Name
+ if tag := field.Tag.Get("json"); tag != "" && tag != "-" {
+ if idx := strings.IndexByte(tag, ','); idx > 0 {
+ key = tag[:idx]
+ } else if idx < 0 {
+ key = tag
+ }
+ }
+
+ result[key] = v.Field(i).Interface()
+ }
+
+ return result, nil
+}
diff --git a/nexus/utils/shortcuts/token.go b/nexus/utils/shortcuts/token.go
new file mode 100644
index 0000000..be9990f
--- /dev/null
+++ b/nexus/utils/shortcuts/token.go
@@ -0,0 +1,16 @@
+package shortcuts
+
+import (
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func BearerToken(context *fiber.Ctx) string {
+ authHeader := context.Get("Authorization")
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return ""
+ }
+ return parts[1]
+}
diff --git a/nexus/utils/token/token.go b/nexus/utils/token/token.go
new file mode 100644
index 0000000..e517a15
--- /dev/null
+++ b/nexus/utils/token/token.go
@@ -0,0 +1,14 @@
+package token
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+)
+
+func Generate() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/nexus/utils/urls/attach.go b/nexus/utils/urls/attach.go
new file mode 100644
index 0000000..b8a1458
--- /dev/null
+++ b/nexus/utils/urls/attach.go
@@ -0,0 +1,12 @@
+package urls
+
+import "github.com/gofiber/fiber/v2"
+
+func Attach(application *fiber.App) {
+ registry.Mutex.Lock()
+ defer registry.Mutex.Unlock()
+
+ for _, route := range registry.Routes.All() {
+ bindPath(application, route)
+ }
+}
diff --git a/nexus/utils/urls/path.go b/nexus/utils/urls/path.go
new file mode 100644
index 0000000..ca44726
--- /dev/null
+++ b/nexus/utils/urls/path.go
@@ -0,0 +1,114 @@
+package urls
+
+import (
+ "strings"
+
+ "nexus/utils/collections"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type HTTPMethod string
+
+const (
+ Delete HTTPMethod = "DELETE"
+ Get HTTPMethod = "GET"
+ Head HTTPMethod = "HEAD"
+ Options HTTPMethod = "OPTIONS"
+ Patch HTTPMethod = "PATCH"
+ Post HTTPMethod = "POST"
+ Put HTTPMethod = "PUT"
+)
+
+func Path(method HTTPMethod, path string, handler fiber.Handler, name string) {
+ registry.Mutex.Lock()
+ defer registry.Mutex.Unlock()
+
+ namespace := registry.CurrentNamespace
+ fullName := resolveFullName(namespace, name)
+ fullPath := resolveFullPath(namespace, path)
+
+ registry.Routes.Set(fullName, RegisteredRoute{
+ Method: method,
+ Path: path,
+ Handler: handler,
+ Namespace: namespace,
+ Name: name,
+ FullPath: fullPath,
+ })
+}
+
+func GetFullPath(routeName string) (string, bool) {
+ registry.Mutex.Lock()
+ defer registry.Mutex.Unlock()
+
+ route, exists := registry.Routes.Get(routeName)
+ if !exists {
+ return "", false
+ }
+
+ return route.FullPath, true
+}
+
+func ResolvePath(routeName string, params collections.Record[string, string]) (string, bool) {
+ registry.Mutex.Lock()
+ defer registry.Mutex.Unlock()
+
+ route, exists := registry.Routes.Get(routeName)
+ if !exists {
+ return "", false
+ }
+
+ resolved := route.FullPath
+ for key, value := range params {
+ resolved = strings.ReplaceAll(resolved, ":"+key, value)
+ }
+
+ return resolved, true
+}
+
+func resolveFullName(namespace string, name string) string {
+ switch namespace {
+ case "":
+ return name
+ default:
+ return namespace + "." + name
+ }
+}
+
+func resolveFullPath(namespace string, path string) string {
+ switch namespace {
+ case "":
+ return ensureLeadingSlash(path)
+ default:
+ return "/" + namespace + ensureLeadingSlash(path)
+ }
+}
+
+func bindPath(application *fiber.App, route RegisteredRoute) {
+ switch route.Method {
+ case Delete:
+ application.Delete(route.FullPath, route.Handler)
+ case Get:
+ application.Get(route.FullPath, route.Handler)
+ case Head:
+ application.Head(route.FullPath, route.Handler)
+ case Options:
+ application.Options(route.FullPath, route.Handler)
+ case Patch:
+ application.Patch(route.FullPath, route.Handler)
+ case Post:
+ application.Post(route.FullPath, route.Handler)
+ case Put:
+ application.Put(route.FullPath, route.Handler)
+ }
+}
+
+func ensureLeadingSlash(path string) string {
+ switch strings.HasPrefix(path, "/") {
+ case true:
+ return path
+ default:
+ return "/" + path
+ }
+}
diff --git a/nexus/utils/urls/registry.go b/nexus/utils/urls/registry.go
new file mode 100644
index 0000000..548a820
--- /dev/null
+++ b/nexus/utils/urls/registry.go
@@ -0,0 +1,34 @@
+package urls
+
+import (
+ "sync"
+
+ "nexus/utils/collections"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type RegisteredRoute struct {
+ Method HTTPMethod
+ Path string
+ Handler fiber.Handler
+ Namespace string
+ Name string
+ FullPath string
+}
+
+type RouteRegistry struct {
+ Mutex sync.Mutex
+ CurrentNamespace string
+ Routes collections.OrderedMap[string, RegisteredRoute]
+}
+
+var registry = &RouteRegistry{
+ Routes: collections.OrderedMapOf[string, RegisteredRoute](),
+}
+
+func SetNamespace(namespace string) {
+ registry.Mutex.Lock()
+ defer registry.Mutex.Unlock()
+ registry.CurrentNamespace = namespace
+}
diff --git a/nexus/utils/validate/email.go b/nexus/utils/validate/email.go
new file mode 100644
index 0000000..70c6e8f
--- /dev/null
+++ b/nexus/utils/validate/email.go
@@ -0,0 +1,9 @@
+package validate
+
+import "regexp"
+
+var emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
+
+func Email(email string) bool {
+ return emailPattern.MatchString(email)
+}
diff --git a/nexus/utils/validate/messages.go b/nexus/utils/validate/messages.go
new file mode 100644
index 0000000..defe6cf
--- /dev/null
+++ b/nexus/utils/validate/messages.go
@@ -0,0 +1,5 @@
+package validate
+
+const (
+ InvalidEmail = "invalid email address"
+)
diff --git a/toolchain/docker-compose.yml b/toolchain/docker-compose.nakama.yml
index 205230c..0499b91 100644
--- a/toolchain/docker-compose.yml
+++ b/toolchain/docker-compose.nakama.yml
@@ -1,4 +1,3 @@
-version: "3"
services:
cockroachdb:
image: cockroachdb/cockroach:latest-v23.1
diff --git a/toolchain/docker-compose.nexus.yml b/toolchain/docker-compose.nexus.yml
new file mode 100644
index 0000000..2aa4c18
--- /dev/null
+++ b/toolchain/docker-compose.nexus.yml
@@ -0,0 +1,18 @@
+services:
+ postgres:
+ image: postgres:18-alpine
+ restart: "no"
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ""
+ POSTGRES_HOST_AUTH_METHOD: trust
+ POSTGRES_DB: nexus
+ volumes:
+ - ../data/postgres:/var/lib/postgresql
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD", "pg_isready", "-U", "postgres"]
+ interval: 3s
+ timeout: 3s
+ retries: 5 \ No newline at end of file