Initial commit

This commit is contained in:
Leo 2026-08-15 23:57:52 +03:00
commit 6301c4f3b5
23 changed files with 1062 additions and 0 deletions

52
.air.toml Normal file
View file

@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "migrations", "scripts"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=backend_template
DB_SSL_MODE=disable
PORT=8080
# goose (./scripts/goose.sh) — keep in sync with the DB_* values above
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:your_password@localhost:5432/backend_template?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations
GOOSE_TABLE=public.goose_migrations

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
main
tmp/
build-errors.log

72
README.md Normal file
View file

@ -0,0 +1,72 @@
# Go Backend Template
A simple Go backend with authentication using native `net/http` and PostgreSQL.
## Features
- Session-based authentication
- Argon2 password hashing
- PostgreSQL database
- CORS support
- Request logging middleware
- Native Go HTTP server (no external frameworks)
## Requirements
- Go 1.24+
- PostgreSQL
- [goose](https://github.com/pressly/goose) for migrations, [air](https://github.com/air-verse/air) for live reload (optional):
```bash
go install github.com/pressly/goose/v3/cmd/goose@latest
go install github.com/air-verse/air@latest
```
## Setup
1. Copy environment variables:
```bash
cp .env.example .env
```
2. Update `.env` with your database credentials (both the `DB_*` and `GOOSE_*` values).
3. Install dependencies:
```bash
go mod tidy
```
4. Run database migrations:
```bash
./scripts/goose.sh up
```
5. Run the server:
```bash
air # live reload, or:
go run ./cmd
```
## Migrations
`goose.sh` loads `.env` and passes through to goose (`up`, `down`, `status`, ...).
Create a new migration with:
```bash
./scripts/goose-create.sh add_widgets
```
## SQLC (optional)
Query codegen is configured in `sqlc.yaml` (engine `postgresql`, schema read from
`migrations/`). Add `.sql` files under `internal/database/queries/` and run
`sqlc generate` to emit typed accessors into `internal/database/sqlc/`.
## API Endpoints
- `POST /login` - User login
- `GET /user` - Get user info (requires session cookie)
## Database Schema
- `user_auth` - User accounts with Argon2 password hashes
- `user_session` - User sessions with SHA-256 hashed token secrets

56
cmd/main.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"log"
"net/http"
"os"
"go-backend/internal/database"
"go-backend/internal/handlers"
"go-backend/internal/services"
"github.com/joho/godotenv"
)
func main() {
// Load .env file
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Connect to database
db, err := database.Connect()
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.Close()
userService := services.NewUserService(db)
authService := services.NewAuthService(db)
handler := handlers.NewHandler(userService, authService)
// Create router
mux := http.NewServeMux()
// Routes
mux.HandleFunc("/login", handler.LoginHandler)
mux.HandleFunc("/user", handler.UserHandler)
// Apply middleware (CORS first, then logging)
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
// Start server
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
if err := http.ListenAndServe(":"+port, muxMiddleware); err != nil {
log.Fatal("Server failed to start:", err)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module go-backend
go 1.24.1
require (
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.40.0
)
require golang.org/x/sys v0.37.0 // indirect

8
go.sum Normal file
View file

@ -0,0 +1,8 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

41
internal/auth/password.go Normal file
View file

@ -0,0 +1,41 @@
package auth
import (
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
func VerifyPassword(hashedPassword, password string) bool {
// Parse the Argon2 hash format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
parts := strings.Split(hashedPassword, "$")
if len(parts) != 6 {
return false
}
// Extract parameters
var memory, time, threads uint32
var version int
fmt.Sscanf(parts[2], "v=%d", &version)
fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
// Generate hash with the same parameters
derivedKey := argon2.IDKey([]byte(password), salt, time, memory, uint8(threads), uint32(len(hash)))
// Compare hashes using constant time comparison
return subtle.ConstantTimeCompare(hash, derivedKey) == 1
}

156
internal/auth/session.go Normal file
View file

@ -0,0 +1,156 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"strings"
"time"
)
type Session struct {
ID string
SecretHash []byte
CreatedAt time.Time
UserID string
}
type SessionWithToken struct {
Session
Token string
}
const sessionExpiresInSeconds = 60 * 60 * 24 // 1 day
func generateSecureRandomString() (string, error) {
// Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion)
alphabet := "abcdefghijklmnpqrstuvwxyz23456789"
// Generate 24 bytes = 192 bits of entropy.
// We're only going to use 5 bits per byte so the total entropy will be 192 * 5 / 8 = 120 bits
bytes := make([]byte, 24)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
var id strings.Builder
for _, b := range bytes {
// >> 3 "removes" the right-most 3 bits of the byte
id.WriteByte(alphabet[b>>3])
}
return id.String(), nil
}
func hashSecret(secret string) []byte {
hash := sha256.Sum256([]byte(secret))
return hash[:]
}
func CreateSession(db *sql.DB, userID string) (*SessionWithToken, error) {
id, err := generateSecureRandomString()
if err != nil {
return nil, err
}
secret, err := generateSecureRandomString()
if err != nil {
return nil, err
}
secretHash := hashSecret(secret)
token := id + "." + secret
now := time.Now()
// Insert session into database
_, err = db.Exec(
"INSERT INTO user_session (id, secret_hash, user_id, created_at) VALUES ($1, $2, $3, $4)",
id, secretHash, userID, now.Unix(),
)
if err != nil {
return nil, err
}
return &SessionWithToken{
Session: Session{
ID: id,
SecretHash: secretHash,
CreatedAt: now,
UserID: userID,
},
Token: token,
}, nil
}
func ValidateSessionToken(db *sql.DB, token string) (*Session, error) {
tokenParts := strings.Split(token, ".")
if len(tokenParts) != 2 {
return nil, nil
}
sessionID := tokenParts[0]
sessionSecret := tokenParts[1]
session, err := getSession(db, sessionID)
if err != nil {
return nil, err
}
if session == nil {
return nil, nil
}
tokenSecretHash := hashSecret(sessionSecret)
if subtle.ConstantTimeCompare(tokenSecretHash, session.SecretHash) != 1 {
return nil, nil
}
return session, nil
}
func getSession(db *sql.DB, sessionID string) (*Session, error) {
var session Session
var createdAtUnix int64
err := db.QueryRow(
"SELECT id, secret_hash, user_id, created_at FROM user_session WHERE id = $1",
sessionID,
).Scan(&session.ID, &session.SecretHash, &session.UserID, &createdAtUnix)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
session.CreatedAt = time.Unix(createdAtUnix, 0)
// Check expiration
if time.Since(session.CreatedAt).Seconds() >= sessionExpiresInSeconds {
_, err = db.Exec("DELETE FROM user_session WHERE id = $1", sessionID)
if err != nil {
return nil, err
}
return nil, nil
}
return &session, nil
}
func ParseCookies(cookieHeader string) map[string]string {
cookies := make(map[string]string)
if cookieHeader == "" {
return cookies
}
pairs := strings.Split(cookieHeader, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
parts := strings.SplitN(pair, "=", 2)
if len(parts) == 2 {
cookies[parts[0]] = parts[1]
}
}
return cookies
}

101
internal/database/db.go Normal file
View file

@ -0,0 +1,101 @@
package database
import (
"database/sql"
"fmt"
"os"
"time"
_ "github.com/lib/pq"
)
func Connect() (*sql.DB, error) {
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "5432")
dbUser := getEnv("DB_USER", "postgres")
dbPassword := getEnv("DB_PASSWORD", "")
dbName := getEnv("DB_NAME", "backend_template")
sslMode := getEnv("DB_SSL_MODE", "disable")
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
dbHost, dbPort, dbUser, dbPassword, dbName, sslMode)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, err
}
// Verify connection
if err = db.Ping(); err != nil {
return nil, err
}
return db, nil
}
func FindUserByID(db *sql.DB, id string) (*User, error) {
var user User
var createdAtUnix, updatedAtUnix int64
err := db.QueryRow(`
SELECT id, username, email, password_hash,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM user_auth
WHERE id = $1
`, id).Scan(
&user.ID, &user.Username, &user.Email, &user.PasswordHash,
&createdAtUnix, &updatedAtUnix,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.CreatedAt = timeFromUnix(createdAtUnix)
user.UpdatedAt = timeFromUnix(updatedAtUnix)
return &user, nil
}
func FindUserByUsername(db *sql.DB, username string) (*User, error) {
var user User
var createdAtUnix, updatedAtUnix int64
err := db.QueryRow(`
SELECT id, username, email, password_hash,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM user_auth
WHERE username = $1
`, username).Scan(
&user.ID, &user.Username, &user.Email, &user.PasswordHash,
&createdAtUnix, &updatedAtUnix,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.CreatedAt = timeFromUnix(createdAtUnix)
user.UpdatedAt = timeFromUnix(updatedAtUnix)
return &user, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func timeFromUnix(timestamp int64) time.Time {
return time.Unix(timestamp, 0)
}

View file

@ -0,0 +1,12 @@
package database
import "time"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"` // Don't serialize password hash
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,32 @@
package handlers
import (
"go-backend/internal/auth"
"go-backend/internal/database"
)
// UserService and AuthService are defined here, on the consumer side, so
// handlers depend only on the methods they actually use and tests can swap
// in fakes without touching the services package.
type UserService interface {
FindByID(id string) (*database.User, error)
FindByUsername(username string) (*database.User, error)
}
type AuthService interface {
CreateSession(userID string) (*auth.SessionWithToken, error)
ValidateSessionToken(token string) (*auth.Session, error)
}
type Handler struct {
users UserService
auth AuthService
}
func NewHandler(users UserService, auth AuthService) *Handler {
return &Handler{
users: users,
auth: auth,
}
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"go-backend/internal/auth"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req LoginRequest
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.users.FindByUsername(req.Username)
if err != nil {
log.Printf("Error finding user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "Invalid username", http.StatusBadRequest)
return
}
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
session, err := h.auth.CreateSession(user.ID)
if err != nil {
log.Printf("Error creating session: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: "session",
Value: session.Token,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24 hours
Path: "/",
}
http.SetCookie(w, cookie)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}

View file

@ -0,0 +1,160 @@
package handlers
import (
"bytes"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"testing"
"go-backend/internal/auth"
"go-backend/internal/database"
"golang.org/x/crypto/argon2"
)
// fakeUsers and fakeAuth satisfy the UserService and AuthService interfaces
// declared in handler.go. No database, no network, no real hashing work —
// each test controls exactly what the "service layer" returns.
type fakeUsers struct {
user *database.User
err error
}
func (f *fakeUsers) FindByUsername(username string) (*database.User, error) {
return f.user, f.err
}
func (f *fakeUsers) FindByID(id string) (*database.User, error) {
return f.user, f.err
}
type fakeAuth struct {
session *auth.SessionWithToken
err error
}
func (f *fakeAuth) CreateSession(userID string) (*auth.SessionWithToken, error) {
return f.session, f.err
}
func (f *fakeAuth) ValidateSessionToken(token string) (*auth.Session, error) {
return nil, nil
}
// hashPassword builds a real Argon2id hash in the format auth.VerifyPassword
// expects, so the "correct password" test case exercises the real
// verification logic rather than a stub.
func hashPassword(password, salt string) string {
saltBytes := []byte(salt)
hash := argon2.IDKey([]byte(password), saltBytes, 3, 65536, 4, 32)
return "$argon2id$v=19$m=65536,t=3,p=4$" +
base64.RawStdEncoding.EncodeToString(saltBytes) + "$" +
base64.RawStdEncoding.EncodeToString(hash)
}
func TestLoginHandler(t *testing.T) {
validUser := &database.User{ID: "user-1", Username: "alice", PasswordHash: hashPassword("correct-horse", "somesalt16bytes!")}
tests := []struct {
name string
body string
users UserService
auth AuthService
wantStatus int
}{
{
name: "successful login sets session cookie",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{session: &auth.SessionWithToken{Token: "abc.def"}},
wantStatus: http.StatusOK,
},
{
name: "wrong password rejected",
body: `{"username":"alice","password":"wrong-password"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "unknown username rejected",
body: `{"username":"nobody","password":"whatever"}`,
users: &fakeUsers{user: nil},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "missing fields rejected before hitting services",
body: `{"username":"","password":""}`,
users: &fakeUsers{err: errors.New("should never be called")},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "user lookup failure returns 500",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{err: errors.New("connection refused")},
auth: &fakeAuth{},
wantStatus: http.StatusInternalServerError,
},
{
name: "session creation failure returns 500",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{err: errors.New("disk full")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewHandler(tt.users, tt.auth)
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(tt.body))
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantStatus == http.StatusOK {
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != "abc.def" {
t.Fatalf("expected session cookie with token abc.def, got %+v", cookies)
}
}
})
}
}
func TestLoginHandlerRejectsInvalidJSON(t *testing.T) {
h := NewHandler(&fakeUsers{}, &fakeAuth{})
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(`not-json`))
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestLoginHandlerRejectsWrongMethod(t *testing.T) {
h := NewHandler(&fakeUsers{}, &fakeAuth{})
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}

View file

@ -0,0 +1,53 @@
package handlers
import (
"log"
"net/http"
"time"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Create a response writer wrapper to capture status code
wrapper := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapper, r)
duration := time.Since(start)
timestamp := time.Now().Format(time.RFC3339)
log.Printf("[%s] %s %s - %d (%v)", timestamp, r.Method, r.URL.Path, wrapper.statusCode, duration)
})
}
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

View file

@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net/http"
)
type SignUpRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req SignUpRequest
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.users.FindByUsername(req.Username)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
if user != nil {
http.Error(w, "Username already taken", http.StatusBadRequest)
}
}

48
internal/handlers/user.go Normal file
View file

@ -0,0 +1,48 @@
package handlers
import (
"encoding/json"
"net/http"
"go-backend/internal/auth"
)
func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get session token from cookie
cookies := auth.ParseCookies(r.Header.Get("Cookie"))
sessionToken, exists := cookies["session"]
if !exists {
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
return
}
// Validate session
session, err := h.auth.ValidateSessionToken(sessionToken)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get user info
user, err := h.users.FindByID(session.UserID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}

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

@ -0,0 +1,25 @@
package services
import (
"database/sql"
"go-backend/internal/auth"
)
type AuthService struct {
db *sql.DB
}
func NewAuthService(db *sql.DB) *AuthService {
return &AuthService{
db: db,
}
}
func (s *AuthService) CreateSession(userID string) (*auth.SessionWithToken, error) {
return auth.CreateSession(s.db, userID)
}
func (s *AuthService) ValidateSessionToken(token string) (*auth.Session, error) {
return auth.ValidateSessionToken(s.db, token)
}

25
internal/services/user.go Normal file
View file

@ -0,0 +1,25 @@
package services
import (
"database/sql"
"go-backend/internal/database"
)
type UserService struct {
db *sql.DB
}
func NewUserService(db *sql.DB) *UserService {
return &UserService{
db: db,
}
}
func (s *UserService) FindByID(id string) (*database.User, error) {
return database.FindUserByID(s.db, id)
}
func (s *UserService) FindByUsername(username string) (*database.User, error) {
return database.FindUserByUsername(s.db, username)
}

42
migrations/00001_init.sql Normal file
View file

@ -0,0 +1,42 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS user_auth (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- created_at is Unix seconds, not a timestamp: internal/auth/session.go writes
-- now.Unix() and scans the column straight into an int64.
CREATE TABLE IF NOT EXISTS user_session (
id TEXT PRIMARY KEY,
secret_hash BYTEA NOT NULL,
user_id TEXT NOT NULL REFERENCES user_auth (id) ON DELETE CASCADE,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON user_session (user_id);
-- +goose StatementBegin
CREATE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER user_auth_set_updated_at
BEFORE UPDATE ON user_auth
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- +goose Down
DROP TRIGGER IF EXISTS user_auth_set_updated_at ON user_auth;
DROP FUNCTION IF EXISTS set_updated_at();
DROP TABLE IF EXISTS user_session;
DROP TABLE IF EXISTS user_auth;

11
scripts/goose-create.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Create a new timestamped SQL migration in ./migrations
# Usage: ./scripts/goose-create.sh add_widgets
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}"
export GOOSE_MIGRATION_DIR="${GOOSE_MIGRATION_DIR:-$SCRIPT_DIR/../migrations}"
goose create -s "$@" sql

16
scripts/goose.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Run goose against the Postgres database configured in .env
# Usage: ./scripts/goose.sh up | down | status | version
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
set -a
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../.env"
set +a
export GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}"
export GOOSE_MIGRATION_DIR="${GOOSE_MIGRATION_DIR:-$SCRIPT_DIR/../migrations}"
goose "$@"

10
sqlc.yaml Normal file
View file

@ -0,0 +1,10 @@
version: "2"
sql:
- engine: "postgresql"
queries: "internal/database/queries"
schema: "migrations"
gen:
go:
package: "sqlc"
out: "internal/database/sqlc"
sql_package: "database/sql"