{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
module Servant.GitHub.Webhook
(
GitHubSignedReqBody''
, GitHubSignedReqBody'
, GitHubSignedReqBody
, GitHubEvent
, GitHubKey'(..)
, GitHubKey
, gitHubKey
, dynamicKey
, repositoryKey, HasRepository
, EventWithHookRepo(..)
, RepoWebhookEvent(..)
, KProxy(..)
, Demote
, Demote'
, Reflect(..)
, parseHeaderMaybe
, matchEvent
) where
import Control.Monad.IO.Class ( liftIO )
import Crypto.Hash.Algorithms ( SHA1 )
import Crypto.MAC.HMAC ( hmac, HMAC(..) )
import Data.Aeson ( decode', encode, Value(String, Object) )
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as AesonType
import Data.ByteArray ( convert, constEq )
import qualified Data.Text as T
import qualified Data.ByteString as BS
import Data.ByteString.Lazy ( fromStrict, toStrict )
import qualified Data.ByteString.Base16 as B16
import qualified Data.HashMap.Strict as HashMap
import Data.List ( intercalate )
import Data.Maybe ( catMaybes, fromMaybe )
import Data.Monoid ( (<>) )
import Data.Proxy
import Data.String.Conversions ( cs )
import qualified Data.Text.Encoding as E
import GHC.TypeLits
import GitHub.Data.Webhooks
import GitHub.Data.Webhooks.Events (EventHasRepo(..))
import GitHub.Data.Webhooks.Payload (whRepoFullName)
import Network.HTTP.Types hiding (Header, ResponseHeaders)
import Network.Wai ( requestHeaders, strictRequestBody )
import Servant
import Servant.API.ContentTypes ( AllCTUnrender(..) )
import Servant.Server.Internal
data GitHubSignedReqBody''
(proxy :: KProxy k)
(key :: k)
(list :: [*])
(result :: *) where
type GitHubSignedReqBody' (key :: k)
= GitHubSignedReqBody'' ('KProxy :: KProxy k) key
type GitHubSignedReqBody = GitHubSignedReqBody' '()
data GitHubEvent (events :: [RepoWebhookEvent]) where
newtype GitHubKey' key result = GitHubKey { unGitHubKey :: key -> result -> IO (Maybe BS.ByteString) }
type GitHubKey result = GitHubKey' () result
gitHubKey :: IO BS.ByteString -> GitHubKey result
gitHubKey f = GitHubKey (\_ _ -> Just <$> f)
dynamicKey
:: (T.Text -> IO (Maybe BS.ByteString))
-> (result -> Maybe T.Text)
-> GitHubKey result
dynamicKey f lk = GitHubKey (\_ r -> maybe (pure Nothing) f (lk r))
repositoryKey
:: HasRepository result
=> (T.Text -> IO (Maybe BS.ByteString))
-> GitHubKey result
repositoryKey f = dynamicKey f getFullName
class HasRepository r where
getFullName:: r -> Maybe T.Text
instance HasRepository Value where
getFullName (Object o) = getFullName o
getFullName _ = Nothing
instance HasRepository AesonType.Object where
getFullName o =
do Object r <- HashMap.lookup "repository" o
String n <- HashMap.lookup "full_name" r
pure n
newtype EventWithHookRepo e = EventWithHookRepo { eventOf :: e }
instance Aeson.FromJSON e => Aeson.FromJSON (EventWithHookRepo e) where
parseJSON o = EventWithHookRepo <$> Aeson.parseJSON o
instance EventHasRepo e => HasRepository (EventWithHookRepo e) where
getFullName = Just . whRepoFullName . repoForEvent . eventOf
instance
( HasServer sublayout context
, HasContextEntry context (GitHubKey' (Demote key) result)
, Reflect key
, AllCTUnrender list result
)
=> HasServer
(GitHubSignedReqBody'' ('KProxy :: KProxy k) key list result :> sublayout)
context where
type ServerT
(GitHubSignedReqBody'' ('KProxy :: KProxy k) key list result :> sublayout)
m
= (Demote key, result) -> ServerT sublayout m
hoistServerWithContext _ _ f s = \p -> hoistServerWithContext p1 p2 f (s p) where
p1 = Proxy :: Proxy sublayout
p2 = Proxy :: Proxy context
route
:: forall env.
Proxy (
GitHubSignedReqBody'' ('KProxy :: KProxy k) key list result
:> sublayout
)
-> Context context
-> Delayed env ((Demote key, result) -> Server sublayout)
-> Router env
route _ context subserver
= route (Proxy :: Proxy sublayout) context (addBodyCheck subserver ct go)
where
lookupSig = lookup "X-Hub-Signature"
keyIndex :: Demote key
keyIndex = reflect (Proxy :: Proxy key)
ct :: DelayedIO (BS.ByteString, Maybe BS.ByteString, result)
ct = withRequest $ \req -> do
let hdrs = requestHeaders req
let contentTypeH =
fromMaybe "application/octet-stream" $ lookup hContentType hdrs
msg <- liftIO (toStrict <$> strictRequestBody req)
let mrqbody =
handleCTypeH (Proxy :: Proxy list) (cs contentTypeH) $
fromStrict msg
case mrqbody of
Nothing -> delayedFailFatal err415
Just (Left e) -> delayedFailFatal err400 { errBody = cs e }
Just (Right v) -> pure (msg, lookupSig hdrs, v)
go
:: (BS.ByteString, Maybe BS.ByteString, result)
-> DelayedIO (Demote key, result)
go tup@(_msg, _hdr, v) = do
keyM <- liftIO (unGitHubKey (getContextEntry context) keyIndex v)
case keyM of
Nothing -> delayedFailFatal err401
Just key -> verifySigWithKey tup key
verifySigWithKey
:: (BS.ByteString, Maybe BS.ByteString, result)
-> BS.ByteString
-> DelayedIO (Demote key, result)
verifySigWithKey (msg, hdr, v) key = do
let sig =
B16.encode $ convert $ hmacGetDigest (hmac key msg :: HMAC SHA1)
case parseHeaderMaybe =<< hdr of
Nothing -> delayedFailFatal err401
Just h -> do
let h' = BS.drop 5 $ E.encodeUtf8 h
if constEq h' sig
then pure (keyIndex, v)
else delayedFailFatal err401
instance
(Reflect events, HasServer sublayout context)
=> HasServer (GitHubEvent events :> sublayout) context where
type ServerT (GitHubEvent events :> sublayout) m
= RepoWebhookEvent -> ServerT sublayout m
hoistServerWithContext _ _ f s = \p -> hoistServerWithContext p1 p2 f (s p) where
p1 = Proxy :: Proxy sublayout
p2 = Proxy :: Proxy context
route
:: forall env. Proxy (GitHubEvent events :> sublayout)
-> Context context
-> Delayed env (RepoWebhookEvent -> Server sublayout)
-> Router env
route Proxy context subserver
= route
(Proxy :: Proxy sublayout)
context
(addAuthCheck subserver go)
where
lookupGHEvent = lookup "X-Github-Event"
events :: [RepoWebhookEvent]
events = reflect (Proxy :: Proxy events)
eventNames :: String
eventNames = intercalate ", " $ (cs . encode) <$> events
go :: DelayedIO RepoWebhookEvent
go = withRequest $ \req -> do
case lookupGHEvent (requestHeaders req) of
Nothing -> delayedFail err401
Just h -> do
case catMaybes $ map (`matchEvent` h) events of
[] -> delayedFail err404
{ errBody = cs $ "supported events: " <> eventNames }
(event:_) -> pure event
type family Demote' (kparam :: KProxy k) :: *
type Demote (a :: k) = Demote' ('KProxy :: KProxy k)
type instance Demote' ('KProxy :: KProxy ()) = ()
type instance Demote' ('KProxy :: KProxy Symbol) = String
type instance Demote' ('KProxy :: KProxy [k]) = [Demote' ('KProxy :: KProxy k)]
type instance Demote' ('KProxy :: KProxy RepoWebhookEvent) = RepoWebhookEvent
class Reflect (a :: k) where
reflect :: Proxy (a :: k) -> Demote a
instance KnownSymbol s => Reflect (s :: Symbol) where
reflect = symbolVal
instance Reflect '() where
reflect _ = ()
instance Reflect '[] where
reflect _ = []
instance (Reflect x, Reflect xs) => Reflect (x ': xs) where
reflect _ = reflect x : reflect xs where
x = Proxy :: Proxy x
xs = Proxy :: Proxy xs
instance Reflect 'WebhookWildcardEvent where
reflect _ = WebhookWildcardEvent
instance Reflect 'WebhookCommitCommentEvent where
reflect _ = WebhookCommitCommentEvent
instance Reflect 'WebhookCreateEvent where
reflect _ = WebhookCreateEvent
instance Reflect 'WebhookDeleteEvent where
reflect _ = WebhookDeleteEvent
instance Reflect 'WebhookDeploymentEvent where
reflect _ = WebhookDeploymentEvent
instance Reflect 'WebhookDeploymentStatusEvent where
reflect _ = WebhookDeploymentStatusEvent
instance Reflect 'WebhookForkEvent where
reflect _ = WebhookForkEvent
instance Reflect 'WebhookGollumEvent where
reflect _ = WebhookGollumEvent
instance Reflect 'WebhookInstallationEvent where
reflect _ = WebhookInstallationEvent
instance Reflect 'WebhookInstallationRepositoriesEvent where
reflect _ = WebhookInstallationRepositoriesEvent
instance Reflect 'WebhookIssueCommentEvent where
reflect _ = WebhookIssueCommentEvent
instance Reflect 'WebhookIssuesEvent where
reflect _ = WebhookIssuesEvent
instance Reflect 'WebhookMemberEvent where
reflect _ = WebhookMemberEvent
instance Reflect 'WebhookPageBuildEvent where
reflect _ = WebhookPageBuildEvent
instance Reflect 'WebhookPingEvent where
reflect _ = WebhookPingEvent
instance Reflect 'WebhookPublicEvent where
reflect _ = WebhookPublicEvent
instance Reflect 'WebhookPullRequestReviewCommentEvent where
reflect _ = WebhookPullRequestReviewCommentEvent
instance Reflect 'WebhookPullRequestEvent where
reflect _ = WebhookPullRequestEvent
instance Reflect 'WebhookPushEvent where
reflect _ = WebhookPushEvent
instance Reflect 'WebhookReleaseEvent where
reflect _ = WebhookReleaseEvent
instance Reflect 'WebhookStatusEvent where
reflect _ = WebhookStatusEvent
instance Reflect 'WebhookTeamAddEvent where
reflect _ = WebhookTeamAddEvent
instance Reflect 'WebhookWatchEvent where
reflect _ = WebhookWatchEvent
parseHeaderMaybe :: FromHttpApiData a => BS.ByteString -> Maybe a
parseHeaderMaybe = eitherMaybe . parseHeader where
eitherMaybe :: Either e a -> Maybe a
eitherMaybe e = case e of
Left _ -> Nothing
Right x -> Just x
matchEvent :: RepoWebhookEvent -> BS.ByteString -> Maybe RepoWebhookEvent
matchEvent WebhookWildcardEvent s = decode' (fromStrict s') where
s' = "\"" <> s <> "\""
matchEvent e name
| toStrict (encode e) == name' = Just e
| otherwise = Nothing
where name' = "\"" <> name <> "\""