Initial commit
This commit is contained in:
commit
6301c4f3b5
23 changed files with 1062 additions and 0 deletions
52
.air.toml
Normal file
52
.air.toml
Normal 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
13
.env.example
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.env
|
||||||
|
main
|
||||||
|
tmp/
|
||||||
|
build-errors.log
|
||||||
72
README.md
Normal file
72
README.md
Normal 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
56
cmd/main.go
Normal 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
11
go.mod
Normal 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
8
go.sum
Normal 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
41
internal/auth/password.go
Normal 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
156
internal/auth/session.go
Normal 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
101
internal/database/db.go
Normal 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)
|
||||||
|
}
|
||||||
12
internal/database/models.go
Normal file
12
internal/database/models.go
Normal 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"`
|
||||||
|
}
|
||||||
32
internal/handlers/handler.go
Normal file
32
internal/handlers/handler.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/handlers/login.go
Normal file
72
internal/handlers/login.go
Normal 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"))
|
||||||
|
}
|
||||||
160
internal/handlers/login_test.go
Normal file
160
internal/handlers/login_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/handlers/middleware.go
Normal file
53
internal/handlers/middleware.go
Normal 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)
|
||||||
|
}
|
||||||
42
internal/handlers/signup.go
Normal file
42
internal/handlers/signup.go
Normal 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
48
internal/handlers/user.go
Normal 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
25
internal/services/auth.go
Normal 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
25
internal/services/user.go
Normal 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
42
migrations/00001_init.sql
Normal 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
11
scripts/goose-create.sh
Executable 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
16
scripts/goose.sh
Executable 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
10
sqlc.yaml
Normal 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"
|
||||||
Loading…
Reference in a new issue