diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 13414694..6fca8caa 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -49,19 +49,18 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { r.Use(otelgin.Middleware("pocket-id-backend")) } - rateLimitMiddleware := middleware.NewRateLimitMiddleware() + rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60) // Setup global middleware r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add()) - r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60)) // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() // Set up API routes - apiGroup := r.Group("/api") + apiGroup := r.Group("/api", rateLimitMiddleware) controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) @@ -79,9 +78,13 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { } // Set up base routes - baseGroup := r.Group("/") + baseGroup := r.Group("/", rateLimitMiddleware) controller.NewWellKnownController(baseGroup, svc.jwtService) + // Set up healthcheck routes + // These are not rate-limited + controller.NewHealthzController(r) + // Set up the server srv := &http.Server{ Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port), diff --git a/backend/internal/controller/healthz_controller.go b/backend/internal/controller/healthz_controller.go new file mode 100644 index 00000000..9fac0426 --- /dev/null +++ b/backend/internal/controller/healthz_controller.go @@ -0,0 +1,29 @@ +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// NewHealthzController creates a new controller for the healthcheck endpoints +// @Summary Healthcheck controller +// @Description Initializes healthcheck endpoints +// @Tags Health +func NewHealthzController(r *gin.Engine) { + hc := &HealthzController{} + + r.GET("/healthz", hc.healthzHandler) +} + +type HealthzController struct{} + +// healthzHandler godoc +// @Summary Responds to healthchecks +// @Description Responds with a successful status code to healthcheck requests +// @Tags Health +// @Success 204 "" +// @Router /healthz [get] +func (hc *HealthzController) healthzHandler(c *gin.Context) { + c.Status(http.StatusNoContent) +} diff --git a/docker-compose.yml b/docker-compose.yml index 7fda7aa9..cdc896d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: - 3000:80 volumes: - "./data:/app/backend/data" - # Optional healthcheck + # Optional healthcheck healthcheck: - test: "curl -f http://localhost/health" + test: "curl -f http://localhost/healthz" interval: 1m30s timeout: 5s retries: 2 diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6197b433..b0acec56 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -23,33 +23,33 @@ const paraglideHandle: Handle = ({ event, resolve }) => { const authenticationHandle: Handle = async ({ event, resolve }) => { const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc') - const isPublicPath = ['/authorize', '/device', '/health'].includes(event.url.pathname); - const isAdminPath = event.url.pathname.startsWith('/settings/admin'); + const path = event.url.pathname; + const isUnauthenticatedOnlyPath = path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/') + const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path); + const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/'); if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) { return new Response(null, { - status: 302, + status: 303, headers: { location: '/login' } }); } if (isUnauthenticatedOnlyPath && isSignedIn) { return new Response(null, { - status: 302, + status: 303, headers: { location: '/settings' } }); } if (isAdminPath && !isAdmin) { return new Response(null, { - status: 302, + status: 303, headers: { location: '/settings' } }); } - const response = await resolve(event); - return response; + return resolve(event); }; export const handle: Handle = sequence(paraglideHandle, authenticationHandle); diff --git a/frontend/src/routes/health/+server.ts b/frontend/src/routes/health/+server.ts index 88bd74ec..f409beff 100644 --- a/frontend/src/routes/health/+server.ts +++ b/frontend/src/routes/health/+server.ts @@ -1,20 +1,2 @@ -import AppConfigService from '$lib/services/app-config-service'; -import type { RequestHandler } from '@sveltejs/kit'; - -export const GET: RequestHandler = async () => { - const appConfigService = new AppConfigService(); - let backendOk = true; - await appConfigService.list().catch(() => (backendOk = false)); - - return new Response( - JSON.stringify({ - status: backendOk ? 'HEALTHY' : 'UNHEALTHY' - }), - { - status: backendOk ? 200 : 500, - headers: { - 'content-type': 'application/json' - } - } - ); -}; +// /health is an alias of /healthz, for backwards-compatibility reasons +export {GET} from '../healthz/+server'; diff --git a/frontend/src/routes/healthz/+server.ts b/frontend/src/routes/healthz/+server.ts new file mode 100644 index 00000000..29a49809 --- /dev/null +++ b/frontend/src/routes/healthz/+server.ts @@ -0,0 +1,18 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import axios from 'axios'; + +export const GET: RequestHandler = async () => { + const backendOK = await axios + .get(process!.env!.INTERNAL_BACKEND_URL + '/healthz') + .then(() => true, () => false); + + return new Response( + backendOK ? `{"status":"HEALTHY"}` : `{"status":"UNHEALTHY"}`, + { + status: backendOK ? 200 : 500, + headers: { + 'content-type': 'application/json' + } + } + ); +};