packages feed

hprox-0.7.0: src/Network/HProx/Route.hs

-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.

module Network.HProx.Route
  ( ReverseProxyRewrite(..)
  , ReverseRoute(..)
  , findMatchingRoute
  , fromReverseRouteTuple
  , hostMatches
  , prefixMatches
  , rewriteReverseProxyRequest
  , sortReverseRoutes
  ) where

import Data.ByteString.Char8     qualified as BS8
import Data.CaseInsensitive      qualified as CI
import Data.List                 (sortOn)
import Data.Maybe                (isJust, listToMaybe)
import Data.Ord                  (Down(..))
import Network.HTTP.Types        (RequestHeaders)
import Network.HTTP.Types.Header qualified as HT

import Network.HProx.Headers
import Network.HProx.Util

data ReverseRoute = ReverseRoute
    { routeHost     :: !(Maybe BS8.ByteString)
    , routePrefix   :: !BS8.ByteString
    , routeUpstream :: !BS8.ByteString
    }
  deriving (Eq, Show)

data ReverseProxyRewrite = ReverseProxyRewrite
    { rewriteRoute       :: !ReverseRoute
    , rewriteRawPath     :: !BS8.ByteString
    , rewriteHeaders     :: !RequestHeaders
    , rewriteRequestHost :: !(Maybe BS8.ByteString)
    , rewriteUpstream    :: !BS8.ByteString
    , rewritePort        :: !Int
    , rewriteSecure      :: !Bool
    }
  deriving (Eq, Show)

fromReverseRouteTuple :: (Maybe BS8.ByteString, BS8.ByteString, BS8.ByteString) -> ReverseRoute
fromReverseRouteTuple (host, prefix, upstream) = ReverseRoute
    { routeHost     = host
    , routePrefix   = normalizeRoutePrefix prefix
    , routeUpstream = upstream
    }

sortReverseRoutes :: [ReverseRoute] -> [ReverseRoute]
sortReverseRoutes = sortOn $ \ReverseRoute{..} -> Down (isJust routeHost, BS8.length routePrefix)

hostMatches :: ReverseRoute -> Maybe BS8.ByteString -> Bool
hostMatches ReverseRoute{ routeHost = Nothing } _               = True
hostMatches ReverseRoute{ routeHost = Just _ } Nothing          = False
hostMatches ReverseRoute{ routeHost = Just domain } (Just host) = CI.mk domain == CI.mk host

prefixMatches :: ReverseRoute -> BS8.ByteString -> Bool
prefixMatches ReverseRoute{..} rawPath
    | prefix == "/"     = True
    | rawPath == prefix = True
    | otherwise         = prefix `BS8.isPrefixOf` rawPath
        && BS8.length rawPath > prefixLength
        && BS8.index rawPath prefixLength == '/'
  where
    prefix = normalizeRoutePrefix routePrefix
    prefixLength = BS8.length prefix

findMatchingRoute :: [ReverseRoute] -> Maybe BS8.ByteString -> BS8.ByteString -> Maybe ReverseRoute
findMatchingRoute routes requestHost rawPath =
    listToMaybe $ filter matches $ sortReverseRoutes routes
  where
    parsedHost = fmap hostOnly requestHost
    matches route = hostMatches route parsedHost && prefixMatches route rawPath

hostOnly :: BS8.ByteString -> BS8.ByteString
hostOnly host = maybe host fst (parseHostPort host)

normalizeRoutePrefix :: BS8.ByteString -> BS8.ByteString
normalizeRoutePrefix prefix
    | BS8.null prefix = "/"
    | otherwise       = stripTrailingSlash prefixed
  where
    prefixed
        | "/" `BS8.isPrefixOf` prefix = prefix
        | otherwise                   = "/" <> prefix

    stripTrailingSlash value
        | BS8.length value > 1 && "/" `BS8.isSuffixOf` value = stripTrailingSlash (BS8.init value)
        | otherwise                                          = value

rewriteReverseProxyRequest :: ReverseRoute -> RequestHeaders -> BS8.ByteString -> ReverseProxyRewrite
rewriteReverseProxyRequest route@ReverseRoute{..} requestHeaders rawPath = ReverseProxyRewrite
    { rewriteRoute       = route
    , rewriteRawPath     = rewritePath route rawPath
    , rewriteHeaders     = (HT.hHost, upstreamHost) : filter keepHeader requestHeaders
    , rewriteRequestHost = Just upstreamHost
    , rewriteUpstream    = upstreamHost
    , rewritePort        = upstreamPort
    , rewriteSecure      = upstreamPort == 443
    }
  where
    (upstreamHost, upstreamPort) = parseHostPortWithDefault 80 routeUpstream

    keepHeader (name, _) = not (isProxyStripHeader name) && name /= HT.hHost

rewritePath :: ReverseRoute -> BS8.ByteString -> BS8.ByteString
rewritePath route@ReverseRoute{..} rawPath
    | not (prefixMatches route rawPath) = rawPath
    | prefix == "/"                     = ensureLeadingSlash rawPath
    | otherwise                         =
        case BS8.drop (BS8.length prefix) rawPath of
            ""   -> "/"
            rest -> ensureLeadingSlash rest
  where
    prefix = normalizeRoutePrefix routePrefix

ensureLeadingSlash :: BS8.ByteString -> BS8.ByteString
ensureLeadingSlash path
    | "/" `BS8.isPrefixOf` path = path
    | otherwise                 = "/" <> path