Initial commit

This commit is contained in:
Hayden 2022-08-29 18:30:36 -08:00
commit 29f583e936
135 changed files with 18463 additions and 0 deletions

View file

@ -0,0 +1,81 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"github.com/ardanlabs/conf/v2"
"github.com/ardanlabs/conf/v2/yaml"
"os"
)
const (
ModeDevelopment = "development"
ModeProduction = "production"
)
type Config struct {
Mode string `yaml:"mode" conf:"default:development"` // development or production
Web WebConfig `yaml:"web"`
Database Database `yaml:"database"`
Log LoggerConf `yaml:"logger"`
Mailer MailerConf `yaml:"mailer"`
Seed Seed `yaml:"seed"`
Swagger SwaggerConf `yaml:"swagger"`
}
type SwaggerConf struct {
Host string `yaml:"host" conf:"default:localhost:7745"`
Scheme string `yaml:"scheme" conf:"default:http"`
}
type WebConfig struct {
Port string `yaml:"port" conf:"default:7745"`
Host string `yaml:"host" conf:"default:127.0.0.1"`
}
// NewConfig parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the
// file is not read. If the file is not empty, the file is read and the Config struct is returned.
func NewConfig(file string) (*Config, error) {
var cfg Config
const prefix = "API"
help, err := func() (string, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return conf.Parse(prefix, &cfg)
} else {
yamlData, err := ioutil.ReadFile(file)
if err != nil {
return "", err
}
return conf.Parse(prefix, &cfg, yaml.WithData(yamlData))
}
}()
if err != nil {
if errors.Is(err, conf.ErrHelpWanted) {
fmt.Println(help)
os.Exit(0)
}
return &cfg, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
// Print prints the configuration to stdout as a json indented string
// This is useful for debugging. If the marshaller errors out, it will panic.
func (c *Config) Print() {
res, err := json.MarshalIndent(c, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(res))
}

View file

@ -0,0 +1,27 @@
package config
const (
DriverSqlite3 = "sqlite3"
DriverPostgres = "postgres"
)
type Database struct {
Driver string `yaml:"driver" conf:"default:sqlite3"`
SqliteUrl string `yaml:"sqlite-url" conf:"default:file:ent?mode=memory&cache=shared&_fk=1"`
PostgresUrl string `yaml:"postgres-url" conf:""`
}
func (d *Database) GetDriver() string {
return d.Driver
}
func (d *Database) GetUrl() string {
switch d.Driver {
case DriverSqlite3:
return d.SqliteUrl
case DriverPostgres:
return d.PostgresUrl
default:
panic("unknown database driver")
}
}

View file

@ -0,0 +1,36 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_DatabaseConfig_Sqlite(t *testing.T) {
dbConf := &Database{
Driver: DriverSqlite3,
SqliteUrl: "file:ent?mode=memory&cache=shared&_fk=1",
}
assert.Equal(t, "sqlite3", dbConf.GetDriver())
assert.Equal(t, "file:ent?mode=memory&cache=shared&_fk=1", dbConf.GetUrl())
}
func Test_DatabaseConfig_Postgres(t *testing.T) {
dbConf := &Database{
Driver: DriverPostgres,
PostgresUrl: "postgres://user:pass@host:port/dbname?sslmode=disable",
}
assert.Equal(t, "postgres", dbConf.GetDriver())
assert.Equal(t, "postgres://user:pass@host:port/dbname?sslmode=disable", dbConf.GetUrl())
}
func Test_DatabaseConfig_Unknown(t *testing.T) {
dbConf := &Database{
Driver: "null",
}
assert.Panics(t, func() { dbConf.GetUrl() })
}

View file

@ -0,0 +1,6 @@
package config
type LoggerConf struct {
Level string `conf:"default:debug"`
File string `conf:""`
}

View file

@ -0,0 +1,15 @@
package config
type MailerConf struct {
Host string `conf:""`
Port int `conf:""`
Username string `conf:""`
Password string `conf:""`
From string `conf:""`
}
// Ready is a simple check to ensure that the configuration is not empty.
// or with it's default state.
func (mc *MailerConf) Ready() bool {
return mc.Host != "" && mc.Port != 0 && mc.Username != "" && mc.Password != "" && mc.From != ""
}

View file

@ -0,0 +1,40 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_MailerReady_Success(t *testing.T) {
mc := &MailerConf{
Host: "host",
Port: 1,
Username: "username",
Password: "password",
From: "from",
}
assert.True(t, mc.Ready())
}
func Test_MailerReady_Failure(t *testing.T) {
mc := &MailerConf{}
assert.False(t, mc.Ready())
mc.Host = "host"
assert.False(t, mc.Ready())
mc.Port = 1
assert.False(t, mc.Ready())
mc.Username = "username"
assert.False(t, mc.Ready())
mc.Password = "password"
assert.False(t, mc.Ready())
mc.From = "from"
assert.True(t, mc.Ready())
}

View file

@ -0,0 +1,13 @@
package config
type SeedUser struct {
Name string `yaml:"name"`
Email string `yaml:"email"`
Password string `yaml:"password"`
IsSuperuser bool `yaml:"isSuperuser"`
}
type Seed struct {
Enabled bool `yaml:"enabled" conf:"default:false"`
Users []SeedUser `yaml:"users"`
}

View file

@ -0,0 +1,27 @@
// Code generated by "/pkgs/automapper"; DO NOT EDIT.
package mapper
import (
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
func UserOutFromModel(from ent.User) types.UserOut {
return types.UserOut{
ID: from.ID,
Name: from.Name,
Email: from.Email,
Password: from.Password,
IsSuperuser: from.IsSuperuser,
}
}
func UserOutToModel(from types.UserOut) ent.User {
return ent.User{
ID: from.ID,
Name: from.Name,
Email: from.Email,
Password: from.Password,
IsSuperuser: from.IsSuperuser,
}
}

View file

@ -0,0 +1,30 @@
package chimocker
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
)
type Params map[string]string
// WithUrlParam returns a pointer to a request object with the given URL params
// added to a new chi.Context object.
func WithUrlParam(r *http.Request, key, value string) *http.Request {
chiCtx := chi.NewRouteContext()
req := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiCtx))
chiCtx.URLParams.Add(key, value)
return req
}
// WithUrlParams returns a pointer to a request object with the given URL params
// added to a new chi.Context object. for single param assignment see WithUrlParam
func WithUrlParams(r *http.Request, params Params) *http.Request {
chiCtx := chi.NewRouteContext()
req := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiCtx))
for key, value := range params {
chiCtx.URLParams.Add(key, value)
}
return req
}

View file

@ -0,0 +1,16 @@
package factories
import (
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/faker"
)
func UserFactory() types.UserCreate {
f := faker.NewFaker()
return types.UserCreate{
Name: f.RandomString(10),
Email: f.RandomEmail(),
Password: f.RandomString(10),
IsSuperuser: f.RandomBool(),
}
}

View file

@ -0,0 +1,11 @@
package mocks
import (
"os"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
)
func GetStructLogger() *logger.Logger {
return logger.New(os.Stdout, logger.LevelDebug)
}

View file

@ -0,0 +1,10 @@
package mocks
import (
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/services"
)
func GetMockServices(repos *repo.AllRepos) *services.AllServices {
return services.NewServices(repos)
}

View file

@ -0,0 +1,22 @@
package mocks
import (
"context"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/repo"
_ "github.com/mattn/go-sqlite3"
)
func GetEntRepos() (*repo.AllRepos, func() error) {
c, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
panic(err)
}
if err := c.Schema.Create(context.Background()); err != nil {
panic(err)
}
return repo.EntAllRepos(c), c.Close
}

View file

@ -0,0 +1,38 @@
package repo
import (
"context"
"log"
"math/rand"
"os"
"testing"
"time"
"github.com/hay-kot/git-web-template/backend/ent"
_ "github.com/mattn/go-sqlite3"
)
var testEntClient *ent.Client
var testRepos *AllRepos
func TestMain(m *testing.M) {
rand.Seed(int64(time.Now().Unix()))
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatalf("failed opening connection to sqlite: %v", err)
}
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
testEntClient = client
testRepos = EntAllRepos(testEntClient)
defer client.Close()
m.Run()
os.Exit(m.Run())
}

View file

@ -0,0 +1,16 @@
package repo
import "github.com/hay-kot/git-web-template/backend/ent"
// AllRepos is a container for all the repository interfaces
type AllRepos struct {
Users UserRepository
AuthTokens TokenRepository
}
func EntAllRepos(db *ent.Client) *AllRepos {
return &AllRepos{
Users: &EntUserRepository{db},
AuthTokens: &EntTokenRepository{db},
}
}

View file

@ -0,0 +1,74 @@
package repo
import (
"context"
"time"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/ent/authtokens"
"github.com/hay-kot/git-web-template/backend/internal/mapper"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type EntTokenRepository struct {
db *ent.Client
}
// GetUserFromToken get's a user from a token
func (r *EntTokenRepository) GetUserFromToken(ctx context.Context, token []byte) (types.UserOut, error) {
dbToken, err := r.db.AuthTokens.Query().
Where(authtokens.Token(token)).
Where(authtokens.ExpiresAtGTE(time.Now())).
WithUser().
Only(ctx)
if err != nil {
return types.UserOut{}, err
}
return mapper.UserOutFromModel(*dbToken.Edges.User), nil
}
// Creates a token for a user
func (r *EntTokenRepository) CreateToken(ctx context.Context, createToken types.UserAuthTokenCreate) (types.UserAuthToken, error) {
tokenOut := types.UserAuthToken{}
dbToken, err := r.db.AuthTokens.Create().
SetToken(createToken.TokenHash).
SetUserID(createToken.UserID).
SetExpiresAt(createToken.ExpiresAt).
Save(ctx)
if err != nil {
return tokenOut, err
}
tokenOut.TokenHash = dbToken.Token
tokenOut.UserID = createToken.UserID
tokenOut.CreatedAt = dbToken.CreatedAt
tokenOut.ExpiresAt = dbToken.ExpiresAt
return tokenOut, nil
}
// DeleteToken remove a single token from the database - equivalent to revoke or logout
func (r *EntTokenRepository) DeleteToken(ctx context.Context, token []byte) error {
_, err := r.db.AuthTokens.Delete().Where(authtokens.Token(token)).Exec(ctx)
return err
}
// PurgeExpiredTokens removes all expired tokens from the database
func (r *EntTokenRepository) PurgeExpiredTokens(ctx context.Context) (int, error) {
tokensDeleted, err := r.db.AuthTokens.Delete().Where(authtokens.ExpiresAtLTE(time.Now())).Exec(ctx)
if err != nil {
return 0, err
}
return tokensDeleted, nil
}
func (r *EntTokenRepository) DeleteAll(ctx context.Context) (int, error) {
amount, err := r.db.AuthTokens.Delete().Exec(ctx)
return amount, err
}

View file

@ -0,0 +1,110 @@
package repo
import (
"context"
"testing"
"time"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
"github.com/stretchr/testify/assert"
)
func Test_EntAuthTokenRepo_CreateToken(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
user := UserFactory()
userOut, _ := testRepos.Users.Create(ctx, user)
expiresAt := time.Now().Add(time.Hour)
generatedToken := hasher.GenerateToken()
token, err := testRepos.AuthTokens.CreateToken(ctx, types.UserAuthTokenCreate{
TokenHash: generatedToken.Hash,
ExpiresAt: expiresAt,
UserID: userOut.ID,
})
assert.NoError(err)
assert.Equal(userOut.ID, token.UserID)
assert.Equal(expiresAt, token.ExpiresAt)
// Cleanup
err = testRepos.Users.Delete(ctx, userOut.ID)
_, err = testRepos.AuthTokens.DeleteAll(ctx)
}
func Test_EntAuthTokenRepo_GetUserByToken(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
user := UserFactory()
userOut, _ := testRepos.Users.Create(ctx, user)
expiresAt := time.Now().Add(time.Hour)
generatedToken := hasher.GenerateToken()
token, err := testRepos.AuthTokens.CreateToken(ctx, types.UserAuthTokenCreate{
TokenHash: generatedToken.Hash,
ExpiresAt: expiresAt,
UserID: userOut.ID,
})
// Get User from token
foundUser, err := testRepos.AuthTokens.GetUserFromToken(ctx, token.TokenHash)
assert.NoError(err)
assert.Equal(userOut.ID, foundUser.ID)
assert.Equal(userOut.Name, foundUser.Name)
assert.Equal(userOut.Email, foundUser.Email)
// Cleanup
err = testRepos.Users.Delete(ctx, userOut.ID)
_, err = testRepos.AuthTokens.DeleteAll(ctx)
}
func Test_EntAuthTokenRepo_PurgeExpiredTokens(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
user := UserFactory()
userOut, _ := testRepos.Users.Create(ctx, user)
createdTokens := []types.UserAuthToken{}
for i := 0; i < 5; i++ {
expiresAt := time.Now()
generatedToken := hasher.GenerateToken()
createdToken, err := testRepos.AuthTokens.CreateToken(ctx, types.UserAuthTokenCreate{
TokenHash: generatedToken.Hash,
ExpiresAt: expiresAt,
UserID: userOut.ID,
})
assert.NoError(err)
assert.NotNil(createdToken)
createdTokens = append(createdTokens, createdToken)
}
// Purge expired tokens
tokensDeleted, err := testRepos.AuthTokens.PurgeExpiredTokens(ctx)
assert.NoError(err)
assert.Equal(5, tokensDeleted)
// Check if tokens are deleted
for _, token := range createdTokens {
_, err := testRepos.AuthTokens.GetUserFromToken(ctx, token.TokenHash)
assert.Error(err)
}
// Cleanup
err = testRepos.Users.Delete(ctx, userOut.ID)
_, err = testRepos.AuthTokens.DeleteAll(ctx)
}

View file

@ -0,0 +1,20 @@
package repo
import (
"context"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type TokenRepository interface {
// GetUserFromToken get's a user from a token
GetUserFromToken(ctx context.Context, token []byte) (types.UserOut, error)
// Creates a token for a user
CreateToken(ctx context.Context, createToken types.UserAuthTokenCreate) (types.UserAuthToken, error)
// DeleteToken remove a single token from the database - equivalent to revoke or logout
DeleteToken(ctx context.Context, token []byte) error
// PurgeExpiredTokens removes all expired tokens from the database
PurgeExpiredTokens(ctx context.Context) (int, error)
// DeleteAll removes all tokens from the database
DeleteAll(ctx context.Context) (int, error)
}

View file

@ -0,0 +1,141 @@
package repo
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/ent/user"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type EntUserRepository struct {
db *ent.Client
}
func (e *EntUserRepository) toUserOut(usr *types.UserOut, entUsr *ent.User) {
usr.ID = entUsr.ID
usr.Password = entUsr.Password
usr.Name = entUsr.Name
usr.Email = entUsr.Email
usr.IsSuperuser = entUsr.IsSuperuser
}
func (e *EntUserRepository) GetOneId(ctx context.Context, id uuid.UUID) (types.UserOut, error) {
usr, err := e.db.User.Query().Where(user.ID(id)).Only(ctx)
usrOut := types.UserOut{}
if err != nil {
return usrOut, err
}
e.toUserOut(&usrOut, usr)
return usrOut, nil
}
func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (types.UserOut, error) {
usr, err := e.db.User.Query().Where(user.Email(email)).Only(ctx)
usrOut := types.UserOut{}
if err != nil {
return usrOut, err
}
e.toUserOut(&usrOut, usr)
return usrOut, nil
}
func (e *EntUserRepository) GetAll(ctx context.Context) ([]types.UserOut, error) {
users, err := e.db.User.Query().All(ctx)
if err != nil {
return nil, err
}
var usrs []types.UserOut
for _, usr := range users {
usrOut := types.UserOut{}
e.toUserOut(&usrOut, usr)
usrs = append(usrs, usrOut)
}
return usrs, nil
}
func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (types.UserOut, error) {
err := usr.Validate()
usrOut := types.UserOut{}
if err != nil {
return usrOut, err
}
entUser, err := e.db.User.
Create().
SetName(usr.Name).
SetEmail(usr.Email).
SetPassword(usr.Password).
SetIsSuperuser(usr.IsSuperuser).
Save(ctx)
e.toUserOut(&usrOut, entUser)
return usrOut, err
}
func (e *EntUserRepository) Update(ctx context.Context, ID uuid.UUID, data types.UserUpdate) error {
bldr := e.db.User.Update().Where(user.ID(ID))
if data.Name != nil {
bldr = bldr.SetName(*data.Name)
}
if data.Email != nil {
bldr = bldr.SetEmail(*data.Email)
}
// TODO: FUTURE
// if data.Password != nil {
// bldr = bldr.SetPassword(*data.Password)
// }
// if data.IsSuperuser != nil {
// bldr = bldr.SetIsSuperuser(*data.IsSuperuser)
// }
_, err := bldr.Save(ctx)
return err
}
func (e *EntUserRepository) Delete(ctx context.Context, id uuid.UUID) error {
_, err := e.db.User.Delete().Where(user.ID(id)).Exec(ctx)
return err
}
func (e *EntUserRepository) DeleteAll(ctx context.Context) error {
_, err := e.db.User.Delete().Exec(ctx)
return err
}
func (e *EntUserRepository) GetSuperusers(ctx context.Context) ([]types.UserOut, error) {
users, err := e.db.User.Query().Where(user.IsSuperuser(true)).All(ctx)
if err != nil {
return nil, err
}
var usrs []types.UserOut
for _, usr := range users {
usrOut := types.UserOut{}
e.toUserOut(&usrOut, usr)
usrs = append(usrs, usrOut)
}
return usrs, nil
}

View file

@ -0,0 +1,148 @@
package repo
import (
"context"
"fmt"
"testing"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/faker"
"github.com/stretchr/testify/assert"
)
func UserFactory() types.UserCreate {
f := faker.NewFaker()
return types.UserCreate{
Name: f.RandomString(10),
Email: f.RandomEmail(),
Password: f.RandomString(10),
IsSuperuser: f.RandomBool(),
}
}
func Test_EntUserRepo_GetOneEmail(t *testing.T) {
assert := assert.New(t)
user := UserFactory()
ctx := context.Background()
testRepos.Users.Create(ctx, user)
foundUser, err := testRepos.Users.GetOneEmail(ctx, user.Email)
assert.NotNil(foundUser)
assert.Nil(err)
assert.Equal(user.Email, foundUser.Email)
assert.Equal(user.Name, foundUser.Name)
// Cleanup
testRepos.Users.DeleteAll(ctx)
}
func Test_EntUserRepo_GetOneId(t *testing.T) {
assert := assert.New(t)
user := UserFactory()
ctx := context.Background()
userOut, _ := testRepos.Users.Create(ctx, user)
foundUser, err := testRepos.Users.GetOneId(ctx, userOut.ID)
assert.NotNil(foundUser)
assert.Nil(err)
assert.Equal(user.Email, foundUser.Email)
assert.Equal(user.Name, foundUser.Name)
// Cleanup
testRepos.Users.DeleteAll(ctx)
}
func Test_EntUserRepo_GetAll(t *testing.T) {
// Setup
toCreate := []types.UserCreate{
UserFactory(),
UserFactory(),
UserFactory(),
UserFactory(),
}
ctx := context.Background()
created := []types.UserOut{}
for _, usr := range toCreate {
usrOut, _ := testRepos.Users.Create(ctx, usr)
created = append(created, usrOut)
}
// Validate
allUsers, err := testRepos.Users.GetAll(ctx)
assert.Nil(t, err)
assert.Equal(t, len(created), len(allUsers))
for _, usr := range created {
fmt.Printf("%+v\n", usr)
assert.Contains(t, allUsers, usr)
}
for _, usr := range created {
testRepos.Users.Delete(ctx, usr.ID)
}
// Cleanup
testRepos.Users.DeleteAll(ctx)
}
func Test_EntUserRepo_Update(t *testing.T) {
t.Skip()
}
func Test_EntUserRepo_Delete(t *testing.T) {
// Create 10 Users
for i := 0; i < 10; i++ {
user := UserFactory()
ctx := context.Background()
_, _ = testRepos.Users.Create(ctx, user)
}
// Delete all
ctx := context.Background()
allUsers, _ := testRepos.Users.GetAll(ctx)
assert.Greater(t, len(allUsers), 0)
testRepos.Users.DeleteAll(ctx)
allUsers, _ = testRepos.Users.GetAll(ctx)
assert.Equal(t, len(allUsers), 0)
}
func Test_EntUserRepo_GetSuperusers(t *testing.T) {
// Create 10 Users
superuser := 0
users := 0
for i := 0; i < 10; i++ {
user := UserFactory()
ctx := context.Background()
_, _ = testRepos.Users.Create(ctx, user)
if user.IsSuperuser {
superuser++
} else {
users++
}
}
// Delete all
ctx := context.Background()
superUsers, err := testRepos.Users.GetSuperusers(ctx)
assert.NoError(t, err)
for _, usr := range superUsers {
assert.True(t, usr.IsSuperuser)
}
// Cleanup
testRepos.Users.DeleteAll(ctx)
}

View file

@ -0,0 +1,27 @@
package repo
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type UserRepository interface {
// GetOneId returns a user by id
GetOneId(ctx context.Context, ID uuid.UUID) (types.UserOut, error)
// GetOneEmail returns a user by email
GetOneEmail(ctx context.Context, email string) (types.UserOut, error)
// GetAll returns all users
GetAll(ctx context.Context) ([]types.UserOut, error)
// Get Super Users
GetSuperusers(ctx context.Context) ([]types.UserOut, error)
// Create creates a new user
Create(ctx context.Context, user types.UserCreate) (types.UserOut, error)
// Update updates a user
Update(ctx context.Context, ID uuid.UUID, user types.UserUpdate) error
// Delete deletes a user
Delete(ctx context.Context, ID uuid.UUID) error
DeleteAll(ctx context.Context) error
}

View file

@ -0,0 +1,15 @@
package services
import "github.com/hay-kot/git-web-template/backend/internal/repo"
type AllServices struct {
User *UserService
Admin *AdminService
}
func NewServices(repos *repo.AllRepos) *AllServices {
return &AllServices{
User: &UserService{repos},
Admin: &AdminService{repos},
}
}

View file

@ -0,0 +1,40 @@
package services
import (
"context"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type contextKeys struct {
name string
}
var (
ContextUser = &contextKeys{name: "User"}
ContextUserToken = &contextKeys{name: "UserToken"}
)
// SetUserCtx is a helper function that sets the ContextUser and ContextUserToken
// values within the context of a web request (or any context).
func SetUserCtx(ctx context.Context, user *types.UserOut, token string) context.Context {
ctx = context.WithValue(ctx, ContextUser, user)
ctx = context.WithValue(ctx, ContextUserToken, token)
return ctx
}
// UseUserCtx is a helper function that returns the user from the context.
func UseUserCtx(ctx context.Context) *types.UserOut {
if val := ctx.Value(ContextUser); val != nil {
return val.(*types.UserOut)
}
return nil
}
// UseTokenCtx is a helper function that returns the user token from the context.
func UseTokenCtx(ctx context.Context) string {
if val := ctx.Value(ContextUserToken); val != nil {
return val.(string)
}
return ""
}

View file

@ -0,0 +1,39 @@
package services
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/stretchr/testify/assert"
)
func Test_SetAuthContext(t *testing.T) {
user := &types.UserOut{
ID: uuid.New(),
}
token := uuid.New().String()
ctx := SetUserCtx(context.Background(), user, token)
ctxUser := UseUserCtx(ctx)
assert.NotNil(t, ctxUser)
assert.Equal(t, user.ID, ctxUser.ID)
ctxUserToken := UseTokenCtx(ctx)
assert.NotEmpty(t, ctxUserToken)
}
func Test_SetAuthContext_Nulls(t *testing.T) {
ctx := SetUserCtx(context.Background(), nil, "")
ctxUser := UseUserCtx(ctx)
assert.Nil(t, ctxUser)
ctxUserToken := UseTokenCtx(ctx)
assert.Empty(t, ctxUserToken)
}

View file

@ -0,0 +1,47 @@
package services
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
type AdminService struct {
repos *repo.AllRepos
}
func (svc *AdminService) Create(ctx context.Context, usr types.UserCreate) (types.UserOut, error) {
return svc.repos.Users.Create(ctx, usr)
}
func (svc *AdminService) GetAll(ctx context.Context) ([]types.UserOut, error) {
return svc.repos.Users.GetAll(ctx)
}
func (svc *AdminService) GetByID(ctx context.Context, id uuid.UUID) (types.UserOut, error) {
return svc.repos.Users.GetOneId(ctx, id)
}
func (svc *AdminService) GetByEmail(ctx context.Context, email string) (types.UserOut, error) {
return svc.repos.Users.GetOneEmail(ctx, email)
}
func (svc *AdminService) UpdateProperties(ctx context.Context, ID uuid.UUID, data types.UserUpdate) (types.UserOut, error) {
err := svc.repos.Users.Update(ctx, ID, data)
if err != nil {
return types.UserOut{}, err
}
return svc.repos.Users.GetOneId(ctx, ID)
}
func (svc *AdminService) Delete(ctx context.Context, id uuid.UUID) error {
return svc.repos.Users.Delete(ctx, id)
}
func (svc *AdminService) DeleteAll(ctx context.Context) error {
return svc.repos.Users.DeleteAll(ctx)
}

View file

@ -0,0 +1,84 @@
package services
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
)
var (
oneWeek = time.Hour * 24 * 7
ErrorInvalidLogin = errors.New("invalid username or password")
ErrorInvalidToken = errors.New("invalid token")
ErrorTokenIdMismatch = errors.New("token id mismatch")
)
type UserService struct {
repos *repo.AllRepos
}
// GetSelf returns the user that is currently logged in based of the token provided within
func (svc *UserService) GetSelf(ctx context.Context, requestToken string) (types.UserOut, error) {
hash := hasher.HashToken(requestToken)
return svc.repos.AuthTokens.GetUserFromToken(ctx, hash)
}
func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data types.UserUpdate) (types.UserOut, error) {
err := svc.repos.Users.Update(ctx, ID, data)
if err != nil {
return types.UserOut{}, err
}
return svc.repos.Users.GetOneId(ctx, ID)
}
// ============================================================================
// User Authentication
func (svc *UserService) createToken(ctx context.Context, userId uuid.UUID) (types.UserAuthTokenDetail, error) {
newToken := hasher.GenerateToken()
created, err := svc.repos.AuthTokens.CreateToken(ctx, types.UserAuthTokenCreate{
UserID: userId,
TokenHash: newToken.Hash,
ExpiresAt: time.Now().Add(oneWeek),
})
return types.UserAuthTokenDetail{Raw: newToken.Raw, ExpiresAt: created.ExpiresAt}, err
}
func (svc *UserService) Login(ctx context.Context, username, password string) (types.UserAuthTokenDetail, error) {
usr, err := svc.repos.Users.GetOneEmail(ctx, username)
if err != nil || !hasher.CheckPasswordHash(password, usr.Password) {
return types.UserAuthTokenDetail{}, ErrorInvalidLogin
}
return svc.createToken(ctx, usr.ID)
}
func (svc *UserService) Logout(ctx context.Context, token string) error {
hash := hasher.HashToken(token)
err := svc.repos.AuthTokens.DeleteToken(ctx, hash)
return err
}
func (svc *UserService) RenewToken(ctx context.Context, token string) (types.UserAuthTokenDetail, error) {
hash := hasher.HashToken(token)
dbToken, err := svc.repos.AuthTokens.GetUserFromToken(ctx, hash)
if err != nil {
return types.UserAuthTokenDetail{}, ErrorInvalidToken
}
newToken, _ := svc.createToken(ctx, dbToken.ID)
return newToken, nil
}

View file

@ -0,0 +1,11 @@
package types
// ApiSummary
//
// @public
type ApiSummary struct {
Healthy bool `json:"health"`
Versions []string `json:"versions"`
Title string `json:"title"`
Message string `json:"message"`
}

View file

@ -0,0 +1,39 @@
package types
import (
"time"
"github.com/google/uuid"
)
type LoginForm struct {
Username string `json:"username"`
Password string `json:"password"`
}
type TokenResponse struct {
BearerToken string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
type UserAuthTokenDetail struct {
Raw string `json:"raw"`
ExpiresAt time.Time `json:"expiresAt"`
}
type UserAuthToken struct {
TokenHash []byte `json:"token"`
UserID uuid.UUID `json:"userId"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
func (u UserAuthToken) IsExpired() bool {
return u.ExpiresAt.Before(time.Now())
}
type UserAuthTokenCreate struct {
TokenHash []byte `json:"token"`
UserID uuid.UUID `json:"userId"`
ExpiresAt time.Time `json:"expiresAt"`
}

View file

@ -0,0 +1,58 @@
package types
import (
"errors"
"github.com/google/uuid"
)
var (
ErrNameEmpty = errors.New("name is empty")
ErrEmailEmpty = errors.New("email is empty")
)
// UserIn is a basic user input struct containing only the fields that are
// required for user creation.
type UserIn struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
// UserCreate is the Data object contain the requirements of creating a user
// in the database. It should to create users from an API unless the user has
// rights to create SuperUsers. For regular user in data use the UserIn struct.
type UserCreate struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
IsSuperuser bool `json:"isSuperuser"`
}
func (u *UserCreate) Validate() error {
if u.Name == "" {
return ErrNameEmpty
}
if u.Email == "" {
return ErrEmailEmpty
}
return nil
}
type UserOut struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"-"`
IsSuperuser bool `json:"isSuperuser"`
}
// IsNull is a proxy call for `usr.Id == uuid.Nil`
func (usr *UserOut) IsNull() bool {
return usr.ID == uuid.Nil
}
type UserUpdate struct {
Name *string `json:"name"`
Email *string `json:"email"`
}

View file

@ -0,0 +1,76 @@
package types
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestUserCreate_Validate(t *testing.T) {
type fields struct {
Name string
Email string
Password string
IsSuperuser bool
}
tests := []struct {
name string
fields fields
wantErr bool
}{
{
name: "no_name",
fields: fields{
Name: "",
Email: "",
Password: "",
IsSuperuser: false,
},
wantErr: true,
},
{
name: "no_email",
fields: fields{
Name: "test",
Email: "",
Password: "",
IsSuperuser: false,
},
wantErr: true,
},
{
name: "valid",
fields: fields{
Name: "test",
Email: "test@email.com",
Password: "mypassword",
IsSuperuser: false,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := &UserCreate{
Name: tt.fields.Name,
Email: tt.fields.Email,
Password: tt.fields.Password,
IsSuperuser: tt.fields.IsSuperuser,
}
if err := u.Validate(); (err != nil) != tt.wantErr {
t.Errorf("UserCreate.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestUserOut_IsNull(t *testing.T) {
nullUser := UserOut{}
assert.True(t, nullUser.IsNull())
nullUser.ID = uuid.New()
assert.False(t, nullUser.IsNull())
}