313 lines
11 KiB
Go
313 lines
11 KiB
Go
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())
|
|
})
|
|
}
|