Compare commits

..

9 Commits

Author SHA1 Message Date
Hayden Hargreaves
4a5bfc3f10 (UI/STYLE): Implemented the simple login page.
Now all that's left for the authentication workflow is session management.
2025-06-14 21:18:35 -07:00
Hayden Hargreaves
780f5dfb84 (CHORE): Updated and removed the last few backend components.
Just needed to clean up the VC a little bit.
2025-06-14 19:33:10 -07:00
Hayden Hargreaves
b6a434ad2a (FEAT): Authentication service with Google OAuth is working!
Still missing a UI and we do not have session management just yet, but
the workflow of calling the Google API's and creating/finding users is
working with the current structure.
2025-06-14 12:35:08 -07:00
Hayden Hargreaves
71e32f1fd7 (FIX): Created a decent dependency inject architecture.
The system allows services and repositories to be created in the main
server definition and then store them in the context. They can then be
retrieved and mapped onto the injection type and accessed in the
handlers. The handlers can then use the deps to call services. Each
service is initialized with the required repositories so they can be
accessed directly.
2025-06-14 12:30:17 -07:00
Hayden Hargreaves
3c4710bb48 (SQL/MIGRATION): First DB migration. Created the users table.
Also, needed to update to using plural table names.
2025-06-14 00:02:04 -07:00
Hayden Hargreaves
86913faed7 (DOCS): Going to need a session table and such for user management.
This is the final piece that is needed for user management, the
sessions. Both in the repository as well as the session/middleware.
2025-06-13 23:01:54 -07:00
Hayden Hargreaves
a9cdc25adf (FEAT): Implemented the first stages of Google OAuth.
All that's left is the UI and repository implementation.
2025-06-13 22:50:46 -07:00
Hayden Hargreaves
6fb4664478 (CHORE): Fixed tailwind css and directory renames. 2025-06-13 22:49:38 -07:00
Hayden Hargreaves
a88d807e40 (FIX): Server and routers setup. 2025-06-13 16:44:23 -07:00
36 changed files with 2129 additions and 103 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/flake.lock
/go.sum
/.env

View File

@ -0,0 +1,12 @@
package main
import "github.com/haydenhargreaves/Potion/internal/app/server"
const PORT = 3000
func main() {
s := server.Init(PORT).ConfigureAuth().ConnectDatabase().Setup()
defer s.DB.Close()
s.Start()
}

View File

@ -213,61 +213,6 @@ creation process will take place here
## Authentication
This section outlines the authentication requirements for the application. This section
is **not** broken down by page, they are simple defined.
Consider looking into using Google OAuth. Appears to be free and can be implemented pretty easily.
Reference [here](https://developers.google.com/identity/protocols/oauth2).
#### Pages
- [ ] **Register Page**
- [ ] Input form with required details: *name, email and password*
- [ ] *Password strength meter**
- [ ] User should be directed to the home page when account is created
- [ ] User will be logged in
- [ ] A notification will be provided to the user indicating success and redirection
- [ ] User should see error messages when validation issues occur
- [ ] Email already in use
- [ ] Passwords do not match
- [ ] Server failure (this should never happen)
- [ ] **Sign In Page**
- [ ] Input form with required details: *email, password and forgot password button*
- [ ] User should be directed to the home page after signing in
- [ ] A notification will be provided to the user indicating success and redirection
- [ ] User should see error messages when validation issues occur
- [ ] Invalid password
- [ ] Server failure (this should never happen)
'*': Future ideas
#### API Requirements
- [ ] **Register Page**
- [ ] Create a new user in the database
- [ ] Password should be stored in the database as a hash
- [ ] Ensure that emails are not duplicated
- [ ] "Log user in" when account is created
- [ ] User should be redirected to the home page on success
- [ ] **Sign In Page**
- [ ] Sign user in and return data to be stored in the session
- [ ] Validate password to the hash stored in the DB
- [ ] User should be redirected to the home page on success
- [ ] **Session Management**
- [ ] *Uses cookies to store required data**
'*': Unsure on technical implementation
## Database Requirements
This section outlines the specific technical requirements for the database store for
@ -277,7 +222,7 @@ this application. It will describe the required tables, fields, and other expect
##### Table ID Choice
Typically, I like to use UUID's for ID's. However, after some research I have concluded that for this
application, the use of the `SERIAL` or `BIGSERIAL` type will work sufficiently. Security is not a huge
application, the use of the `SERIAL` or `SERIAL` type will work sufficiently. Security is not a huge
concern with this application since no major data will be stored, however, measures will still be in
place (of course).
@ -294,8 +239,8 @@ also have a list of attributes which are to be implemented at the database level
data fields will also have a small example object. A more in-depth data structure can be
found in **OTHER** section.
- [ ] Recipe: Represents a single recipe.
- [ ] ID (PK) BigSerial
- [ ] Recipes: Represents a single recipe.
- [ ] ID (PK) Serial
- [ ] Title (Unique, Required) string(128)
- [ ] Description (Required) text
- [ ] Instructions (Required) string(1024)[]
@ -304,69 +249,78 @@ found in **OTHER** section.
- [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int })
- [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section)
- [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... })
- [ ] UserId (FK: User.Id) BigSerial
- [ ] UserId (FK: User.Id) Serial
- [ ] Modified () date/time stamp
- [ ] Created (Required) date/time stamp
- [ ] User: Represents a single user.
- [ ] ID (PK) BigSerial
- [ ] Users: Represents a single user.
- [ ] ID (PK) Serial
- [ ] GoogleId (Unique, Required) text
- [ ] Name (Required) string(64)
- [ ] Email (Unique, Required) string(128)
- [ ] Password (Required) string(128) *stored as hash***
- [ ] ImageURL () text
- [ ] GoogleRefreshToken () text
- [ ] Created (Required) date/time stamp
- [ ] Engagement: Represents a single engagement from a single user.
- [ ] ID (PK) BigSerial
- [ ] Sessions: Represents a single user-session.
- [ ] ID (PK) Serial
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] Token (Required) text
- [ ] Expiration (Required) date/time stamp
- [ ] Created (Required) date/time stamp
- [ ] Engagements: Represents a single engagement from a single user.
- [ ] ID (PK) Serial
- [ ] Message () text (Used to store any relevant notes, if needed)
- [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
- [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed)
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] Created (Required) date/time stamp
- [ ] Like: **Many-to-many** table to represent a list of recipes liked by a user.
- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user.
- [ ] ID (PK) *Composite key***
- [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
- [ ] Created (Required) date/time stamp
- [ ] Tag: Represents a single tag that can be had by many recipes.
- [ ] ID (PK) BigSerial
- [ ] Tags: Represents a single tag that can be had by many recipes.
- [ ] ID (PK) Serial
- [ ] Name (Unique, Required) string(32)
- [ ] Created (Required) date/time stamp
- [ ] RecipeTag: **Many-to-many** table to represent a list of tags on a recipe.
- [ ] ID (PK) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] TagId (FK: Tag.Id, Required) BigSerial
- [ ] RecipeTags: **Many-to-many** table to represent a list of tags on a recipe.
- [ ] ID (PK) Serial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
- [ ] TagId (FK: Tag.Id, Required) Serial
- [ ] Created (Required) date/time stamp
- [ ] List: Represents a single users shopping list.
- [ ] ID (PK) BigSerial
- [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Lists: Represents a single users shopping list.
- [ ] ID (PK) Serial
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] Content (Required) JSONB([ { "name": string, "quantity": string }, ... ])
- [ ] Created (Required) date/time stamp
- [ ] Image: Represents a single image used by a single recipe.
- [ ] ID (PK) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] Images: Represents a single image used by a single recipe.
- [ ] ID (PK) Serial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
- [ ] Alt (Required) string(128) (alt text for accessibility, same as recipe title)
- [ ] Url (Required) text
- [ ] Created (Required) date/time stamp
- [ ] Review: Represents a single review on a recipe from a single author.
- [ ] ID (PK) BigSerial
- [ ] Reviews: Represents a single review on a recipe from a single author.
- [ ] ID (PK) Serial
- [ ] Comment (Required) text
- [ ] Rating () int(0..5) (Optional b/c nested replies don't need a rating)
- [ ] ReviewId (FK: Review.Id) BigSerial (This is used for nested replies)
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] ReviewId (FK: Review.Id) Serial (This is used for nested replies)
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] Created (Required) date/time stamp
- [ ] Notification: Represents a single user notification.
- [ ] ID (PK) BigSerial
- [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Notifications: Represents a single user notification.
- [ ] ID (PK) Serial
- [ ] UserId (FK: User.Id, Required) Serial
- [ ] Type (Required) E_Notification
- [ ] Message (Required) text
- [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed)
- [ ] Read (Required, Default: F) boolean
- [ ] Created (Required) date/time stamp

View File

@ -24,6 +24,7 @@
templ
tailwindcss_4
tailwindcss-language-server
watchman
];
# Define the shell that will be executed.

44
go.mod
View File

@ -2,4 +2,46 @@ module github.com/haydenhargreaves/Potion
go 1.24.3
require github.com/a-h/templ v0.3.898 // indirect
require (
github.com/a-h/templ v0.3.898
github.com/gin-gonic/gin v1.10.1
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/a-h/templ/examples/integration-gin v0.0.0-20250610141150-9b34663a6ef0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/cors v1.7.5 // indirect
github.com/gin-contrib/sessions v1.0.4 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,30 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
func GoogleLogin(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
url := deps.AuthService.GetGoogleAuthUrl()
ctx.Redirect(http.StatusSeeOther, url)
}
func GoogleCallback(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
var (
state string = ctx.Query("state")
code string = ctx.Query("code")
)
if dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} else {
ctx.JSON(http.StatusOK, gin.H{"googleUserInfo": googleUserInfo, "dbUser": dbUser})
}
}

View File

@ -0,0 +1,14 @@
package handlers
import (
"github.com/gin-gonic/gin"
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts"
pages "github.com/haydenhargreaves/Potion/internal/templates/pages"
)
func LoginPage(ctx *gin.Context) {
title := "Potion - Login"
page := pages.LoginPage()
ctx.HTML(200, "", layouts.AppLayout(title, page))
}

View File

@ -0,0 +1 @@
package handlers

View File

@ -0,0 +1,15 @@
package server
import (
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
// DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
// that is used to apply the required services.
func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Set("deps", deps)
ctx.Next()
}
}

View File

@ -0,0 +1,137 @@
package server
import (
"database/sql"
"fmt"
"os"
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/haydenhargreaves/Potion/internal/app/handlers"
"github.com/haydenhargreaves/Potion/internal/app/service"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
)
type Server struct {
port int
Router *gin.Engine
config cors.Config
DB *sql.DB
}
// Init initializes the server with the provided port. CORS settings are defined here.
// A pointer to a server object is returned which allows for method chaining.
func Init(port int) *Server {
// TODO: Set this to release in prod
gin.SetMode(gin.DebugMode)
server := &Server{
Router: gin.Default(),
port: port,
config: cors.DefaultConfig(),
}
// Some stuff for templ rendering
htmlRenderer := server.Router.HTMLRender
server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer}
// Disable proxy warnings
server.Router.SetTrustedProxies(nil)
// Setup the CORS settings and active them
server.config.AllowAllOrigins = true
server.Router.Use(cors.New(server.config))
return server
}
func (s *Server) ConfigureAuth() *Server {
err := godotenv.Load(".env")
if err != nil {
panic("Could not load env file")
}
var (
redirectUrl string = "http://localhost:3000/v1/api/auth/callback"
clientId string = os.Getenv("GOOGLE_CLIENT_ID")
clientSecret string = os.Getenv("GOOGLE_CLIENT_SECRET")
scope []string = []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
}
)
// Setup Google OAuth
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
return s
}
func (s *Server) ConnectDatabase() *Server {
err := godotenv.Load(".env")
if err != nil {
panic("Could not load env file")
}
var connUrl string = os.Getenv("DATABASE_URL")
db, err := sql.Open("postgres", connUrl)
if err != nil {
panic("Could not connect to database: " + err.Error())
}
if err = db.Ping(); err != nil {
panic("Error pinging database: " + err.Error())
}
s.DB = db
return s
}
// Start starts the server on the port provided when the server was initialized
func (s *Server) Start() {
s.Router.Run(fmt.Sprintf(":%d", s.port))
}
func (s *Server) Setup() *Server {
// Initialize and inject dependencies
userRepo := repository.NewUserRepository(s.DB)
userService := service.NewUserService(userRepo)
authService := service.NewAuthService(userRepo)
deps := &domain.InjectedDependencies{
UserService: userService,
AuthService: authService,
}
s.Router.Use(DepedencyInjectionMiddleware(deps))
// Wrap all routes with a version
router_v1 := s.Router.Group("/v1")
// Domain specific routers
router_web := router_v1.Group("/web")
router_api := router_v1.Group("/api")
// Static routes
router_web.Static("/static", "./web/static")
// API router endpoints
router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) })
// WEB router endpoints
router_web.GET("/login", handlers.LoginPage)
// Google oauth
router_api.GET("/auth/login", handlers.GoogleLogin)
router_api.GET("/auth/callback", handlers.GoogleCallback)
return s
}

View File

@ -0,0 +1,94 @@
package service
import (
"context"
"fmt"
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"golang.org/x/oauth2"
)
// NOTE: HOW THIS WORKS
//
// I need to store the refresh token along side the user in the DB.
// Create a "session token" (cookie) with an expiration and store that in the DB and the session.
// Send this session token along with all the requests (stored in the session) and when
// authorization is needed, use it.
// Once the expiration of MY token expires, prompt the user to log back in.
//
// So what is the point of the Google refresh token? Well, its not really useful right now, but if
// we need to perform Google actions, we can use it to get more access tokens, which are needed for
// the Google actions. Currently, I have no need for it, but it will be stored anyway.
//
// ------------------------------------------------------------------------------------------------
// AuthService implements the domain.AuthService defined in the domain module.
type AuthService struct {
userRepository domain.UserRepository
}
// Compile-time check to ensure the AuthService implements domain.AuthService
var _ domainAuth.AuthService = (*AuthService)(nil)
// NewAuthService creates a user service object which can be passed into the context. The service
// requires a user repository which it will use to hit the database when needed.
func NewAuthService(userRepository domain.UserRepository) domainAuth.AuthService {
return &AuthService{userRepository: userRepository}
}
// GetGoogleAuthUrl generates a URL which is used to redirect the user to the Google sign in page.
// This URL is in the Google domain and is not coupled with this application. The data from this URL
// will be passed into a callback and work to complete the Google OAuth workflow.
func (s *AuthService) GetGoogleAuthUrl() string {
url := auth.GoogleAuthConfig.AuthCodeURL(
"randomstate",
oauth2.AccessTypeOffline,
oauth2.ApprovalForce,
)
return url
}
// GoogleAuthSuccess accepts the data from the Google login endpoint and uses it to fetch the users
// data. The data is then used to log the user in or create an account.
func (s *AuthService) GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) {
// Ensure the state matches, prevents M.I.T.M. attacks
if state != "randomstate" {
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("States don't match, received %s", state)
}
// Get access token from Google
token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code)
if err != nil {
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Code exchange failed: %s", err.Error())
}
// Use the access token to get user data
googleUserInfo, err := auth.GetUserData(token.AccessToken)
if err != nil {
return domain.User{}, domain.GoogleUserInfo{}, err
}
// Attempt to get the user, user is nil when they don't exit
user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id)
if err != nil {
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Failed to get db user: %s", err)
}
// A user was found
if user != nil {
return *user, googleUserInfo, nil
}
// user did not exist, need to create one
newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken)
if err != nil {
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Repository failed to create user: %s", err.Error())
}
// temporary
return newUser, googleUserInfo, nil
}

View File

@ -0,0 +1,17 @@
package service
import domain "github.com/haydenhargreaves/Potion/internal/domain/user"
// UserService implements the domain.UserService defined in the domain module.
type UserService struct {
userRepository domain.UserRepository
}
// Compile-time check to ensure the UserService implements domain.UserService
var _ domain.UserService = (*UserService)(nil)
// NewUserService creates a user service object which can be passed into the context. The service
// requires a user repository which it will use to hit the database when needed.
func NewUserService(userRepository domain.UserRepository) domain.UserService {
return &UserService{userRepository: userRepository}
}

View File

@ -0,0 +1,10 @@
package domain
import (
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
type AuthService interface {
GetGoogleAuthUrl() string
GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error)
}

View File

@ -0,0 +1,11 @@
package domain
import (
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
)
type InjectedDependencies struct {
UserService domainUser.UserService
AuthService domainAuth.AuthService
}

View File

@ -0,0 +1,6 @@
package domain
type UserRepository interface {
CreateGoogleUser(googleUserInfo *GoogleUserInfo, googleRefreshToken string) (User, error)
GetGoogleUser(googleId string) (*User, error)
}

View File

@ -0,0 +1,4 @@
package domain
type UserService interface {
}

View File

@ -0,0 +1,26 @@
package domain
import "time"
// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call.
type GoogleUserInfo struct {
Id string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
// User is the database model of a user. There is no need to map to a different model so
// this will remain in the domain.
type User struct {
Id int
GoogleId string
Name string
Email string
ImageUrl string
GoogleRefreshToken string
Created time.Time
}

View File

@ -0,0 +1,61 @@
package auth
import (
"encoding/json"
"fmt"
"io"
"net/http"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// GoogleAuthConfig is a global variable that stores the current state of the Google configuration.
// NewGoogleConfig should be called before this value is accessed, or the application will panic
// to prevent a null reference.
var GoogleAuthConfig oauth2.Config
// NewGOogleConfig creates a google authentication configuration. The configuration is set as a
// global variable which can be used by importing this module. The configuration is used to query
// the google OAuth API and collect user data.
//
// This function should be called before any calls to the Google API are made, otherwise a null
// value will be accessed and the call will panic.
//
// The global variable is returned by this function but is not needed.
func NewGoogleConfig(redirectUrl, clientId, clientSecret string, scope []string) oauth2.Config {
GoogleAuthConfig = oauth2.Config{
RedirectURL: redirectUrl,
ClientID: clientId,
ClientSecret: clientSecret,
Scopes: scope,
Endpoint: google.Endpoint,
}
return GoogleAuthConfig
}
// GetUserData accepts a access token and calls the Google OAuth API to receive the respective user
// data. Any errors that occur will be wrapped and returned to the caller.
func GetUserData(accessToken string) (domain.GoogleUserInfo, error) {
googleApiUrl := fmt.Sprintf("https://www.googleapis.com/oauth2/v2/userinfo?access_token=%s", accessToken)
resp, err := http.Get(googleApiUrl)
if err != nil {
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to fetch user info from Google: %s", err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to read body from response: %s", err.Error())
}
var googleUserInfo domain.GoogleUserInfo
if err := json.Unmarshal(body, &googleUserInfo); err != nil {
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to parser response: %s", err.Error())
}
return googleUserInfo, nil
}

View File

@ -0,0 +1,18 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- Desc: Create the users table in the database.
-- Date: 06/13/2025
BEGIN;
-- Create the users table
CREATE TABLE IF NOT EXISTS Users (
Id SERIAL PRIMARY KEY NOT NULL,
GoogleId TEXT UNIQUE NOT NULL,
Name VARCHAR(64) NOT NULL,
Email VARCHAR(128) UNIQUE NOT NULL,
ImageUrl TEXT,
GoogleRefreshToken TEXT,
Created TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMIT;

View File

@ -0,0 +1,107 @@
package repository
import (
"database/sql"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
_ "github.com/lib/pq"
)
type UserRepository struct {
db *sql.DB
}
// Compile-time check to ensure the UserRepository implements domain.UserRepository
var _ domain.UserRepository = (*UserRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access
// the database. Any user related database operations will take place in this repository.
func NewUserRepository(db *sql.DB) domain.UserRepository {
return &UserRepository{db: db}
}
// CreateGoogleUser creates a user entry in the users table. The refresh token is required along
// with the Google user info, in order to complete the database schema. Currently, Google login is
// the only support method of authentication, if this changes, this repository may need updates to
// match an updated table schema.
//
// This function will NOT check if the user already exists, if they do, it will return an error. For
// best results, pair this function with the GetGoogleUser which will return the user if it can find
// it.
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return domain.User{}, err
}
var user domain.User
query := `INSERT INTO users
(GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
VALUES ($1, $2, $3, $4, $5) RETURNING *;`
if err := tx.QueryRow(
query,
googleUserInfo.Id,
googleUserInfo.Name,
googleUserInfo.Email,
googleUserInfo.Picture,
googleRefreshToken,
).Scan(
&user.Id,
&user.GoogleId,
&user.Name,
&user.Email,
&user.ImageUrl,
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
tx.Rollback()
return domain.User{}, err
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.User{}, err
}
return user, nil
}
// GetGoogleUser attempts to find a user in the database via its Google ID, not the database ID. This
// function is used when a user logs in with Google to prevent duplicate entries from being made. If
// no user is found, this function will return a null pointer but not an error.
func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return nil, err
}
var user domain.User
query := `SELECT * FROM users WHERE GoogleId = $1`
if err := tx.QueryRow(query, googleId).Scan(
&user.Id,
&user.GoogleId,
&user.Name,
&user.Email,
&user.ImageUrl,
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
// If no user was found, don't error, just return
if err == sql.ErrNoRows {
return nil, nil
}
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return nil, err
}
return &user, nil
}

View File

@ -0,0 +1,20 @@
package templates
// AppLayout is the main application layout, this does not contain any content other than
// meta data, links, scripts and whatever is passed into it as a component.
templ AppLayout(title string, child templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{ title }</title>
<link rel="stylesheet" href="/v1/web/static/css/tailwind.css" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
@child
</body>
</html>
}

View File

@ -1,10 +1,63 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
var _ = templruntime.GeneratedTemplate
// AppLayout is the main application layout, this does not contain any content other than
// meta data, links, scripts and whatever is passed into it as a component.
func AppLayout(title string, child templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 12, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = child.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,10 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,45 @@
package templates
templ LoginPage() {
<div class="h-screen w-full grid place-items-center bg-gray-100">
<div class="w-3/4 sm:w-3/4 md:w-1/2 lg:w-2/7 bg-white border border-gray-200 rounded-xl shadow-2xs">
<div class="p-4 sm:p-7">
<div class="">
<h1 class="block text-2xl font-bold text-gray-800">
Sign in to Continue
</h1>
<p class="mt-2 text-sm text-gray-600">
You need to sign in to continue. Don't have an account? Signing in will
create one for you!
</p>
</div>
<div class="mt-5">
<a
href="/v1/api/auth/login"
class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
>
<svg class="w-4 h-auto" width="46" height="47" viewBox="0 0 46 47" fill="none">
<path
d="M46 24.0287C46 22.09 45.8533 20.68 45.5013 19.2112H23.4694V27.9356H36.4069C36.1429 30.1094 34.7347 33.37 31.5957 35.5731L31.5663 35.8669L38.5191 41.2719L38.9885 41.3306C43.4477 37.2181 46 31.1669 46 24.0287Z"
fill="#4285F4"
></path>
<path
d="M23.4694 47C29.8061 47 35.1161 44.9144 39.0179 41.3012L31.625 35.5437C29.6301 36.9244 26.9898 37.8937 23.4987 37.8937C17.2793 37.8937 12.0281 33.7812 10.1505 28.1412L9.88649 28.1706L2.61097 33.7812L2.52296 34.0456C6.36608 41.7125 14.287 47 23.4694 47Z"
fill="#34A853"
></path>
<path
d="M10.1212 28.1413C9.62245 26.6725 9.32908 25.1156 9.32908 23.5C9.32908 21.8844 9.62245 20.3275 10.0918 18.8588V18.5356L2.75765 12.8369L2.52296 12.9544C0.909439 16.1269 0 19.7106 0 23.5C0 27.2894 0.909439 30.8731 2.49362 34.0456L10.1212 28.1413Z"
fill="#FBBC05"
></path>
<path
d="M23.4694 9.07688C27.8699 9.07688 30.8622 10.9863 32.5344 12.5725L39.1645 6.11C35.0867 2.32063 29.8061 0 23.4694 0C14.287 0 6.36607 5.2875 2.49362 12.9544L10.0918 18.8588C11.9987 13.1894 17.25 9.07688 23.4694 9.07688Z"
fill="#EB4335"
></path>
</svg>
Sign in with Google
</a>
</div>
</div>
</div>
</div>
}

View File

@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func LoginPage() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"h-screen w-full grid place-items-center bg-gray-100\"><div class=\"w-3/4 sm:w-3/4 md:w-1/2 lg:w-2/7 bg-white border border-gray-200 rounded-xl shadow-2xs\"><div class=\"p-4 sm:p-7\"><div class=\"\"><h1 class=\"block text-2xl font-bold text-gray-800\">Sign in to Continue</h1><p class=\"mt-2 text-sm text-gray-600\">You need to sign in to continue. Don't have an account? Signing in will create one for you!</p></div><div class=\"mt-5\"><a href=\"/v1/api/auth/login\" class=\"w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none\"><svg class=\"w-4 h-auto\" width=\"46\" height=\"47\" viewBox=\"0 0 46 47\" fill=\"none\"><path d=\"M46 24.0287C46 22.09 45.8533 20.68 45.5013 19.2112H23.4694V27.9356H36.4069C36.1429 30.1094 34.7347 33.37 31.5957 35.5731L31.5663 35.8669L38.5191 41.2719L38.9885 41.3306C43.4477 37.2181 46 31.1669 46 24.0287Z\" fill=\"#4285F4\"></path> <path d=\"M23.4694 47C29.8061 47 35.1161 44.9144 39.0179 41.3012L31.625 35.5437C29.6301 36.9244 26.9898 37.8937 23.4987 37.8937C17.2793 37.8937 12.0281 33.7812 10.1505 28.1412L9.88649 28.1706L2.61097 33.7812L2.52296 34.0456C6.36608 41.7125 14.287 47 23.4694 47Z\" fill=\"#34A853\"></path> <path d=\"M10.1212 28.1413C9.62245 26.6725 9.32908 25.1156 9.32908 23.5C9.32908 21.8844 9.62245 20.3275 10.0918 18.8588V18.5356L2.75765 12.8369L2.52296 12.9544C0.909439 16.1269 0 19.7106 0 23.5C0 27.2894 0.909439 30.8731 2.49362 34.0456L10.1212 28.1413Z\" fill=\"#FBBC05\"></path> <path d=\"M23.4694 9.07688C27.8699 9.07688 30.8622 10.9863 32.5344 12.5725L39.1645 6.11C35.0867 2.32063 29.8061 0 23.4694 0C14.287 0 6.36607 5.2875 2.49362 12.9544L10.0918 18.8588C11.9987 13.1894 17.25 9.07688 23.4694 9.07688Z\" fill=\"#EB4335\"></path></svg> Sign in with Google</a></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,5 +1,8 @@
module.exports = {
content: ["./**/*.html", "./**/*.templ", "./**/*.go",],
content: [
"./internal/templates/**/*.templ",
"./internal/templates/**/*.html",
],
theme: { extend: {}, },
plugins: [],
}

3
web/static/css/main.css Normal file
View File

@ -0,0 +1,3 @@
@import "tailwindcss";
@source "./internal/templates/**/*.templ";
@plugin "daisyui";

1311
web/static/css/tailwind.css Normal file

File diff suppressed because it is too large Load Diff