Compare commits

..

No commits in common. "42409429e6834130b541a5dab22ccedf225d6217" and "24f4d8023e65e70fdcc2ef8a4f3eef3e98e98afa" have entirely different histories.

14 changed files with 83 additions and 228 deletions

View File

@ -1,5 +1,3 @@
# shellcheck disable=all
# Database # Database
PG_USER="" PG_USER=""
PG_PASS="" PG_PASS=""
@ -7,11 +5,4 @@ PG_DB=""
# Server # Server
JWT_SECRET="" JWT_SECRET=""
CSRF_SECRET="" # Should be 32 bytes long LOG_LEVEL="debug"
ADMIN_USERNAME=""
ADMIN_PASSWORD=""
LOG_LEVEL="info"
APP_ENV="production"
DOMAIN=""
FRONTEND_URL=""

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
data/
TASKS.md TASKS.md
.env .env

View File

@ -1,52 +0,0 @@
services:
notatest-psql:
image: postgres:16-alpine
container_name: notatest-psql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- notatest-data:/var/lib/postgresql/data
networks:
- notatest
environment:
POSTGRES_USER: ${PG_USER:-notatest}
POSTGRES_PASSWORD: ${PG_PASS:?db password required}
POSTGRES_DB: ${PG_DB:-notatest}
notatest-server:
container_name: notatest-server
build:
context: ${PWD}/server
dockerfile: ${PWD}/server/Dockerfile
image: notatest-server:latest
networks:
- notatest
ports:
- 8080:8080 # !
depends_on:
notatest-psql:
condition: service_healthy
environment:
JWT_SECRET: ${JWT_SECRET:?jwt secret required}
DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable
CSRF_SECRET: ${CSRF_SECRET:?csrf secret required}
ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required}
LOG_LEVEL: debug
APP_ENV: development
DOMAIN: localhost
FRONTEND_URL: http://localhost:3000
networks:
notatest:
external: false
name: notatest
volumes:
notatest-data:
name: notatest-data

View File

@ -10,7 +10,8 @@ services:
retries: 5 retries: 5
timeout: 5s timeout: 5s
volumes: volumes:
- ${PWD}/data:/var/lib/postgresql/data # Alternatively mount to disk -> ${PWD}/data
- notatest-data:/var/lib/postgresql/data
networks: networks:
- notatest - notatest
environment: environment:
@ -26,32 +27,35 @@ services:
image: notatest-server:latest image: notatest-server:latest
networks: networks:
- notatest - notatest
ports:
- 8080:8080 # TODO: remove forwarding after testing
depends_on: depends_on:
notatest-psql: notatest-psql:
condition: service_healthy condition: service_healthy
environment: environment:
JWT_SECRET: ${JWT_SECRET:?jwt secret required} JWT_SECRET: ${JWT_SECRET:?jwt secret required}
DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable
CSRF_SECRET: ${CSRF_SECRET:?csrf secret required} LOG_LEVEL: ${LOG_LEVEL:-info}
ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required} ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required}
LOG_LEVEL: ${LOG_LEVEL:-info}
APP_ENV: ${APP_ENV:-production}
DOMAIN: ${DOMAIN:-localhost}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
notatest-web: # notatest-web:
build: ${PWD}/web # build: ${PWD}/web
image: notatest/web # image: notatest/web
container_name: notatest-web # container_name: notatest-web
networks: # networks:
- notatest # - notatest
ports: # ports:
- 3000:80 # Defined in nginx.conf # - 3030:3030
depends_on: # depends_on:
- notatest-server # notatest-psql:
# condition: service_healthy
# notatest-server:
networks: networks:
notatest: notatest:
external: false external: false
name: notatest name: notatest
volumes:
notatest-data:

View File

@ -3,4 +3,4 @@
# Delete all Docker artifacts (e.g. DB) from previous test runs # Delete all Docker artifacts (e.g. DB) from previous test runs
docker stop notatest-server notatest-psql && docker rm -f notatest-server notatest-psql docker stop notatest-server notatest-psql && docker rm -f notatest-server notatest-psql
docker volume rm -f notatest-data docker volume rm -f notatest_notatest-data

View File

@ -1,10 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
DEV_COMPOSE_FILE="docker-compose-dev-backend.yml"
[ "$( docker container inspect -f '{{.State.Status}}' notatest-psql )" = "running" ] || docker compose up notatest-psql -d [ "$( docker container inspect -f '{{.State.Status}}' notatest-psql )" = "running" ] || docker compose up notatest-psql -d
# Shutdown, rebuild, & restart # Shutdown, rebuild, & restart
docker compose stop notatest-server && docker compose rm -f notatest-server docker compose stop notatest-server && docker compose rm -f notatest-server
docker compose -f $DEV_COMPOSE_FILE build docker compose build
docker compose -f $DEV_COMPOSE_FILE up notatest-server docker compose up notatest-server

View File

@ -5,11 +5,9 @@ go 1.24.1
require ( require (
github.com/caarlos0/env/v10 v10.0.0 github.com/caarlos0/env/v10 v10.0.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.2
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@ -19,7 +17,6 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect

View File

@ -22,8 +22,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -35,14 +33,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=

View File

@ -16,7 +16,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -80,9 +79,9 @@ type UserStore interface {
// (especially in production) the `UserStore` and `TokenStore` will point to the same database // (especially in production) the `UserStore` and `TokenStore` will point to the same database
// handler, but for code readability they should be kept in separate structs. // handler, but for code readability they should be kept in separate structs.
type authResource struct { type authResource struct {
Config SvcConfig JWTSecret string
Users UserStore Users UserStore
Tokens TokenStore Tokens TokenStore
} }
func (rs authResource) Routes() chi.Router { func (rs authResource) Routes() chi.Router {
@ -94,9 +93,9 @@ func (rs authResource) Routes() chi.Router {
// Protected routes (access token required) // Protected routes (access token required)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx. r.Use(requireAccessToken(rs.JWTSecret)) // JWT claims -> ctx.
r.Get("/me", rs.Get) // GET /auth/me - current user data r.Get("/me", rs.Get) // GET /auth/me - current user data
r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies
// Owner routes // Owner routes
r.Route("/owner", func(r chi.Router) { r.Route("/owner", func(r chi.Router) {
@ -116,18 +115,9 @@ func (rs authResource) Routes() chi.Router {
}) })
// Protected routes (refresh token required) // Protected routes (refresh token required)
r.Route("/cookie", func(r chi.Router) { r.Group(func(r chi.Router) {
// The refresh token httpOnly cookie is restricted to `/api/auth/cookie`, which is why this r.Use(requireRefreshToken(rs.JWTSecret))
// is the only endpoint where CSRF must be taken into account (HTTPS requirement disabled r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/refresh - convert refresh token to new token pair
// for local development)
if rs.Config.IsProd {
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret)))
} else {
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret), csrf.Secure(false)))
}
r.Use(requireRefreshToken(rs.Config.JWTSecret))
r.Get("/csrf", rs.GetCSRFToken) // GET /auth/cookie/csrf - get a new CSRF token in response headers
r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/cookie/refresh - convert refresh token to new token pair
}) })
return r return r
@ -224,12 +214,12 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
// Set refresh token into a httpOnly cookie // Set refresh token into a httpOnly cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token", Name: "refresh_token",
Value: tokenPair.RefreshToken, Value: tokenPair.RefreshToken,
Path: "/api/auth/cookie", Path: "/",
MaxAge: int(refreshTokenDuration.Seconds()), MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true, HttpOnly: true,
Secure: rs.Config.IsProd, Secure: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
@ -403,7 +393,7 @@ func (rs authResource) AdminDelete(w http.ResponseWriter, r *http.Request) {
// ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further // ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further
// token rotations. // token rotations.
func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) { func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) {
tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.Config.JWTSecret) tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.JWTSecret)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -452,14 +442,6 @@ func (rs authResource) ValidateRefreshToken(ctx context.Context, token string) (
return &dbToken, nil return &dbToken, nil
} }
// Handler for returning the CSRF token in the `X-CSRF-Token` header. Notably this request doesn't
// need to contain a valid CSRF token as it uses "safe" (non-mutating) method GET, which means CSRF
// won't be enforced.
func (rs authResource) GetCSRFToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
w.WriteHeader(http.StatusNoContent)
}
// Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh // Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh
// token is a single use utility) and exchanging it for a new pair of refresh and access tokens. // token is a single use utility) and exchanging it for a new pair of refresh and access tokens.
func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) { func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) {
@ -504,12 +486,12 @@ func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request
// Set refresh token into a httpOnly cookie // Set refresh token into a httpOnly cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token", Name: "refresh_token",
Value: tokenPair.RefreshToken, Value: tokenPair.RefreshToken,
Path: "/api/auth/cookie", Path: "/",
MaxAge: int(refreshTokenDuration.Seconds()), MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true, HttpOnly: true,
Secure: rs.Config.IsProd, Secure: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
@ -538,12 +520,12 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
// Clear the refresh token cookie // Clear the refresh token cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token", Name: "refresh_token",
Value: "", Value: "",
Path: "/api/auth/cookie", Path: "/",
MaxAge: 0, // Expires immediately MaxAge: 0, // Expires immediately
HttpOnly: true, HttpOnly: true,
Secure: rs.Config.IsProd, Secure: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
@ -555,30 +537,6 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based
// on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data.
func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User {
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return nil
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID")
return nil
}
user, err := rs.Users.GetUserByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found")
return nil
}
return &user
}
// Helper function for generating the initial administrator level account if one doesn't already // Helper function for generating the initial administrator level account if one doesn't already
// exists in the database. // exists in the database.
func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error { func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error {
@ -632,7 +590,7 @@ func getTokenFromHeader(r *http.Request) (string, error) {
// Parse the JWT token from the request's cookies (httpOnly cookie). // Parse the JWT token from the request's cookies (httpOnly cookie).
func getTokenFromCookie(r *http.Request) (string, error) { func getTokenFromCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie("notatest.refresh_token") cookie, err := r.Cookie("refresh_token")
if err != nil { if err != nil {
return "", err return "", err
} }
@ -678,6 +636,30 @@ func generateTokenPair(userID string, isAdmin bool, jwtSecret string) (*tokenPai
return &tokenPair{AccessToken: t, RefreshToken: rt}, nil return &tokenPair{AccessToken: t, RefreshToken: rt}, nil
} }
// Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based
// on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data.
func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User {
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return nil
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID")
return nil
}
user, err := rs.Users.GetUserByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found")
return nil
}
return &user
}
// Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), i.e. // Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), i.e.
// whether an entry with the given details already exists in the database table. // whether an entry with the given details already exists in the database table.
func isDuplicateEntry(err error) bool { func isDuplicateEntry(err error) bool {

View File

@ -210,7 +210,7 @@ func loggerMiddleware(log *zerolog.Logger) func(http.Handler) http.Handler {
} }
// Log a regular HTTP request with some metadata // Log a regular HTTP request with some metadata
log.Debug(). log.Info().
Str("type", "access"). Str("type", "access").
Timestamp(). Timestamp().
Fields(map[string]any{ Fields(map[string]any{

View File

@ -110,9 +110,8 @@ func TestRequireRTMiddleware(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
if tc.token != "" { if tc.token != "" {
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: "notatest.refresh_token", Name: "refresh_token",
Value: tc.token, Value: tc.token,
HttpOnly: true,
}) })
} }

View File

@ -38,15 +38,15 @@ type NoteStore interface {
// Chi HTTP router for notes related CRUD actions. // Chi HTTP router for notes related CRUD actions.
type notesResource struct { type notesResource struct {
Config SvcConfig JWTSecret string
Notes NoteStore Notes NoteStore
} }
func (rs notesResource) Routes() chi.Router { func (rs notesResource) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx. r.Use(requireAccessToken(rs.JWTSecret)) // JWT claims -> ctx.
r.Post("/", rs.Create) // POST /notes - create new note r.Post("/", rs.Create) // POST /notes - create new note
r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles) r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles)

View File

@ -6,61 +6,27 @@ import (
"git.umbrella.haus/ae/notatest/internal/data" "git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type SvcConfig struct { func Run(conn *pgx.Conn, q *data.Queries, jwtSecret string) error {
JWTSecret string
CSRFSecret string
IsProd bool
Domain string
FrontendURL string
}
func (sc *SvcConfig) allowedOrigins() []string {
var allowed []string
if sc.IsProd {
allowed = []string{sc.FrontendURL}
} else {
allowed = []string{"http://localhost:3000"}
}
log.Debug().Msgf("CORS allowedOrigins: %v", allowed)
return allowed
}
func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
r := chi.NewRouter() r := chi.NewRouter()
if !config.IsProd {
log.Warn().Msg("Running in *INSECURE* development mode")
}
authRouter := authResource{ authRouter := authResource{
Config: config, JWTSecret: jwtSecret,
Users: q, Users: q,
Tokens: q, Tokens: q,
} }
notesRouter := notesResource{ notesRouter := notesResource{
Config: config, JWTSecret: jwtSecret,
Notes: q, Notes: q,
} }
// Global middlewares // Global middlewares
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(loggerMiddleware(&log.Logger)) r.Use(loggerMiddleware(&log.Logger))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: config.allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"List"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.AllowContentType("application/json")) r.Use(middleware.AllowContentType("application/json"))
@ -69,15 +35,8 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Mount("/auth", authRouter.Routes()) r.Mount("/auth", authRouter.Routes())
r.Mount("/notes", notesRouter.Routes()) r.Mount("/notes", notesRouter.Routes())
r.Get("/ping", ping)
}) })
log.Info().Msg("Starting server on :8080") log.Info().Msg("Starting server on :8080")
return http.ListenAndServe(":8080", r) return http.ListenAndServe(":8080", r)
} }
func ping(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{
"message": "pong",
})
}

View File

@ -21,20 +21,15 @@ import (
var migrationsFS embed.FS var migrationsFS embed.FS
var ( var (
config Config config Config
svcConfig service.SvcConfig
) )
type Config struct { type Config struct {
JWTSecret string `env:"JWT_SECRET,notEmpty"` JWTSecret string `env:"JWT_SECRET,notEmpty"`
CSRFSecret string `env:"CSRF_SECRET,notEmpty"`
DatabaseURL string `env:"DB_URL,notEmpty"` DatabaseURL string `env:"DB_URL,notEmpty"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
AdminUsername string `env:"ADMIN_USERNAME,notEmpty,unset"` AdminUsername string `env:"ADMIN_USERNAME,notEmpty,unset"`
AdminPassword string `env:"ADMIN_PASSWORD,notEmpty,unset"` AdminPassword string `env:"ADMIN_PASSWORD,notEmpty,unset"`
Domain string `env:"DOMAIN" envDefault:"localhost"`
FrontendURL string `env:"FRONTEND_URL" envDefault:"http://localhost:5173"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
AppEnv string `env:"APP_ENV" envDefault:"production"`
} }
func init() { func init() {
@ -43,15 +38,6 @@ func init() {
log.Fatal().Err(err).Msg("Failed to parse environment variables") log.Fatal().Err(err).Msg("Failed to parse environment variables")
} }
initLogger() initLogger()
svcConfig = service.SvcConfig{
JWTSecret: config.JWTSecret,
CSRFSecret: config.CSRFSecret,
IsProd: config.AppEnv == "production",
Domain: config.Domain,
FrontendURL: config.FrontendURL,
}
log.Debug().Msg("Initialization completed") log.Debug().Msg("Initialization completed")
} }
@ -85,7 +71,7 @@ func main() {
} }
log.Info().Msg("Migrations applied succesfully, proceeding to HTTP server startup") log.Info().Msg("Migrations applied succesfully, proceeding to HTTP server startup")
service.Run(conn, q, svcConfig) service.Run(conn, q, config.JWTSecret)
} }
func initLogger() { func initLogger() {