package httpapi import ( "context" "encoding/json" "errors" "io" "log/slog" "net" "net/http" "strings" "time" "drive.local/drivev2/internal/application" "drive.local/drivev2/internal/config" "drive.local/drivev2/internal/domain" ) var ErrServerClosed = http.ErrServerClosed const ( sessionCookieName = "__Host-drive_session" csrfCookieName = "__Host-drive_csrf" csrfHeaderName = "X-CSRF-Token" maximumJSONBody = 1 << 20 ) type Server struct { httpServer *http.Server } type SystemService interface { Readiness(context.Context) error SetupStatus(context.Context) (application.SetupStatus, error) } type AuthenticationService interface { SetupOwner(context.Context, application.SetupOwnerInput) (application.SetupOwnerResult, error) Login(context.Context, application.LoginInput) (application.AuthenticatedSession, error) CurrentSession(context.Context, string) (application.BrowserSession, error) Logout(context.Context, string, string, string) error } func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, authentication AuthenticationService, frontend http.Handler) *Server { mux := http.NewServeMux() mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"})) mux.HandleFunc("GET /health/ready", readinessHandler(logger, system)) mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(logger, system)) mux.HandleFunc("POST /api/v1/setup", ownerSetupHandler(logger, authentication)) mux.HandleFunc("POST /api/v1/sessions", loginHandler(logger, authentication)) mux.HandleFunc("GET /api/v1/sessions/current", currentSessionHandler(logger, authentication)) mux.HandleFunc("DELETE /api/v1/sessions/current", logoutHandler(logger, authentication)) mux.Handle("/", frontend) return &Server{httpServer: &http.Server{ Addr: cfg.HTTPAddress, Handler: securityHeaders(requestLogger(logger, mux)), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute, }} } type ownerSetupRequest struct { Username string `json:"username"` DisplayName string `json:"displayName"` Password string `json:"password"` } type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } type ownerResponse struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` RootNodeID string `json:"rootNodeId"` } type setupResponse struct { Owner ownerResponse `json:"owner"` RecoveryCodes []string `json:"recoveryCodes"` Session application.BrowserSession `json:"session"` } func ownerSetupHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var request ownerSetupRequest if err := decodeJSON(w, r, &request); err != nil { problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON") return } result, err := authentication.SetupOwner(r.Context(), application.SetupOwnerInput{ Username: request.Username, DisplayName: request.DisplayName, Password: request.Password, RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(), }) if err != nil { switch { case errors.Is(err, application.ErrSetupAlreadyComplete): problemResponse(w, http.StatusConflict, "setup_already_complete", "Owner setup has already been completed") case errors.Is(err, domain.ErrInvalidUsername), errors.Is(err, domain.ErrInvalidDisplayName), errors.Is(err, domain.ErrInvalidPassword): problemResponse(w, http.StatusUnprocessableEntity, "validation_failed", "Username, display name, or password is invalid") default: logger.Error("owner setup failed", "error", err) problemResponse(w, http.StatusInternalServerError, "internal_error", "Owner setup could not be completed") } return } setAuthenticationCookies(w, result.Authentication) writeJSON(w, http.StatusCreated, setupResponse{ Owner: ownerResponse{ID: result.OwnerID, Username: result.Username, DisplayName: result.DisplayName, RootNodeID: result.RootNodeID}, RecoveryCodes: result.RecoveryCodes, Session: result.Authentication.Session, }) } } func loginHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var request loginRequest if err := decodeJSON(w, r, &request); err != nil { problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON") return } authenticated, err := authentication.Login(r.Context(), application.LoginInput{ Username: request.Username, Password: request.Password, RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(), }) if err != nil { if errors.Is(err, application.ErrInvalidCredentials) { problemResponse(w, http.StatusUnauthorized, "invalid_credentials", "The username or password is invalid") return } logger.Error("login failed", "error", err) problemResponse(w, http.StatusInternalServerError, "internal_error", "Login could not be completed") return } setAuthenticationCookies(w, authenticated) writeJSON(w, http.StatusCreated, authenticated.Session) } } func currentSessionHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session, err := authentication.CurrentSession(r.Context(), cookieValue(r, sessionCookieName)) if err != nil { if errors.Is(err, application.ErrSessionNotFound) { clearAuthenticationCookies(w) problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required") return } logger.Error("current session failed", "error", err) problemResponse(w, http.StatusInternalServerError, "internal_error", "The current session could not be read") return } writeJSON(w, http.StatusOK, session) } } func logoutHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { err := authentication.Logout( r.Context(), cookieValue(r, sessionCookieName), cookieValue(r, csrfCookieName), r.Header.Get(csrfHeaderName), ) if err != nil { switch { case errors.Is(err, application.ErrSessionNotFound): clearAuthenticationCookies(w) problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required") case errors.Is(err, application.ErrCSRFValidation): problemResponse(w, http.StatusForbidden, "csrf_validation_failed", "The CSRF token is missing or invalid") default: logger.Error("logout failed", "error", err) problemResponse(w, http.StatusInternalServerError, "internal_error", "The session could not be revoked") } return } clearAuthenticationCookies(w) w.WriteHeader(http.StatusNoContent) } } func readinessHandler(logger *slog.Logger, system SystemService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := system.Readiness(r.Context()); err != nil { logger.Error("readiness check failed", "error", err) problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } } func setupStatusHandler(logger *slog.Logger, system SystemService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { status, err := system.SetupStatus(r.Context()) if err != nil { logger.Error("setup status failed", "error", err) problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable") return } writeJSON(w, http.StatusOK, status) } } func (s *Server) ListenAndServe() error { return s.httpServer.ListenAndServe() } func (s *Server) Shutdown(ctx context.Context) error { return s.httpServer.Shutdown(ctx) } func jsonResponse(status int, payload any) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, status, payload) } } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { slog.Error("encode response", "error", err) } } func problemResponse(w http.ResponseWriter, status int, code, detail string) { w.Header().Set("Content-Type", "application/problem+json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(map[string]any{ "type": "about:blank", "title": http.StatusText(status), "status": status, "detail": detail, "code": code, }); err != nil { slog.Error("encode problem response", "error", err) } } func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error { r.Body = http.MaxBytesReader(w, r.Body, maximumJSONBody) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { return err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return errors.New("request body must contain one JSON value") } return nil } func setAuthenticationCookies(w http.ResponseWriter, authenticated application.AuthenticatedSession) { maxAge := int(time.Until(authenticated.Session.ExpiresAt).Seconds()) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: authenticated.SessionToken, Path: "/", MaxAge: maxAge, Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: authenticated.CSRFToken, Path: "/", MaxAge: maxAge, Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: false, SameSite: http.SameSiteStrictMode, }) } func clearAuthenticationCookies(w http.ResponseWriter) { expires := time.Unix(1, 0).UTC() for _, name := range []string{sessionCookieName, csrfCookieName} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, Expires: expires, Secure: true, HttpOnly: name == sessionCookieName, SameSite: http.SameSiteStrictMode, }) } } func cookieValue(r *http.Request, name string) string { cookie, err := r.Cookie(name) if err != nil { return "" } return cookie.Value } func remoteAddress(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return host } return strings.TrimSpace(r.RemoteAddr) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") next.ServeHTTP(w, r) }) } func requestLogger(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() next.ServeHTTP(w, r) logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds()) }) }