Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ crash.*.log
# personal
docs/ideas.md
backup/
.kiro
26 changes: 17 additions & 9 deletions backend/db/init.sql
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
-- Auto-generated schema snapshot. DO NOT EDIT MANUALLY.
-- Regenerated by CI after each successful goose migration run.
-- Dual purpose:
-- 1. Used by Docker Compose to bootstrap a fresh local dev database container.
-- 2. Referenced by migration integration tests (TestInitSQLMatchesMigrationEndState)
-- as the schema reference snapshot.
-- Source of truth for schema changes: backend/migration/db/migrations/*.sql

CREATE TABLE IF NOT EXISTS users (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mentioned, SERIAL in the design doc but in the code it's mentioned BIGSERIAL.

Why this deviation

id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY,
item TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id UUID REFERENCES users(id)
id BIGSERIAL PRIMARY KEY,
item TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS blacklisted_tokens (
token TEXT PRIMARY KEY,
expired_at TIMESTAMP NOT NULL
token TEXT PRIMARY KEY,
expired_at TIMESTAMPTZ NOT NULL
);
1 change: 0 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.25.5
require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.11.2
golang.org/x/crypto v0.48.0
Expand Down
2 changes: 0 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
Expand Down
52 changes: 28 additions & 24 deletions backend/internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"capuchin/internal/services"
"net/http"

"github.com/gin-gonic/gin"
)
Expand All @@ -14,51 +15,54 @@ func NewAuthHandler(svc services.AuthService) *AuthHandler {
return &AuthHandler{authService: svc}
}

func (h *AuthHandler) Signup(c *gin.Context) {
var reqBody struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
type signupRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}

if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."})
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}

func (h *AuthHandler) Signup(c *gin.Context) {
var req signupRequest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can name it as reqBody

if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: missing fields or invalid format. Password must be >= 8 characters."})
return
}

_, err := h.authService.Signup(reqBody.Email, reqBody.Password)
_, err := h.authService.Signup(req.Email, req.Password)
if err != nil {
if err == services.ErrUserExists {
c.JSON(409, gin.H{"error": "User with this email already exists"})
c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"})
return
}
c.JSON(500, gin.H{"error": "Failed to create user"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}

c.JSON(201, gin.H{"message": "User created successfully"})
c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"})
}

func (h *AuthHandler) Login(c *gin.Context) {
var reqBody struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

tokenString, err := h.authService.Login(reqBody.Email, reqBody.Password)
tokenString, err := h.authService.Login(req.Email, req.Password)
if err != nil {
if err == services.ErrInvalidCredentials {
c.JSON(401, gin.H{"error": "Invalid credentials"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
c.JSON(500, gin.H{"error": "Login failed"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
return
}

c.JSON(200, gin.H{"token": tokenString})
c.JSON(http.StatusOK, gin.H{"token": tokenString})
}

func (h *AuthHandler) Logout(c *gin.Context) {
Expand All @@ -67,12 +71,12 @@ func (h *AuthHandler) Logout(c *gin.Context) {
err := h.authService.Logout(tokenStr)
if err != nil {
if err == services.ErrInvalidToken {
c.JSON(400, gin.H{"error": "Invalid token components"})
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token components"})
return
}
c.JSON(500, gin.H{"error": "Failed to logout"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to logout"})
return
}

c.JSON(200, gin.H{"message": "Logged out successfully"})
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
65 changes: 34 additions & 31 deletions backend/internal/handlers/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package handlers

import (
"capuchin/internal/services"
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
)

type TodoHandler struct {
Expand All @@ -15,83 +16,85 @@ func NewTodoHandler(svc services.TodoService) *TodoHandler {
return &TodoHandler{todoService: svc}
}

type addTodoRequest struct {
Item string `json:"item" binding:"required"`
Completed bool `json:"completed"`
}

type updateTodoRequest struct {
Item *string `json:"item"`
Completed *bool `json:"completed"`
}

func (h *TodoHandler) GetTodos(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
userID := c.MustGet("userID").(int64)
todos, err := h.todoService.GetTodos(userID)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to get todos"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get todos"})
return
}
c.JSON(200, todos)
c.JSON(http.StatusOK, todos)
}

func (h *TodoHandler) AddTodo(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
var req struct {
Item string `json:"item" binding:"required"`
Completed bool `json:"completed"`
}
userID := c.MustGet("userID").(int64)
var req addTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

todo, err := h.todoService.AddTodo(userID, req.Item, req.Completed)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to add todo"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add todo"})
return
}
c.JSON(200, todo)
c.JSON(http.StatusOK, todo)
}

func (h *TodoHandler) UpdateTodo(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
idParam := c.Param("id")
id, err := uuid.Parse(idParam)
userID := c.MustGet("userID").(int64)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "invalid id"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}

var req struct {
Item *string `json:"item"`
Completed *bool `json:"completed"`
}
var req updateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

todo, err := h.todoService.UpdateTodo(userID, id, req.Item, req.Completed)
if err != nil {
if err == services.ErrTodoNotFound {
c.JSON(404, gin.H{"error": "Todo not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}
c.JSON(500, gin.H{"error": "Failed to update todo"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update todo"})
return
}
c.JSON(200, todo)
c.JSON(http.StatusOK, todo)
}

func (h *TodoHandler) DeleteTodo(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
idParam := c.Param("id")
id, err := uuid.Parse(idParam)
userID := c.MustGet("userID").(int64)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "invalid id"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}

err = h.todoService.DeleteTodo(userID, id)
if err != nil {
if err == services.ErrTodoNotFound {
c.JSON(404, gin.H{"error": "Todo not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}
c.JSON(500, gin.H{"error": "Failed to delete todo"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"})
return
}

c.JSON(200, gin.H{"message": "Todo deleted successfully"})
c.JSON(http.StatusOK, gin.H{"message": "Todo deleted successfully"})
}
34 changes: 10 additions & 24 deletions backend/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,51 @@ package middleware
import (
"capuchin/internal/config"
"capuchin/internal/database"
"database/sql"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)

func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := c.GetHeader("Authorization")
if tokenStr == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "Authorization header required"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}

// Accept standard Authorization header format without forcing clients to preprocess it.
tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")

var exists bool
// Check revocation before claim extraction so logout takes effect immediately.
err := database.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM blacklisted_tokens WHERE token=$1)", tokenStr).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
c.AbortWithStatusJSON(503, gin.H{"error": "authentication service unavailable"})
return
}
if exists {
c.AbortWithStatusJSON(401, gin.H{"error": "Token has been revoked"})
if err == nil && exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"})
return
}

// Restrict acceptable algorithms and require exp to reduce token confusion attacks.
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
return config.JWTKey, nil
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())

if err != nil || !token.Valid {
c.AbortWithStatusJSON(401, gin.H{"error": "Invalid or expired token"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}

if claims, ok := token.Claims.(jwt.MapClaims); ok {

raw, ok := claims["user_id"].(string)
// JWT numbers are decoded as float64 by encoding/json.
raw, ok := claims["user_id"].(float64)
if !ok {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token claims"})
return
}
// Parse into UUID once so handlers can rely on a strongly typed user identity.
uid, err := uuid.Parse(raw)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid user id in token"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
return
}

c.Set("userID", uid)
c.Set("userID", int64(raw))
c.Next()
} else {
c.AbortWithStatusJSON(401, gin.H{"error": "Invalid token claims"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
return
}
}
Expand Down
16 changes: 7 additions & 9 deletions backend/internal/models/models.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package models

import "github.com/google/uuid"

type Todo struct {
ID uuid.UUID `json:"id"`
Item string `json:"item"`
Completed bool `json:"completed"`
UserID uuid.UUID `json:"-"`
ID int64 `json:"id"`
Item string `json:"item"`
Completed bool `json:"completed"`
UserID int64 `json:"-"`
}

type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
}
Loading