Added a basic homepage

This commit is contained in:
CamZawacki 2026-05-20 16:09:54 +01:00
parent d40640a648
commit 12fd3c04ca
113 changed files with 414 additions and 506 deletions

218
internal/services/auth.go Normal file
View file

@ -0,0 +1,218 @@
package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/ent/passwordtoken"
"github.com/camzawacki/personal-site/ent/user"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/session"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
const (
// authSessionName stores the name of the session which contains authentication data
authSessionName = "ua"
// authSessionKeyUserID stores the key used to store the user ID in the session
authSessionKeyUserID = "user_id"
// authSessionKeyAuthenticated stores the key used to store the authentication status in the session
authSessionKeyAuthenticated = "authenticated"
)
// NotAuthenticatedError is an error returned when a user is not authenticated
type NotAuthenticatedError struct{}
// Error implements the error interface.
func (e NotAuthenticatedError) Error() string {
return "user not authenticated"
}
// InvalidPasswordTokenError is an error returned when an invalid token is provided
type InvalidPasswordTokenError struct{}
// Error implements the error interface.
func (e InvalidPasswordTokenError) Error() string {
return "invalid password token"
}
// AuthClient is the client that handles authentication requests
type AuthClient struct {
config *config.Config
orm *ent.Client
}
// NewAuthClient creates a new authentication client
func NewAuthClient(cfg *config.Config, orm *ent.Client) *AuthClient {
return &AuthClient{
config: cfg,
orm: orm,
}
}
// Login logs in a user of a given ID
func (c *AuthClient) Login(ctx echo.Context, userID int) error {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return err
}
sess.Values[authSessionKeyUserID] = userID
sess.Values[authSessionKeyAuthenticated] = true
return sess.Save(ctx.Request(), ctx.Response())
}
// Logout logs the requesting user out
func (c *AuthClient) Logout(ctx echo.Context) error {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return err
}
sess.Values[authSessionKeyAuthenticated] = false
return sess.Save(ctx.Request(), ctx.Response())
}
// GetAuthenticatedUserID returns the authenticated user's ID, if the user is logged in
func (c *AuthClient) GetAuthenticatedUserID(ctx echo.Context) (int, error) {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return 0, err
}
if sess.Values[authSessionKeyAuthenticated] == true {
return sess.Values[authSessionKeyUserID].(int), nil
}
return 0, NotAuthenticatedError{}
}
// GetAuthenticatedUser returns the authenticated user if the user is logged in
func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*ent.User, error) {
if userID, err := c.GetAuthenticatedUserID(ctx); err == nil {
return c.orm.User.Query().
Where(user.ID(userID)).
Only(ctx.Request().Context())
}
return nil, NotAuthenticatedError{}
}
// CheckPassword check if a given password matches a given hash
func (c *AuthClient) CheckPassword(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// GeneratePasswordResetToken generates a password reset token for a given user.
// For security purposes, the token itself is not stored in the database but rather
// a hash of the token, exactly how passwords are handled. This method returns both
// the generated token and the token entity which only contains the hash.
func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *ent.PasswordToken, error) {
// Generate the token, which is what will go in the URL, but not the database
token, err := c.RandomToken(c.config.App.PasswordToken.Length)
if err != nil {
return "", nil, err
}
// Create and save the password reset token
pt, err := c.orm.PasswordToken.
Create().
SetToken(token).
SetUserID(userID).
Save(ctx.Request().Context())
return token, pt, err
}
// GetValidPasswordToken returns a valid, non-expired password token entity for a given user, token ID and token.
// Since the actual token is not stored in the database for security purposes, if a matching password token entity is
// found a hash of the provided token is compared with the hash stored in the database in order to validate.
func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int, token string) (*ent.PasswordToken, error) {
// Ensure expired tokens are never returned
expiration := time.Now().Add(-c.config.App.PasswordToken.Expiration)
// Query to find a password token entity that matches the given user and token ID
pt, err := c.orm.PasswordToken.
Query().
Where(passwordtoken.ID(tokenID)).
Where(passwordtoken.HasUserWith(user.ID(userID))).
Where(passwordtoken.CreatedAtGTE(expiration)).
Only(ctx.Request().Context())
switch err.(type) {
case *ent.NotFoundError:
case nil:
// Check the token for a hash match
if err := c.CheckPassword(token, pt.Token); err == nil {
return pt, nil
}
default:
if !context.IsCanceledError(err) {
return nil, err
}
}
return nil, InvalidPasswordTokenError{}
}
// DeletePasswordTokens deletes all password tokens in the database for a belonging to a given user.
// This should be called after a successful password reset.
func (c *AuthClient) DeletePasswordTokens(ctx echo.Context, userID int) error {
_, err := c.orm.PasswordToken.
Delete().
Where(passwordtoken.HasUserWith(user.ID(userID))).
Exec(ctx.Request().Context())
return err
}
// RandomToken generates a random token string of a given length
func (c *AuthClient) RandomToken(length int) (string, error) {
b := make([]byte, (length/2)+1)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
return token[:length], nil
}
// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which
// is set to expire based on the duration stored in configuration
func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"email": email,
"exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(),
})
return token.SignedString([]byte(c.config.App.EncryptionKey))
}
// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if
// the token is valid and has not expired
func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) {
t, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(c.config.App.EncryptionKey), nil
})
if err != nil {
return "", err
}
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
return claims["email"].(string), nil
}
return "", errors.New("invalid or expired token")
}

View file

@ -0,0 +1,147 @@
package services
import (
"context"
"errors"
"testing"
"time"
"github.com/camzawacki/personal-site/ent/passwordtoken"
"github.com/camzawacki/personal-site/ent/user"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestAuthClient_Auth(t *testing.T) {
assertNoAuth := func() {
_, err := c.Auth.GetAuthenticatedUserID(ctx)
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
_, err = c.Auth.GetAuthenticatedUser(ctx)
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
}
assertNoAuth()
err := c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
uid, err := c.Auth.GetAuthenticatedUserID(ctx)
require.NoError(t, err)
assert.Equal(t, usr.ID, uid)
u, err := c.Auth.GetAuthenticatedUser(ctx)
require.NoError(t, err)
assert.Equal(t, u.ID, usr.ID)
err = c.Auth.Logout(ctx)
require.NoError(t, err)
assertNoAuth()
}
func TestAuthClient_CheckPassword(t *testing.T) {
pw := "testcheckpassword"
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
require.NoError(t, err)
assert.NotEqual(t, hash, pw)
err = c.Auth.CheckPassword(pw, string(hash))
assert.NoError(t, err)
}
func TestAuthClient_GeneratePasswordResetToken(t *testing.T) {
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
assert.Len(t, token, c.Config.App.PasswordToken.Length)
assert.NoError(t, c.Auth.CheckPassword(token, pt.Token))
}
func TestAuthClient_GetValidPasswordToken(t *testing.T) {
// Check that a fake token is not valid
_, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, 1, "faketoken")
assert.Error(t, err)
// Generate a valid token and check that it is returned
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
pt2, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
require.NoError(t, err)
assert.Equal(t, pt.ID, pt2.ID)
// Expire the token by pushing the date far enough back
count, err := c.ORM.PasswordToken.
Update().
SetCreatedAt(time.Now().Add(-(c.Config.App.PasswordToken.Expiration + time.Hour))).
Where(passwordtoken.ID(pt.ID)).
Save(context.Background())
require.NoError(t, err)
require.Equal(t, 1, count)
// Expired tokens should not be valid
_, err = c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
assert.Error(t, err)
}
func TestAuthClient_DeletePasswordTokens(t *testing.T) {
// Create three tokens for the user
for i := 0; i < 3; i++ {
_, _, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
}
// Delete all tokens for the user
err := c.Auth.DeletePasswordTokens(ctx, usr.ID)
require.NoError(t, err)
// Check that no tokens remain
count, err := c.ORM.PasswordToken.
Query().
Where(passwordtoken.HasUserWith(user.ID(usr.ID))).
Count(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestAuthClient_RandomToken(t *testing.T) {
length := c.Config.App.PasswordToken.Length
a, err := c.Auth.RandomToken(length)
require.NoError(t, err)
b, err := c.Auth.RandomToken(length)
require.NoError(t, err)
assert.Len(t, a, length)
assert.Len(t, b, length)
assert.NotEqual(t, a, b)
}
func TestAuthClient_EmailVerificationToken(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
email := "test@localhost.com"
token, err := c.Auth.GenerateEmailVerificationToken(email)
require.NoError(t, err)
tokenEmail, err := c.Auth.ValidateEmailVerificationToken(token)
require.NoError(t, err)
assert.Equal(t, email, tokenEmail)
})
t.Run("invalid token", func(t *testing.T) {
badToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0LmNvbSIsImV4cCI6MTkxNzg2NDAwMH0.ScJCpfEEzlilKfRs_aVouzwPNKI28M3AIm-hyImQHUQ"
_, err := c.Auth.ValidateEmailVerificationToken(badToken)
assert.Error(t, err)
})
t.Run("expired token", func(t *testing.T) {
c.Config.App.EmailVerificationTokenExpiration = -time.Hour
email := "test@localhost.com"
token, err := c.Auth.GenerateEmailVerificationToken(email)
require.NoError(t, err)
_, err = c.Auth.ValidateEmailVerificationToken(token)
assert.Error(t, err)
c.Config.App.EmailVerificationTokenExpiration = time.Hour * 12
})
}

351
internal/services/cache.go Normal file
View file

@ -0,0 +1,351 @@
package services
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/maypok86/otter"
)
// ErrCacheMiss indicates that the requested key does not exist in the cache
var ErrCacheMiss = errors.New("cache miss")
type (
// CacheStore provides an interface for cache storage
CacheStore interface {
// get attempts to get a cached value
get(context.Context, *CacheGetOp) (any, error)
// set attempts to set an entry in the cache
set(context.Context, *CacheSetOp) error
// flush removes a given key and/or tags from the cache
flush(context.Context, *CacheFlushOp) error
// close shuts down the cache storage
close()
}
// CacheClient is the client that allows you to interact with the cache
CacheClient struct {
// store holds the Cache storage
store CacheStore
}
// CacheSetOp handles chaining a set operation
CacheSetOp struct {
client *CacheClient
key string
group string
data any
expiration time.Duration
tags []string
}
// CacheGetOp handles chaining a get operation
CacheGetOp struct {
client *CacheClient
key string
group string
}
// CacheFlushOp handles chaining a flush operation
CacheFlushOp struct {
client *CacheClient
key string
group string
tags []string
}
// inMemoryCacheStore is a cache store implementation in memory
inMemoryCacheStore struct {
store *otter.CacheWithVariableTTL[string, any]
tagIndex *tagIndex
}
// tagIndex maintains an index to support cache tags for in-memory cache stores.
// There is a performance and memory impact to using cache tags since set and get operations using tags will require
// locking, and we need to keep track of this index in order to keep everything in sync.
// If using something like Redis for caching, you can leverage sets to store the index.
// Cache tags can be useful and convenient, so you should decide if your app benefits enough from this.
// As it stands here, there is no limiting how much memory this will consume and it will track all keys
// and tags added and removed from the cache. You could store these in the cache itself but allowing these to
// be evicted poses challenges.
tagIndex struct {
sync.Mutex
tags map[string]map[string]struct{} // tag->keys
keys map[string]map[string]struct{} // key->tags
}
)
// NewCacheClient creates a new cache client
func NewCacheClient(store CacheStore) *CacheClient {
return &CacheClient{store: store}
}
// Close closes the connection to the cache
func (c *CacheClient) Close() {
c.store.close()
}
// Set creates a cache set operation
func (c *CacheClient) Set() *CacheSetOp {
return &CacheSetOp{
client: c,
}
}
// Get creates a cache get operation
func (c *CacheClient) Get() *CacheGetOp {
return &CacheGetOp{
client: c,
}
}
// Flush creates a cache flush operation
func (c *CacheClient) Flush() *CacheFlushOp {
return &CacheFlushOp{
client: c,
}
}
// cacheKey formats a cache key with an optional group
func (c *CacheClient) cacheKey(group, key string) string {
if group != "" {
return fmt.Sprintf("%s::%s", group, key)
}
return key
}
// Key sets the cache key
func (c *CacheSetOp) Key(key string) *CacheSetOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheSetOp) Group(group string) *CacheSetOp {
c.group = group
return c
}
// Data sets the data to cache
func (c *CacheSetOp) Data(data any) *CacheSetOp {
c.data = data
return c
}
// Expiration sets the expiration duration of the cached data
func (c *CacheSetOp) Expiration(expiration time.Duration) *CacheSetOp {
c.expiration = expiration
return c
}
// Tags sets the cache tags
func (c *CacheSetOp) Tags(tags ...string) *CacheSetOp {
c.tags = tags
return c
}
// Save saves the data in the cache
func (c *CacheSetOp) Save(ctx context.Context) error {
switch {
case c.key == "":
return errors.New("no cache key specified")
case c.data == nil:
return errors.New("no cache data specified")
case c.expiration == 0:
return errors.New("no cache expiration specified")
}
return c.client.store.set(ctx, c)
}
// Key sets the cache key
func (c *CacheGetOp) Key(key string) *CacheGetOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheGetOp) Group(group string) *CacheGetOp {
c.group = group
return c
}
// Fetch fetches the data from the cache
func (c *CacheGetOp) Fetch(ctx context.Context) (any, error) {
if c.key == "" {
return nil, errors.New("no cache key specified")
}
return c.client.store.get(ctx, c)
}
// Key sets the cache key
func (c *CacheFlushOp) Key(key string) *CacheFlushOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheFlushOp) Group(group string) *CacheFlushOp {
c.group = group
return c
}
// Tags sets the cache tags
func (c *CacheFlushOp) Tags(tags ...string) *CacheFlushOp {
c.tags = tags
return c
}
// Execute flushes the data from the cache
func (c *CacheFlushOp) Execute(ctx context.Context) error {
return c.client.store.flush(ctx, c)
}
// newInMemoryCache creates a new in-memory CacheStore
func newInMemoryCache(capacity int) (CacheStore, error) {
s := &inMemoryCacheStore{
tagIndex: newTagIndex(),
}
store, err := otter.MustBuilder[string, any](capacity).
WithVariableTTL().
DeletionListener(func(key string, value any, cause otter.DeletionCause) {
s.tagIndex.purgeKeys(key)
}).
Build()
if err != nil {
return nil, err
}
s.store = &store
return s, nil
}
func (s *inMemoryCacheStore) get(_ context.Context, op *CacheGetOp) (any, error) {
v, exists := s.store.Get(op.client.cacheKey(op.group, op.key))
if !exists {
return nil, ErrCacheMiss
}
return v, nil
}
func (s *inMemoryCacheStore) set(_ context.Context, op *CacheSetOp) error {
key := op.client.cacheKey(op.group, op.key)
added := s.store.Set(
key,
op.data,
op.expiration,
)
if len(op.tags) > 0 {
s.tagIndex.setTags(key, op.tags...)
}
if !added {
return errors.New("cache set failed")
}
return nil
}
func (s *inMemoryCacheStore) flush(_ context.Context, op *CacheFlushOp) error {
keys := make([]string, 0)
if key := op.client.cacheKey(op.group, op.key); key != "" {
keys = append(keys, key)
}
if len(op.tags) > 0 {
keys = append(keys, s.tagIndex.purgeTags(op.tags...)...)
}
for _, key := range keys {
s.store.Delete(key)
}
s.tagIndex.purgeKeys(keys...)
return nil
}
func (s *inMemoryCacheStore) close() {
s.store.Close()
}
func newTagIndex() *tagIndex {
return &tagIndex{
tags: make(map[string]map[string]struct{}),
keys: make(map[string]map[string]struct{}),
}
}
func (i *tagIndex) setTags(key string, tags ...string) {
i.Lock()
defer i.Unlock()
if _, exists := i.keys[key]; !exists {
i.keys[key] = make(map[string]struct{})
}
for _, tag := range tags {
if _, exists := i.tags[tag]; !exists {
i.tags[tag] = make(map[string]struct{})
}
i.tags[tag][key] = struct{}{}
i.keys[key][tag] = struct{}{}
}
}
func (i *tagIndex) purgeTags(tags ...string) []string {
i.Lock()
defer i.Unlock()
keys := make([]string, 0)
for _, tag := range tags {
if tagKeys, exists := i.tags[tag]; exists {
delete(i.tags, tag)
for key := range tagKeys {
delete(i.keys[key], tag)
if len(i.keys[key]) == 0 {
delete(i.keys, key)
}
keys = append(keys, key)
}
}
}
return keys
}
func (i *tagIndex) purgeKeys(keys ...string) {
i.Lock()
defer i.Unlock()
for _, key := range keys {
if keyTags, exists := i.keys[key]; exists {
delete(i.keys, key)
for tag := range keyTags {
delete(i.tags[tag], key)
if len(i.tags[tag]) == 0 {
delete(i.tags, tag)
}
}
}
}
}

View file

@ -0,0 +1,105 @@
package services
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCacheClient(t *testing.T) {
type cacheTest struct {
Value string
}
// Cache some data
data := cacheTest{Value: "abcdef"}
group := "testgroup"
key := "testkey"
err := c.Cache.
Set().
Group(group).
Key(key).
Data(data).
Expiration(500 * time.Millisecond).
Save(context.Background())
require.NoError(t, err)
// Get the data
fromCache, err := c.Cache.
Get().
Group(group).
Key(key).
Fetch(context.Background())
require.NoError(t, err)
cast, ok := fromCache.(cacheTest)
require.True(t, ok)
assert.Equal(t, data, cast)
// The same key with the wrong group should fail
_, err = c.Cache.
Get().
Key(key).
Fetch(context.Background())
assert.Equal(t, ErrCacheMiss, err)
// Flush the data
err = c.Cache.
Flush().
Group(group).
Key(key).
Execute(context.Background())
require.NoError(t, err)
// The data should be gone
assertFlushed := func(key string) {
// The data should be gone
_, err = c.Cache.
Get().
Group(group).
Key(key).
Fetch(context.Background())
assert.Equal(t, ErrCacheMiss, err)
}
assertFlushed(key)
// Set with tags
key = "testkey2"
err = c.Cache.
Set().
Group(group).
Key(key).
Data(data).
Tags("tag1", "tag2").
Expiration(time.Hour).
Save(context.Background())
require.NoError(t, err)
// Check the tag index
index := c.Cache.store.(*inMemoryCacheStore).tagIndex
gk := c.Cache.cacheKey(group, key)
_, exists := index.tags["tag1"][gk]
assert.True(t, exists)
_, exists = index.tags["tag2"][gk]
assert.True(t, exists)
_, exists = index.keys[gk]["tag1"]
assert.True(t, exists)
_, exists = index.keys[gk]["tag2"]
assert.True(t, exists)
// Flush one of tags
err = c.Cache.
Flush().
Tags("tag1").
Execute(context.Background())
require.NoError(t, err)
// The data should be gone
assertFlushed(key)
// The index should be empty
assert.Empty(t, index.tags)
assert.Empty(t, index.keys)
}

View file

@ -0,0 +1,246 @@
package services
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math/rand"
"os"
"strings"
entsql "entgo.io/ent/dialect/sql"
"github.com/labstack/echo/v4"
_ "github.com/mattn/go-sqlite3"
"github.com/mikestefanello/backlite"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/log"
"github.com/spf13/afero"
// Required by ent.
_ "github.com/camzawacki/personal-site/ent/runtime"
)
// Container contains all services used by the application and provides an easy way to handle dependency
// injection including within tests.
type Container struct {
// Validator stores a validator
Validator *Validator
// Web stores the web framework.
Web *echo.Echo
// Config stores the application configuration.
Config *config.Config
// Cache contains the cache client.
Cache *CacheClient
// Database stores the connection to the database.
Database *sql.DB
// Files stores the file system.
Files afero.Fs
// ORM stores a client to the ORM.
ORM *ent.Client
// Mail stores an email sending client.
Mail *MailClient
// Auth stores an authentication client.
Auth *AuthClient
// Tasks stores the task client.
Tasks *backlite.Client
}
// NewContainer creates and initializes a new Container.
func NewContainer() *Container {
c := new(Container)
c.initConfig()
c.initValidator()
c.initWeb()
c.initCache()
c.initDatabase()
c.initFiles()
c.initORM()
c.initAuth()
c.initMail()
c.initTasks()
return c
}
// Shutdown gracefully shuts the Container down and disconnects all connections.
func (c *Container) Shutdown() error {
// Shutdown the web server.
webCtx, webCancel := context.WithTimeout(context.Background(), c.Config.HTTP.ShutdownTimeout)
defer webCancel()
if err := c.Web.Shutdown(webCtx); err != nil {
return err
}
// Shutdown the task runner.
taskCtx, taskCancel := context.WithTimeout(context.Background(), c.Config.Tasks.ShutdownTimeout)
defer taskCancel()
c.Tasks.Stop(taskCtx)
// Shutdown the ORM.
if err := c.ORM.Close(); err != nil {
return err
}
// Shutdown the database.
if err := c.Database.Close(); err != nil {
return err
}
// Shutdown the cache.
c.Cache.Close()
return nil
}
// initConfig initializes configuration.
func (c *Container) initConfig() {
cfg, err := config.GetConfig()
if err != nil {
panic(fmt.Sprintf("failed to load config: %v", err))
}
c.Config = &cfg
// Configure logging.
switch cfg.App.Environment {
case config.EnvProduction:
slog.SetLogLoggerLevel(slog.LevelInfo)
default:
slog.SetLogLoggerLevel(slog.LevelDebug)
}
}
// initValidator initializes the validator.
func (c *Container) initValidator() {
c.Validator = NewValidator()
}
// initWeb initializes the web framework.
func (c *Container) initWeb() {
c.Web = echo.New()
c.Web.HideBanner = true
c.Web.Validator = c.Validator
}
// initCache initializes the cache.
func (c *Container) initCache() {
store, err := newInMemoryCache(c.Config.Cache.Capacity)
if err != nil {
panic(err)
}
c.Cache = NewCacheClient(store)
}
// initDatabase initializes the database.
func (c *Container) initDatabase() {
var err error
var connection string
switch c.Config.App.Environment {
case config.EnvTest:
// TODO: Drop/recreate the DB, if this isn't in memory?
connection = c.Config.Database.TestConnection
default:
connection = c.Config.Database.Connection
}
c.Database, err = openDB(c.Config.Database.Driver, connection)
if err != nil {
panic(err)
}
}
// initFiles initializes the file system.
func (c *Container) initFiles() {
// Use in-memory storage for tests.
if c.Config.App.Environment == config.EnvTest {
c.Files = afero.NewMemMapFs()
return
}
fs := afero.NewOsFs()
if err := fs.MkdirAll(c.Config.Files.Directory, 0755); err != nil {
panic(err)
}
c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory)
}
// initORM initializes the ORM.
func (c *Container) initORM() {
drv := entsql.OpenDB(c.Config.Database.Driver, c.Database)
c.ORM = ent.NewClient(ent.Driver(drv))
// Run the auto migration tool.
if err := c.ORM.Schema.Create(context.Background()); err != nil {
panic(err)
}
}
// initAuth initializes the authentication client.
func (c *Container) initAuth() {
c.Auth = NewAuthClient(c.Config, c.ORM)
}
// initMail initialize the mail client.
func (c *Container) initMail() {
var err error
c.Mail, err = NewMailClient(c.Config)
if err != nil {
panic(fmt.Sprintf("failed to create mail client: %v", err))
}
}
// initTasks initializes the task client.
func (c *Container) initTasks() {
var err error
// You could use a separate database for tasks, if you'd like, but using one
// makes transaction support easier.
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database,
Logger: log.Default(),
NumWorkers: c.Config.Tasks.Goroutines,
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
CleanupInterval: c.Config.Tasks.CleanupInterval,
})
if err != nil {
panic(fmt.Sprintf("failed to create task client: %v", err))
}
if err = c.Tasks.Install(); err != nil {
panic(fmt.Sprintf("failed to install task schema: %v", err))
}
}
// openDB opens a database connection.
func openDB(driver, connection string) (*sql.DB, error) {
if driver == "sqlite3" {
// Helper to automatically create the directories that the specified sqlite file
// should reside in, if one.
d := strings.Split(connection, "/")
if len(d) > 1 {
dirpath := strings.Join(d[:len(d)-1], "/")
if err := os.MkdirAll(dirpath, 0755); err != nil {
return nil, err
}
}
// Check if a random value is required, which is often used for in-memory test databases.
if strings.Contains(connection, "$RAND") {
connection = strings.Replace(connection, "$RAND", fmt.Sprint(rand.Int()), 1)
}
}
return sql.Open(driver, connection)
}

View file

@ -0,0 +1,20 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewContainer(t *testing.T) {
assert.NotNil(t, c.Web)
assert.NotNil(t, c.Config)
assert.NotNil(t, c.Validator)
assert.NotNil(t, c.Cache)
assert.NotNil(t, c.Database)
assert.NotNil(t, c.Files)
assert.NotNil(t, c.ORM)
assert.NotNil(t, c.Mail)
assert.NotNil(t, c.Auth)
assert.NotNil(t, c.Tasks)
}

127
internal/services/mail.go Normal file
View file

@ -0,0 +1,127 @@
package services
import (
"bytes"
"errors"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/internal/log"
"maragu.dev/gomponents"
"github.com/labstack/echo/v4"
)
type (
// MailClient provides a client for sending email
// This is purposely not completed because there are many different methods and services
// for sending email, many of which are very different. Choose what works best for you
// and populate the methods below. For now, emails will just be logged.
MailClient struct {
// config stores application configuration.
config *config.Config
}
// mail represents an email to be sent.
mail struct {
client *MailClient
from string
to string
subject string
body string
component gomponents.Node
}
)
// NewMailClient creates a new MailClient.
func NewMailClient(cfg *config.Config) (*MailClient, error) {
return &MailClient{
config: cfg,
}, nil
}
// Compose creates a new email.
func (m *MailClient) Compose() *mail {
return &mail{
client: m,
from: m.config.Mail.FromAddress,
}
}
// skipSend determines if mail sending should be skipped.
func (m *MailClient) skipSend() bool {
return m.config.App.Environment != config.EnvProduction
}
// send attempts to send the email.
func (m *MailClient) send(email *mail, ctx echo.Context) error {
switch {
case email.to == "":
return errors.New("email cannot be sent without a to address")
case email.body == "" && email.component == nil:
return errors.New("email cannot be sent without a body or component to render")
}
// Check if a component was supplied.
if email.component != nil {
// Render the component and use as the body.
// TODO pool the buffers?
buf := bytes.NewBuffer(nil)
if err := email.component.Render(buf); err != nil {
return err
}
email.body = buf.String()
}
// Check if mail sending should be skipped.
if m.skipSend() {
log.Ctx(ctx).Debug("skipping email delivery",
"to", email.to,
)
return nil
}
// TODO: Finish based on your mail sender of choice or stop logging below!
log.Ctx(ctx).Info("sending email",
"to", email.to,
"subject", email.subject,
"body", email.body,
)
return nil
}
// From sets the email from address.
func (m *mail) From(from string) *mail {
m.from = from
return m
}
// To sets the email address this email will be sent to.
func (m *mail) To(to string) *mail {
m.to = to
return m
}
// Subject sets the subject line of the email.
func (m *mail) Subject(subject string) *mail {
m.subject = subject
return m
}
// Body sets the body of the email.
// This is not required and will be ignored if a component is set via Component().
func (m *mail) Body(body string) *mail {
m.body = body
return m
}
// Component sets a renderable component to use as the body of the email.
func (m *mail) Component(component gomponents.Node) *mail {
m.component = component
return m
}
// Send attempts to send the email.
func (m *mail) Send(ctx echo.Context) error {
return m.client.send(m, ctx)
}

View file

@ -0,0 +1,3 @@
package services
// Fill this in once you implement your mail client

View file

@ -0,0 +1,46 @@
package services
import (
"os"
"testing"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/labstack/echo/v4"
)
var (
c *Container
ctx echo.Context
usr *ent.User
)
func TestMain(m *testing.M) {
// Set the environment to test
config.SwitchEnvironment(config.EnvTest)
// Create a new container
c = NewContainer()
// Create a web context
ctx, _ = tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Create a test user
var err error
if usr, err = tests.CreateUser(c.ORM); err != nil {
panic(err)
}
// Run tests
exitVal := m.Run()
// Shutdown the container
if err = c.Shutdown(); err != nil {
panic(err)
}
os.Exit(exitVal)
}

View file

@ -0,0 +1,26 @@
package services
import (
"github.com/go-playground/validator/v10"
)
// Validator provides validation mainly validating structs within the web context
type Validator struct {
// validator stores the underlying validator
validator *validator.Validate
}
// NewValidator creats a new Validator
func NewValidator() *Validator {
return &Validator{
validator: validator.New(),
}
}
// Validate validates a struct
func (v *Validator) Validate(i any) error {
if err := v.validator.Struct(i); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,19 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator(t *testing.T) {
type example struct {
Value string `validate:"required"`
}
e := example{}
err := c.Validator.Validate(e)
assert.Error(t, err)
e.Value = "a"
err = c.Validator.Validate(e)
assert.NoError(t, err)
}