diff --git a/.env.example b/.env.example index 4be9aee..d5201b7 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # These are probably fine -POSTGRES_DB=letsgo +POSTGRES_DB=gonext NODE_ENV=development # Maybe change these JWT_ACCESS_SECRET=SneakySecret POSTGRES_USER=pgUser POSTGRES_PASSWORD=pgPass - -# Production only DOCKERHUB_NAME= +RESEND_KEY= +MAIL_FROM= # Will be overridden by deploy script TAG=latest diff --git a/.github/workflows/build-n-deploy.yml b/.github/workflows/build-n-deploy.yml index 3a9cfec..db8774a 100644 --- a/.github/workflows/build-n-deploy.yml +++ b/.github/workflows/build-n-deploy.yml @@ -28,8 +28,8 @@ jobs: file: ./frontend/Dockerfile push: true tags: | - ${{ secrets.DOCKERHUB_NAME }}/letsgo-frontend:${{ steps.tag.outputs.version }} - ${{ secrets.DOCKERHUB_NAME }}/letsgo-frontend:latest + ${{ secrets.DOCKERHUB_NAME }}/gonext-frontend:${{ steps.tag.outputs.version }} + ${{ secrets.DOCKERHUB_NAME }}/gonext-frontend:latest - name: Build and push backend uses: docker/build-push-action@v5 @@ -38,8 +38,8 @@ jobs: file: ./backend/Dockerfile push: true tags: | - ${{ secrets.DOCKERHUB_NAME }}/letsgo-backend:${{ steps.tag.outputs.version }} - ${{ secrets.DOCKERHUB_NAME }}/letsgo-backend:latest + ${{ secrets.DOCKERHUB_NAME }}/gonext-backend:${{ steps.tag.outputs.version }} + ${{ secrets.DOCKERHUB_NAME }}/gonext-backend:latest deploy: needs: build @@ -85,13 +85,13 @@ jobs: echo "Cleaning up old deployment..." docker compose -f docker-compose.yml down --remove-orphans || true - docker compose -p letsgo-prod -f docker-compose.prod.yml down --remove-orphans || true + docker compose -p gonext-prod -f docker-compose.prod.yml down --remove-orphans || true echo "Pulling images..." - docker compose -p letsgo-prod -f docker-compose.prod.yml pull || { echo "❌ Docker compose pull failed"; exit 1; } + docker compose -p gonext-prod -f docker-compose.prod.yml pull || { echo "❌ Docker compose pull failed"; exit 1; } echo "Docker composing..." - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans || { + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans || { echo "❌ Deployment failed" if ! grep -q "^LAST_WORKING_TAG=" .env; then echo "⚠️ No last working version" @@ -101,7 +101,7 @@ jobs: echo "Rolling back to last working version: $LAST_WORKING" git checkout $LAST_WORKING sed -i "s/^TAG=.*/TAG=$LAST_WORKING/" .env - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans exit 1 } @@ -121,7 +121,7 @@ jobs: echo "Rolling back to last working version: $LAST_WORKING" git checkout $LAST_WORKING sed -i "s/^TAG=.*/TAG=$LAST_WORKING/" .env - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans exit 1 fi echo "Health check attempt $i failed, retrying in 10s..." diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 942cceb..737e11b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - name: Build frontend Docker image - run: docker build -t letsgo-frontend:test ./frontend + run: docker build -t gonext-frontend:test ./frontend backend: runs-on: ubuntu-latest @@ -21,4 +21,4 @@ jobs: - uses: actions/checkout@v4 - name: Build backend Docker image - run: docker build -t letsgo-backend:test ./backend \ No newline at end of file + run: docker build -t gonext-backend:test ./backend \ No newline at end of file diff --git a/README.md b/README.md index b9965ef..5ac743f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# LetsGo: A Real-time Full-Stack Web Application +# GoNext: A Real-time Full-Stack Web Application based on Go and Next.js -[![GitHub Actions CI Status](https://github.com/dann-huang/letsGo/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/dann-huang/letsGo/actions/workflows/pr-checks.yml) -[![GitHub Actions Deployment Status](https://github.com/dann-huang/letsGo/actions/workflows/build-n-deploy.yml/badge.svg)](https://github.com/dann-huang/letsGo/actions/workflows/build-n-deploy.yml) +[![GitHub Actions CI Status](https://github.com/dann-huang/gonext/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/dann-huang/gonext/actions/workflows/pr-checks.yml) +[![GitHub Actions Deployment Status](https://github.com/dann-huang/gonext/actions/workflows/build-n-deploy.yml/badge.svg)](https://github.com/dann-huang/gonext/actions/workflows/build-n-deploy.yml) -LetsGo is a personal project and portfolio piece showcasing a full-stack web application designed for interactive, real-time experiences. It highlights expertise in modern backend (Go), frontend (Next.js), and robust containerized deployment with Docker, Nginx, PostgreSQL, and Redis. +GoNext is a personal project and portfolio piece showcasing a full-stack web application designed for interactive, real-time experiences. It highlights expertise in modern backend (Go), frontend (Next.js), and robust containerized deployment with Docker, Nginx, PostgreSQL, and Redis. **Live Demo:** [https://daniel-h.ca](https://daniel-h.ca) (If actively deployed and available) @@ -31,8 +31,12 @@ This project emphasizes real-time interactivity and robust architecture, demonst * **Full-Stack Architecture:** A high-performance Go backend seamlessly integrated with a modern, reactive Next.js and TypeScript frontend. * **Real-time Communication:** Custom WebSocket implementations power all interactive features. -* **Live Chat:** Instant, real-time text communication between users. +* **Text Chat:** Instant, real-time text communication between users. +* **Video Chat:** Integrating WebRTC for peer-to-peer video communication. * **Collaborative Drawing Canvas:** A shared, interactive canvas where multiple users can draw together in real-time. +* **Singleplayer Games:** Currently just minesweeper, but more to come! +* **Multiplayer Games:** Implementing classic real-time board games like Chess or Connect Four. + * **User Authentication:** Secure user registration and login using JSON Web Tokens (JWT). * **Containerized Environment:** The entire application stack (frontend, backend, database, cache, proxy) is orchestrated using Docker Compose for consistent local development and production environments. * **Automated Deployment:** Features a robust CI/CD pipeline for automatic deployment to `daniel-h.ca`. @@ -40,12 +44,11 @@ This project emphasizes real-time interactivity and robust architecture, demonst --- ## Project Roadmap +GoNext is a continually evolving personal project. Planned enhancements include: -LetsGo is a continually evolving personal project. Planned enhancements include: - -* **Multiplayer Board Games:** Implementing classic real-time board games like Chess or Connect Four. -* **Video Chat:** Integrating WebRTC for peer-to-peer video communication. -* **Expanded User Profiles & Social Features:** Building out more personalized user experiences. +* **More single player games:** Planning Sudoku incl. solver and variants +* **More multipleayer games:** Planning card and board games, once I bite the bullet and get the UX sorted out. +* **User Features:** Persistant user profiles, friend lists, more direct ways to play with them etc. --- @@ -78,7 +81,7 @@ LetsGo is a continually evolving personal project. Planned enhancements include: ## Architecture -LetsGo employs a fully containerized, microservices-oriented architecture. Nginx serves as the entry point, routing requests to the Next.js frontend or the Go backend. The Go backend interacts with PostgreSQL for persistent data and Redis for real-time messaging and caching. All components are defined and orchestrated via Docker Compose. +GoNext employs a fully containerized, microservices-oriented architecture. Nginx serves as the entry point, routing requests to the Next.js frontend or the Go backend. The Go backend interacts with PostgreSQL for persistent data and Redis for real-time messaging and caching. All components are defined and orchestrated via Docker Compose. --- @@ -96,8 +99,8 @@ This section provides instructions for setting up the project for local developm 1. **Clone the repository:** ```sh - git clone https://github.com/dann-huang/letsGo.git - cd letsGo + git clone https://github.com/dann-huang/gonext.git + cd gonext ``` 2. **Run the application:** @@ -105,7 +108,7 @@ This section provides instructions for setting up the project for local developm docker-compose up --build ``` -This will build the Docker images and start all services. The frontend will be available at `http://localhost:3000`. +This will build the Docker images and start all services. The frontend will be available at `http://localhost`. --- diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go deleted file mode 100644 index 03f0608..0000000 --- a/backend/cmd/migrate/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package setupdb - -import ( - "database/sql" - "fmt" - "letsgo/internal/db" - "log" - - "letsgo/internal/config" - - _ "github.com/lib/pq" // PostgreSQL driver -) - -func RunInitialSchema(db *sql.DB) error { - const createTableSQL = ` - CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, - displayname VARCHAR(255) NOT NULL DEFAULT '', - passhash VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() - );` - - log.Println("Applying initial schema...") - _, err := db.Exec(createTableSQL) - if err != nil { - return fmt.Errorf("failed to create users table: %w", err) - } - log.Println("Users table created or already exists.") - return nil -} - -//nolint:unused -func main() { - appConfig, err := config.Load() - if err != nil { - panic(err) - } - db, _, err := db.Open(appConfig.DB) - if err != nil { - log.Fatalf("Error opening database: %v", err) - } - defer db.Close() - - if err = db.Ping(); err != nil { - log.Fatalf("Error connecting to database: %v", err) - } - - if err := RunInitialSchema(db); err != nil { - log.Fatalf("Error running initial schema: %v", err) - } - log.Println("Database setup complete.") -} diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 4f976fb..4c61377 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -5,16 +5,18 @@ import ( "net/http" "os" - "letsgo/internal/auth" - "letsgo/internal/config" - "letsgo/internal/db" - "letsgo/internal/external" - "letsgo/internal/game" - "letsgo/internal/live" - "letsgo/internal/mdw" - "letsgo/internal/repo" - "letsgo/internal/token" - "letsgo/pkg/jwt/v2" + "gonext/internal/auth" + "gonext/internal/config" + "gonext/internal/db" + "gonext/internal/external" + "gonext/internal/game" + "gonext/internal/live" + "gonext/internal/mail" + "gonext/internal/mdw" + "gonext/internal/repo" + "gonext/internal/token" + "gonext/pkg/jwt/v2" + "gonext/pkg/util/httputil" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -35,25 +37,38 @@ func main() { panic(err) } defer postgres.Close() - store := repo.NewStore(postgres, redis) + mailer := mail.NewResendMailer(appCfg.Mail) + validator := httputil.NewValidator() - accessManager, err := jwt.NewManager(appCfg.Auth.AccSecret, - appCfg.Auth.AccTTL, appCfg.Auth.Issuer, appCfg.Auth.Audience, token.UserPayload{}) + accessManager, err := jwt.NewManager( + appCfg.Auth.AccSecret, + appCfg.Auth.AccTTL, + appCfg.Auth.Issuer, + appCfg.Auth.Audience, + token.UserPayload{}, + ) if err != nil { panic(err) } - authModule := auth.NewModule(store.User, store.KVStore, accessManager, appCfg.Auth) - - const userCtxKey mdw.ContextKey = "userPayload" - userAccMdw := mdw.AccessMdw(accessManager, appCfg.Auth.AccCookieName, appCfg.Auth.AccTTL, userCtxKey) + kvMngr := token.NewRedisKVMngr(store.KVStore, appCfg.Token) + authMdw := mdw.AccessMdw(accessManager, appCfg.Auth.AccCookieName, appCfg.Auth.AccTTL) + authModule := auth.NewModule( + store.User, + kvMngr, + accessManager, + mailer, + appCfg.Auth, + authMdw, + validator, + ) r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) - gameReg := game.NewRegistry() - gameReg.RegisterAll() + gameRegistry := game.NewRegistry() + gameRegistry.RegisterAll() r.Route("/api", func(api chi.Router) { api.Get("/health", func(w http.ResponseWriter, r *http.Request) { @@ -65,14 +80,18 @@ func main() { }) api.Mount("/auth", authModule.Router()) - api.Mount("/live", live.Router(userAccMdw, userCtxKey, gameReg, appCfg.WS)) + + api.Group(func(protected chi.Router) { + protected.Use(authMdw) + protected.Mount("/live", live.Router(gameRegistry, appCfg.WS)) + }) }) - // static pages r.Get("/stat/*", external.StaticPageHandler(appCfg.StaticPages)) - // frontend - // r.Mount("/", external.FrontendRevProxy(cfg.FrontendUrl)) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + httputil.RespondErr(w, http.StatusNotFound, "Route not found", nil) + }) println("---Server start---") if err := http.ListenAndServe(":3333", r); err != nil { diff --git a/backend/go.mod b/backend/go.mod index c65f265..8e87495 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module letsgo +module gonext go 1.24.3 @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.9.0 + github.com/resend/resend-go/v2 v2.21.0 golang.org/x/crypto v0.38.0 ) diff --git a/backend/go.sum b/backend/go.sum index 553c982..fbe25d5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM= github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/resend/resend-go/v2 v2.21.0 h1:8aZwFd5Mry5fcBXSuZYHyKhsbnQooj5+Q/ebyMtd3Rc= +github.com/resend/resend-go/v2 v2.21.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 4c5eef5..3e62d5f 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -1,175 +1,286 @@ package auth import ( - "encoding/json" "errors" - "letsgo/internal/config" - "letsgo/internal/model" - "letsgo/internal/repo" - "letsgo/pkg/util" + "gonext/internal/config" + "gonext/internal/mdw" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/pkg/util/httputil" "log/slog" "net/http" "time" - - "github.com/go-playground/validator/v10" ) type handler interface { indexHandler() http.HandlerFunc - registerHandler() http.HandlerFunc - loginHandler() http.HandlerFunc + guestHandler() http.HandlerFunc logoutHandler() http.HandlerFunc refreshHandler() http.HandlerFunc -} -func newHandler(service service, config *config.Auth) handler { - return &handlerImpl{ - service: service, - config: config, - } + setEmailHandler() http.HandlerFunc + verifyEmailHandler() http.HandlerFunc + reqPassHandler() http.HandlerFunc + setPassHandler() http.HandlerFunc + + emailCodeHandler() http.HandlerFunc + emailLoginHandler() http.HandlerFunc + passLoginHandler() http.HandlerFunc } type handlerImpl struct { - service service - config *config.Auth + service service + cfg *config.Auth + validator *httputil.Validator +} + +func newHandler(service service, config *config.Auth, validator *httputil.Validator) handler { + return &handlerImpl{ + service: service, + cfg: config, + validator: validator, + } } func (h *handlerImpl) indexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := util.RespondJSON(w, http.StatusOK, map[string]string{"message": "Auth is running"}); err != nil { - slog.Error("failed to write health response", "error", err) - } + httputil.RespondJSON(w, http.StatusOK, map[string]string{"message": "Auth is running"}) } } -func (h *handlerImpl) setAuthCookie(w http.ResponseWriter, name, value, path string, expires time.Time) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: value, - // Quoted, - Path: path, - // Domain: h.config.Domain, // not needed when share domains apparently - Expires: expires, - // RawExpires, - // MaxAge, - Secure: false, - HttpOnly: true, - // SameSite: http.SameSiteNoneMode, - // Partitioned, - // Raw, - // Unparsed, +func (h *handlerImpl) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) time.Time { + httputil.SetAuthCookie( + w, + h.cfg.RefCookieName, + refreshToken, + "/api/auth/refresh", + time.Now().Add(h.cfg.RefTTL), + ) + expires := time.Now().Add(h.cfg.AccTTL) + httputil.SetAuthCookie( + w, + h.cfg.AccCookieName, + accessToken, + "/", + expires, + ) + return expires +} + +func (h *handlerImpl) authResponse(w http.ResponseWriter, code int, user *model.User, expiresAt time.Time) { + httputil.RespondJSON(w, code, &authRes{ + User: &userInfo{ + Username: user.Username, + DisplayName: user.DisplayName, + AccountType: string(user.AccountType), + }, + AccessExp: expiresAt.UnixMilli(), }) } -func (h *handlerImpl) registerHandler() http.HandlerFunc { +func (h *handlerImpl) guestHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var params model.UserReq - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Bad request", err) + var req guestReq + if !h.validator.DecodeValidate(w, r, &req) { return } - if err := validator.New(validator.WithRequiredStructEnabled()).Struct(params); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Missing required fields", err) + result, err := h.service.createGuest(r.Context(), req.Name) + if err != nil { + httputil.RespondErr(w, http.StatusInternalServerError, "Something went wrong; please try again.", err) return } - usr, err := h.service.createUser(r.Context(), params.Username, params.Password) + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusCreated, result.user, expires) + } +} + +func (h *handlerImpl) logoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("refresh_token") if err != nil { - if errors.Is(err, repo.ErrAlreadyExists) { - util.RespondErr(w, http.StatusConflict, "Username taken", nil) - } else { - util.RespondErr(w, http.StatusInternalServerError, "Something went wrong", err) - } + httputil.RespondErr(w, http.StatusBadRequest, "No refresh token provided", nil) return } - if err := util.RespondJSON(w, http.StatusOK, map[string]string{ - "message": "Success", - "username": usr.Username, - "displayname": usr.DisplayName, - }); err != nil { - slog.Error("failed to write register response", "error", err) + + if err := h.service.logoutUser(r.Context(), cookie.Value); err != nil { + slog.Error("failed to logout user", "error", err) + httputil.RespondErr(w, http.StatusInternalServerError, "Failed to logout", nil) + return } + + httputil.SetAuthCookie(w, "access_token", "", "/", time.Unix(0, 0)) + httputil.SetAuthCookie(w, "refresh_token", "", "/auth/refresh", time.Unix(0, 0)) + + httputil.RespondJSON(w, http.StatusOK, map[string]string{"message": "Successfully logged out"}) } } -func (h *handlerImpl) loginHandler() http.HandlerFunc { +func (h *handlerImpl) refreshHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req model.UserReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Invalid request", nil) + cookie, err := r.Cookie("refresh_token") + if err != nil { + httputil.RespondErr(w, http.StatusBadRequest, "No refresh token provided", nil) + return } - usr, access, refresh, err := h.service.loginUser(r.Context(), req.Username, req.Password) + result, err := h.service.refreshUser(r.Context(), cookie.Value) if err != nil { - if errors.Is(err, repo.ErrNotFound) { - util.RespondErr(w, http.StatusUnauthorized, "Credentials not found", nil) - } else { - util.RespondErr(w, http.StatusInternalServerError, "Something went wrong", err) - } + slog.Error("failed to refresh token", "error", err) + httputil.RespondErr(w, http.StatusUnauthorized, "Invalid refresh token", nil) return } - accExpire := time.Now().Add(h.config.AccTTL) - refExpire := time.Now().Add(h.config.RefTTL) - h.setAuthCookie(w, h.config.AccCookieName, access, "/", accExpire) - h.setAuthCookie(w, h.config.RefCookieName, refresh, "/api/auth", refExpire) + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusOK, result.user, expires) + } +} - if err := util.RespondJSON(w, http.StatusOK, map[string]any{ - "message": "login success", - "username": usr.Username, - "displayname": usr.DisplayName, - "accessExpires": accExpire.UnixMilli(), - "refreshExpires": refExpire.UnixMilli(), - }); err != nil { - slog.Error("failed to write login response", "error", err) +func (h *handlerImpl) setEmailHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := mdw.GetUser(r.Context()) + if user == nil { + httputil.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) + return + } + var req setEmailReq + if !h.validator.DecodeValidate(w, r, &req) { + return + } + if err := h.service.setupEmail(r.Context(), user, req.Email); err != nil { + slog.Error("failed to initiate upgrade", "error", err) + httputil.RespondErr(w, http.StatusInternalServerError, "Failed to initiate upgrade", nil) + return } + httputil.RespondJSON(w, http.StatusOK, map[string]string{ + "message": "email may be sent", + }) } } -func (h *handlerImpl) logoutHandler() http.HandlerFunc { +func (h *handlerImpl) verifyEmailHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(h.config.RefCookieName) - if err == nil { - if err := h.service.logoutUser(r.Context(), refreshCookie.Value); err != nil { - slog.Error("failed to logout user", "error", err) + user := mdw.GetUser(r.Context()) + if user == nil { + httputil.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) + return + } + var req verifyEmailReq + if !h.validator.DecodeValidate(w, r, &req) { + return + } + result, err := h.service.verifyEmail(r.Context(), user, req.Code) + if err != nil { + if errors.Is(err, ErrInvalidCode) { + httputil.RespondErr(w, http.StatusBadRequest, "Invalid or expired verification code", nil) + } else if errors.Is(err, ErrCollision) { + httputil.RespondErr(w, http.StatusBadRequest, "Email is already in use by another account", nil) + } else if errors.Is(err, ErrUsernameCollision) { + httputil.RespondErr(w, http.StatusBadRequest, "Username is already in use by another account", nil) + } else { + httputil.RespondErr(w, http.StatusInternalServerError, "Failed to verify email", err) } - h.setAuthCookie(w, h.config.AccCookieName, "", "/", time.Unix(0, 0)) - h.setAuthCookie(w, h.config.RefCookieName, "", "/api/auth", time.Unix(0, 0)) - } else { - slog.Error(err.Error()) + return } - if err := util.RespondJSON(w, http.StatusOK, map[string]string{"message": "logout success"}); err != nil { - slog.Error("failed to write logout response", "error", err) + + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusOK, result.user, expires) + } +} + +func (h *handlerImpl) reqPassHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := mdw.GetUser(r.Context()) + if user == nil { + httputil.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) + return } + + if err := h.service.reqPassCode(r.Context(), user); err != nil { + slog.Error("failed to set password", "error", err) + switch err.Error() { + case "current password is required": + httputil.RespondErr(w, http.StatusBadRequest, err.Error(), nil) + case "invalid current password": + httputil.RespondErr(w, http.StatusUnauthorized, err.Error(), nil) + default: + httputil.RespondErr(w, http.StatusInternalServerError, "Failed to set password", nil) + } + return + } + + httputil.RespondJSON(w, http.StatusOK, map[string]string{ + "message": "Password updated successfully", + }) } } -func (h *handlerImpl) refreshHandler() http.HandlerFunc { +func (h *handlerImpl) setPassHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(h.config.RefCookieName) - if err != nil { - util.RespondErr(w, http.StatusUnauthorized, "No Refresh Token", nil) + user := mdw.GetUser(r.Context()) + if user == nil { + httputil.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) return } - access, refresh, err := h.service.refreshUser(r.Context(), refreshCookie.Value) + var req passReq + if !h.validator.DecodeValidate(w, r, &req) { + return + } + result, err := h.service.setPassword(r.Context(), user, req.Code, req.Pass) if err != nil { - slog.Error(err.Error()) + httputil.RespondErr(w, http.StatusUnauthorized, "Invalid password", nil) + return + } + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusOK, result.user, expires) + } +} + +func (h *handlerImpl) emailCodeHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req setEmailReq + if !h.validator.DecodeValidate(w, r, &req) { + return + } + + if err := h.service.sendEmailCode(r.Context(), req.Email); err != nil && !errors.Is(err, repo.ErrAlreadyExists) { + slog.Error("failed to send email code", "error", err, "email", req.Email) } - if access == "" { - util.RespondErr(w, http.StatusInternalServerError, "Refresh failed", nil) + httputil.RespondJSON(w, http.StatusOK, map[string]string{"message": "Email may be sent"}) + } +} + +func (h *handlerImpl) emailLoginHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req emailLoginReq + if !h.validator.DecodeValidate(w, r, &req) { + slog.Info("email login validation failed", "email", req.Email) return } - accExpire := time.Now().Add(h.config.AccTTL) - refExpire := time.Now().Add(h.config.RefTTL) - h.setAuthCookie(w, h.config.AccCookieName, access, "/", accExpire) - h.setAuthCookie(w, h.config.RefCookieName, refresh, "/api/auth", refExpire) + result, err := h.service.emailLogin(r.Context(), req.Email, req.Code) + if err != nil { + slog.Info("email code login failed", "email", req.Email, "error", err) + httputil.RespondErr(w, http.StatusUnauthorized, "Invalid or expired code", nil) + return + } + + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusOK, result.user, expires) + } +} - if err := util.RespondJSON(w, http.StatusOK, map[string]any{ - "message": "refresh success", - "accessExpires": accExpire.UnixMilli(), - "refreshExpires": refExpire.UnixMilli(), - }); err != nil { - slog.Error("failed to write refresh response", "error", err) +func (h *handlerImpl) passLoginHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req passLoginReq + if !h.validator.DecodeValidate(w, r, &req) { + return + } + result, err := h.service.passwordLogin(r.Context(), req.Email, req.Pass) + if err != nil { + httputil.RespondErr(w, http.StatusUnauthorized, "Invalid email or password", nil) + return } + expires := h.setAuthCookies(w, result.access, result.refresh) + h.authResponse(w, http.StatusOK, result.user, expires) } } diff --git a/backend/internal/auth/module.go b/backend/internal/auth/module.go index e8d993c..bfdb1cc 100644 --- a/backend/internal/auth/module.go +++ b/backend/internal/auth/module.go @@ -3,9 +3,13 @@ package auth import ( "github.com/go-chi/chi/v5" - "letsgo/internal/config" - "letsgo/internal/repo" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/mail" + "gonext/internal/mdw" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/internal/token" + "gonext/pkg/util/httputil" ) type AuthModule interface { @@ -16,14 +20,28 @@ type authImpl struct { router chi.Router } -func NewModule(userRepo repo.UserRepo, kvStore repo.KVStore, accMngr token.UserManager, config *config.Auth) AuthModule { - service := newService(accMngr, userRepo, kvStore, config) - handler := newHandler(service, config) - router := newRouter(handler) +func NewModule( + userRepo repo.UserRepo, + kvMngr token.KVManager, + accMngr token.UserManager, + mailer mail.Mailer, + config *config.Auth, + authMdw mdw.Middleware, + validator *httputil.Validator, +) AuthModule { + service := newService(accMngr, userRepo, kvMngr, mailer, config) + handler := newHandler(service, config, validator) + router := newRouter(handler, authMdw) return &authImpl{router: router} } func (m *authImpl) Router() chi.Router { return m.router } + +type authResult struct { + access string + refresh string + user *model.User +} diff --git a/backend/internal/auth/router.go b/backend/internal/auth/router.go index c870b0f..59a52b6 100644 --- a/backend/internal/auth/router.go +++ b/backend/internal/auth/router.go @@ -1,17 +1,38 @@ package auth import ( + "net/http" + "github.com/go-chi/chi/v5" ) -func newRouter(h handler) chi.Router { +type Middleware func(http.Handler) http.Handler + +func newRouter(h handler, authMdw Middleware) chi.Router { r := chi.NewRouter() r.Get("/", h.indexHandler()) - r.Post("/register", h.registerHandler()) - r.Post("/login", h.loginHandler()) + r.Post("/guest", h.guestHandler()) r.Post("/logout", h.logoutHandler()) r.Post("/refresh", h.refreshHandler()) + r.Group(func(r chi.Router) { + r.Use(authMdw) + r.Route("/email", func(r chi.Router) { + r.Post("/setup", h.setEmailHandler()) + r.Post("/verify", h.verifyEmailHandler()) + }) + r.Route("/pass", func(r chi.Router) { + r.Post("/request", h.reqPassHandler()) + r.Post("/set", h.setPassHandler()) + }) + }) + + r.Route("/login", func(r chi.Router) { + r.Post("/getCode", h.emailCodeHandler()) + r.Post("/useCode", h.emailLoginHandler()) + r.Post("/password", h.passLoginHandler()) + }) + return r } diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 8be46d0..c9db097 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -2,135 +2,264 @@ package auth import ( "context" + "errors" "fmt" - "letsgo/internal/config" - "letsgo/internal/model" - "letsgo/internal/repo" - "letsgo/internal/token" - "letsgo/pkg/util" + "gonext/internal/config" + "gonext/internal/mail" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/internal/token" + "log/slog" + "math/rand" + "strings" - "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCode = errors.New("invalid or expired verification code") + ErrCollision = errors.New("email is already set") + ErrUsernameCollision = errors.New("username is already set") ) type service interface { - createUser(ctx context.Context, username string, password string) (*model.User, error) - loginUser(ctx context.Context, username, password string) (*model.User, string, string, error) + createGuest(ctx context.Context, displayName string) (*authResult, error) logoutUser(ctx context.Context, refreshToken string) error - refreshUser(ctx context.Context, refreshToken string) (string, string, error) + refreshUser(ctx context.Context, refreshToken string) (*authResult, error) + + setupEmail(ctx context.Context, userToken *token.UserPayload, email string) error + verifyEmail(ctx context.Context, userToken *token.UserPayload, code string) (*authResult, error) + reqPassCode(ctx context.Context, userToken *token.UserPayload) error + setPassword(ctx context.Context, userToken *token.UserPayload, code string, password string) (*authResult, error) + + sendEmailCode(ctx context.Context, email string) error + emailLogin(ctx context.Context, email, code string) (*authResult, error) + passwordLogin(ctx context.Context, email, password string) (*authResult, error) } type serviceImpl struct { - accTokenManager token.UserManager - repo repo.UserRepo - kv repo.KVStore - config *config.Auth + accessMngr token.UserManager + repo repo.UserRepo + kvMngr token.KVManager + mailer mail.Mailer + cfg *config.Auth } -func newService(accMngr token.UserManager, repo repo.UserRepo, kv repo.KVStore, config *config.Auth) service { - return &serviceImpl{ - accTokenManager: accMngr, - repo: repo, - kv: kv, - config: config, +func (s *serviceImpl) hashPass(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) } + return string(hash), nil } -func (s *serviceImpl) createUser(ctx context.Context, username string, password string) (*model.User, error) { - passhash, err := util.PasswordHash(password) +func (s *serviceImpl) verifyPass(hashedPassword, password string) bool { + if hashedPassword == "" { + return false + } + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} + +func (s *serviceImpl) loginUser(ctx context.Context, user *model.User) (*authResult, error) { + userToken := &token.UserPayload{ + UserID: user.ID, + Username: user.Username, + Displayname: user.DisplayName, + AccountType: user.AccountType, + Email: user.Email, + } + accessToken, err := s.accessMngr.GenerateToken(userToken) if err != nil { - return nil, fmt.Errorf("service: create user failed: %w", err) + return nil, fmt.Errorf("failed to generate access token: %w", err) } - user, err := s.repo.CreateUser(ctx, &model.User{ - Username: username, - DisplayName: username, - PassHash: passhash, - }) + refreshToken, err := s.kvMngr.SetRefToken(ctx, user.ID) if err != nil { - return nil, fmt.Errorf("service: create user failed : %w", err) + return nil, fmt.Errorf("failed to create refresh token: %w", err) } - return user, nil + + if err := s.repo.UpdateLastLogin(ctx, user.ID); err != nil { + slog.Error("failed to update last login", "error", err) + } + + return &authResult{ + access: accessToken, + refresh: refreshToken, + user: user, + }, nil } -func (s *serviceImpl) loginUser(ctx context.Context, username, password string) (*model.User, string, string, error) { - user, err := s.repo.ReadUser(ctx, username) - if err != nil { - return nil, "", "", fmt.Errorf("service: login user failed: %w", err) +const alphaNum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func (s *serviceImpl) randGuestName() string { + builder := strings.Builder{} + builder.Grow(14) + builder.WriteString("guest-") + for range 8 { + builder.WriteByte(alphaNum[rand.Intn(len(alphaNum))]) + } + return builder.String() +} + +func newService(accessManager token.UserManager, repo repo.UserRepo, kvMngr token.KVManager, mailer mail.Mailer, config *config.Auth) service { + return &serviceImpl{ + accessMngr: accessManager, + repo: repo, + kvMngr: kvMngr, + mailer: mailer, + cfg: config, } - if !util.PasswordCheck(password, user.PassHash) { - return nil, "", "", repo.ErrNotFound +} + +func (s *serviceImpl) createGuest(ctx context.Context, displayName string) (*authResult, error) { + var user *model.User + var err error + for range 5 { + user, err = s.repo.CreateUser(ctx, &model.User{ + Username: s.randGuestName(), + DisplayName: displayName, + AccountType: model.AccountTypeGuest, + }) + if err != nil && !errors.Is(err, repo.ErrUsernameExists) { + return nil, fmt.Errorf("failed to create guest user: %w", err) + } + if user != nil { + break + } } + return s.loginUser(ctx, user) +} - accessToken, err := s.accTokenManager.GenerateToken(token.NewUserPayload(user.Username, user.DisplayName)) +func (s *serviceImpl) setupEmail(ctx context.Context, userToken *token.UserPayload, email string) error { + token, err := s.kvMngr.SetEmailSetupToken(ctx, userToken.UserID, email) if err != nil { - return nil, "", "", fmt.Errorf("service: set access token failed: %w", err) + return fmt.Errorf("failed to store verification code: %w", err) } - refreshToken := uuid.New().String() - if err := s.setRefreshToken(ctx, username, refreshToken); err != nil { - return nil, "", "", err + if err := s.mailer.VerificationEmail(email, userToken.Displayname, token); err != nil { + return fmt.Errorf("failed to send verification email: %w", err) } - - return user, accessToken, refreshToken, nil + return nil } -func (s *serviceImpl) logoutUser(ctx context.Context, refreshToken string) error { - username, err := s.kv.Get(ctx, refreshToken) +func (s *serviceImpl) verifyEmail(ctx context.Context, userToken *token.UserPayload, code string) (*authResult, error) { + email, err := s.kvMngr.UseEmailSetupToken(ctx, userToken.UserID, code) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, ErrInvalidCode + } + return nil, fmt.Errorf("failed to verify code: %w", err) + } + update := model.UserUpdate{ + Email: &email, + } + if userToken.AccountType == model.AccountTypeGuest { + newType := model.AccountTypeUser + update.AccountType = &newType + } + user, err := s.repo.UpdateUser(ctx, userToken.UserID, &update) if err != nil { - return fmt.Errorf("service: logout without finding refresh token: %w", err) + if errors.Is(err, repo.ErrEmailExists) { + return nil, ErrCollision + } + return nil, fmt.Errorf("failed to upgrade user: %w", err) } - return s.unsetRefreshToken(ctx, username, refreshToken) + return s.loginUser(ctx, user) } -func (s *serviceImpl) refreshUser(ctx context.Context, refreshToken string) (string, string, error) { - username, err := s.kv.Get(ctx, refreshToken) - if err != nil { - return "", "", fmt.Errorf("service: no refresh token found: %w", err) +func (s *serviceImpl) reqPassCode(ctx context.Context, userToken *token.UserPayload) error { + if userToken.Email == nil { + return errors.New("email not set") } - user, err := s.repo.ReadUser(ctx, username) + tokenStr, err := s.kvMngr.SetMailToken(ctx, token.PurposePasswordReset, userToken.UserID) if err != nil { - return "", "", fmt.Errorf("service: read user failed: %w", err) + return fmt.Errorf("failed to store verification code: %w", err) + } + if err := s.mailer.SendPasswordCode(*userToken.Email, userToken.Displayname, tokenStr); err != nil { + return fmt.Errorf("failed to send verification email: %w", err) } + return nil +} - newRefToken := uuid.New().String() - if err := s.setRefreshToken(ctx, username, newRefToken); err != nil { - return "", "", err +func (s *serviceImpl) setPassword(ctx context.Context, userToken *token.UserPayload, code string, password string) (*authResult, error) { + if userToken.Email == nil { + return nil, errors.New("email not set") } - accessToken, err := s.accTokenManager.GenerateToken(token.NewUserPayload(user.Username, user.DisplayName)) + _, err := s.kvMngr.UseMailToken(ctx, token.PurposePasswordReset, code) if err != nil { - return "", "", fmt.Errorf("service: set access token failed: %w", err) + return nil, ErrInvalidCode } + hashedPassword, err := s.hashPass(password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + user, err := s.repo.UpdateUser(ctx, userToken.UserID, &model.UserUpdate{ + PassHash: &hashedPassword, + }) + if err != nil { + return nil, fmt.Errorf("can't update password: %w", err) + } + return s.loginUser(ctx, user) +} - if err := s.unsetRefreshToken(ctx, username, refreshToken); err != nil { - return accessToken, newRefToken, err +func (s *serviceImpl) logoutUser(ctx context.Context, refreshToken string) error { + _, err := s.kvMngr.UseRefToken(ctx, refreshToken) + return err +} + +func (s *serviceImpl) refreshUser(ctx context.Context, refreshToken string) (*authResult, error) { + userID, err := s.kvMngr.UseRefToken(ctx, refreshToken) + if err != nil { + return nil, fmt.Errorf("invalid refresh token: %w", err) } - return accessToken, newRefToken, nil + user, err := s.repo.ReadUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("can't refresh user: %w", err) + } + return s.loginUser(ctx, user) } -func (s *serviceImpl) setRefreshToken(ctx context.Context, username, token string) error { - if err := s.kv.Set(ctx, token, username, s.config.RefTTL); err != nil { - return fmt.Errorf("service: set refresh token failed: %w", err) +func (s *serviceImpl) sendEmailCode(ctx context.Context, email string) error { + user, err := s.repo.ReadUserByEmail(ctx, email) + if err != nil { + return err } - if err := s.kv.ListAdd(ctx, fmt.Sprintf(s.config.RefStoredFormat, - username), token, s.config.RefTTL); err != nil { - return fmt.Errorf("service: add refresh token to list failed: %w", err) + if user != nil { + return ErrCollision } - if err := s.kv.ListTrim(ctx, fmt.Sprintf(s.config.RefStoredFormat, - username), s.config.RefTTL); err != nil { - return fmt.Errorf("service: trim refresh token list failed: %w", err) + token, err := s.kvMngr.SetMailToken(ctx, token.PurposeEmailLogin, email) + if err != nil { + return fmt.Errorf("failed to store code: %w", err) + } + err = s.mailer.SendLoginCode(email, email, token) + if err != nil { + return fmt.Errorf("failed to send code: %w", err) } return nil } -func (s *serviceImpl) unsetRefreshToken(ctx context.Context, username, token string) error { - var errList []error - if err := s.kv.Del(ctx, token); err != nil { - errList = append(errList, fmt.Errorf("service: refresh token delete failed: %w", err)) +func (s *serviceImpl) emailLogin(ctx context.Context, email, code string) (*authResult, error) { + _, err := s.kvMngr.UseMailToken(ctx, token.PurposeEmailLogin, code) + if err != nil { + return nil, ErrInvalidCode + } + user, err := s.repo.ReadUserByEmail(ctx, email) + if err != nil { + return nil, err + } + return s.loginUser(ctx, user) +} + +func (s *serviceImpl) passwordLogin(ctx context.Context, email, password string) (*authResult, error) { + user, err := s.repo.ReadUserByEmail(ctx, email) + if err != nil { + return nil, err } - if err := s.kv.ListDel(ctx, fmt.Sprintf(s.config.RefStoredFormat, username), - token); err != nil { - errList = append(errList, fmt.Errorf("service: refresh list delete failed: %w", err)) + + if user.PassHash == nil { + return nil, fmt.Errorf("no password") } - if len(errList) > 0 { - return fmt.Errorf("service: unset refresh token errors: %v", errList) + if !s.verifyPass(*user.PassHash, password) { + return nil, fmt.Errorf("bad password") } - return nil + return s.loginUser(ctx, user) } diff --git a/backend/internal/auth/types.go b/backend/internal/auth/types.go new file mode 100644 index 0000000..cc13ce1 --- /dev/null +++ b/backend/internal/auth/types.go @@ -0,0 +1,111 @@ +package auth + +import ( + "github.com/go-playground/validator/v10" +) + +type userInfo struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + AccountType string `json:"accountType"` +} + +type authRes struct { + User *userInfo `json:"user"` + AccessExp int64 `json:"accessExp"` +} + +type guestReq struct { + Name string `json:"name" validate:"required,alphanum,min=5,max=30"` +} + +func (r guestReq) ErrMsg(err error) string { + if validationErrs, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrs { + switch e.Tag() { + case "required": + return "This field is required" + case "alphanum": + return "Must contain only letters and numbers" + case "min": + return "Must be at least 5 characters" + case "max": + return "Cannot be longer than 30 characters" + } + } + } + return "" +} + +type setEmailReq struct { + Email string `json:"email" validate:"required,email"` +} + +func (r setEmailReq) ErrMsg(err error) string { + return "Doesn't look like an email" +} + +type verifyEmailReq struct { + Code string `json:"code" validate:"required,len=6"` +} + +func (r verifyEmailReq) ErrMsg(err error) string { + return "Code should be 6 digits" +} + +type emailLoginReq struct { + Email string `json:"email" validate:"required,email"` + Code string `json:"code" validate:"required,len=6"` +} + +func (r emailLoginReq) ErrMsg(err error) string { + if validationErrs, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrs { + if e.Field() == "Email" && e.Tag() == "email" { + return "Doesn't look like an email" + } + if e.Field() == "Code" && e.Tag() == "len" { + return "Code should be 6 digits" + } + } + } + return "Invalid email or verification code" +} + +type passReq struct { + Pass string `json:"pass" validate:"required,min=8"` + Code string `json:"code" validate:"required,len=6"` +} + +func (r passReq) ErrMsg(err error) string { + if validationErrs, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrs { + if e.Field() == "Pass" && e.Tag() == "min" { + return "Password must be at least 8 characters" + } + if e.Field() == "Code" && e.Tag() == "len" { + return "Code should be 6 digits" + } + } + } + return "Invalid password or verification code" +} + +type passLoginReq struct { + Email string `json:"email" validate:"required,email"` + Pass string `json:"pass" validate:"required"` +} + +func (r passLoginReq) ErrMsg(err error) string { + if validationErrs, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrs { + if e.Field() == "Email" && e.Tag() == "email" { + return "Doesn't look like an email" + } + if e.Field() == "Pass" && e.Tag() == "required" { + return "Password is required" + } + } + } + return "Invalid email or password" +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8ed47b6..9382f6f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,15 +7,18 @@ import ( ) type Auth struct { - AccCookieName string - AccSecret string - AccTTL time.Duration - RefCookieName string - RefStoredFormat string - RefTTL time.Duration - Issuer string - Audience string - Domain string + AccCookieName string + AccSecret string + AccTTL time.Duration + RefCookieName string + RefreshKey string + RefTTL time.Duration + Issuer string + Audience string + EmailCodeTTL time.Duration + EmailSetupKey string + EmailLoginKey string + EmailPassKey string } type DB struct { @@ -39,20 +42,25 @@ type WS struct { RecvBuffer int64 } -func (c *DB) ConnectionStrings() (string, string) { - pString := fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", - c.PostgresUser, c.PostgresPass, c.PostgresUrl, c.PostgresDB) - rString := fmt.Sprintf("redis://%s", c.RedisURL) - return pString, rString +type Mail struct { + MailKey string + MailFrom string +} + +type Token struct { + RefTTL time.Duration + EmailedCodeTTL time.Duration } type AppConfig struct { - Port string - FrontendUrl string + // Port string + // FrontendUrl string StaticPages string Auth *Auth DB *DB WS *WS + Mail *Mail + Token *Token } func Load() (*AppConfig, error) { @@ -60,38 +68,55 @@ func Load() (*AppConfig, error) { // Port: os.Getenv("GO_PORT"), // FrontendUrl: os.Getenv("FRONTEND"), StaticPages: "/app/static", - } - - cfg.Auth = &Auth{ - AccCookieName: "access_token", - AccSecret: os.Getenv("JWT_ACCESS_SECRET"), - AccTTL: 10 * time.Minute, - RefCookieName: "refresh_token", - RefStoredFormat: "refToken:%v", - RefTTL: 24 * time.Hour, - Issuer: "letsgo", - Audience: "AuthService", - Domain: os.Getenv("DOMAIN"), - } - cfg.DB = &DB{ - PostgresUrl: os.Getenv("POSTGRES_URL"), - PostgresUser: os.Getenv("POSTGRES_USER"), - PostgresPass: os.Getenv("POSTGRES_PASSWORD"), - PostgresDB: os.Getenv("POSTGRES_DB"), - RedisURL: os.Getenv("REDIS_URL"), - } - cfg.WS = &WS{ - ReadTimeout: 15 * time.Second, - PongTimeout: 10 * time.Second, - WriteTimeout: 5 * time.Second, - MaxMsgSize: 65536, // 64kb + Mail: &Mail{ + MailKey: os.Getenv("RESEND_KEY"), + MailFrom: os.Getenv("MAIL_FROM"), + }, + Auth: &Auth{ + AccCookieName: "access_token", + AccSecret: os.Getenv("JWT_ACCESS_SECRET"), + AccTTL: 10 * time.Minute, + RefCookieName: "refresh_token", + RefTTL: 24 * time.Hour, + Issuer: "gonext", + Audience: "AuthService", + EmailCodeTTL: 10 * time.Minute, - RegisterBuffer: 20, - RoomBuffer: 20, - MsgBuffer: 256, - SendBuffer: 64, - RecvBuffer: 64, + RefreshKey: "refToken:%v", + EmailSetupKey: "emailSetup:%v|%v", + EmailLoginKey: "emailLogin:%v", + EmailPassKey: "emailPass:%v", + }, + DB: &DB{ + PostgresUrl: os.Getenv("POSTGRES_URL"), + PostgresUser: os.Getenv("POSTGRES_USER"), + PostgresPass: os.Getenv("POSTGRES_PASSWORD"), + PostgresDB: os.Getenv("POSTGRES_DB"), + RedisURL: os.Getenv("REDIS_URL"), + }, + WS: &WS{ + ReadTimeout: 15 * time.Second, + PongTimeout: 10 * time.Second, + WriteTimeout: 5 * time.Second, + MaxMsgSize: 65536, // 64kb + RegisterBuffer: 20, + RoomBuffer: 20, + MsgBuffer: 256, + SendBuffer: 64, + RecvBuffer: 64, + }, + Token: &Token{ + RefTTL: 24 * time.Hour, + EmailedCodeTTL: 10 * time.Minute, + }, } return cfg, nil } + +func (c *DB) ConnectionStrings() (string, string) { + pString := fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.PostgresUser, c.PostgresPass, c.PostgresUrl, c.PostgresDB) + rString := fmt.Sprintf("redis://%s", c.RedisURL) + return pString, rString +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 5670b53..1a88b81 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" - "letsgo/internal/config" + "gonext/internal/config" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" diff --git a/backend/internal/live/client.go b/backend/internal/live/client.go index a1b5b3c..46fb6c7 100644 --- a/backend/internal/live/client.go +++ b/backend/internal/live/client.go @@ -3,8 +3,8 @@ package live import ( "context" "encoding/json" - "letsgo/internal/config" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/token" "log/slog" "time" @@ -19,21 +19,21 @@ type client struct { send chan []byte recv chan []byte room *room - token *token.UserPayload + user *token.UserPayload ctx context.Context cancel context.CancelFunc } -func newClient(h *hub, conn *websocket.Conn, token *token.UserPayload, cfg *config.WS) *client { +func newClient(h *hub, conn *websocket.Conn, user *token.UserPayload, cfg *config.WS) *client { ctx, cancel := context.WithCancel(context.Background()) return &client{ cfg: cfg, - ID: token.Username, + user: user, + ID: user.Username, hub: h, conn: conn, send: make(chan []byte, cfg.SendBuffer), recv: make(chan []byte, cfg.RecvBuffer), - token: token, ctx: ctx, cancel: cancel, room: nil, diff --git a/backend/internal/live/hub.go b/backend/internal/live/hub.go index e2ba83c..1bfb19b 100644 --- a/backend/internal/live/hub.go +++ b/backend/internal/live/hub.go @@ -1,8 +1,8 @@ package live import ( - "letsgo/internal/config" - "letsgo/internal/game" + "gonext/internal/config" + "gonext/internal/game" "log/slog" "time" ) diff --git a/backend/internal/live/message.go b/backend/internal/live/message.go index b16631a..78babdf 100644 --- a/backend/internal/live/message.go +++ b/backend/internal/live/message.go @@ -3,7 +3,7 @@ package live import ( "encoding/json" "errors" - "letsgo/internal/game" + "gonext/internal/game" "log/slog" ) diff --git a/backend/internal/live/room.go b/backend/internal/live/room.go index 1c346cd..918b27f 100644 --- a/backend/internal/live/room.go +++ b/backend/internal/live/room.go @@ -4,7 +4,7 @@ import ( "encoding/json" "sync" - "letsgo/internal/game" + "gonext/internal/game" ) const ( @@ -18,12 +18,12 @@ func (r *room) broadcastLocked(msg []byte) { } } -func (r *room) clientListMsgLocked() [][2]string { - clientList := make([][2]string, 0, len(r.clients)) +func (r *room) clientListMsgLocked() map[string]string { + clientMap := make(map[string]string, len(r.clients)) for client := range r.clients { - clientList = append(clientList, [2]string{client.ID, client.token.Displayname}) + clientMap[client.ID] = client.user.Displayname } - return clientList + return clientMap } func (r *room) addClient(client *client) { @@ -34,7 +34,7 @@ func (r *room) addClient(client *client) { client.room = r client.trySend(sendKeyVal(msgJoinRoom, "roomName", r.name)) - r.broadcastLocked(sendMessage(msgStatus, client.token.Displayname+" has joined "+r.name)) + r.broadcastLocked(sendMessage(msgStatus, client.user.Displayname+" has joined "+r.name)) r.broadcastLocked(sendKeyVal(msgGetClients, "clients", r.clientListMsgLocked())) } @@ -66,7 +66,7 @@ func (r *room) removeClient(client *client) { } delete(r.clients, client) - r.broadcastLocked(sendMessage(msgStatus, client.token.Displayname+" has left "+r.name)) + r.broadcastLocked(sendMessage(msgStatus, client.user.Displayname+" has left "+r.name)) r.broadcastLocked(sendKeyVal(msgGetClients, "clients", r.clientListMsgLocked())) } diff --git a/backend/internal/live/router.go b/backend/internal/live/router.go index 855e66f..e03aabf 100644 --- a/backend/internal/live/router.go +++ b/backend/internal/live/router.go @@ -1,10 +1,9 @@ package live import ( - "letsgo/internal/config" - "letsgo/internal/game" - "letsgo/internal/mdw" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/game" + "gonext/internal/mdw" "log/slog" "net/http" @@ -12,22 +11,23 @@ import ( "github.com/go-chi/chi/v5" ) -func Router(authMdw mdw.Middleware, ctxKey mdw.ContextKey, registry *game.Registry, cfg *config.WS) chi.Router { +func Router(registry *game.Registry, cfg *config.WS) chi.Router { hub := newhub(registry, cfg) go hub.run() r := chi.NewRouter() - r.Use(authMdw) r.Get("/", func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if err != nil { slog.Error("Failed to accept WebSocket connection.", "error", err) return } - user := r.Context().Value(ctxKey).(*token.UserPayload) - + user := mdw.GetUser(r.Context()) + if user == nil { + slog.Error("No user in context for WebSocket connection") + return + } hub.register <- newClient(hub, conn, user, hub.cfg) }) - return r } diff --git a/backend/internal/mail/mail.go b/backend/internal/mail/mail.go new file mode 100644 index 0000000..8150709 --- /dev/null +++ b/backend/internal/mail/mail.go @@ -0,0 +1,111 @@ +package mail + +import ( + "fmt" + + "gonext/internal/config" + + "github.com/resend/resend-go/v2" +) + +type Mailer interface { + VerificationEmail(email, name, code string) error + SendLoginCode(email, name, code string) error + SendPasswordCode(email, name, code string) error +} + +type resendMailer struct { + client *resend.Client + from string +} + +func NewResendMailer(cfg *config.Mail) Mailer { + client := resend.NewClient(cfg.MailKey) + return &resendMailer{ + client: client, + from: cfg.MailFrom, + } +} + +func (s *resendMailer) VerificationEmail(email, name, code string) error { + emailBody := fmt.Sprintf(` +

Verify Your Email

+

Hello %s,

+

Your verification code is: %s

+

This code will expire in 10 minutes.

+ `, name, code) + + if _, err := s.client.Emails.Send(&resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Verify Your Email", + Html: emailBody, + }); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (s *resendMailer) SendLoginCode(email, username, code string) error { + emailBody := fmt.Sprintf(` +

Login Code

+

Hello %s,

+

Your login code is: %s

+

This code will expire in 10 minutes.

+

If you didn't request this code, you can safely ignore this email.

+ `, username, code) + + if _, err := s.client.Emails.Send(&resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Your Login Code", + Html: emailBody, + }); err != nil { + return fmt.Errorf("failed to send login code email: %w", err) + } + + return nil +} + +func (s *resendMailer) SendPasswordCode(email, username, code string) error { + emailBody := fmt.Sprintf(` +

Password Code

+

Hello %s,

+

Your password setup code is: %s

+

This code will expire in 10 minutes.

+

If you didn't request this code, you can safely ignore this email.

+ `, username, code) + + if _, err := s.client.Emails.Send(&resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Your Password Code", + Html: emailBody, + }); err != nil { + return fmt.Errorf("failed to send password code email: %w", err) + } + + return nil +} + +type mockMailer struct{} + +func NewMockMailer() Mailer { + return &mockMailer{} +} + +func (m *mockMailer) VerificationEmail(email, name, code string) error { + fmt.Printf("***** Verification Code for %s<%s>: %s *****\n", name, email, code) + return nil +} + +func (m *mockMailer) SendLoginCode(email, username, code string) error { + fmt.Printf("***** Login Code for %s<%s>: %s *****\n", username, email, code) + return nil +} + +func (m *mockMailer) SendPasswordCode(email, username, code string) error { + fmt.Printf("***** Password Code for %s<%s>: %s *****\n", username, email, code) + return nil +} diff --git a/backend/internal/mdw/auth.go b/backend/internal/mdw/auth.go index 80e074e..1b915ab 100644 --- a/backend/internal/mdw/auth.go +++ b/backend/internal/mdw/auth.go @@ -2,7 +2,7 @@ package mdw import ( "context" - "letsgo/internal/token" + "gonext/internal/token" "net/http" "time" ) @@ -11,8 +11,9 @@ type ContextKey string type Middleware = func(http.Handler) http.Handler -func AccessMdw(jwt token.UserManager, cookieName string, - cookieTTL time.Duration, payloadKey ContextKey) Middleware { +const userCtxKey ContextKey = "user" + +func AccessMdw(mngr token.UserManager, cookieName string, cookieTTL time.Duration) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -21,28 +22,23 @@ func AccessMdw(jwt token.UserManager, cookieName string, http.Error(w, "Unauthorized - missing token", http.StatusUnauthorized) return } - payload, err := jwt.ValidateToken(cookie.Value) + payload, err := mngr.ValidateToken(cookie.Value) if err != nil { http.Error(w, "Unauthorized - invalid token", http.StatusUnauthorized) return } - // // rotate token. Maybe look into rotating every x uses? - // newToken, err := jwt.GenerateToken(payload) - // if err != nil { - // http.Error(w, "Internal Server Error - token rotation failed", http.StatusInternalServerError) - // return - // } - // http.SetCookie(w, &http.Cookie{ - // Name: cookieName, - // Value: newToken, - // Expires: time.Now().Add(cookieTTL), - // HttpOnly: true, - // Secure: true, - // }) - - ctx := context.WithValue(r.Context(), payloadKey, payload) + ctx := context.WithValue(r.Context(), userCtxKey, payload) + + // TODO: Implement token rotation next.ServeHTTP(w, r.WithContext(ctx)) }) } } + +func GetUser(ctx context.Context) *token.UserPayload { + if user, ok := ctx.Value(userCtxKey).(*token.UserPayload); ok { + return user + } + return nil +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 8cbcf5a..0da4860 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -1,24 +1,51 @@ package model import ( + "database/sql/driver" + "errors" "time" ) -type User struct { - ID int `db:"id"` - Username string `db:"username"` - DisplayName string `db:"displayname"` - PassHash string `db:"passhash"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` +type AccountType string + +const ( + AccountTypeGuest AccountType = "guest" + AccountTypeUser AccountType = "user" + AccountTypeAdmin AccountType = "admin" +) + +func (a *AccountType) Scan(value any) error { + if value == nil { + *a = AccountTypeGuest + return nil + } + if s, ok := value.([]byte); ok { + *a = AccountType(s) + return nil + } + return errors.New("failed to scan AccountType") } -type UserReq struct { - Username string `json:"username" validate:"required,alphanum,min=3,max=10"` - Password string `json:"password" validate:"required"` +func (a AccountType) Value() (driver.Value, error) { + return string(a), nil +} + +type User struct { + ID string `db:"id"` + Username string `db:"username"` + DisplayName string `db:"displayname"` + AccountType AccountType `db:"account_type"` + Email *string `db:"email"` + PassHash *string `db:"passhash"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + LastLoginAt time.Time `db:"last_login_at"` } type UserUpdate struct { - DisplayName *string `json:"displayName"` - Password *string `json:"password"` + Username *string + DisplayName *string + AccountType *AccountType + Email *string + PassHash *string } diff --git a/backend/internal/repo/userRepo.go b/backend/internal/repo/userRepo.go index 72de0a8..0b98824 100644 --- a/backend/internal/repo/userRepo.go +++ b/backend/internal/repo/userRepo.go @@ -3,18 +3,26 @@ package repo import ( "context" "database/sql" + "errors" "fmt" - "letsgo/internal/model" + "gonext/internal/model" "strings" "github.com/lib/pq" ) +var ( + ErrEmailExists = errors.New("email already exists") + ErrUsernameExists = errors.New("username already exists") +) + type UserRepo interface { CreateUser(ctx context.Context, user *model.User) (*model.User, error) - ReadUser(ctx context.Context, username string) (*model.User, error) - UpdateUser(ctx context.Context, username string, params *model.UserUpdate) (*model.User, error) - DeleteUser(ctx context.Context, username string) error + ReadUserByID(ctx context.Context, id string) (*model.User, error) + ReadUserByEmail(ctx context.Context, email string) (*model.User, error) + UpdateUser(ctx context.Context, id string, params *model.UserUpdate) (*model.User, error) + DeleteUser(ctx context.Context, id string) error + UpdateLastLogin(ctx context.Context, id string) error } func newUserRepo(db *sql.DB) UserRepo { @@ -27,16 +35,58 @@ type pgUserRepo struct { func (r *pgUserRepo) CreateUser(ctx context.Context, user *model.User) (*model.User, error) { var newUser model.User - query := `INSERT INTO users (username, displayname, passhash) VALUES ($1, $2, $3) RETURNING id, username, displayname, passhash` - if err := r.db.QueryRowContext(ctx, query, user.Username, user.DisplayName, user.PassHash).Scan( + query := ` + INSERT INTO users (username, displayname, email, passhash, account_type) + VALUES ($1, $2, $3, $4, $5::account_type_enum) + RETURNING id, username, displayname, email, passhash, account_type, + created_at, updated_at, last_login_at + ` + + err := r.db.QueryRowContext( + ctx, + query, + user.Username, + user.DisplayName, + sql.NullString{ + String: func() string { + if user.Email != nil { + return strings.ToLower(*user.Email) + } + return "" + }(), + Valid: user.Email != nil, + }, + sql.NullString{ + String: func() string { + if user.PassHash != nil { + return *user.PassHash + } + return "" + }(), + Valid: user.PassHash != nil, + }, + user.AccountType, + ).Scan( &newUser.ID, &newUser.Username, &newUser.DisplayName, + &newUser.Email, &newUser.PassHash, - ); err != nil { + &newUser.AccountType, + &newUser.CreatedAt, + &newUser.UpdatedAt, + &newUser.LastLoginAt, + ) + + if err != nil { if pqErr, ok := err.(*pq.Error); ok { - if pqErr.Code.Name() == "unique_violation" || pqErr.Code == "23505" { - err = ErrAlreadyExists + switch { + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "username"): + err = fmt.Errorf("%w", ErrUsernameExists) + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "email"): + err = fmt.Errorf("%w", ErrEmailExists) + default: + err = fmt.Errorf("%w: %w", ErrAlreadyExists, err) } } return nil, fmt.Errorf("repo: failed to create user: %w", err) @@ -44,65 +94,150 @@ func (r *pgUserRepo) CreateUser(ctx context.Context, user *model.User) (*model.U return &newUser, nil } -func (r *pgUserRepo) ReadUser(ctx context.Context, username string) (*model.User, error) { +func (r *pgUserRepo) ReadUserByID(ctx context.Context, id string) (*model.User, error) { + var user model.User + query := ` + SELECT id, username, displayname, email, account_type, created_at, updated_at, last_login_at + FROM users + WHERE id = $1 AND deleted_at IS NULL + ` + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.AccountType, + &user.CreatedAt, + &user.UpdatedAt, + &user.LastLoginAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, fmt.Errorf("repo: failed to get user by ID=%v: %w", id, err) + } + + return &user, nil +} + +func (r *pgUserRepo) ReadUserByEmail(ctx context.Context, email string) (*model.User, error) { var user model.User - query := `SELECT id, username, displayname, passhash FROM users WHERE username = $1` - err := r.db.QueryRowContext(ctx, query, username).Scan( + query := ` + SELECT id, username, displayname, email, passhash, account_type, + created_at, updated_at, last_login_at + FROM users + WHERE email = $1 AND deleted_at IS NULL + ` + + lowerEmail := strings.ToLower(email) + err := r.db.QueryRowContext(ctx, query, lowerEmail).Scan( &user.ID, &user.Username, &user.DisplayName, + &user.Email, &user.PassHash, + &user.AccountType, + &user.CreatedAt, + &user.UpdatedAt, + &user.LastLoginAt, ) + if err != nil { if err == sql.ErrNoRows { - err = ErrNotFound + return nil, fmt.Errorf("%w: user not found with email %s", ErrNotFound, email) } - return nil, fmt.Errorf("repo: failed to to get by username=%v: %w", username, err) + return nil, fmt.Errorf("repo: failed to get user by email: %w", err) } + return &user, nil } -func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *model.UserUpdate) (*model.User, error) { +func (r *pgUserRepo) UpdateUser(ctx context.Context, id string, params *model.UserUpdate) (*model.User, error) { updates := []string{} args := []any{} argCounter := 1 + if params.Email != nil { + lowerEmail := strings.ToLower(*params.Email) + updates = append(updates, fmt.Sprintf("email = $%d", argCounter)) + args = append(args, lowerEmail) + argCounter++ + } + if params.Username != nil { + updates = append(updates, fmt.Sprintf("username = $%d", argCounter)) + args = append(args, *params.Username) + argCounter++ + } if params.DisplayName != nil { updates = append(updates, fmt.Sprintf("displayname = $%d", argCounter)) args = append(args, *params.DisplayName) argCounter++ } - if params.Password != nil { + if params.PassHash != nil { updates = append(updates, fmt.Sprintf("passhash = $%d", argCounter)) - args = append(args, *params.Password) + args = append(args, *params.PassHash) + argCounter++ + } + if params.AccountType != nil { + updates = append(updates, fmt.Sprintf("account_type = $%d", argCounter)) + args = append(args, *params.AccountType) argCounter++ } + if len(updates) == 0 { - return r.ReadUser(ctx, username) + return r.ReadUserByID(ctx, id) } - query := fmt.Sprintf("UPDATE users SET %s WHERE username = $%d RETURNING id, username, displayname, passhash", - strings.Join(updates, ", "), argCounter) - args = append(args, username) + args = append(args, id) + + query := fmt.Sprintf(` + UPDATE users + SET %s + WHERE id = $%d + RETURNING id, username, displayname, email, passhash, + account_type, created_at, updated_at, last_login_at + `, strings.Join(updates, ", "), argCounter) var updatedUser model.User err := r.db.QueryRowContext(ctx, query, args...).Scan( &updatedUser.ID, &updatedUser.Username, &updatedUser.DisplayName, + &updatedUser.Email, &updatedUser.PassHash, + &updatedUser.AccountType, + &updatedUser.CreatedAt, + &updatedUser.UpdatedAt, + &updatedUser.LastLoginAt, ) + if err != nil { - if err == sql.ErrNoRows { + if pqErr, ok := err.(*pq.Error); ok { + switch { + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "username"): + err = fmt.Errorf("%w", ErrUsernameExists) + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "email"): + err = fmt.Errorf("%w", ErrEmailExists) + default: + err = fmt.Errorf("%w", ErrAlreadyExists) + } + } else if err == sql.ErrNoRows { err = ErrNotFound } return nil, fmt.Errorf("repo: failed to update user: %w", err) } + return &updatedUser, nil } -func (r *pgUserRepo) DeleteUser(ctx context.Context, username string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE username = $1`, username) +func (r *pgUserRepo) DeleteUser(ctx context.Context, id string) error { + result, err := r.db.ExecContext( + ctx, + `DELETE FROM users WHERE id = $1`, + id, + ) if err != nil { return fmt.Errorf("repo: failed to delete user: %w", err) } @@ -116,3 +251,25 @@ func (r *pgUserRepo) DeleteUser(ctx context.Context, username string) error { return nil } + +func (r *pgUserRepo) UpdateLastLogin(ctx context.Context, id string) error { + result, err := r.db.ExecContext( + ctx, + `UPDATE users SET last_login_at = NOW() WHERE id = $1`, + id, + ) + if err != nil { + return fmt.Errorf("repo: failed to update last login: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("repo: failed to update last login: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("repo: user not found: %w", ErrNotFound) + } + + return nil +} diff --git a/backend/internal/token/access.go b/backend/internal/token/access.go index 0db39b7..6094e99 100644 --- a/backend/internal/token/access.go +++ b/backend/internal/token/access.go @@ -1,19 +1,16 @@ package token import ( - "letsgo/pkg/jwt/v2" + "gonext/internal/model" + "gonext/pkg/jwt/v2" ) type UserPayload struct { - Username string - Displayname string -} - -func NewUserPayload(username, displayname string) *UserPayload { - return &UserPayload{ - Username: username, - Displayname: displayname, - } + UserID string `json:"user_id"` + Username string `json:"username"` + Displayname string `json:"displayname"` + AccountType model.AccountType `json:"account_type"` + Email *string `json:"email,omitempty"` } type UserManager = jwt.Manager[UserPayload] diff --git a/backend/internal/token/kvToken.go b/backend/internal/token/kvToken.go new file mode 100644 index 0000000..5c955b5 --- /dev/null +++ b/backend/internal/token/kvToken.go @@ -0,0 +1,145 @@ +package token + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "gonext/internal/config" + "gonext/internal/repo" + + "github.com/google/uuid" +) + +func Rand6d() (string, error) { + b := make([]byte, 3) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random code: %w", err) + } + return hex.EncodeToString(b)[:6], nil +} + +type TokenPurpose int + +const ( + PurposePasswordReset TokenPurpose = iota + PurposeEmailLogin +) + +func refKey(id string) string { + return fmt.Sprintf("refToken:%v", id) +} + +func setEmailKey(userID, code string) string { + return fmt.Sprintf("email:verify:%s:%s", userID, code) +} + +func tokenKey(purpose TokenPurpose, key string) string { + switch purpose { + case PurposePasswordReset: + return fmt.Sprintf("set_password:%s", key) + case PurposeEmailLogin: + return fmt.Sprintf("email_login:%s", key) + default: + panic("unknown token purpose") + } +} + +type KVManager interface { + SetRefToken(ctx context.Context, id string) (string, error) + UseRefToken(ctx context.Context, token string) (string, error) + + SetEmailSetupToken(ctx context.Context, userID, email string) (string, error) + UseEmailSetupToken(ctx context.Context, userID, token string) (string, error) + + SetMailToken(ctx context.Context, purpose TokenPurpose, value string) (string, error) + UseMailToken(ctx context.Context, purpose TokenPurpose, token string) (string, error) +} + +type redisMngrImpl struct { + store repo.KVStore + cfg *config.Token +} + +func NewRedisKVMngr(store repo.KVStore, cfg *config.Token) KVManager { + return &redisMngrImpl{ + store: store, + cfg: cfg, + } +} + +func (r *redisMngrImpl) SetRefToken(ctx context.Context, id string) (string, error) { + token := uuid.NewString() + if err := r.store.Set(ctx, token, id, r.cfg.RefTTL); err != nil { + return "", fmt.Errorf("failed to set ref token: %w", err) + } + if err := r.store.ListAdd(ctx, refKey(id), token, r.cfg.RefTTL); err != nil { + return "", fmt.Errorf("failed to set ref token map: %w", err) + } + return token, nil +} + +func (r *redisMngrImpl) UseRefToken(ctx context.Context, token string) (string, error) { + var errList []error + id, err := r.store.Get(ctx, token) + if err != nil { + return "", fmt.Errorf("no such ref token: %w", err) + } + if err := r.store.Del(ctx, token); err != nil { + errList = append(errList, fmt.Errorf("failed to delete ref token: %w", err)) + } + if err := r.store.ListDel(ctx, refKey(id), token); err != nil { + errList = append(errList, fmt.Errorf("failed to delete ref token map: %w", err)) + } + if len(errList) > 0 { + return id, errors.Join(errList...) + } + return id, nil +} + +func (r *redisMngrImpl) SetEmailSetupToken(ctx context.Context, userID, email string) (string, error) { + token, err := Rand6d() + if err != nil { + return "", err + } + if err := r.store.Set(ctx, setEmailKey(userID, token), email, r.cfg.EmailedCodeTTL); err != nil { + return "", err + } + return token, nil +} +func (r *redisMngrImpl) UseEmailSetupToken(ctx context.Context, userID, token string) (string, error) { + key := setEmailKey(userID, token) + email, err := r.store.Get(ctx, key) + if err != nil { + return "", err + } + if err := r.store.Del(ctx, key); err != nil { + return "", err + } + return email, nil +} + +func (r *redisMngrImpl) SetMailToken(ctx context.Context, purpose TokenPurpose, value string) (string, error) { + token, err := Rand6d() + if err != nil { + return "", err + } + storeKey := tokenKey(purpose, token) + if err := r.store.Set(ctx, storeKey, value, r.cfg.EmailedCodeTTL); err != nil { + return "", err + } + return token, nil +} + +func (r *redisMngrImpl) UseMailToken(ctx context.Context, purpose TokenPurpose, token string) (string, error) { + key := tokenKey(purpose, token) + value, err := r.store.Get(ctx, key) + if err != nil { + return "", err + } + if err := r.store.Del(ctx, key); err != nil { + return value, err + } + return value, nil +} diff --git a/backend/internal/user/router.go b/backend/internal/user/router.go index d30529a..33c38b2 100644 --- a/backend/internal/user/router.go +++ b/backend/internal/user/router.go @@ -1,10 +1,9 @@ package user import ( - "letsgo/internal/mdw" - "letsgo/internal/token" - "letsgo/pkg/util" - "log/slog" + "gonext/internal/mdw" + "gonext/internal/token" + "gonext/pkg/util/httputil" "net/http" "github.com/go-chi/chi/v5" @@ -15,13 +14,11 @@ func Router(authMdw mdw.Middleware, ctxKey mdw.ContextKey) chi.Router { r.Use(authMdw) r.Get("/", func(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(ctxKey).(*token.UserPayload) - if err := util.RespondJSON(w, http.StatusOK, map[string]string{ + httputil.RespondJSON(w, http.StatusOK, map[string]string{ "message": "Success", "username": user.Username, "displayname": user.Displayname, - }); err != nil { - slog.Error("failed to write user response", "error", err) - } + }) }) return r diff --git a/backend/pkg/jwt/v2/jwt.go b/backend/pkg/jwt/v2/jwt.go index 22b6fbd..5332b71 100644 --- a/backend/pkg/jwt/v2/jwt.go +++ b/backend/pkg/jwt/v2/jwt.go @@ -16,9 +16,6 @@ type Manager[P any] interface { func NewManager[P any](secret string, expiry time.Duration, issuer, audience string, payloadType P) (Manager[P], error) { val := reflect.ValueOf(payloadType) - // if val.Kind() == reflect.Ptr { - // val = val.Elem() - // } if val.Kind() != reflect.Struct { return nil, fmt.Errorf("jwt: payloadType must be struct") } @@ -91,8 +88,6 @@ func (m *jwtManager[P]) ValidateToken(token string) (*P, error) { if !ok { return nil, fmt.Errorf("fwt: payload doesn't conform to type %v", m.payloadType) } - //payload := reflect.New(m.payloadType).Elem().Interface().(P) //or do this - err = json.Unmarshal(claims.Payload, payloadPtr) if err != nil { return nil, fmt.Errorf("fwt: payload unmarshal failed %w", err) diff --git a/backend/pkg/util/http.go b/backend/pkg/util/http.go deleted file mode 100644 index 5ca3719..0000000 --- a/backend/pkg/util/http.go +++ /dev/null @@ -1,40 +0,0 @@ -package util - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" -) - -func RespondErr(w http.ResponseWriter, status int, msg string, err error) { - if err != nil { - slog.Error(err.Error()) - } - if err := RespondJSON(w, status, map[string]string{"message": msg}); err != nil { - slog.Error("failed to write error response", "error", err) - } -} - -func RespondJSON(w http.ResponseWriter, code int, payload any) error { - response, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - if _, err := w.Write(response); err != nil { - return fmt.Errorf("failed to write response: %w", err) - } - return nil -} - -func RespondWithError(w http.ResponseWriter, code int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - if _, err := w.Write([]byte(`{"error": "` + message + `"}`)); err != nil { - // Log the error since we can't send it to the client if the write failed - slog.Error("Failed to write error response", "error", err) - } -} diff --git a/backend/pkg/util/httputil/httputil.go b/backend/pkg/util/httputil/httputil.go new file mode 100644 index 0000000..31e1ff2 --- /dev/null +++ b/backend/pkg/util/httputil/httputil.go @@ -0,0 +1,47 @@ +package httputil + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" +) + +func RespondErr(w http.ResponseWriter, status int, msg string, err error) { + if err != nil { + slog.Error(msg, "error", err) + } + RespondJSON(w, status, map[string]string{ + "error": msg, + }) +} + +func RespondJSON(w http.ResponseWriter, code int, payload any) { + response, err := json.Marshal(payload) + if err != nil { + slog.Error("failed to marshal response", "error", err) + // If we can't marshal the error, we can't return a proper error response + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if _, err := w.Write(response); err != nil { + slog.Error("failed to write response", "error", err) + } +} + +func SetAuthCookie(w http.ResponseWriter, name, value, path string, expires time.Time) { + cookie := &http.Cookie{ + Name: name, + Value: value, + Path: path, + Expires: expires, + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteStrictMode, + } + + http.SetCookie(w, cookie) +} diff --git a/backend/pkg/util/httputil/validator.go b/backend/pkg/util/httputil/validator.go new file mode 100644 index 0000000..8bf2453 --- /dev/null +++ b/backend/pkg/util/httputil/validator.go @@ -0,0 +1,41 @@ +package httputil + +import ( + "encoding/json" + "net/http" + + "github.com/go-playground/validator/v10" +) + +type HasMsg interface { + ErrMsg(err error) string +} + +type Validator struct { + validate *validator.Validate +} + +func NewValidator() *Validator { + return &Validator{ + validate: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (v *Validator) DecodeValidate(w http.ResponseWriter, r *http.Request, target any) bool { + if err := json.NewDecoder(r.Body).Decode(target); err != nil { + RespondErr(w, http.StatusBadRequest, "Invalid request", err) + return false + } + + if err := v.validate.Struct(target); err != nil { + if hasMsg, ok := target.(HasMsg); ok { + if msg := hasMsg.ErrMsg(err); msg != "" { + RespondErr(w, http.StatusBadRequest, msg, nil) + return false + } + } + RespondErr(w, http.StatusBadRequest, "Invalid input", nil) + return false + } + return true +} diff --git a/backend/pkg/util/util.go b/backend/pkg/util/util.go new file mode 100644 index 0000000..c7d8682 --- /dev/null +++ b/backend/pkg/util/util.go @@ -0,0 +1 @@ +package util diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a9cc59d..749055e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,13 +14,13 @@ services: - go next: - image: ${DOCKERHUB_NAME}/letsgo-frontend:${TAG} + image: ${DOCKERHUB_NAME}/gonext-frontend:${TAG} restart: unless-stopped environment: - NODE_ENV=production go: - image: ${DOCKERHUB_NAME}/letsgo-backend:${TAG} + image: ${DOCKERHUB_NAME}/gonext-backend:${TAG} restart: unless-stopped environment: - REDIS_URL=redis:6379 @@ -29,6 +29,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - RESEND_KEY=${RESEND_KEY} + - MAIL_FROM=${MAIL_FROM} depends_on: redis: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index ebf540c..73f5b29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - RESEND_KEY=${RESEND_KEY} + - MAIL_FROM=${MAIL_FROM} expose: - "3333" depends_on: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7fa05f..3382c39 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,13 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@tsparticles/engine": "^3.8.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.8.1", "clsx": "^2.1.1", "framer-motion": "^12.16.0", + "html-to-image": "^1.11.13", + "html2canvas": "^1.4.1", "immer": "^10.1.1", "lucide-react": "^0.518.0", "next": "15.3.3", @@ -3296,6 +3301,470 @@ "node": ">=10.13.0" } }, + "node_modules/@tsparticles/basic": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.8.1.tgz", + "integrity": "sha512-my114zRmekT/+I2cGuEnHxlX5G/jO0iVtNnsxxlsgspXUTSY+fDixmrNF4UgFkuaIwd9Bv/yH+7S/4HE4qte7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1", + "@tsparticles/move-base": "3.8.1", + "@tsparticles/plugin-hex-color": "3.8.1", + "@tsparticles/plugin-hsl-color": "3.8.1", + "@tsparticles/plugin-rgb-color": "3.8.1", + "@tsparticles/shape-circle": "3.8.1", + "@tsparticles/updater-color": "3.8.1", + "@tsparticles/updater-opacity": "3.8.1", + "@tsparticles/updater-out-modes": "3.8.1", + "@tsparticles/updater-size": "3.8.1" + } + }, + "node_modules/@tsparticles/engine": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/engine/-/engine-3.8.1.tgz", + "integrity": "sha512-S8h10nuZfElY7oih//NUHnT5qf4v3/dnsU8CMs7dz5lBEGr3amrYrXk0V+YKPTIQwfdmJHUaSBoAqFiv4aEGIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/@tsparticles/interaction-external-attract": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-attract/-/interaction-external-attract-3.8.1.tgz", + "integrity": "sha512-GWzyj5MOzjb5pNWuqAueNZS2ilPcZ0isiqwcb0BjjpwfiGfL72UyIbNUDMLncsW+4jcwB4WyMsv/qOGDmAwVfQ==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-bounce": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-bounce/-/interaction-external-bounce-3.8.1.tgz", + "integrity": "sha512-tgVzsE3orneSeSUc1XhRD6Iqs8Rkm11iRdkncKSpNx4SI2eJWFPhwit2wIiHQ+IuvgCmM2DXRtLgEVeaux71zg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-bubble": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-bubble/-/interaction-external-bubble-3.8.1.tgz", + "integrity": "sha512-edRVFybiVFd5vEjfEkHgrBTXfPTKc05EqCmRuOEd5gOll1ui0nPtknzj9JiLrPacQAJ7OgZKlHWYQb1u5Yy5Tw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-connect": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-connect/-/interaction-external-connect-3.8.1.tgz", + "integrity": "sha512-DQ0nNB0VDSxFxeaJQvm91NDUU/UPoiHE+uUzyw5qSoWJEGTUOj/QkW0GuBinCo99i8MH/wLDqMS9nb+7ZejpUw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-grab": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-grab/-/interaction-external-grab-3.8.1.tgz", + "integrity": "sha512-nPaHrazEr14CGokGGkFHYXZJTN3Inshe04uQNj+Rj4Lz9dAIqq8EFuSejp0g9lk2cTHWfVf4SK4r8+aJz9Ow4Q==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-pause": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-pause/-/interaction-external-pause-3.8.1.tgz", + "integrity": "sha512-W+6bjNDddtzlikwnfmk2G/GJsz4ZnoqvK0c63earvnPNUAJmkzrvmLS52JoaIOSyclOIeD4LmubT6IsQDv5ohA==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-push": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-push/-/interaction-external-push-3.8.1.tgz", + "integrity": "sha512-LgaXaBM5QXRCeYt3DzphEhE/OirEGnV4iJrXKGJ/FrYMH7kOao85rPmCtYQNYzIy6K0XstmATmTvFRziZ/M4VQ==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-remove": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-remove/-/interaction-external-remove-3.8.1.tgz", + "integrity": "sha512-mwo1DRJPIqzrWfs2G+kfQ5/HyM5j/soIj11zur3BkIlm9vdYIxUpA+hvO734oekSjJxY7YFmYUaqc4vC5TFE5w==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-repulse": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-repulse/-/interaction-external-repulse-3.8.1.tgz", + "integrity": "sha512-r0E828zrKIRHA27daItHtI9QEp1tO8d8dmF8Ld8+orn7q0+BKG+uGvNTYJFZ+hqR+lp5AkLOiThf7L2wLS9M1A==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-external-slow": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-slow/-/interaction-external-slow-3.8.1.tgz", + "integrity": "sha512-U4P6c9V6/fSDsWchD4oAYAIPHA/203LzQ7+792cMxa7YThza0VS7YyJUQ1PACjGMmfeKbE34/eoGPqESKakeLw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-particles-attract": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-attract/-/interaction-particles-attract-3.8.1.tgz", + "integrity": "sha512-lo5JAVdeh1tQq/7SDsIllNdyIJgF3hSquWLARUIwGolezD91bEmHp/rlhTscX5NrqiM3y7z3inJPhR0nP5kGeg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-particles-collisions": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-collisions/-/interaction-particles-collisions-3.8.1.tgz", + "integrity": "sha512-teqn1CZVoJkT/ubhkb4R/H1rnx7DoIeerHXS5uME+vrLIqzkn8QlWdEdTJ7PhdB+Ct2iYAeXCrJWwIqnKaAL3w==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/interaction-particles-links": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-links/-/interaction-particles-links-3.8.1.tgz", + "integrity": "sha512-D+X7wEWyhfV7J0uDWf5vWDhxjfaNovNZW0BWscR9qSy8pl3hjRpv0sJ/QaQFscmK5SzVz28tUFDRLbH1aV5v/Q==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/move-base": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/move-base/-/move-base-3.8.1.tgz", + "integrity": "sha512-DNFRL1QT8ZQYLg3fIk74EbHJq5HGOq9CM2bCci9dDcdymvN4L7aWVFQavRiWDbi3y1EUW3+jeHSMbD3qHAfOeA==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/move-parallax": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/move-parallax/-/move-parallax-3.8.1.tgz", + "integrity": "sha512-umrIttaJGUgfxpnolbMU2BekoN4gw0RgcfVsWR7jzHErA7eTzdJ2mikbQFD+3/1DfTDgJOjWx+dy8a3G/bSsZg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/plugin-easing-quad": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/plugin-easing-quad/-/plugin-easing-quad-3.8.1.tgz", + "integrity": "sha512-+BiPNHgsNbbh0AhWKjrmJaAu5c37naqjbME8ZYl0BClI0AC5AzBUaezYRxECaLrdtHJvKrZXFMr6Q0sxjDc6QQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/plugin-hex-color": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/plugin-hex-color/-/plugin-hex-color-3.8.1.tgz", + "integrity": "sha512-AmgB7XIYBCvg5HcqYb19YpcjEx2k4DpU2e24n0rradDDeqKKcz7EWI/08FlAnDb5HUs1em63vaAanl1vdm3+OA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/plugin-hsl-color": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/plugin-hsl-color/-/plugin-hsl-color-3.8.1.tgz", + "integrity": "sha512-Ja6oEX6yu0064e4a+Fv1TBJiG5y0hqWwoOKSqf/Ra/zo01ageOEvDVX70FOVSrP+iEPGPznKVNcZs1tEOOvO0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/plugin-rgb-color": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/plugin-rgb-color/-/plugin-rgb-color-3.8.1.tgz", + "integrity": "sha512-xNLqnaFUYjU+7dCHQXjZdM4UojUAVorPVmXlYmkh1xmujLljEaFTwCg1UJVlNq+fXENIFkeaf3/XT0U/q0ZBTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tsparticles/react/-/react-3.0.0.tgz", + "integrity": "sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q==", + "peerDependencies": { + "@tsparticles/engine": "^3.0.2", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@tsparticles/shape-circle": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-circle/-/shape-circle-3.8.1.tgz", + "integrity": "sha512-dM/f+qcpd8/KfviuVuKiTS8KLDE/T7xxHK7EI2S49yPW6yrJJBXdL7T4N9/n/6PF+Wslcl+kf/eTDjEAI3WjNQ==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-emoji": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-emoji/-/shape-emoji-3.8.1.tgz", + "integrity": "sha512-xiXNZ/afdecengUXhOqgUwR+vysgaseVpzEjoGoliOMWq4WHWv+S6ujNfes2oz3x736mTlvKdXcEWRncSXaKWw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-image": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-image/-/shape-image-3.8.1.tgz", + "integrity": "sha512-7Yi25uLXvcY5A6TzyVBjYPsTmeTrE+0a2YO8kdp3O7V9NRGCSfXKnPRFp+lNOTiQRRvOG+SSzx2G18dfc/jwQg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-line": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-line/-/shape-line-3.8.1.tgz", + "integrity": "sha512-aXVKkpGLgi1hbU/JO+opzy3OTt6PfxWrhGZyI0ms3vdcRX9uYlq4GoNUoKPVfntjWzhecF+FNNZ9gqUG/+WZLQ==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-polygon": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-polygon/-/shape-polygon-3.8.1.tgz", + "integrity": "sha512-1pAx85NJbgmsOngl+ZAYH8vxwPJmoddjWCbWTD0wlp/x+2NRjn1iaGBKObPKLgwVzsAXb9qNHMsUX/x0C54svw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-square": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-square/-/shape-square-3.8.1.tgz", + "integrity": "sha512-4cjDt6542dkc15zxG1VYT7ScgPXM3+5VGtwMfh5CYNBx+GZZ3R+XUo1Q66JadcqKcNdHXfMWbXCMxs0GaiTtSw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/shape-star": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/shape-star/-/shape-star-3.8.1.tgz", + "integrity": "sha512-wBxnawqan/ocguNxY6cOEXF+YVnLIUmGBlnVGYx/7U9E2UHuHEKkoumob4fUflKISjvj5eQLpm/E1eUfYMd6RA==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/slim": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/slim/-/slim-3.8.1.tgz", + "integrity": "sha512-b6JV8MrxMz0XYn0eBCI/Mq8VCRyeaWfUyQaQyxLiRd96xpBXCeULooJF+Eaz9it1sUI898a5QfvY8djNXs4OJw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "license": "MIT", + "dependencies": { + "@tsparticles/basic": "3.8.1", + "@tsparticles/engine": "3.8.1", + "@tsparticles/interaction-external-attract": "3.8.1", + "@tsparticles/interaction-external-bounce": "3.8.1", + "@tsparticles/interaction-external-bubble": "3.8.1", + "@tsparticles/interaction-external-connect": "3.8.1", + "@tsparticles/interaction-external-grab": "3.8.1", + "@tsparticles/interaction-external-pause": "3.8.1", + "@tsparticles/interaction-external-push": "3.8.1", + "@tsparticles/interaction-external-remove": "3.8.1", + "@tsparticles/interaction-external-repulse": "3.8.1", + "@tsparticles/interaction-external-slow": "3.8.1", + "@tsparticles/interaction-particles-attract": "3.8.1", + "@tsparticles/interaction-particles-collisions": "3.8.1", + "@tsparticles/interaction-particles-links": "3.8.1", + "@tsparticles/move-parallax": "3.8.1", + "@tsparticles/plugin-easing-quad": "3.8.1", + "@tsparticles/shape-emoji": "3.8.1", + "@tsparticles/shape-image": "3.8.1", + "@tsparticles/shape-line": "3.8.1", + "@tsparticles/shape-polygon": "3.8.1", + "@tsparticles/shape-square": "3.8.1", + "@tsparticles/shape-star": "3.8.1", + "@tsparticles/updater-life": "3.8.1", + "@tsparticles/updater-rotate": "3.8.1", + "@tsparticles/updater-stroke-color": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-color": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-color/-/updater-color-3.8.1.tgz", + "integrity": "sha512-HKrZzrF8YJ+TD+FdIwaWOPV565bkBhe+Ewj7CwKblG7H/SG+C6n1xIYobXkGP5pYkkQ+Cm1UV/Aq0Ih7sa+rJg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-life": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-life/-/updater-life-3.8.1.tgz", + "integrity": "sha512-5rCFFKD7js1lKgTpKOLo2OfmisWp4qqMVUVR4bNPeR0Ne/dcwDbKDzWyYS2AMsvWv/gcTTtWiarRfAiVQ5HtNg==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-opacity": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-opacity/-/updater-opacity-3.8.1.tgz", + "integrity": "sha512-41dJ0T7df7AUFFkV9yU0buUfUwh+hLYcViXxkDy+6CJiiNCNZ4H404w1DTpBQLL4fbxUcDk9BXZLV7gkE2OfAw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-out-modes": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-out-modes/-/updater-out-modes-3.8.1.tgz", + "integrity": "sha512-BY8WqQwoDFpgPybwTzBU2GnxtRkjWnGStqBnR53x5+f1j7geTSY6WjcOvl1W+IkjtwtjiifriwBl41EbqMrjdQ==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-rotate": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-rotate/-/updater-rotate-3.8.1.tgz", + "integrity": "sha512-gpI07H1+diuuUdhJsQ1RlfHSD3fzBJrjyuwGuoXgHmvKzak6EWKpYfUMOraH4Dm41m/4kJZelle4nST+NpIuoA==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-size": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-size/-/updater-size-3.8.1.tgz", + "integrity": "sha512-SC2ZxewtpwKadCalotK6x2YanxRO3hTMW1Rxzx9V2rcjAIgh/Nw49Vuithy2TDq8RtTc9rHDAPic2vMQ/lYQwA==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, + "node_modules/@tsparticles/updater-stroke-color": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@tsparticles/updater-stroke-color/-/updater-stroke-color-3.8.1.tgz", + "integrity": "sha512-rofHCf5oRHP2H+BTJ4D3r4mTqZtre3c8bsdJHATle26+gLpzbt6I1a83wAY8xnsQa1BNnRAfEsnb7GpdZ1vYaw==", + "license": "MIT", + "dependencies": { + "@tsparticles/engine": "3.8.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -4231,6 +4700,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4566,6 +5044,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -6030,6 +6517,25 @@ "node": ">= 0.4" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8466,6 +8972,15 @@ "node": ">=18" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -8805,6 +9320,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a970e28..f197445 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,13 @@ "lint": "next lint" }, "dependencies": { + "@tsparticles/engine": "^3.8.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.8.1", "clsx": "^2.1.1", "framer-motion": "^12.16.0", + "html-to-image": "^1.11.13", + "html2canvas": "^1.4.1", "immer": "^10.1.1", "lucide-react": "^0.518.0", "next": "15.3.3", diff --git a/frontend/src/api/authRoutes.ts b/frontend/src/api/authRoutes.ts new file mode 100644 index 0000000..5e4a425 --- /dev/null +++ b/frontend/src/api/authRoutes.ts @@ -0,0 +1,82 @@ +import api from '@/lib/api'; +import { + AuthResponse, + GuestRequest, + SetEmailRequest, + VerifyEmailRequest, + EmailLoginRequest, + PassLoginRequest, + PassRequest, +} from '@/types/authTypes'; + +const useUserRoute = () => { + const registerGuest = async (name: string): Promise => { + return api.postJson('/auth/guest', { name }); + }; + const logout = async (): Promise => { + await api.postJson('/auth/logout', {}); + }; + const refreshAccess = async (): Promise => { + return api.postJson('/auth/refresh', {}); + }; + + const setEmail = async (email: string): Promise => { + await api.postJson('/auth/email/setup', { email }); + }; + const verifyEmail = async (code: string): Promise => { + return api.postJson( + '/auth/email/verify', + { code } + ); + }; + const getPassCode = async (): Promise => { + await api.postJson('/auth/pass/request', {}); + }; + const setPass = async ( + password: string, + code: string + ): Promise => { + return api.postJson('/auth/pass/set', { + pass: password, + code, + }); + }; + + const getLoginCode = async (email: string): Promise => { + await api.postJson('/auth/login/getCode', { email }); + }; + const loginWithCode = async ( + email: string, + code: string + ): Promise => { + return api.postJson( + '/auth/login/useCode', + { email, code } + ); + }; + + const loginWithPassword = async ( + email: string, + password: string + ): Promise => { + return api.postJson( + '/auth/login/password', + { email, pass: password } + ); + }; + + return { + registerGuest, + logout, + refreshAccess, + setEmail, + verifyEmail, + getPassCode, + setPass, + getLoginCode, + loginWithCode, + loginWithPassword, + }; +}; + +export default useUserRoute; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f37e0e7..522ade1 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,17 +1,25 @@ import type { Metadata } from 'next'; -import { Roboto } from 'next/font/google'; +import { Roboto, Poppins } from 'next/font/google'; import './globals.css'; import NavBar from '@/components/UI/NavBar'; import Footer from '@/components/UI/Footer'; import ChatWindow from '@/components/Chat/ChatWindow'; -import ThemeInitializer from '@/components/UI/ThemeInitializer'; +import ClientInit from '@/components/ClientInit'; +import CookieConsent from '@/components/UI/CookieConsent'; const roboto = Roboto({ weight: ['300', '400', '500', '700'], subsets: ['latin'], variable: '--font-roboto', display: 'swap', -}) +}); + +const poppins = Poppins({ + weight: ['400', '600', '700'], + subsets: ['latin'], + variable: '--font-poppins', + display: 'swap', +}); export const metadata: Metadata = { title: 'My App', @@ -23,20 +31,25 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - return - - - - -
- -
- {children} -
-
-
- - - ; + return ( + + + + + +
+ +
+ {children} +
+
+
+ + + + + ); } diff --git a/frontend/src/app/live/boardgame/page.tsx b/frontend/src/app/live/boardgame/page.tsx index 1f057c9..4d547cb 100644 --- a/frontend/src/app/live/boardgame/page.tsx +++ b/frontend/src/app/live/boardgame/page.tsx @@ -8,7 +8,7 @@ import GameStatus from '@/components/games/GameStatus'; import CreateGame from '@/components/games/CreateGame'; import { GameBoardProps } from '@/types/gameTypes'; import useBoardGame from '@/hooks/useWSGame'; -import { useWebSocket } from '@/hooks/useWebsocket'; +import useWebSocket from '@/hooks/useWebsocket'; import { GAME_DISPLAY_NAMES } from '@/config/consts'; import { Trophy } from 'lucide-react'; diff --git a/frontend/src/app/live/draw/page.tsx b/frontend/src/app/live/draw/page.tsx index d084bee..932efc0 100644 --- a/frontend/src/app/live/draw/page.tsx +++ b/frontend/src/app/live/draw/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useDraw } from '@/hooks/useDraw'; -import { useWebSocket } from '@/hooks/useWebsocket'; +import useDraw from '@/hooks/useDraw'; +import useWebSocket from '@/hooks/useWebsocket'; import { DrawToolbar } from '@/components/draw/DrawToolbar'; import { useState, useRef, useEffect } from 'react'; import { DRAW_CANVAS_WIDTH, DRAW_CANVAS_HEIGHT } from '@/config/consts'; diff --git a/frontend/src/app/live/layout.tsx b/frontend/src/app/live/layout.tsx index 6d7e278..f723005 100644 --- a/frontend/src/app/live/layout.tsx +++ b/frontend/src/app/live/layout.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import ChatIndicator from '@/components/UI/ChatIndicator'; -import { useWebSocket } from '@/hooks/useWebsocket'; +import useWebSocket from '@/hooks/useWebsocket'; export default function LiveLayout({ children, @@ -28,9 +28,9 @@ export default function LiveLayout({ if (!currentRoom) { return
-

Let's Go Live!

+

Hold on!

- These pages are only accessible while in a chat channel. + These pages are only accessible while logged in; we need a name to display after all. Use the bottom right button to connect~

diff --git a/frontend/src/app/live/page.tsx b/frontend/src/app/live/page.tsx index 516edda..be7b53b 100644 --- a/frontend/src/app/live/page.tsx +++ b/frontend/src/app/live/page.tsx @@ -5,7 +5,7 @@ import Button from '@/components/UI/Button'; export default function LivePage() { return
-

Let's Go Live!

+

Go Live!

👋 Getting Started

diff --git a/frontend/src/app/live/video/page.tsx b/frontend/src/app/live/video/page.tsx index 94383d0..cd90f5b 100644 --- a/frontend/src/app/live/video/page.tsx +++ b/frontend/src/app/live/video/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useRef, useState, useEffect } from 'react'; -import { useWebSocket } from '@/hooks/useWebsocket'; -import { useGroupCall } from '@/hooks/useGroupCall'; +import useWebSocket from '@/hooks/useWebsocket'; +import useGroupCall from '@/hooks/useGroupCall'; import Button from '@/components/UI/Button'; function VideoContainer({ children, username }: { children: React.ReactNode; username?: string }) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2fff2ff..575952f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,30 +1,115 @@ import Link from 'next/link'; +import { + AlternatingSection, + SectionContent, +} from '@/components/layout/AlternatingSection'; +import TitleBanner from '@/components/UI/particles/ParticleBanner'; +import { Gamepad2, PencilRuler, Video, User2, Github } from 'lucide-react'; +import Logo from '@/assets/gonext.svg'; +import ParticleBox from '@/components/UI/ParticleBox'; export default function HomePage() { - return
-
-

Let's Go!

- -

- A collection of random things I made. -

+ return ( +
+
+ + + + +

+ Interactive experiences built with Go and Next.js +

+ + + View on GitHub + +
-

- Currently featuring live board games, collaborative drawing, and video chat. -

+
+ + +
+ {[ + { + icon: , + title: 'Board Games', + href: '/live/boardgame', + }, + { + icon: , + title: 'Drawing', + href: '/live/draw', + }, + { + icon:
+
-
- - Explore Live Features → - -
+ +

+ What Is This? +

+

+ Go Next is a collection of fun things I've built for you to + enjoy with friends. +

+

+ From classic board games to creative drawing sessions, everything + happens in real-time in your browser. No downloads, no + installations - just pure web magic! +

+

Choose something from the right to try it out!

+
+
-

- More static content coming soon! Let me know if you'd like to see anything specific. -

-
-
; -} \ No newline at end of file + + +
+
+ +
+
+
+ +

Who Am I

+

+ Hi there! I'm a passionate developer who loves creating + interactive experiences on the web. +

+

+ This project is my playground for experimenting with real-time web + technologies and building things hopefully entertains people. +

+
+
+ + + ); +} diff --git a/frontend/src/assets/gonext.svg b/frontend/src/assets/gonext.svg new file mode 100644 index 0000000..a26d032 --- /dev/null +++ b/frontend/src/assets/gonext.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/components/Chat/ChatBox.tsx b/frontend/src/components/Chat/ChatBox.tsx index e8ffc16..9349e66 100644 --- a/frontend/src/components/Chat/ChatBox.tsx +++ b/frontend/src/components/Chat/ChatBox.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { useWebSocket } from '@/hooks/useWebsocket'; -import { useUserStore } from '@/hooks/useUserStore'; +import useWebSocket from '@/hooks/useWebsocket'; +import useUserStore from '@/hooks/useUserStore'; import Input from '../UI/Input'; import Button from '../UI/Button'; import { msgError } from '@/types/wsTypes'; @@ -10,19 +10,16 @@ export default function ChatBox() { const [message, setMessage] = useState(''); const [showRoomInput, setShowRoomInput] = useState(false); const [newRoom, setNewRoom] = useState(''); - const { msgLog, currentRoom, joinRoom, leaveRoom, sendChat } = useWebSocket(); - const { username, displayName } = useUserStore(); + const { msgLog, currentRoom, joinRoom, leaveRoom, sendChat, clients } = + useWebSocket(); + const { username } = useUserStore(); const messagesEndRef = useRef(null); const handleLogout = async (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - try { - await useUserStore.getState().logout(); - } catch (error) { - console.error('Logout failed:', error); - } + useUserStore.getState().logout(); }; useEffect(() => { @@ -33,117 +30,136 @@ export default function ChatBox() { const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); if (message.trim()) { - sendChat(username, displayName, message.trim()); + sendChat(message.trim()); setMessage(''); } }; - return
-
- {showRoomInput ? ( -
{ - e.preventDefault(); - if (newRoom.trim()) { - joinRoom(newRoom.trim()); - setNewRoom(''); - setShowRoomInput(false); - } - }} className='flex gap-2'> - ) => setNewRoom(e.target.value)} - className='flex-grow text-sm' - autoFocus - /> - - -
- ) : ( -
-
setShowRoomInput(true)} + return ( +
+
+ {showRoomInput ? ( +
{ + e.preventDefault(); + if (newRoom.trim()) { + joinRoom(newRoom.trim()); + setNewRoom(''); + setShowRoomInput(false); + } + }} + className="flex gap-2" > - #{currentRoom} - + ) => + setNewRoom(e.target.value) + } + className="flex-grow text-sm" + autoFocus + /> + + + + ) : ( +
+
setShowRoomInput(true)} + > + #{currentRoom} + +
+ +
- - -
- )} -
+ )} +
-
- {msgLog.length === 0 - ?

No messages yet.

- : msgLog.map((msg, index) => { - const isServerMessage = msg.sender === '_server'; - const isMyMessage = msg.sender === username; - return
-
+ {msgLog.length === 0 ? ( +

No messages yet.

+ ) : ( + msgLog.map((msg, index) => { + const isServerMessage = msg.sender === '_server'; + const isMyMessage = msg.sender === username; + return ( +
+
- {!isServerMessage && - - {msg.sender} - - } - {msg.payload.message} -
-
; - })} -
-
+ }`} + > + {!isServerMessage && ( + + {clients[msg.sender] || '_' + msg.sender} + + )} + {msg.payload.message} +
+
+ ); + }) + )} +
+
-
- setMessage(e.target.value)} - className='flex-grow rounded-r-none' - /> - -
-
; -}; \ No newline at end of file + setMessage(e.target.value)} + className="flex-grow rounded-r-none" + /> + + +
+ ); +} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index fe1af89..5cf8f64 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,16 +1,16 @@ 'use client'; import { useState } from 'react'; -import { useUserStore } from '@/hooks/useUserStore'; +import useUserStore from '@/hooks/useUserStore'; import { useWSConnect } from '@/hooks/useWebsocket'; -import LoginBox from './LoginBox'; +import LoginBox from '../Login/LoginBox'; import ChatBox from './ChatBox'; import Button from '../UI/Button'; -import { MessageCircleMore } from 'lucide-react'; +import { MessageCircleMore, X } from 'lucide-react'; export default function ChatWindow() { const [isOpen, setIsOpen] = useState(false); - const expire = useUserStore((state) => state.accessExp); + const expire = useUserStore(state => state.accessExp); const loggedIn = expire > Date.now(); useWSConnect(); @@ -18,42 +18,43 @@ export default function ChatWindow() { setIsOpen(!isOpen); }; - return
- {!isOpen && ( - - )} - {isOpen && ( -
+ > + {isOpen ? ( +
+ +
+ {loggedIn ? : } +
+
+ ) : ( -
- {loggedIn ? : } -
-
- )} -
; -}; \ No newline at end of file + )} +
+ ); +} diff --git a/frontend/src/components/Chat/LoginBox.tsx b/frontend/src/components/Chat/LoginBox.tsx deleted file mode 100644 index 92e4884..0000000 --- a/frontend/src/components/Chat/LoginBox.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useState } from 'react'; -import { useUserStore } from '@/hooks/useUserStore'; // Adjust path if different -import Input from '../UI/Input'; -import Button from '../UI/Button'; - -export default function LoginBox() { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [success, setSuccess] = useState(false); - - const { login, register, loading, error, } = useUserStore(); - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - useUserStore.setState({ error: '' }); - - if (username && password) { - setSuccess(await login(username, password)); - } else { - useUserStore.setState({ error: 'Username and password are required.' }); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - useUserStore.setState({ error: '' }); - - if (username && password) { - setSuccess(await register(username, password)); - } else { - useUserStore.setState({ error: 'Username and password are required.' }); - } - }; - - return
-

Account Access

-

Username should be 3+ characters alphanum.

-

Password just needs to exist.

-
{/* Form acts as a container for inputs and buttons */} - setUsername(e.target.value)} - autoComplete='username' - required - /> - setPassword(e.target.value)} - autoComplete='password' - required - /> - -
- - -
-
- - {loading &&

Processing...

} - {error &&

{error}

} - {success &&

Success!

} -
; -}; \ No newline at end of file diff --git a/frontend/src/components/UI/ThemeInitializer.tsx b/frontend/src/components/ClientInit.tsx similarity index 51% rename from frontend/src/components/UI/ThemeInitializer.tsx rename to frontend/src/components/ClientInit.tsx index 8cf983a..bad226a 100644 --- a/frontend/src/components/UI/ThemeInitializer.tsx +++ b/frontend/src/components/ClientInit.tsx @@ -1,9 +1,26 @@ 'use client'; -// anti FOUC -export default function ThemeInitializer() { - return