From 9eb9b7f4bd552a641235764f66483e1f940fcfd9 Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 29 Mar 2026 22:52:46 +0530 Subject: feat: nexus account manager scaffold with auth, characters, realms --- Makefile | 40 +++++++ nexus/.air.toml | 13 +++ nexus/.air.windows.toml | 13 +++ nexus/.gitignore | 43 +++++++ nexus/Makefile | 47 ++++++++ nexus/api/account/account.go | 20 ++++ nexus/api/auth/auth.go | 69 ++++++++++++ nexus/api/characters/characters.go | 66 +++++++++++ nexus/api/characters/messages.go | 3 + nexus/api/realms/realms.go | 23 ++++ nexus/config/config.go | 38 +++++++ nexus/config/defaults.go | 3 + nexus/config/env.go | 37 ++++++ nexus/config/messages.go | 10 ++ nexus/controllers/auth/defaults.go | 3 + nexus/controllers/auth/login.go | 39 +++++++ nexus/controllers/auth/logout.go | 20 ++++ nexus/controllers/auth/messages.go | 6 + nexus/controllers/auth/register.go | 48 ++++++++ nexus/database/database.go | 49 ++++++++ nexus/database/defaults.go | 10 ++ nexus/database/logger.go | 14 +++ nexus/database/messages.go | 9 ++ nexus/database/migration.go | 7 ++ nexus/go.mod | 41 +++++++ nexus/go.sum | 179 ++++++++++++++++++++++++++++++ nexus/middleware/account.go | 30 +++++ nexus/middleware/middleware.go | 9 ++ nexus/middleware/request.go | 12 ++ nexus/middleware/session.go | 17 +++ nexus/models/account.go | 77 +++++++++++++ nexus/models/character.go | 51 +++++++++ nexus/models/defaults.go | 7 ++ nexus/models/realm.go | 36 ++++++ nexus/models/session.go | 36 ++++++ nexus/nexus/defaults.go | 3 + nexus/nexus/main.go | 56 ++++++++++ nexus/nexus/messages.go | 9 ++ nexus/pages/account/account.go | 13 +++ nexus/pages/auth/login.go | 13 +++ nexus/pages/auth/register.go | 13 +++ nexus/pages/characters/characters.go | 52 +++++++++ nexus/repositories/account/account.go | 42 +++++++ nexus/repositories/character/character.go | 38 +++++++ nexus/repositories/realm/realm.go | 28 +++++ nexus/repositories/session/session.go | 38 +++++++ nexus/router/api.go | 29 +++++ nexus/router/router.go | 21 ++++ nexus/router/web.go | 25 +++++ nexus/services/account/account.go | 46 ++++++++ nexus/services/account/defaults.go | 3 + nexus/services/account/messages.go | 7 ++ nexus/services/auth/auth.go | 129 +++++++++++++++++++++ nexus/services/auth/defaults.go | 3 + nexus/services/auth/messages.go | 17 +++ nexus/services/character/character.go | 79 +++++++++++++ nexus/services/character/defaults.go | 3 + nexus/services/character/messages.go | 11 ++ nexus/sessions/defaults.go | 11 ++ nexus/sessions/functions.go | 55 +++++++++ nexus/sessions/kv.go | 17 +++ nexus/sessions/messages.go | 5 + nexus/sessions/sessions.go | 36 ++++++ nexus/static/css/main.css | 18 +++ nexus/tags/active.go | 20 ++++ nexus/tags/defaults.go | 6 + nexus/tags/messages.go | 12 ++ nexus/tags/static.go | 32 ++++++ nexus/tags/tags.go | 40 +++++++ nexus/tags/url.go | 97 ++++++++++++++++ nexus/templates/account/index.django | 8 ++ nexus/templates/auth/login.django | 17 +++ nexus/templates/auth/register.django | 19 ++++ nexus/templates/characters/create.django | 20 ++++ nexus/templates/characters/index.django | 7 ++ nexus/templates/errors/error.django | 8 ++ nexus/templates/layouts/base.django | 12 ++ nexus/types/account/response.go | 15 +++ nexus/types/auth/request.go | 16 +++ nexus/types/auth/response.go | 6 + nexus/types/character/context.go | 5 + nexus/types/character/request.go | 10 ++ nexus/types/character/response.go | 16 +++ nexus/types/realm/response.go | 10 ++ nexus/utils/auth/auth.go | 54 +++++++++ nexus/utils/collections/maps.go | 37 ++++++ nexus/utils/collections/record.go | 3 + nexus/utils/env/defaults.go | 6 + nexus/utils/env/extract.go | 33 ++++++ nexus/utils/env/getenv.go | 75 +++++++++++++ nexus/utils/env/messages.go | 5 + nexus/utils/env/parse.go | 27 +++++ nexus/utils/env/setenv.go | 62 +++++++++++ nexus/utils/logger/defaults.go | 25 +++++ nexus/utils/logger/format.go | 63 +++++++++++ nexus/utils/logger/logger.go | 81 ++++++++++++++ nexus/utils/logger/messages.go | 5 + nexus/utils/meta/account.go | 15 +++ nexus/utils/meta/body.go | 11 ++ nexus/utils/meta/defaults.go | 8 ++ nexus/utils/meta/messages.go | 5 + nexus/utils/meta/request.go | 108 ++++++++++++++++++ nexus/utils/meta/session.go | 16 +++ nexus/utils/meta/title.go | 7 ++ nexus/utils/shortcuts/errors.go | 20 ++++ nexus/utils/shortcuts/flash.go | 36 ++++++ nexus/utils/shortcuts/json.go | 12 ++ nexus/utils/shortcuts/messages.go | 5 + nexus/utils/shortcuts/redirect.go | 11 ++ nexus/utils/shortcuts/render.go | 103 +++++++++++++++++ nexus/utils/shortcuts/token.go | 16 +++ nexus/utils/token/token.go | 14 +++ nexus/utils/urls/attach.go | 12 ++ nexus/utils/urls/path.go | 114 +++++++++++++++++++ nexus/utils/urls/registry.go | 34 ++++++ nexus/utils/validate/email.go | 9 ++ nexus/utils/validate/messages.go | 5 + toolchain/docker-compose.nakama.yml | 81 ++++++++++++++ toolchain/docker-compose.nexus.yml | 18 +++ toolchain/docker-compose.yml | 82 -------------- 120 files changed, 3457 insertions(+), 82 deletions(-) create mode 100644 Makefile create mode 100644 nexus/.air.toml create mode 100644 nexus/.air.windows.toml create mode 100644 nexus/.gitignore create mode 100644 nexus/Makefile create mode 100644 nexus/api/account/account.go create mode 100644 nexus/api/auth/auth.go create mode 100644 nexus/api/characters/characters.go create mode 100644 nexus/api/characters/messages.go create mode 100644 nexus/api/realms/realms.go create mode 100644 nexus/config/config.go create mode 100644 nexus/config/defaults.go create mode 100644 nexus/config/env.go create mode 100644 nexus/config/messages.go create mode 100644 nexus/controllers/auth/defaults.go create mode 100644 nexus/controllers/auth/login.go create mode 100644 nexus/controllers/auth/logout.go create mode 100644 nexus/controllers/auth/messages.go create mode 100644 nexus/controllers/auth/register.go create mode 100644 nexus/database/database.go create mode 100644 nexus/database/defaults.go create mode 100644 nexus/database/logger.go create mode 100644 nexus/database/messages.go create mode 100644 nexus/database/migration.go create mode 100644 nexus/go.mod create mode 100644 nexus/go.sum create mode 100644 nexus/middleware/account.go create mode 100644 nexus/middleware/middleware.go create mode 100644 nexus/middleware/request.go create mode 100644 nexus/middleware/session.go create mode 100644 nexus/models/account.go create mode 100644 nexus/models/character.go create mode 100644 nexus/models/defaults.go create mode 100644 nexus/models/realm.go create mode 100644 nexus/models/session.go create mode 100644 nexus/nexus/defaults.go create mode 100644 nexus/nexus/main.go create mode 100644 nexus/nexus/messages.go create mode 100644 nexus/pages/account/account.go create mode 100644 nexus/pages/auth/login.go create mode 100644 nexus/pages/auth/register.go create mode 100644 nexus/pages/characters/characters.go create mode 100644 nexus/repositories/account/account.go create mode 100644 nexus/repositories/character/character.go create mode 100644 nexus/repositories/realm/realm.go create mode 100644 nexus/repositories/session/session.go create mode 100644 nexus/router/api.go create mode 100644 nexus/router/router.go create mode 100644 nexus/router/web.go create mode 100644 nexus/services/account/account.go create mode 100644 nexus/services/account/defaults.go create mode 100644 nexus/services/account/messages.go create mode 100644 nexus/services/auth/auth.go create mode 100644 nexus/services/auth/defaults.go create mode 100644 nexus/services/auth/messages.go create mode 100644 nexus/services/character/character.go create mode 100644 nexus/services/character/defaults.go create mode 100644 nexus/services/character/messages.go create mode 100644 nexus/sessions/defaults.go create mode 100644 nexus/sessions/functions.go create mode 100644 nexus/sessions/kv.go create mode 100644 nexus/sessions/messages.go create mode 100644 nexus/sessions/sessions.go create mode 100644 nexus/static/css/main.css create mode 100644 nexus/tags/active.go create mode 100644 nexus/tags/defaults.go create mode 100644 nexus/tags/messages.go create mode 100644 nexus/tags/static.go create mode 100644 nexus/tags/tags.go create mode 100644 nexus/tags/url.go create mode 100644 nexus/templates/account/index.django create mode 100644 nexus/templates/auth/login.django create mode 100644 nexus/templates/auth/register.django create mode 100644 nexus/templates/characters/create.django create mode 100644 nexus/templates/characters/index.django create mode 100644 nexus/templates/errors/error.django create mode 100644 nexus/templates/layouts/base.django create mode 100644 nexus/types/account/response.go create mode 100644 nexus/types/auth/request.go create mode 100644 nexus/types/auth/response.go create mode 100644 nexus/types/character/context.go create mode 100644 nexus/types/character/request.go create mode 100644 nexus/types/character/response.go create mode 100644 nexus/types/realm/response.go create mode 100644 nexus/utils/auth/auth.go create mode 100644 nexus/utils/collections/maps.go create mode 100644 nexus/utils/collections/record.go create mode 100644 nexus/utils/env/defaults.go create mode 100644 nexus/utils/env/extract.go create mode 100644 nexus/utils/env/getenv.go create mode 100644 nexus/utils/env/messages.go create mode 100644 nexus/utils/env/parse.go create mode 100644 nexus/utils/env/setenv.go create mode 100644 nexus/utils/logger/defaults.go create mode 100644 nexus/utils/logger/format.go create mode 100644 nexus/utils/logger/logger.go create mode 100644 nexus/utils/logger/messages.go create mode 100644 nexus/utils/meta/account.go create mode 100644 nexus/utils/meta/body.go create mode 100644 nexus/utils/meta/defaults.go create mode 100644 nexus/utils/meta/messages.go create mode 100644 nexus/utils/meta/request.go create mode 100644 nexus/utils/meta/session.go create mode 100644 nexus/utils/meta/title.go create mode 100644 nexus/utils/shortcuts/errors.go create mode 100644 nexus/utils/shortcuts/flash.go create mode 100644 nexus/utils/shortcuts/json.go create mode 100644 nexus/utils/shortcuts/messages.go create mode 100644 nexus/utils/shortcuts/redirect.go create mode 100644 nexus/utils/shortcuts/render.go create mode 100644 nexus/utils/shortcuts/token.go create mode 100644 nexus/utils/token/token.go create mode 100644 nexus/utils/urls/attach.go create mode 100644 nexus/utils/urls/path.go create mode 100644 nexus/utils/urls/registry.go create mode 100644 nexus/utils/validate/email.go create mode 100644 nexus/utils/validate/messages.go create mode 100644 toolchain/docker-compose.nakama.yml create mode 100644 toolchain/docker-compose.nexus.yml delete mode 100644 toolchain/docker-compose.yml 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 %} +
+

My Account

+

My Characters

+

Sign Out

+
+{% 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 %} +
+

Sign In

+ {% if Error %} +

{{ Error }}

+ {% endif %} +
+ + + + + +
+

No account? Create one

+
+{% 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 %} +
+

Create Account

+ {% if Error %} +

{{ Error }}

+ {% endif %} +
+ + + + + + + +
+

Have an account? Sign in

+
+{% 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 %} +
+

Create Character

+ {% if Error %} +

{{ Error }}

+ {% endif %} +
+ + + + + + + + + +
+
+{% 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 %} +
+

Characters

+ Create Character +
+{% 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 %} +
+

{{ Code }}

+

{{ Message }}

+ Go Home +
+{% 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 @@ + + + + + + {{ Title }} — Echoes of Vaelun + + + + {% block content %}{% endblock %} + + \ 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.nakama.yml b/toolchain/docker-compose.nakama.yml new file mode 100644 index 0000000..0499b91 --- /dev/null +++ b/toolchain/docker-compose.nakama.yml @@ -0,0 +1,81 @@ +services: + cockroachdb: + image: cockroachdb/cockroach:latest-v23.1 + command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/ + restart: "no" + volumes: + - data:/var/lib/cockroach + expose: + - "8080" + - "26257" + ports: + - "26257:26257" + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"] + interval: 3s + timeout: 3s + retries: 5 + + nakama: + image: registry.heroiclabs.com/heroiclabs/nakama:3.22.0 + entrypoint: + - "/bin/sh" + - "-ecx" + - > + /nakama/nakama migrate up --database.address root@cockroachdb:26257 && + exec /nakama/nakama --name nakama1 --database.address root@cockroachdb:26257 + --logger.level DEBUG --session.token_expiry_sec 7200 + --metrics.prometheus_port 9100 + restart: "no" + links: + - "cockroachdb:db" + depends_on: + cockroachdb: + condition: service_healthy + prometheus: + condition: service_started + volumes: + - ../data/nakama:/nakama/data + expose: + - "7349" + - "7350" + - "7351" + - "9100" + ports: + - "7349:7349" + - "7350:7350" + - "7351:7351" + healthcheck: + test: ["CMD", "/nakama/nakama", "healthcheck"] + interval: 10s + timeout: 5s + retries: 5 + + prometheus: + image: prom/prometheus + entrypoint: /bin/sh -c + command: | + 'sh -s < ./prometheus.yml < - /nakama/nakama migrate up --database.address root@cockroachdb:26257 && - exec /nakama/nakama --name nakama1 --database.address root@cockroachdb:26257 - --logger.level DEBUG --session.token_expiry_sec 7200 - --metrics.prometheus_port 9100 - restart: "no" - links: - - "cockroachdb:db" - depends_on: - cockroachdb: - condition: service_healthy - prometheus: - condition: service_started - volumes: - - ../data/nakama:/nakama/data - expose: - - "7349" - - "7350" - - "7351" - - "9100" - ports: - - "7349:7349" - - "7350:7350" - - "7351:7351" - healthcheck: - test: ["CMD", "/nakama/nakama", "healthcheck"] - interval: 10s - timeout: 5s - retries: 5 - - prometheus: - image: prom/prometheus - entrypoint: /bin/sh -c - command: | - 'sh -s < ./prometheus.yml <