Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.

This commit is contained in:
2025-10-12 22:13:19 +01:00
commit 708b741a32
138 changed files with 7797 additions and 0 deletions
+13
View File
@@ -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"]
+56
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package auth
+65
View File
@@ -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 ""
}
+64
View File
@@ -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 ""
}
+34
View File
@@ -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
)
+62
View File
@@ -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=
+1
View File
@@ -0,0 +1 @@
package admin
+1
View File
@@ -0,0 +1 @@
package admin
+1
View File
@@ -0,0 +1 @@
package admin
+354
View File
@@ -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(&currentRole)
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})
}
+1
View File
@@ -0,0 +1 @@
package auth
+12
View File
@@ -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)
}
+34
View File
@@ -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)
}
+209
View File
@@ -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)
}
+54
View File
@@ -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 ""
}
+55
View File
@@ -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 ""
}
+276
View File
@@ -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)
}
+187
View File
@@ -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
View File
@@ -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)
}
+91
View File
@@ -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
}