Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy in your prebuilt Go binary (from local ./backend/bin/backend)
|
||||
COPY bin/backend ./backend
|
||||
|
||||
# Copy env file if you want to bake it in (or mount via volume/env_file in compose)
|
||||
COPY .env ./
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./backend"]
|
||||
@@ -0,0 +1,56 @@
|
||||
// auth/jwt.go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
var TokenAuth *jwtauth.JWTAuth
|
||||
|
||||
// AuthResponse is the response structure for login/refresh endpoints
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func InitJWT(secret string) {
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
}
|
||||
|
||||
// GenerateToken creates a JWT with user_id and role
|
||||
func GenerateToken(userID string, role string) (string, error) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
|
||||
})
|
||||
return tokenString, err
|
||||
}
|
||||
|
||||
// VerifyToken validates JWT and returns user_id and role
|
||||
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) {
|
||||
token, err := TokenAuth.Decode(tokenString)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
claims, err := token.AsMap(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userID, ok := claims["user_id"].(string)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("invalid user_id claim")
|
||||
}
|
||||
|
||||
role, ok = claims["role"].(string)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("invalid role claim")
|
||||
}
|
||||
|
||||
return userID, role, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package auth
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var DB *pgxpool.Pool
|
||||
|
||||
func Connect() error {
|
||||
// Connect to Postgres on local network (127.x.x.x)
|
||||
dsn := fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:5432/%s",
|
||||
getEnv("POSTGRES_USER"),
|
||||
getEnv("POSTGRES_PASSWORD"),
|
||||
getEnv("POSTGRES_HOST"),
|
||||
getEnv("POSTGRES_DB"),
|
||||
)
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DB = pool
|
||||
|
||||
err = testDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testDB() error {
|
||||
ctx := context.Background()
|
||||
conn, err := DB.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
row := conn.QueryRow(ctx, "SELECT 1")
|
||||
var result int
|
||||
err = row.Scan(&result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
log.Fatal("FATAL: Environment variable not set:", key)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var DB *pgxpool.Pool
|
||||
|
||||
func Connect() error {
|
||||
// Connect to Postgres inside Docker network
|
||||
dsn := fmt.Sprintf(
|
||||
"postgres://%s:%s@localhost:5432/%s?sslmode=disable",
|
||||
getEnv("POSTGRES_USER"),
|
||||
getEnv("POSTGRES_PASSWORD"),
|
||||
getEnv("POSTGRES_DB"),
|
||||
)
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DB = pool
|
||||
|
||||
err = testDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testDB() error {
|
||||
ctx := context.Background()
|
||||
conn, err := DB.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
row := conn.QueryRow(ctx, "SELECT 1")
|
||||
var result int
|
||||
err = row.Scan(&result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
log.Fatal("FATAL: Environment variable not set:", key)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
module crussell
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
golang.org/x/text v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/nyaruka/phonenumbers v1.6.6
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/nyaruka/phonenumbers v1.6.6 h1:cZv5/vslJh65zuOrLjdVDHKHzVEwVuUsXAPQi3bjGJU=
|
||||
github.com/nyaruka/phonenumbers v1.6.6/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA=
|
||||
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1 @@
|
||||
package admin
|
||||
@@ -0,0 +1 @@
|
||||
package admin
|
||||
@@ -0,0 +1 @@
|
||||
package admin
|
||||
@@ -0,0 +1,354 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/auth"
|
||||
"crussell/db"
|
||||
"crussell/internal/dav"
|
||||
"crussell/mw"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
titleCaser = cases.Title(language.English)
|
||||
)
|
||||
|
||||
// Login state management
|
||||
var (
|
||||
loginStateMu sync.Mutex
|
||||
loginInProgress = make(map[string]bool)
|
||||
loginAttempts = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
loginStateMu.Lock()
|
||||
now := time.Now()
|
||||
for userID, lastAttempt := range loginAttempts {
|
||||
// Remove attempts older than 1 hour
|
||||
if now.Sub(lastAttempt) > 1*time.Hour {
|
||||
delete(loginAttempts, userID)
|
||||
}
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Phone string `json:"phone"`
|
||||
DateOfBirth string `json:"dateOfBirth"`
|
||||
AgreedToPolicy bool `json:"agreedToPolicy"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// POST /api/register
|
||||
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Must accept terms
|
||||
if !req.AgreedToPolicy {
|
||||
http.Error(w, "must agree to terms", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize input
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||
req.LastName = strings.TrimSpace(req.LastName)
|
||||
req.Phone = strings.TrimSpace(req.Phone)
|
||||
req.DateOfBirth = strings.TrimSpace(req.DateOfBirth)
|
||||
|
||||
// Check required fields
|
||||
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" || req.DateOfBirth == "" {
|
||||
http.Error(w, "all fields are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate name (unicode letters, spaces, hyphen, apostrophe, dot)
|
||||
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
||||
|
||||
if !nameRegex.MatchString(req.FirstName) {
|
||||
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate length
|
||||
if len(req.FirstName) > 50 || len(req.FirstName) < 1 {
|
||||
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.LastName) > 50 || len(req.LastName) < 1 {
|
||||
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
_, err := mail.ParseAddress(req.Email)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid email format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize phone (remove spaces, hyphens, parentheses)
|
||||
req.Phone = strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' || r == '+' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, req.Phone)
|
||||
|
||||
// Validate UK phone number
|
||||
phone, err := ValidateUKPhoneNumber(req.Phone)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Phone = strings.TrimSpace(phone)
|
||||
|
||||
// Convert names to title case
|
||||
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
|
||||
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
|
||||
|
||||
// Parse date of birth
|
||||
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid date format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject if younger than 16
|
||||
if !dob.Before(time.Now().AddDate(-16, 0, 0)) {
|
||||
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Insert and return the generated ID
|
||||
var userID string
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO users
|
||||
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
|
||||
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
|
||||
RETURNING id
|
||||
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "an account with this email already exists", http.StatusConflict)
|
||||
} else {
|
||||
http.Error(w, "could not create user", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
input := dav.ContactInput{
|
||||
UserID: userID,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
DOB: req.DateOfBirth,
|
||||
}
|
||||
if err := dav.Service.CreateContact(1, userID, input); err != nil {
|
||||
log.Printf("Warning: Failed to create contact in DAV for user %s: %v", userID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func ValidateUKPhoneNumber(phone string) (string, error) {
|
||||
num, err := phonenumbers.Parse(phone, "GB")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !phonenumbers.IsValidNumber(num) {
|
||||
return "", fmt.Errorf("invalid phone number")
|
||||
}
|
||||
|
||||
// Check if it's actually a UK number
|
||||
if phonenumbers.GetRegionCodeForNumber(num) != "GB" {
|
||||
return "", fmt.Errorf("only UK numbers allowed")
|
||||
}
|
||||
|
||||
// Format in E.164 format (+44...)
|
||||
return phonenumbers.Format(num, phonenumbers.E164), nil
|
||||
}
|
||||
|
||||
// POST /api/login
|
||||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize email
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
var userID, passwordHash, role string
|
||||
ctx := context.Background()
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, password_hash, account_role
|
||||
FROM users
|
||||
WHERE email = $1 AND account_type = 'email'
|
||||
`, req.Email).Scan(&userID, &passwordHash, &role)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is already logging in
|
||||
loginStateMu.Lock()
|
||||
if loginInProgress[userID] {
|
||||
loginStateMu.Unlock()
|
||||
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
||||
return
|
||||
}
|
||||
loginInProgress[userID] = true
|
||||
loginStateMu.Unlock()
|
||||
|
||||
// Always clear flag when done
|
||||
defer func() {
|
||||
loginStateMu.Lock()
|
||||
delete(loginInProgress, userID)
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
|
||||
// Enforce 1 attempt per 5s
|
||||
loginStateMu.Lock()
|
||||
if last, ok := loginAttempts[userID]; ok {
|
||||
since := time.Since(last)
|
||||
if since < 5*time.Second {
|
||||
wait := 5*time.Second - since
|
||||
loginStateMu.Unlock()
|
||||
time.Sleep(wait)
|
||||
} else {
|
||||
loginStateMu.Unlock()
|
||||
}
|
||||
} else {
|
||||
loginStateMu.Unlock()
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
loginStateMu.Lock()
|
||||
loginAttempts[userID] = time.Now()
|
||||
loginStateMu.Unlock()
|
||||
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// On success, clear attempts
|
||||
loginStateMu.Lock()
|
||||
delete(loginAttempts, userID)
|
||||
loginStateMu.Unlock()
|
||||
|
||||
// Update last login
|
||||
_, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to update last_login_at:", err)
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
tokenString, err := auth.GenerateToken(userID, role)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString})
|
||||
}
|
||||
|
||||
// POST /api/refresh-token (requires auth middleware)
|
||||
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
role, _ := mw.GetUserRole(r.Context())
|
||||
|
||||
// Verify user still exists and role hasn't changed
|
||||
var currentRole string
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT account_role FROM users WHERE id = $1
|
||||
`, userID).Scan(¤tRole)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "user not found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If role changed, force re-login
|
||||
if currentRole != role {
|
||||
http.Error(w, "role changed, please log in again", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
newToken, err := auth.GenerateToken(userID, currentRole)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package auth
|
||||
@@ -0,0 +1,12 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DELETE /api/user/account
|
||||
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Delete user's data
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
)
|
||||
|
||||
type LoyaltyResponse struct {
|
||||
Stamps int `json:"stamps"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
}
|
||||
|
||||
// GET /api/user/loyalty
|
||||
func GetLoyaltyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
|
||||
var loyalty LoyaltyResponse
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT loyalty_stamps, referral_code
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&loyalty.Stamps, &loyalty.ReferralCode)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get loyalty info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(loyalty)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/auth"
|
||||
"crussell/mw"
|
||||
)
|
||||
|
||||
var titleCaser = cases.Title(language.English)
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
DateOfBirth *string `json:"dateOfBirth,omitempty"`
|
||||
Role string `json:"role"`
|
||||
LoyaltyStamps int `json:"loyaltyStamps"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
// GET /api/user/profile
|
||||
func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var user UserProfile
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT
|
||||
id, email, n_first_name, n_last_name, phone,
|
||||
date_of_birth::text, account_role, loyalty_stamps,
|
||||
referral_code, profile_pic_url
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.FirstName, &user.LastName,
|
||||
&user.Phone, &user.DateOfBirth, &user.Role,
|
||||
&user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// PUT /api/user/profile
|
||||
// updateCardDAV updates an existing contact in SabreDAV using user ID
|
||||
func updateCardDAV(userID, firstName, lastName, email, phone, dob string) error {
|
||||
// Use user ID as filename - consistent with registration
|
||||
filename := fmt.Sprintf("%s.vcf", userID)
|
||||
url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename)
|
||||
|
||||
// Create vCard with user ID as UID (no need to fetch existing)
|
||||
timestamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
uid := fmt.Sprintf("%s@example.com", userID)
|
||||
|
||||
vcard := fmt.Sprintf(`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:%s
|
||||
FN:%s %s
|
||||
N:%s;%s;;;
|
||||
EMAIL;TYPE=INTERNET:%s
|
||||
TEL;TYPE=CELL:%s
|
||||
BDAY:%s
|
||||
REV:%s
|
||||
END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, timestamp)
|
||||
|
||||
// PUT updated vCard
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/vcard; charset=utf-8")
|
||||
req.SetBasicAuth("admin", "admin")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update CardDAV: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("CardDAV returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT /api/user/profile
|
||||
func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize input
|
||||
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||
req.LastName = strings.TrimSpace(req.LastName)
|
||||
req.Phone = strings.TrimSpace(req.Phone)
|
||||
|
||||
// Required fields
|
||||
if req.FirstName == "" || req.LastName == "" || req.Phone == "" {
|
||||
http.Error(w, "first name, last name and phone are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate names (unicode letters, spaces, hyphen, apostrophe, dot)
|
||||
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
||||
|
||||
if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) {
|
||||
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate lengths
|
||||
if len(req.FirstName) < 1 || len(req.FirstName) > 50 {
|
||||
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.LastName) < 1 || len(req.LastName) > 50 {
|
||||
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize phone (strip spaces, hyphens, brackets)
|
||||
req.Phone = strings.Map(func(r rune) rune {
|
||||
if (r >= '0' && r <= '9') || r == '+' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, req.Phone)
|
||||
|
||||
// Validate UK phone number
|
||||
phone, err := auth.ValidateUKPhoneNumber(req.Phone)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Phone = strings.TrimSpace(phone)
|
||||
|
||||
// Title case names
|
||||
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
|
||||
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
|
||||
|
||||
// Fetch user's email and DOB for CardDAV update
|
||||
var email string
|
||||
var dob time.Time
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT email, date_of_birth FROM users WHERE id = $1
|
||||
`, userID).Scan(&email, &dob)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "failed to fetch user data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update DB
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, req.FirstName, req.LastName, req.Phone, userID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update CardDAV (non-blocking)
|
||||
go func() {
|
||||
dobStr := dob.Format("2006-01-02")
|
||||
if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr); err != nil {
|
||||
fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var Service *BaseService
|
||||
|
||||
func init() {
|
||||
if err := connect(); err != nil {
|
||||
log.Fatalf("failed to initialize dev service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func connect() error {
|
||||
dsn := fmt.Sprintf(
|
||||
"postgres://%s:%s@localhost:5432/%s?sslmode=disable",
|
||||
getEnv("POSTGRES_USER"),
|
||||
getEnv("POSTGRES_PASSWORD"),
|
||||
getEnv("POSTGRES_DB"),
|
||||
)
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Service = newBaseService(pool)
|
||||
return testDB(pool)
|
||||
}
|
||||
|
||||
func testDB(pool *pgxpool.Pool) error {
|
||||
var n int
|
||||
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
|
||||
if err != nil || n != 1 {
|
||||
return fmt.Errorf("db test failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
log.Fatalf("FATAL: environment variable %s not set", key)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var Service *BaseService
|
||||
|
||||
func init() {
|
||||
if err := connect(); err != nil {
|
||||
log.Fatalf("failed to initialize prod service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func connect() error {
|
||||
dsn := fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:5432/%s",
|
||||
getEnv("POSTGRES_USER"),
|
||||
getEnv("POSTGRES_PASSWORD"),
|
||||
getEnv("POSTGRES_HOST"),
|
||||
getEnv("POSTGRES_DB"),
|
||||
)
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Service = newBaseService(pool)
|
||||
return testDB(pool)
|
||||
}
|
||||
|
||||
func testDB(pool *pgxpool.Pool) error {
|
||||
var n int
|
||||
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
|
||||
if err != nil || n != 1 {
|
||||
return fmt.Errorf("db test failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
log.Fatalf("FATAL: environment variable %s not set", key)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// BaseService holds shared DB connection
|
||||
type BaseService struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// newBaseService returns a new BaseService instance
|
||||
func newBaseService(db *pgxpool.Pool) *BaseService {
|
||||
return &BaseService{db: db}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Calendar Functions
|
||||
// ============================================================================
|
||||
|
||||
func (s *BaseService) ListEventsForMonth(year int, month time.Month) ([]CalendarEvent, error) {
|
||||
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.AddDate(0, 1, 0).Add(-time.Second)
|
||||
|
||||
query := `
|
||||
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||
firstoccurence, lastoccurence, uid
|
||||
FROM dav_calendarobjects
|
||||
WHERE firstoccurence >= $1 AND firstoccurence <= $2
|
||||
ORDER BY firstoccurence
|
||||
`
|
||||
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
func (s *BaseService) ListEventsBetween(start, end time.Time) ([]CalendarEvent, error) {
|
||||
query := `
|
||||
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||
firstoccurence, lastoccurence, uid
|
||||
FROM dav_calendarobjects
|
||||
WHERE firstoccurence >= $1 AND firstoccurence <= $2
|
||||
ORDER BY firstoccurence
|
||||
`
|
||||
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
func (s *BaseService) ListEventsTomorrow() ([]CalendarEvent, error) {
|
||||
now := time.Now()
|
||||
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
dayAfter := tomorrow.Add(24 * time.Hour)
|
||||
return s.ListEventsBetween(tomorrow, dayAfter)
|
||||
}
|
||||
|
||||
func (s *BaseService) ListEventsThisWeek() ([]CalendarEvent, error) {
|
||||
now := time.Now()
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 { // Sunday
|
||||
weekday = 7
|
||||
}
|
||||
monday := now.AddDate(0, 0, -weekday+1)
|
||||
monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location())
|
||||
sunday := monday.AddDate(0, 0, 7)
|
||||
return s.ListEventsBetween(monday, sunday)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contact Functions
|
||||
// ============================================================================
|
||||
|
||||
func (s *BaseService) GetContactByURI(addressBookID int, uri string) (*Contact, error) {
|
||||
query := `
|
||||
SELECT id, addressbookid, uri, carddata, lastmodified, etag, size
|
||||
FROM dav_cards
|
||||
WHERE addressbookid = $1 AND uri = $2
|
||||
`
|
||||
var c Contact
|
||||
err := s.db.QueryRow(context.Background(), query, addressBookID, uri).Scan(
|
||||
&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contact not found: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *BaseService) ListAllContacts() ([]Contact, error) {
|
||||
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards ORDER BY lastmodified DESC`
|
||||
rows, err := s.db.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).Unix()
|
||||
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards WHERE lastmodified >= $1 ORDER BY lastmodified DESC`
|
||||
rows, err := s.db.Query(context.Background(), query, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
func (s *BaseService) queryEventsWithContacts(query string, args ...interface{}) ([]CalendarEvent, error) {
|
||||
rows, err := s.db.Query(context.Background(), query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []CalendarEvent
|
||||
for rows.Next() {
|
||||
var e CalendarEvent
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.CalendarID, &e.URI, &e.CalendarData, &e.LastModified, &e.Etag, &e.Size,
|
||||
&e.ComponentType, &e.FirstOccurence, &e.LastOccurence, &e.UID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ContactURIs = extractContactURIsFromICalendar(e.CalendarData)
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func extractContactURIsFromICalendar(icalData string) []string {
|
||||
var uris []string
|
||||
for _, line := range strings.Split(icalData, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "ATTENDEE") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) >= 2 {
|
||||
uris = append(uris, strings.TrimSpace(parts[len(parts)-1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return uris
|
||||
}
|
||||
|
||||
// CreateContact adds a new contact to an address book
|
||||
func (s *BaseService) CreateContact(addressBookID int, userID string, input ContactInput) error {
|
||||
now := time.Now().Unix()
|
||||
uri := fmt.Sprintf("%s.vcf", userID)
|
||||
cardData := GenerateVCard(input)
|
||||
|
||||
query := `
|
||||
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
_, err := s.db.Exec(context.Background(), query,
|
||||
addressBookID,
|
||||
uri,
|
||||
cardData,
|
||||
now,
|
||||
fmt.Sprintf("%d", now),
|
||||
len(cardData),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateContact updates an existing contact by URI
|
||||
func (s *BaseService) UpdateContact(addressBookID int, uri string, input ContactInput) error {
|
||||
now := time.Now().Unix()
|
||||
cardData := GenerateVCard(input)
|
||||
query := `
|
||||
UPDATE dav_cards
|
||||
SET carddata = $1, lastmodified = $2, etag = $3, size = $4
|
||||
WHERE addressbookid = $5 AND uri = $6
|
||||
`
|
||||
_, err := s.db.Exec(context.Background(), query, cardData, now, fmt.Sprintf("%d", now), len(cardData), addressBookID, uri)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteContact deletes a contact by URI
|
||||
func (s *BaseService) DeleteContact(addressBookID int, uri string) error {
|
||||
query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2`
|
||||
_, err := s.db.Exec(context.Background(), query, addressBookID, uri)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateEvent adds a new event to a calendar
|
||||
func (s *BaseService) CreateEvent(calendarID int, input EventInput) error {
|
||||
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
|
||||
now := time.Now().Unix()
|
||||
calendarData := GenerateICalEvent(input)
|
||||
|
||||
query := `
|
||||
INSERT INTO dav_calendarobjects
|
||||
(calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||
firstoccurence, lastoccurence, uid)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'VEVENT', $7, $8, $9)
|
||||
`
|
||||
_, err := s.db.Exec(context.Background(), query,
|
||||
calendarID,
|
||||
uid+".ics",
|
||||
calendarData,
|
||||
now,
|
||||
fmt.Sprintf("%d", now),
|
||||
len(calendarData),
|
||||
input.Start.Unix(),
|
||||
input.End.Unix(),
|
||||
uid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEvent updates an existing calendar event by UID
|
||||
func (s *BaseService) UpdateEvent(calendarID int, uid string, input EventInput) error {
|
||||
now := time.Now().Unix()
|
||||
calendarData := GenerateICalEvent(input)
|
||||
|
||||
query := `
|
||||
UPDATE dav_calendarobjects
|
||||
SET calendardata = $1, lastmodified = $2, etag = $3, size = $4,
|
||||
firstoccurence = $5, lastoccurence = $6
|
||||
WHERE calendarid = $7 AND uid = $8
|
||||
`
|
||||
_, err := s.db.Exec(context.Background(), query,
|
||||
calendarData, now, fmt.Sprintf("%d", now), len(calendarData),
|
||||
input.Start.Unix(), input.End.Unix(),
|
||||
calendarID, uid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteEvent deletes an event by UID
|
||||
func (s *BaseService) DeleteEvent(calendarID int, uid string) error {
|
||||
query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2`
|
||||
_, err := s.db.Exec(context.Background(), query, calendarID, uid)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListEventsForContactSQL returns all calendar events where the given contact URI is an attendee (SQL optimized)
|
||||
func (s *BaseService) ListEventsForContact(contactURI string) ([]CalendarEvent, error) {
|
||||
// Use pattern matching to find events containing the contact URI in ATTENDEE lines
|
||||
likePattern := "%" + contactURI + "%"
|
||||
|
||||
query := `
|
||||
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||
firstoccurence, lastoccurence, uid
|
||||
FROM dav_calendarobjects
|
||||
WHERE calendardata LIKE $1
|
||||
ORDER BY firstoccurence
|
||||
`
|
||||
|
||||
return s.queryEventsWithContacts(query, likePattern)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package dav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Calendar Types
|
||||
// ============================================================================
|
||||
|
||||
type Calendar struct {
|
||||
ID int `json:"id"`
|
||||
PrincipalURI string `json:"principaluri"`
|
||||
DisplayName string `json:"displayname"`
|
||||
URI string `json:"uri"`
|
||||
Description string `json:"description"`
|
||||
CalendarOrder int `json:"calendarorder"`
|
||||
CalendarColor string `json:"calendarcolor"`
|
||||
Components string `json:"components"`
|
||||
}
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID int `json:"id"`
|
||||
CalendarID int `json:"calendarid"`
|
||||
URI string `json:"uri"`
|
||||
CalendarData string `json:"calendardata"`
|
||||
LastModified int64 `json:"lastmodified"`
|
||||
Etag string `json:"etag"`
|
||||
Size int `json:"size"`
|
||||
ComponentType string `json:"componenttype"`
|
||||
FirstOccurence int64 `json:"firstoccurence"`
|
||||
LastOccurence int64 `json:"lastoccurence"`
|
||||
UID string `json:"uid"`
|
||||
ContactURIs []string `json:"contact_uris"` // Extracted from ATTENDEE fields
|
||||
}
|
||||
|
||||
type EventInput struct {
|
||||
Summary string
|
||||
Description string
|
||||
Location string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
AllDay bool
|
||||
ContactURIs []string // URIs of contacts to attach as attendees
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CardDAV Types
|
||||
// ============================================================================
|
||||
|
||||
type AddressBook struct {
|
||||
ID int `json:"id"`
|
||||
PrincipalURI string `json:"principaluri"`
|
||||
DisplayName string `json:"displayname"`
|
||||
URI string `json:"uri"`
|
||||
Description string `json:"description"`
|
||||
SyncToken int `json:"synctoken"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id"`
|
||||
AddressBookID int `json:"addressbookid"`
|
||||
URI string `json:"uri"`
|
||||
CardData string `json:"carddata"`
|
||||
LastModified int64 `json:"lastmodified"`
|
||||
Etag string `json:"etag"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type ContactInput struct {
|
||||
UserID string
|
||||
FirstName string
|
||||
LastName string
|
||||
Email string
|
||||
Phone string
|
||||
DOB string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// GenerateICalEvent creates iCalendar format for UK timezone
|
||||
func GenerateICalEvent(input EventInput) string {
|
||||
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
|
||||
dtstamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
|
||||
var dtstart, dtend string
|
||||
if input.AllDay {
|
||||
dtstart = fmt.Sprintf("DTSTART;VALUE=DATE:%s", input.Start.Format("20060102"))
|
||||
dtend = fmt.Sprintf("DTEND;VALUE=DATE:%s", input.End.Format("20060102"))
|
||||
} else {
|
||||
dtstart = fmt.Sprintf("DTSTART;TZID=Europe/London:%s", input.Start.Format("20060102T150405"))
|
||||
dtend = fmt.Sprintf("DTEND;TZID=Europe/London:%s", input.End.Format("20060102T150405"))
|
||||
}
|
||||
|
||||
// Build attendees section
|
||||
attendees := ""
|
||||
for _, contactURI := range input.ContactURIs {
|
||||
attendees += fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI)
|
||||
}
|
||||
|
||||
ical := fmt.Sprintf(`BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Your App//EN
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/London
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0000
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:BST
|
||||
DTSTART:19700329T010000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0000
|
||||
TZNAME:GMT
|
||||
DTSTART:19701025T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:%s
|
||||
DTSTAMP:%s
|
||||
%s
|
||||
%s
|
||||
SUMMARY:%s
|
||||
DESCRIPTION:%s
|
||||
LOCATION:%s
|
||||
%sSEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR`, uid, dtstamp, dtstart, dtend,
|
||||
escapeICalText(input.Summary),
|
||||
escapeICalText(input.Description),
|
||||
escapeICalText(input.Location),
|
||||
attendees)
|
||||
|
||||
return ical
|
||||
}
|
||||
|
||||
// GenerateVCard creates vCard format (version 3.0)
|
||||
func GenerateVCard(input ContactInput) string {
|
||||
vcard := fmt.Sprintf(`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:%s
|
||||
FN:%s %s
|
||||
N:%s;%s;;;
|
||||
EMAIL;TYPE=INTERNET:%s
|
||||
TEL;TYPE=CELL:%s
|
||||
BDAY:%s
|
||||
REV:%s
|
||||
END:VCARD`,
|
||||
input.UserID,
|
||||
input.FirstName, input.LastName,
|
||||
input.LastName, input.FirstName,
|
||||
input.Email,
|
||||
input.Phone,
|
||||
input.DOB,
|
||||
time.Now().UTC().Format("20060102T150405Z"))
|
||||
|
||||
return vcard
|
||||
}
|
||||
|
||||
func escapeICalText(text string) string {
|
||||
text = replaceAll(text, "\\", "\\\\")
|
||||
text = replaceAll(text, "\n", "\\n")
|
||||
text = replaceAll(text, ",", "\\,")
|
||||
text = replaceAll(text, ";", "\\;")
|
||||
return text
|
||||
}
|
||||
|
||||
func replaceAll(s, old, new string) string {
|
||||
result := ""
|
||||
for _, char := range s {
|
||||
if string(char) == old {
|
||||
result += new
|
||||
} else {
|
||||
result += string(char)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crussell/auth"
|
||||
"crussell/internal/dav"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
|
||||
authHandlers "crussell/handlers/auth"
|
||||
userHandlers "crussell/handlers/user"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 1. Read the environment variable
|
||||
jwtSecret := os.Getenv("JWT_SECRET_KEY")
|
||||
|
||||
// 2. Add a check to ensure the secret is set
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
|
||||
}
|
||||
|
||||
// 3. Use the environment variable for initialization
|
||||
auth.InitJWT(jwtSecret)
|
||||
}
|
||||
|
||||
func initDB() {
|
||||
if err := db.Connect(); err != nil {
|
||||
log.Fatal("Failed to connect to DB:", err)
|
||||
}
|
||||
fmt.Println("Connected to DB successfully")
|
||||
}
|
||||
|
||||
func initDav() {
|
||||
if dav.Service == nil {
|
||||
log.Fatal("Failed to initialize DAV service")
|
||||
}
|
||||
fmt.Println("DAV Service connected successfully")
|
||||
}
|
||||
|
||||
func main() {
|
||||
initDB()
|
||||
initDav()
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID) // Add X-Request-ID header
|
||||
r.Use(middleware.RealIP) // Get real IP from headers
|
||||
r.Use(middleware.Logger) // Basic logging
|
||||
r.Use(middleware.Recoverer) // Panic recovery
|
||||
r.Use(middleware.Timeout(15 * time.Second)) // Request timeout
|
||||
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
// Public auth routes
|
||||
r.Post("/api/register", authHandlers.RegisterHandler)
|
||||
r.Post("/api/login", authHandlers.LoginHandler)
|
||||
|
||||
// Protected routes - any authenticated user
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
|
||||
// Auth
|
||||
r.Post("/api/refresh-token", authHandlers.RefreshTokenHandler)
|
||||
|
||||
// User profile
|
||||
r.Get("/api/user/profile", userHandlers.GetProfileHandler)
|
||||
r.Put("/api/user/profile", userHandlers.UpdateProfileHandler)
|
||||
r.Delete("/api/user/account", userHandlers.DeleteAccountHandler)
|
||||
|
||||
// Loyalty
|
||||
r.Get("/api/user/loyalty", userHandlers.GetLoyaltyHandler)
|
||||
})
|
||||
|
||||
// Protected routes - verified users only
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireVerified)
|
||||
|
||||
// Add booking routes, etc.
|
||||
})
|
||||
|
||||
// Admin routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
|
||||
// Add admin routes
|
||||
})
|
||||
|
||||
fmt.Println("Server is listening on :8080")
|
||||
http.ListenAndServe(":8080", r)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package mw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crussell/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserRoleKey contextKey = "user_role"
|
||||
)
|
||||
|
||||
// RequireAuth middleware - validates JWT and adds user info to context
|
||||
func RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "missing or invalid authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
userID, role, err := auth.VerifyToken(tokenString, r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add user info to context
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireRole middleware - checks if user has required role(s)
|
||||
func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role, ok := r.Context().Value(UserRoleKey).(string)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has one of the allowed roles
|
||||
hasRole := false
|
||||
for _, allowedRole := range allowedRoles {
|
||||
if role == allowedRole {
|
||||
hasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRole {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireVerified middleware - only allows verified_email and admin
|
||||
func RequireVerified(next http.Handler) http.Handler {
|
||||
return RequireRole("verified_email", "admin")(next)
|
||||
}
|
||||
|
||||
// RequireAdmin middleware - only allows admin
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return RequireRole("admin")(next)
|
||||
}
|
||||
|
||||
// Helper functions to get user info from context
|
||||
func GetUserID(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(UserIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func GetUserRole(ctx context.Context) (string, bool) {
|
||||
role, ok := ctx.Value(UserRoleKey).(string)
|
||||
return role, ok
|
||||
}
|
||||
Reference in New Issue
Block a user