Compare commits

..

No commits in common. "4a5bfc3f1066aa5c57f12b81aa3acd93f3408abf" and "0b29602cb870741ea761fd25db1e01fc2ac78956" have entirely different histories.

36 changed files with 103 additions and 2129 deletions

1
.gitignore vendored
View File

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

View File

@ -1,12 +0,0 @@
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,6 +213,61 @@ 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 ## Database Requirements
This section outlines the specific technical requirements for the database store for This section outlines the specific technical requirements for the database store for
@ -222,7 +277,7 @@ this application. It will describe the required tables, fields, and other expect
##### Table ID Choice ##### Table ID Choice
Typically, I like to use UUID's for ID's. However, after some research I have concluded that for this 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 `SERIAL` type will work sufficiently. Security is not a huge application, the use of the `SERIAL` or `BIGSERIAL` 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 concern with this application since no major data will be stored, however, measures will still be in
place (of course). place (of course).
@ -239,8 +294,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 data fields will also have a small example object. A more in-depth data structure can be
found in **OTHER** section. found in **OTHER** section.
- [ ] Recipes: Represents a single recipe. - [ ] Recipe: Represents a single recipe.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] Title (Unique, Required) string(128) - [ ] Title (Unique, Required) string(128)
- [ ] Description (Required) text - [ ] Description (Required) text
- [ ] Instructions (Required) string(1024)[] - [ ] Instructions (Required) string(1024)[]
@ -249,78 +304,69 @@ found in **OTHER** section.
- [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int }) - [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int })
- [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section) - [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section)
- [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... }) - [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... })
- [ ] UserId (FK: User.Id) Serial - [ ] UserId (FK: User.Id) BigSerial
- [ ] Modified () date/time stamp - [ ] Modified () date/time stamp
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Users: Represents a single user. - [ ] User: Represents a single user.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] GoogleId (Unique, Required) text
- [ ] Name (Required) string(64) - [ ] Name (Required) string(64)
- [ ] Email (Unique, Required) string(128) - [ ] Email (Unique, Required) string(128)
- [ ] ImageURL () text - [ ] Password (Required) string(128) *stored as hash***
- [ ] GoogleRefreshToken () text
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Sessions: Represents a single user-session. - [ ] Engagement: Represents a single engagement from a single user.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] 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) - [ ] Message () text (Used to store any relevant notes, if needed)
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed) - [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
- [ ] UserId (FK: User.Id, Required) Serial - [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user. - [ ] Like: **Many-to-many** table to represent a list of recipes liked by a user.
- [ ] ID (PK) *Composite key*** - [ ] ID (PK) *Composite key***
- [ ] UserId (FK: User.Id, Required) Serial - [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial - [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Tags: Represents a single tag that can be had by many recipes. - [ ] Tag: Represents a single tag that can be had by many recipes.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] Name (Unique, Required) string(32) - [ ] Name (Unique, Required) string(32)
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] RecipeTags: **Many-to-many** table to represent a list of tags on a recipe. - [ ] RecipeTag: **Many-to-many** table to represent a list of tags on a recipe.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial - [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] TagId (FK: Tag.Id, Required) Serial - [ ] TagId (FK: Tag.Id, Required) BigSerial
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Lists: Represents a single users shopping list. - [ ] List: Represents a single users shopping list.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] UserId (FK: User.Id, Required) Serial - [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Content (Required) JSONB([ { "name": string, "quantity": string }, ... ]) - [ ] Content (Required) JSONB([ { "name": string, "quantity": string }, ... ])
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Images: Represents a single image used by a single recipe. - [ ] Image: Represents a single image used by a single recipe.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial - [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] Alt (Required) string(128) (alt text for accessibility, same as recipe title) - [ ] Alt (Required) string(128) (alt text for accessibility, same as recipe title)
- [ ] Url (Required) text - [ ] Url (Required) text
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Reviews: Represents a single review on a recipe from a single author. - [ ] Review: Represents a single review on a recipe from a single author.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] Comment (Required) text - [ ] Comment (Required) text
- [ ] Rating () int(0..5) (Optional b/c nested replies don't need a rating) - [ ] Rating () int(0..5) (Optional b/c nested replies don't need a rating)
- [ ] ReviewId (FK: Review.Id) Serial (This is used for nested replies) - [ ] ReviewId (FK: Review.Id) BigSerial (This is used for nested replies)
- [ ] RecipeId (FK: Recipe.Id, Required) Serial - [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
- [ ] UserId (FK: User.Id, Required) Serial - [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp
- [ ] Notifications: Represents a single user notification. - [ ] Notification: Represents a single user notification.
- [ ] ID (PK) Serial - [ ] ID (PK) BigSerial
- [ ] UserId (FK: User.Id, Required) Serial - [ ] UserId (FK: User.Id, Required) BigSerial
- [ ] Type (Required) E_Notification - [ ] Type (Required) E_Notification
- [ ] Message (Required) text - [ ] Message (Required) text
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed) - [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
- [ ] Read (Required, Default: F) boolean - [ ] Read (Required, Default: F) boolean
- [ ] Created (Required) date/time stamp - [ ] Created (Required) date/time stamp

View File

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

44
go.mod
View File

@ -2,46 +2,4 @@ module github.com/haydenhargreaves/Potion
go 1.24.3 go 1.24.3
require ( require github.com/a-h/templ v0.3.898 // indirect
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

@ -1,30 +0,0 @@
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

@ -1,14 +0,0 @@
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

View File

@ -1 +0,0 @@
package handlers

View File

@ -1,15 +0,0 @@
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

@ -1,137 +0,0 @@
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

@ -1,94 +0,0 @@
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

@ -1,17 +0,0 @@
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

View File

View File

@ -1,10 +0,0 @@
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

@ -1,11 +0,0 @@
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

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

View File

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

View File

@ -1,26 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,18 +0,0 @@
-- 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

@ -1,107 +0,0 @@
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

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

View File

View File

@ -0,0 +1,10 @@
// 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

@ -1,45 +0,0 @@
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

@ -1,40 +0,0 @@
// 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,8 +1,5 @@
module.exports = { module.exports = {
content: [ content: ["./**/*.html", "./**/*.templ", "./**/*.go",],
"./internal/templates/**/*.templ",
"./internal/templates/**/*.html",
],
theme: { extend: {}, }, theme: { extend: {}, },
plugins: [], plugins: [],
} }

View File

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

0
web/static/css/style.css Normal file
View File

File diff suppressed because it is too large Load Diff