refactor: remove unused assets and components, update styles and structure
- Deleted base.css and logo.svg as they were no longer needed. - Updated main.css with new color palette and improved styles for various components. - Removed HelloWorld.vue and TheWelcome.vue components to streamline the project. - Deleted WelcomeItem.vue and associated icon components to clean up unused code. - Enhanced responsiveness and accessibility across styles and components.
This commit is contained in:
647
main.go
647
main.go
@@ -1,12 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/segmentio/kafka-go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
@@ -21,120 +35,224 @@ type Task struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type taskStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
items map[int64]Task
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
users map[string]User
|
||||
sessions map[string]int64
|
||||
type tokenManager struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newTaskStore() *taskStore {
|
||||
return &taskStore{
|
||||
nextID: 1,
|
||||
items: make(map[int64]Task),
|
||||
type postgresStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type tokenCache struct {
|
||||
client *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
type taskEmitter struct {
|
||||
writer *kafka.Writer
|
||||
topic string
|
||||
}
|
||||
|
||||
func newTokenManager(secret string, ttl time.Duration) *tokenManager {
|
||||
return &tokenManager{
|
||||
secret: []byte(secret),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthStore() *authStore {
|
||||
return &authStore{
|
||||
nextID: 1,
|
||||
users: make(map[string]User),
|
||||
sessions: make(map[string]int64),
|
||||
func (t *tokenManager) Generate(user User) (string, error) {
|
||||
payload := struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Exp int64 `json:"exp"`
|
||||
}{
|
||||
UserID: user.ID,
|
||||
Exp: time.Now().Add(t.ttl).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authStore) register(email, password string) (User, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.users[email]; exists {
|
||||
return User{}, false
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user := User{
|
||||
ID: s.nextID,
|
||||
Email: email,
|
||||
Password: password,
|
||||
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sig := t.sign(encoded)
|
||||
return encoded + "." + sig, nil
|
||||
}
|
||||
|
||||
func (t *tokenManager) Validate(token string) (int64, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
s.nextID++
|
||||
s.users[email] = user
|
||||
return user, true
|
||||
}
|
||||
|
||||
func (s *authStore) login(email, password string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
user, ok := s.users[email]
|
||||
if !ok || user.Password != password {
|
||||
return "", false
|
||||
payload, sig := parts[0], parts[1]
|
||||
if !t.verify(payload, sig) {
|
||||
return 0, false
|
||||
}
|
||||
token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10)
|
||||
s.sessions[token] = user.ID
|
||||
return token, true
|
||||
}
|
||||
|
||||
func (s *authStore) validate(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, ok := s.sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *taskStore) list() []Task {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
result := make([]Task, 0, len(s.items))
|
||||
for _, t := range s.items {
|
||||
result = append(result, t)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return result
|
||||
var data struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if data.UserID == 0 || time.Now().Unix() > data.Exp {
|
||||
return 0, false
|
||||
}
|
||||
return data.UserID, true
|
||||
}
|
||||
|
||||
func (s *taskStore) get(id int64) (Task, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
t, ok := s.items[id]
|
||||
return t, ok
|
||||
func (t *tokenManager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, t.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *taskStore) create(input Task) Task {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
func (t *tokenManager) verify(payload, signature string) bool {
|
||||
expected := t.sign(payload)
|
||||
return hmac.Equal([]byte(signature), []byte(expected))
|
||||
}
|
||||
|
||||
input.ID = s.nextID
|
||||
s.nextID++
|
||||
now := time.Now().UTC()
|
||||
input.CreatedAt = now
|
||||
input.UpdatedAt = now
|
||||
func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
store := &postgresStore{pool: pool}
|
||||
if err := store.initSchema(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) initSchema(ctx context.Context) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
due_at TEXT,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := s.pool.Exec(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) Register(ctx context.Context, email, password string) (User, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
var id int64
|
||||
err = s.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, email, string(hashed)).Scan(&id)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return User{}, errAlreadyExists
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
return User{ID: id, Email: email}, nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) Login(ctx context.Context, email, password string) (User, error) {
|
||||
var user User
|
||||
var hash string
|
||||
err := s.pool.QueryRow(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).Scan(&user.ID, &user.Email, &hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, errInvalidCredentials
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return User{}, errInvalidCredentials
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Task{}
|
||||
for rows.Next() {
|
||||
var task Task
|
||||
var tags []string
|
||||
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Tags = tags
|
||||
result = append(result, task)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
|
||||
var task Task
|
||||
var tags []string
|
||||
err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
|
||||
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
task.Tags = tags
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (Task, error) {
|
||||
if input.Status == "" {
|
||||
input.Status = "todo"
|
||||
}
|
||||
s.items[input.ID] = input
|
||||
return input
|
||||
var tags []string
|
||||
if input.Tags != nil {
|
||||
tags = input.Tags
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
RETURNING id, created_at, updated_at`,
|
||||
userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags,
|
||||
).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *taskStore) update(id int64, input Task) (Task, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
existing, ok := s.items[id]
|
||||
if !ok {
|
||||
return Task{}, false
|
||||
func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) {
|
||||
existing, err := s.Get(ctx, userID, id)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if input.Title != "" {
|
||||
existing.Title = input.Title
|
||||
@@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) {
|
||||
if input.Tags != nil {
|
||||
existing.Tags = input.Tags
|
||||
}
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
s.items[id] = existing
|
||||
return existing, true
|
||||
var updatedAt time.Time
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE tasks
|
||||
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
|
||||
WHERE id = $7 AND user_id = $8
|
||||
RETURNING updated_at`,
|
||||
existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID,
|
||||
).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
existing.UpdatedAt = updatedAt
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *taskStore) delete(id int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.items[id]; !ok {
|
||||
return false
|
||||
func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.items, id)
|
||||
return true
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) Close() {
|
||||
if s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTokenCache(addr, password string, db int) (*tokenCache, error) {
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tokenCache{client: client, prefix: "auth:token:"}, nil
|
||||
}
|
||||
|
||||
func (c *tokenCache) Save(ctx context.Context, token string, ttl time.Duration) error {
|
||||
return c.client.Set(ctx, c.prefix+token, "1", ttl).Err()
|
||||
}
|
||||
|
||||
func (c *tokenCache) Delete(ctx context.Context, token string) error {
|
||||
return c.client.Del(ctx, c.prefix+token).Err()
|
||||
}
|
||||
|
||||
func (c *tokenCache) Exists(ctx context.Context, token string) (bool, error) {
|
||||
count, err := c.client.Exists(ctx, c.prefix+token).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
func newTaskEmitter(brokers []string, topic string) *taskEmitter {
|
||||
if len(brokers) == 0 {
|
||||
return nil
|
||||
}
|
||||
writer := &kafka.Writer{
|
||||
Addr: kafka.TCP(brokers...),
|
||||
Topic: topic,
|
||||
Balancer: &kafka.LeastBytes{},
|
||||
}
|
||||
return &taskEmitter{writer: writer, topic: topic}
|
||||
}
|
||||
|
||||
func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, userID int64) {
|
||||
if e == nil || e.writer == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": eventType,
|
||||
"task_id": task.ID,
|
||||
"user_id": userID,
|
||||
"status": task.Status,
|
||||
"priority": task.Priority,
|
||||
"at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
if err := e.writer.WriteMessages(writeCtx, kafka.Message{
|
||||
Key: []byte(strconv.FormatInt(task.ID, 10)),
|
||||
Value: data,
|
||||
}); err != nil {
|
||||
log.Printf("kafka write failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *taskEmitter) Close() {
|
||||
if e == nil || e.writer == nil {
|
||||
return
|
||||
}
|
||||
if err := e.writer.Close(); err != nil {
|
||||
log.Printf("kafka close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errAlreadyExists = errors.New("already exists")
|
||||
errInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
store := newTaskStore()
|
||||
authStore := newAuthStore()
|
||||
store, tokens, cache, emitter := buildDependencies()
|
||||
defer store.Close()
|
||||
if cache != nil {
|
||||
defer cache.client.Close()
|
||||
}
|
||||
defer emitter.Close()
|
||||
|
||||
gin.SetMode(gin.DebugMode)
|
||||
router := gin.Default()
|
||||
router.RedirectTrailingSlash = false
|
||||
@@ -193,16 +425,6 @@ func main() {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.File("test/web/index.html")
|
||||
})
|
||||
router.GET("/styles.css", func(c *gin.Context) {
|
||||
c.File("test/web/styles.css")
|
||||
})
|
||||
router.GET("/app.js", func(c *gin.Context) {
|
||||
c.File("test/web/app.js")
|
||||
})
|
||||
|
||||
auth := router.Group("/api/v1/auth")
|
||||
{
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
@@ -214,13 +436,18 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Email = strings.TrimSpace(input.Email)
|
||||
if input.Email == "" || input.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
user, ok := authStore.register(input.Email, input.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
||||
user, err := store.Register(c.Request.Context(), input.Email, input.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
|
||||
@@ -234,41 +461,90 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Email = strings.TrimSpace(input.Email)
|
||||
if input.Email == "" || input.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
token, ok := authStore.login(input.Email, input.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
user, err := store.Login(c.Request.Context(), input.Email, input.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||
return
|
||||
}
|
||||
token, err := tokens.Generate(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token generation failed"})
|
||||
return
|
||||
}
|
||||
if cache != nil {
|
||||
if err := cache.Save(c.Request.Context(), token, tokens.ttl); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
})
|
||||
auth.POST("/logout", func(c *gin.Context) {
|
||||
token := extractBearerToken(c)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
if _, ok := tokens.Validate(token); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
if cache != nil {
|
||||
if err := cache.Delete(c.Request.Context(), token); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
api.Use(func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
token := extractBearerToken(c)
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
token := authHeader
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
if token == "" || !authStore.validate(token) {
|
||||
userID, ok := tokens.Validate(token)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
if cache != nil {
|
||||
exists, err := cache.Exists(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
tasks := api.Group("/tasks")
|
||||
{
|
||||
tasks.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, store.list())
|
||||
userID := c.GetInt64("user_id")
|
||||
items, err := store.List(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tasks"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
})
|
||||
tasks.POST("", func(c *gin.Context) {
|
||||
var input Task
|
||||
@@ -276,7 +552,13 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created := store.create(input)
|
||||
userID := c.GetInt64("user_id")
|
||||
created, err := store.Create(c.Request.Context(), userID, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
|
||||
return
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), "task.created", created, userID)
|
||||
c.JSON(http.StatusCreated, created)
|
||||
})
|
||||
tasks.GET(":id", func(c *gin.Context) {
|
||||
@@ -285,9 +567,14 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
task, ok := store.get(id)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
userID := c.GetInt64("user_id")
|
||||
task, err := store.Get(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
@@ -303,11 +590,17 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, ok := store.update(id, input)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
userID := c.GetInt64("user_id")
|
||||
updated, err := store.Update(c.Request.Context(), userID, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
||||
return
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), "task.updated", updated, userID)
|
||||
c.JSON(http.StatusOK, updated)
|
||||
})
|
||||
tasks.DELETE(":id", func(c *gin.Context) {
|
||||
@@ -316,10 +609,16 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if !store.delete(id) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
userID := c.GetInt64("user_id")
|
||||
if err := store.Delete(c.Request.Context(), userID, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
|
||||
return
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), "task.deleted", Task{ID: id}, userID)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
@@ -329,6 +628,78 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func buildDependencies() (*postgresStore, *tokenManager, *tokenCache, *taskEmitter) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://todo:todo@localhost:5432/todo?sslmode=disable"
|
||||
}
|
||||
store, err := newPostgresStore(ctx, dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("postgres connection failed: %v", err)
|
||||
}
|
||||
|
||||
secret := strings.TrimSpace(os.Getenv("AUTH_SECRET"))
|
||||
if secret == "" {
|
||||
secret = "dev-secret-change-me"
|
||||
}
|
||||
tokens := newTokenManager(secret, 24*time.Hour)
|
||||
|
||||
redisAddr := strings.TrimSpace(os.Getenv("REDIS_ADDR"))
|
||||
redisPassword := os.Getenv("REDIS_PASSWORD")
|
||||
redisDB := parseEnvInt("REDIS_DB", 0)
|
||||
cache, err := newTokenCache(redisAddr, redisPassword, redisDB)
|
||||
if err != nil {
|
||||
log.Fatalf("redis connection failed: %v", err)
|
||||
}
|
||||
|
||||
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
|
||||
topic := strings.TrimSpace(os.Getenv("KAFKA_TOPIC"))
|
||||
if topic == "" {
|
||||
topic = "todo.tasks"
|
||||
}
|
||||
emitter := newTaskEmitter(brokers, topic)
|
||||
|
||||
return store, tokens, cache, emitter
|
||||
}
|
||||
|
||||
func parseID(value string) (int64, error) {
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimSpace(authHeader[7:])
|
||||
}
|
||||
return authHeader
|
||||
}
|
||||
|
||||
func parseEnvInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
item := strings.TrimSpace(part)
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user