packages feed

gopher-proxy-0.1.0.0: src/Main.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE TypeOperators       #-}
{-# LANGUAGE ScopedTypeVariables #-}

import GopherProxy.Types
import GopherProxy.Protocol
import GopherProxy.Params
import Paths_gopher_proxy

import Prelude hiding (takeWhile)
import Control.Exception
import Control.Monad (when)
import Data.Attoparsec.ByteString.Lazy
import Data.ByteString.Lazy (ByteString ())
import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as BS
import Data.Char (chr)
import Data.Maybe (isNothing)
import Data.Monoid ((<>))
import Data.Text (Text ())
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.Encoding.Error (UnicodeException (..))
import GHC.IO.Handle
import GHC.IO.IOMode
import Network.HTTP.Types
import Network.Mime
import Network.Socket hiding (send, sendTo, recv, recvFrom)
import Network.Socket.ByteString
import Network.Wai
import Network.Wai.Handler.Warp
import Lucid
import qualified Options.Applicative as O
import System.IO (stderr)
import System.Directory (doesFileExist)
import System.Timeout

gopherProxy :: Params -> Application
gopherProxy cfg r respond
  | requestMethod r == "GET" &&
    rawPathInfo r == cssUrl cfg = cssResponse cfg r respond `catch` \(e::IOException) ->
      exceptionResponse status500 e "Could not open css file" r respond
  | requestMethod r == "GET" = gopherResponse cfg r respond `catches`
      [ Handler (\(e::IOException) -> exceptionResponse status502 e "Could not reach the gopher server" r respond)
      , Handler (\(e::UnicodeException) -> exceptionResponse status502 e "Couldn't decode text using UTF-8" r respond)
      ]
  | otherwise = badRequestResponse cfg r respond

cssResponse :: Params -> Application
cssResponse cfg _ respond = do
  path <- case cssPath cfg of
            Just p -> pure p
            Nothing -> getDataFileName "gopher-proxy.css"
  exists <- doesFileExist path
  if exists
    then B.readFile path >>=
        respond . responseLBS status200 [("Content-type", "text/css")]
    else respond $
      responseLBS status404 [("Content-type", "text/plain")] "Could not find css"

gopherResponse :: Params -> Application
gopherResponse cfg r respond = do
  (resp, mime) <- (flip fmap)
    (makeGopherRequest cfg (B.fromStrict (rawPathInfo r))) $
     \case
       Just r  -> r
       Nothing -> ( MenuResponse [ MenuItem '3' "An error occured while retrieving server's response." "" "" 0 ]
                  , "text/html")

  let status =
        case resp of
          FileResponse _ -> status200
          MenuResponse items -> if all (\(MenuItem c _ _ _ _) -> c == '3') items
                                  then status502
                                  else status200
  respond $ uncurry (responseLBS status) $
    case resp of
      MenuResponse _ ->
        ([("Content-type", "text/html")], renderBS (gResponseToHtml cfg resp))
      FileResponse b ->
        case mimeTuple mime of
          ("text", "html") -> ([("Content-type", mime)], b)
          ("text", _)      -> ([("Content-type", "text/html")], renderBS (gResponseToHtml cfg resp))
          _                -> ([("Content-type", mime)], b)

mimeTuple :: MimeType -> (BS.ByteString, BS.ByteString)
mimeTuple = fmap BS.tail . BS.span (/= 47)

badRequestResponse :: Params -> Application
badRequestResponse cfg _ respond = respond $ responseLBS badRequest400
  [("Content-type", "text/plain")] "gopher-proxy did not understand your request"

exceptionResponse :: Exception e => Status -> e -> Text -> Application
exceptionResponse status exp err _ resp = do
  T.hPutStr stderr (err <> " (" <> T.pack (show exp) <> ")\n")
  resp $ responseLBS status [("Content-type", "text/plain")] (B.fromStrict (encodeUtf8 err))

makeGopherRequest :: Params -> ByteString -> IO (Maybe (GopherResponse, MimeType))
makeGopherRequest cfg req = do
  addri:_ <- getAddrInfo Nothing (Just (hostname cfg)) Nothing
  let addr =
        case (addrAddress addri) of
          SockAddrInet _ h -> SockAddrInet (port cfg) h
          SockAddrInet6 _ f h s -> SockAddrInet6 (port cfg) f h s
          x -> x

  sock <- socket (addrFamily addri) Stream (addrProtocol addri)

  connected <- timeout (timeoutms cfg) $ connect sock addr
  when (isNothing connected) $ throw (userError "connection timeout")

  hdl <- socketToHandle sock ReadWriteMode
  hSetBuffering hdl NoBuffering
  B.hPutStr hdl (req <> "\r\n")
  resp <- BS.hGetContents hdl
  pure $ case parseOnly (gopherResponseParser Nothing) resp of
    Left _ -> Nothing
    Right r -> case r of
                 MenuResponse _ -> Just (r, "text/html")
                 FileResponse _ -> Just (r, mimeByExt defaultMimeMap (defaultMime cfg) (decodeUtf8 (B.toStrict req)))

prependBaseUrl :: Text -> Text -> Text
prependBaseUrl base path
  | T.null base || T.null path = base <> path
  | T.head path == '/' = if T.last base == '/'
                           then base <> T.tail path
                           else base <> path
  | T.last base == '/' = base <> path
  | otherwise = base <> "/" <> path

-- we generally assume that everything is utf-8 encoded
gResponseToHtml :: Params -> GopherResponse -> Html ()
gResponseToHtml cfg res
  = doctype_ <> html_
      (head_ (meta_ [charset_ "utf-8"]
             <> meta_ [term "viewport" "width=device-width"]
             <> title_ "gopher-proxy"
             <> link_ [rel_ "stylesheet", type_ "text/css", href_ . decodeUtf8 . cssUrl $ cfg])
      <> body_ bodyContent)
  where bodyContent = case res of
                        FileResponse bytes -> pre_ (toHtml bytes)
                        MenuResponse items -> ul_ $ foldl (itemChain cfg) mempty items

itemChain :: Params -> Html () -> MenuItem -> Html ()
itemChain cfg acc (MenuItem typec desc path' host' port')
  = acc <> li_ itemHtml
    where path = decodeUtf8 . B.toStrict $ path'
          itemHtml = case typec of
                       'i' -> toHtml desc
                       '3' -> span_ [class_ "error"] (toHtml desc)
                       _   -> a_ [href_ url] (toHtml desc)
          url = if "URL:" `T.isPrefixOf` path
                  then T.drop 4 path
                  else if host' == hostname cfg && port' == port cfg
                    then prependBaseUrl (baseUrl cfg) path
                    else prependBaseUrl ("gopher://" <> (T.pack host') <> ":" <> (T.pack (show port'))) path

main :: IO ()
main = do
  params <- O.execParser helpfulParams
  let preference = if listenPublic params then "*" else "127.0.0.1"
      settings = setPort (httpPort params) . setHost preference $ defaultSettings
  runSettings settings (gopherProxy params)