feat: initial commit

This commit is contained in:
2025-03-31 16:17:35 +01:00
commit 6719f8a61a
91 changed files with 7119 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
package auth
import "github.com/golang-jwt/jwt/v5"
type Authenticator interface {
GenerateToken(jwt.Claims) (string, error)
ValidateToken(string) (*jwt.Token, error)
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
)
type JWTAuthenticator struct {
secret string
aud string
iss string
}
func NewJWTAuthenticator(secret, aud, iss string) *JWTAuthenticator {
return &JWTAuthenticator{
secret: secret,
aud: aud,
iss: iss,
}
}
func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(j.secret))
if err != nil {
return "", err
}
return tokenString, nil
}
func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, 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(j.secret), nil
},
jwt.WithExpirationRequired(),
jwt.WithAudience(j.aud),
jwt.WithIssuer(j.iss),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
)
}
+31
View File
@@ -0,0 +1,31 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type TestAuthenticator struct {
}
const testToken = "test-token"
var testClaims = jwt.MapClaims{
"aud": "test-audience",
"iss": "test-issuer",
"sub": 1,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims)
return token.SignedString([]byte(testToken))
}
func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil
})
}
+27
View File
@@ -0,0 +1,27 @@
package db
import (
"context"
"database/sql"
"time"
)
func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", addr)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxIdleTime(maxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
+186
View File
@@ -0,0 +1,186 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"math/rand"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
)
var first_names = []string{
"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy",
"Mallory", "Nathan", "Olivia", "Peggy", "Quinn", "Rachel", "Sam", "Trent", "Ursula", "Victor",
"Wendy", "Xander", "Yvonne", "Zach", "Amy", "Brian", "Carl", "Diana", "Edward", "Fiona",
"George", "Hannah", "Ian", "Jessica", "Kevin", "Lisa", "Michael", "Nina", "Oscar", "Paula",
"Quentin", "Rebecca", "Steve", "Tina", "Uma", "Vincent", "Willow", "Xenia", "Yuri", "Zoe",
}
var last_names = []string{
"Smith", "Johnson", "Williams", "Jones", "Brown", "Davis", "Miller", "Wilson", "Moore", "Taylor",
"Anderson", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Garcia", "Martinez", "Robinson",
"Clark", "Rodriguez", "Lewis", "Lee", "Walker", "Hall", "Allen", "Young", "Hernandez", "King",
"Wright", "Lopez", "Hill", "Scott", "Green", "Adams", "Baker", "Gonzalez", "Nelson", "Carter",
"Mitchell", "Perez", "Roberts", "Turner", "Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins",
}
var usernames = []string{
"alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy",
"mallory", "nathan", "olivia", "peggy", "quinn", "rachel", "sam", "trent", "ursula", "victor",
"wendy", "xander", "yvonne", "zach", "amy", "brian", "carl", "diana", "edward", "fiona",
"george", "hannah", "ian", "jessica", "kevin", "lisa", "michael", "nina", "oscar", "paula",
"quentin", "rebecca", "steve", "tina", "uma", "vincent", "willow", "xenia", "yuri", "zoe",
}
var titles = []string{
"How to Learn Go in 10 Days",
"Understanding Concurrency in Go",
"Building REST APIs with Go",
"Mastering Go: Tips and Tricks",
"Go vs. Python: A Comparison",
"Error Handling in Go",
"Go Routines and Channels Explained",
"Building a Web Server in Go",
"Go for Beginners: A Comprehensive Guide",
"Advanced Go Programming Techniques",
"Testing in Go: Best Practices",
"Go Modules: Managing Dependencies",
"Introduction to Go Interfaces",
"Go Data Structures and Algorithms",
"Building CLI Applications with Go",
"Go Memory Management",
"Go Design Patterns",
"Deploying Go Applications",
"Go and Microservices",
"Profiling and Optimizing Go Code",
}
var contents = []string{
"Learn the basics of Go programming in just 10 days with this comprehensive guide.",
"Understand the intricacies of concurrency in Go and how to effectively manage goroutines.",
"Step-by-step guide to building RESTful APIs using the Go programming language.",
"Discover tips and tricks to master Go programming and write efficient code.",
"Compare the features and performance of Go and Python in various scenarios.",
"Learn how to handle errors gracefully in Go and write robust applications.",
"An in-depth explanation of goroutines and channels in Go for concurrent programming.",
"Build a simple yet powerful web server using Go and the net/http package.",
"A comprehensive guide for beginners to learn Go programming from scratch.",
"Explore advanced programming techniques in Go to write high-performance applications.",
"Best practices for writing and running tests in Go to ensure code quality.",
"Manage dependencies in your Go projects using Go Modules effectively.",
"An introduction to interfaces in Go and how to use them for polymorphism.",
"Learn about various data structures and algorithms implemented in Go.",
"Build command-line applications in Go with ease using the flag package.",
"Understand Go's memory management and how to optimize memory usage.",
"Explore common design patterns in Go to write maintainable and scalable code.",
"Learn how to deploy Go applications to various environments.",
"Discover how to build microservices architecture using Go.",
"Techniques for profiling and optimizing the performance of Go code.",
}
var tags = []string{
"golang", "programming", "tutorial", "webdev", "backend", "frontend", "api", "concurrency", "microservices", "testing",
"designpatterns", "cli", "memory", "optimization", "deployment", "databases", "security", "networking", "algorithms", "datastructures",
}
var comments = []string{
"Great post! Thanks for sharing.",
"Very informative. I learned a lot.",
"I disagree with some points, but overall a good read.",
"Can you provide more details on this topic?",
"Excellent writing! Keep it up.",
"This helped me understand Go better. Thanks!",
"Interesting perspective. I never thought about it that way.",
"Could you share the source code?",
"Looking forward to your next post.",
"Thanks for the insights. Very helpful.",
"Well explained. I appreciate the examples.",
"I have a question about concurrency in Go.",
"Can you recommend more resources on this topic?",
"Great tips! I'll definitely try them out.",
"This post is a bit confusing. Can you clarify?",
"Thanks for the tutorial. It was easy to follow.",
"I found a typo in the code snippet.",
"Can you write about error handling in Go?",
"Your posts are always so detailed and helpful.",
"I tried this and it worked perfectly. Thanks!",
}
func Seed(store store.Storage, db *sql.DB) {
ctx := context.Background()
users := generateUsers(100)
tx, _ := db.BeginTx(ctx, nil)
for _, u := range users {
if err := store.Users.Create(ctx, tx, u); err != nil {
tx.Rollback()
log.Println("failed to create user:", err)
}
}
tx.Commit()
posts := generatePosts(200, users)
for _, p := range posts {
if err := store.Posts.Create(ctx, p); err != nil {
log.Println("failed to create post:", err)
}
}
comments := generateComments(500, users, posts)
for _, c := range comments {
if err := store.Comments.Create(ctx, c); err != nil {
log.Println("failed to create comment:", err)
}
}
log.Println("seeded database with sample data")
}
func generateUsers(n int) []*store.User {
users := make([]*store.User, n)
for i := 0; i < n; i++ {
users[i] = &store.User{
FirstName: first_names[i%len(first_names)],
LastName: last_names[i%len(last_names)],
Username: usernames[i%len(usernames)] + fmt.Sprintf("%d", i),
Email: usernames[i%len(usernames)] + fmt.Sprintf("%d", i) + "@example.com",
Role: store.Role{
Name: "user",
},
}
}
return users
}
func generatePosts(n int, users []*store.User) []*store.Post {
posts := make([]*store.Post, n)
for i := 0; i < n; i++ {
user := users[rand.Intn(len(users))]
posts[i] = &store.Post{
UserID: user.ID,
Title: titles[rand.Intn(len(titles))],
Content: contents[rand.Intn(len(contents))],
Tags: []string{tags[rand.Intn(len(tags))], tags[rand.Intn(len(tags))]},
}
}
return posts
}
func generateComments(n int, users []*store.User, posts []*store.Post) []*store.Comment {
post_comments := make([]*store.Comment, n)
for i := 0; i < n; i++ {
user := users[rand.Intn(len(users))]
post := posts[rand.Intn(len(posts))]
post_comments[i] = &store.Comment{
PostID: post.ID,
UserID: user.ID,
Content: comments[rand.Intn(len(comments))],
}
}
return post_comments
}
+64
View File
@@ -0,0 +1,64 @@
package env
import (
"os"
"strconv"
"strings"
"time"
)
func GetString(key, fallback string) string {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
return val
}
func GetInt(key string, fallback int) int {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
valAsInt, err := strconv.Atoi(val)
if err != nil {
return fallback
}
return valAsInt
}
func GetDuration(key string, fallback string) time.Duration {
parsedFallback, _ := time.ParseDuration(fallback)
val, ok := os.LookupEnv(key)
if !ok {
return parsedFallback
}
parsedVal, err := time.ParseDuration(val)
if err != nil {
return parsedFallback
}
return parsedVal
}
func GetBool(key string, fallback bool) bool {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
valAsBool, err := strconv.ParseBool(val)
if err != nil {
return fallback
}
return valAsBool
}
func GetList(key string, fallback []string) []string {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
return strings.Split(val, ",")
}
+16
View File
@@ -0,0 +1,16 @@
package mailer
import "embed"
const (
FromName = "LK API"
maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl"
)
//go:embed "templates"
var FS embed.FS
type Client interface {
Send(templateFile, username, email string, data any, isDevEnv bool) (int, error)
}
+67
View File
@@ -0,0 +1,67 @@
package mailer
import (
"bytes"
"fmt"
"text/template"
"time"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
type SendGridMailer struct {
fromEmail string
apiKey string
client *sendgrid.Client
}
func NewSendGrid(apiKey, fromEmail string) *SendGridMailer {
client := sendgrid.NewSendClient(apiKey)
return &SendGridMailer{
fromEmail: fromEmail,
apiKey: apiKey,
client: client,
}
}
func (s *SendGridMailer) Send(templateFile, username, email string, data any, isDevEnv bool) (int, error) {
from := mail.NewEmail(FromName, s.fromEmail)
to := mail.NewEmail(username, email)
tmpl, err := template.ParseFS(FS, "templates/"+templateFile)
if err != nil {
return -1, err
}
// Template Parsing and sending
subject := new(bytes.Buffer)
if err := tmpl.ExecuteTemplate(subject, "subject", data); err != nil {
return -1, err
}
body := new(bytes.Buffer)
if err := tmpl.ExecuteTemplate(body, "body", data); err != nil {
return -1, err
}
message := mail.NewSingleEmail(from, subject.String(), to, "", body.String())
message.SetMailSettings(&mail.MailSettings{
SandboxMode: &mail.Setting{
Enable: &isDevEnv,
},
})
var retryErr error
for i := 0; i < maxRetries; i++ {
resp, retryErr := s.client.Send(message)
if retryErr != nil {
//* Exponential backoff
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
return resp.StatusCode, nil
}
return -1, fmt.Errorf("failed to send the email after %d attempts, error: %v", maxRetries, retryErr)
}
@@ -0,0 +1,22 @@
{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body> <p>Hi {{.Username}},</p>
<p>Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!</p></p>
<p>Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:</p>
<p><a href="{{.ActivationURL}}">{{.ActivationURL}}</a></p>
<p>If you want to activate your account manually copy and paste the code from the link above</p>
<p>If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
+51
View File
@@ -0,0 +1,51 @@
package ratelimiter
import (
"log"
"sync"
"time"
)
type FixedWindowLimiter struct {
sync.RWMutex
clients map[string]int
limit int
window time.Duration
}
func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter {
return &FixedWindowLimiter{
clients: make(map[string]int),
limit: limit,
window: window,
}
}
func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
rl.RLock()
count, exists := rl.clients[ip]
rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit {
rl.Lock()
if !exists {
go rl.resetCount(ip)
}
rl.clients[ip]++
rl.Unlock()
return true, 0
}
return false, rl.window
}
func (rl *FixedWindowLimiter) resetCount(ip string) {
time.Sleep(rl.window)
rl.Lock()
delete(rl.clients, ip)
rl.Unlock()
}
+15
View File
@@ -0,0 +1,15 @@
package ratelimiter
import "time"
//! Use Redis for rate limiting when in multiple instances
type Limiter interface {
Allow(ip string) (bool, time.Duration)
}
type Config struct {
RequestsPerTimeFrame int
TimeFrame time.Duration
Enabled bool
}
+33
View File
@@ -0,0 +1,33 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/stretchr/testify/mock"
)
func NewMockCache() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Get(ctx context.Context, id int64) (*store.User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) Set(ctx context.Context, user *store.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+11
View File
@@ -0,0 +1,11 @@
package cache
import "github.com/go-redis/redis/v8"
func NewRedisClient(addr, pw string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: pw,
DB: db,
})
}
+22
View File
@@ -0,0 +1,22 @@
package cache
import (
"context"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type Storage struct {
Users interface {
Get(context.Context, int64) (*store.User, error)
Set(context.Context, *store.User) error
Delete(context.Context, int64) error
}
}
func NewRedisStorage(rdb *redis.Client) Storage {
return Storage{
Users: &UserStore{rdb: rdb},
}
}
+60
View File
@@ -0,0 +1,60 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
"github.com/go-redis/redis/v8"
)
type UserStore struct {
rdb *redis.Client
}
const UserExpTime = time.Hour * 24
func (s *UserStore) Get(ctx context.Context, id int64) (*store.User, error) {
cacheKey := fmt.Sprintf("user-%d", id)
data, err := s.rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return nil, nil
} else if err != nil {
return nil, err
}
var user store.User
if data != "" {
err := json.Unmarshal([]byte(data), &user)
if err != nil {
return nil, err
}
}
return &user, nil
}
func (s *UserStore) Set(ctx context.Context, user *store.User) error {
if user.ID == 0 {
return fmt.Errorf("user ID is required")
}
cacheKey := fmt.Sprintf("user-%d", user.ID)
json, err := json.Marshal(user)
if err != nil {
return err
}
return s.rdb.SetEX(ctx, cacheKey, json, UserExpTime).Err()
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
cacheKey := fmt.Sprintf("user-%d", id)
return s.rdb.Del(ctx, cacheKey).Err()
}
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"context"
"database/sql"
)
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
UserID int64 `json:"user_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
User User `json:"user"`
}
type CommentStore struct {
db *sql.DB
}
func (s *CommentStore) Create(ctx context.Context, c *Comment) error {
query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at;`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(ctx, query, c.PostID, c.UserID, c.Content).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *CommentStore) GetByPostID(ctx context.Context, postID int64) ([]Comment, error) {
query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c
JOIN users on c.user_id = users.id
WHERE c.post_id = $1
ORDER BY c.created_at DESC;
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
comments := []Comment{}
for rows.Next() {
var comment Comment
comment.User = User{}
err := rows.Scan(
&comment.ID,
&comment.PostID,
&comment.UserID,
&comment.Content,
&comment.CreatedAt,
&comment.User.Username,
&comment.User.ID,
)
if err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, nil
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"context"
"database/sql"
"github.com/lib/pq"
)
type Follower struct {
ID int64 `json:"id"`
FolloweeID int64 `json:"followee_id"`
CreatedAt string `json:"created_at"`
}
type FollowerStore struct {
db *sql.DB
}
func (s *FollowerStore) Follow(ctx context.Context, followeeID, followerID int64) error {
query := `INSERT INTO followers (user_id, follower_id) VALUES ($1, $2)`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrConflict
}
}
return nil
}
func (s *FollowerStore) Unfollow(ctx context.Context, followeeID, followerID int64) error {
query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return ErrNotFound
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"context"
"database/sql"
"time"
"github.com/stretchr/testify/mock"
)
func NewMockStorage() Storage {
return Storage{
Users: &MockUserStore{},
}
}
type MockUserStore struct {
mock.Mock
}
func (m *MockUserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
args := m.Called(tx, user)
return args.Error(0)
}
func (m *MockUserStore) Activate(ctx context.Context, token string) error {
args := m.Called(token)
return args.Error(0)
}
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
args := m.Called(id)
return nil, args.Error(1)
}
func (m *MockUserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
args := m.Called(email)
return nil, args.Error(1)
}
func (m *MockUserStore) Update(ctx context.Context, user *User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token string, expiresAt time.Duration) error {
args := m.Called(user, token, expiresAt)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockUserStore) DeleteUser(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"net/http"
"strconv"
"strings"
"time"
)
type PaginatedFeedQuery struct {
Limit int `json:"limit" validate:"gte=1,lte=20"`
Offset int `json:"offset" validate:"gte=0"`
Sort string `json:"sort" validate:"oneof=asc desc"`
Tags []string `json:"tags" validate:"max=5"`
Search string `json:"search" validate:"max=100"`
Since string `json:"since"`
Until string `json:"until"`
}
func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) {
qs := r.URL.Query()
limit := qs.Get("limit")
if limit != "" {
l, err := strconv.Atoi(limit)
if err != nil {
return fq, err
}
fq.Limit = l
}
offset := qs.Get("offset")
if offset != "" {
o, err := strconv.Atoi(offset)
if err != nil {
return PaginatedFeedQuery{}, err
}
fq.Offset = o
}
sort := qs.Get("sort")
if sort != "" {
fq.Sort = sort
}
tags := qs.Get("tags")
if tags != "" {
fq.Tags = strings.Split(tags, ",")
}
search := qs.Get("search")
if search != "" {
fq.Search = search
}
since := qs.Get("since")
if since != "" {
fq.Since = parseTime(since)
}
until := qs.Get("until")
if until != "" {
fq.Until = parseTime(until)
}
return fq, nil
}
func parseTime(s string) string {
t, err := time.Parse(time.DateTime, s)
if err != nil {
return ""
}
return t.Format(time.DateTime)
}
+205
View File
@@ -0,0 +1,205 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/lib/pq"
)
type Post struct {
ID int64 `json:"id"`
Content string `json:"content"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Version int `json:"version"`
Comments []Comment `json:"comments"`
User User `json:"user"`
}
type PostWithMetadata struct {
Post
CommentCount int `json:"comment_count"`
}
type PostStore struct {
db *sql.DB
}
func (s *PostStore) Create(ctx context.Context, post *Post) error {
query := `
INSERT INTO posts (content, title, user_id, tags)
VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.UserID,
pq.Array(post.Tags),
).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt)
if err != nil {
return err
}
return nil
}
func (s *PostStore) GetByID(ctx context.Context, id int64) (*Post, error) {
query := `
SELECT id, content, title, user_id, tags, created_at, updated_at, version
FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
var post Post
err := s.db.QueryRowContext(ctx, query, id).Scan(
&post.ID,
&post.Content,
&post.Title,
&post.UserID,
pq.Array(&post.Tags),
&post.CreatedAt,
&post.UpdatedAt,
&post.Version,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
default:
return nil, err
}
}
return &post, nil
}
func (s *PostStore) GetUserFeed(ctx context.Context, userID int64, fq PaginatedFeedQuery) ([]PostWithMetadata, error) {
query := `
SELECT
p.id, p.user_id, p.title, p.content, p.created_at, p.updated_at, p.version, p.tags,
u.username,
COUNT(c.id) AS comments_count
FROM posts p
LEFT JOIN comments c on c.post_id = p.id
LEFT JOIN users u ON p.user_id = u.id
JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1
WHERE
f.user_id = $1 AND
(p.title ILIKE '%' || $4 || '%' OR p.content ILIKE '%' || $4 || '%') AND
(p.tags @> $5 OR $5 = '{}')
GROUP BY p.id, u.username
ORDER BY p.created_at ` + fq.Sort + `
LIMIT $2 OFFSET $3
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, fq.Limit, fq.Offset, fq.Search, pq.Array(fq.Tags))
if err != nil {
return nil, err
}
defer rows.Close()
var feed []PostWithMetadata
for rows.Next() {
var p PostWithMetadata
err := rows.Scan(
&p.ID,
&p.UserID,
&p.Title,
&p.Content,
&p.CreatedAt,
&p.UpdatedAt,
&p.Version,
pq.Array(&p.Tags),
&p.User.Username,
&p.CommentCount,
)
if err != nil {
return nil, err
}
fmt.Println(p)
feed = append(feed, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return feed, nil
}
func (s *PostStore) Update(ctx context.Context, post *Post) error {
query := `
UPDATE posts
SET content = $1, title = $2, updated_at = NOW(), version = version + 1
WHERE id = $3 AND version = $4
RETURNING version
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err := s.db.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.ID,
post.Version,
).Scan(&post.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
default:
return err
}
}
return nil
}
func (s *PostStore) Delete(ctx context.Context, id int64) error {
query := `
DELETE FROM posts
WHERE id = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
res, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return ErrNotFound
}
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package store
import (
"context"
"database/sql"
)
type Role struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int64 `json:"level"`
Description string `json:"description"`
}
type RoleStore struct {
db *sql.DB
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
query := `
SELECT id, name, level, description
FROM roles
WHERE name = $1
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan(
&role.ID,
&role.Name,
&role.Level,
&role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return role, nil
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
var (
QueryTimeout = 5 * time.Second
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrDuplicateEmail = errors.New("email already exists")
ErrDuplicateUsername = errors.New("username already exists")
ErrPasswordNotSet = errors.New("password is empty")
)
type Storage struct {
Posts interface {
Create(context.Context, *Post) error
GetByID(context.Context, int64) (*Post, error)
GetUserFeed(context.Context, int64, PaginatedFeedQuery) ([]PostWithMetadata, error)
Update(context.Context, *Post) error
Delete(context.Context, int64) error
}
Users interface {
Create(context.Context, *sql.Tx, *User) error
Activate(context.Context, string) error
GetByID(context.Context, int64) (*User, error)
GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error
Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error
}
Comments interface {
Create(context.Context, *Comment) error
GetByPostID(context.Context, int64) ([]Comment, error)
}
Followers interface {
Follow(context.Context, int64, int64) error
Unfollow(context.Context, int64, int64) error
}
Roles interface {
GetByName(context.Context, string) (*Role, error)
}
}
func NewStorage(db *sql.DB) Storage {
return Storage{
Posts: &PostStore{db},
Users: &UserStore{db},
Comments: &CommentStore{db},
Followers: &FollowerStore{db},
Roles: &RoleStore{db},
}
}
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := f(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
+363
View File
@@ -0,0 +1,363 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
Email string `json:"email"`
Password password `json:"-"`
IsActive bool `json:"is_active"`
RoleID int64 `json:"role_id"`
Role Role `json:"role"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type password struct {
text *string `validate:"required,password"`
hash []byte
}
func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
p.text = &password
p.hash = hash
return nil
}
type UserStore struct {
db *sql.DB
}
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name
if role == "" {
role = "user"
}
err := tx.QueryRowContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
role,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
switch {
case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`:
return ErrDuplicateUsername
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
return ErrDuplicateEmail
default:
return err
}
}
return nil
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token string, invitationExp time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.Create(ctx, tx, user); err != nil {
return err
}
if err := s.createUserInvitation(ctx, tx, user.ID, invitationExp, token); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token)
if err != nil {
return err
}
// Update the user's status to active
user.IsActive = true
if err := s.update(ctx, tx, user); err != nil {
return err
}
// Delete the token from the invitations table
if err := s.deleteUserInvitation(ctx, tx, user.ID); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Delete(ctx context.Context, id int64) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.delete(ctx, tx, id); err != nil {
return err
}
if err := s.deleteUserInvitation(ctx, tx, id); err != nil {
return err
}
return nil
})
}
// * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}
func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
}
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.IsActive,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
return nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.IsActive,
user.ID,
)
if err != nil {
return err
}
return nil
}
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID)
if err != nil {
return err
}
return nil
}
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, id)
if err != nil {
return err
}
return nil
}