packages feed

git-annex-10.20240731: P2P/Http/Types.hs

{- P2P protocol over HTTP,
 - data types for servant not including the servant API
 -
 - Copyright 2024 Joey Hess <id@joeyh.name>
 -
 - Licensed under the GNU AGPL version 3 or higher.
 -}

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE CPP #-}

module P2P.Http.Types where

import Annex.Common
import qualified P2P.Protocol as P2P
import Utility.MonotonicClock

#ifdef WITH_SERVANT
import Servant
import Data.Aeson hiding (Key)
import Text.Read (readMaybe)
#endif
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString as B
import Codec.Binary.Base64Url as B64
import Data.Char
import Control.DeepSeq
import GHC.Generics (Generic)

data V3 = V3 deriving (Show)
data V2 = V2 deriving (Show)
data V1 = V1 deriving (Show)
data V0 = V0 deriving (Show)

class APIVersion v where
	protocolVersion :: v -> P2P.ProtocolVersion

instance APIVersion V3 where protocolVersion _ = P2P.ProtocolVersion 3
instance APIVersion V2 where protocolVersion _ = P2P.ProtocolVersion 2
instance APIVersion V1 where protocolVersion _ = P2P.ProtocolVersion 1
instance APIVersion V0 where protocolVersion _ = P2P.ProtocolVersion 0

-- Keys, UUIDs, and filenames can be base64 encoded since Servant uses 
-- Text and so needs UTF-8.
newtype B64Key = B64Key Key
	deriving (Show)

newtype B64FilePath = B64FilePath RawFilePath
	deriving (Show)

associatedFileToB64FilePath :: AssociatedFile -> Maybe B64FilePath
associatedFileToB64FilePath (AssociatedFile Nothing) = Nothing
associatedFileToB64FilePath (AssociatedFile (Just f)) = Just (B64FilePath f)

b64FilePathToAssociatedFile :: Maybe B64FilePath -> AssociatedFile
b64FilePathToAssociatedFile Nothing = AssociatedFile Nothing
b64FilePathToAssociatedFile (Just (B64FilePath f)) = AssociatedFile (Just f)

newtype B64UUID t = B64UUID { fromB64UUID :: UUID }
	deriving (Show, Ord, Eq, Generic, NFData)

encodeB64Text :: B.ByteString -> T.Text
encodeB64Text b = case TE.decodeUtf8' b of
	Right t
		| (snd <$> B.unsnoc b) == Just closebracket 
			&& (fst <$> B.uncons b) == Just openbracket ->
				b64wrapped
		| otherwise -> t
	Left _ -> b64wrapped
  where
	b64wrapped = TE.decodeUtf8 $ "[" <> B64.encode b <> "]"
	openbracket = fromIntegral (ord '[')
	closebracket = fromIntegral (ord ']')

decodeB64Text :: T.Text -> Either T.Text B.ByteString
decodeB64Text t = 
	case T.unsnoc t of
		Just (t', lastc) | lastc == ']' ->
			case T.uncons t' of
				Just (firstc, t'') | firstc == '[' ->
					case B64.decode (TE.encodeUtf8 t'') of
						Right b -> Right b
						Left _ -> Left "unable to base64 decode [] wrapped value"
				_ -> Right (TE.encodeUtf8 t)
		_ -> Right (TE.encodeUtf8 t)

-- Phantom types.
data ClientSide
data ServerSide
data Bypass
data Plus
data Lock

type LockID = B64UUID Lock

newtype DataLength = DataLength Integer
	deriving (Show)

newtype CheckPresentResult = CheckPresentResult Bool
	deriving (Show)

newtype RemoveResult = RemoveResult Bool
	deriving (Show)

data RemoveResultPlus = RemoveResultPlus Bool [B64UUID Plus]
	deriving (Show)

newtype GetTimestampResult = GetTimestampResult Timestamp
	deriving (Show)

newtype PutResult = PutResult Bool
	deriving (Eq, Show)

data PutResultPlus = PutResultPlus Bool [B64UUID Plus]
	deriving (Show)

data PutOffsetResult
	= PutOffsetResult Offset
	| PutOffsetResultAlreadyHave
	deriving (Show)

data PutOffsetResultPlus 
	= PutOffsetResultPlus Offset
	| PutOffsetResultAlreadyHavePlus [B64UUID Plus]
	deriving (Show, Generic, NFData)

newtype Offset = Offset P2P.Offset
	deriving (Show, Generic, NFData)

newtype Timestamp = Timestamp MonotonicTimestamp
	deriving (Show)

data LockResult = LockResult Bool (Maybe LockID)
	deriving (Show, Generic, NFData)

newtype UnlockRequest = UnlockRequest Bool
	deriving (Show, Generic, NFData)

-- Not using servant's built-in basic authentication support,
-- because whether authentication is needed depends on server
-- configuration.
data Auth = Auth B.ByteString B.ByteString
	deriving (Show, Generic, NFData, Eq, Ord)

#ifdef WITH_SERVANT

instance ToHttpApiData Auth where
	toHeader (Auth u p) = "Basic " <> B64.encode (u <> ":" <> p)
#if MIN_VERSION_text(2,0,0)
	toUrlPiece = TE.decodeUtf8Lenient . toHeader
#else
	toUrlPiece = TE.decodeUtf8With (\_ _ -> Just '\xfffd') . toHeader
#endif

instance FromHttpApiData Auth where
	parseHeader h =
		let (b, rest) = B.break (isSpace . chr . fromIntegral) h
		in if map toLower (decodeBS b) == "basic"
			then case B64.decode (B.dropWhile (isSpace . chr . fromIntegral) rest) of
				Right v -> case B.split (fromIntegral (ord ':')) v of
					(u:ps) -> Right $
						Auth u (B.intercalate ":" ps)
					_ -> bad
				Left _ -> bad
			else bad
	  where
		bad = Left "invalid basic auth header"
	parseUrlPiece = parseHeader . encodeBS . T.unpack

newtype ConnectionKeepAlive = ConnectionKeepAlive T.Text

connectionKeepAlive :: ConnectionKeepAlive
connectionKeepAlive = ConnectionKeepAlive "Keep-Alive"

newtype KeepAlive = KeepAlive T.Text

keepAlive :: KeepAlive
keepAlive = KeepAlive "timeout=1200"

instance ToHttpApiData ConnectionKeepAlive where
	toUrlPiece (ConnectionKeepAlive t) = t

instance FromHttpApiData ConnectionKeepAlive where
	parseUrlPiece = Right . ConnectionKeepAlive

instance ToHttpApiData KeepAlive where
	toUrlPiece (KeepAlive t) = t

instance FromHttpApiData KeepAlive where
	parseUrlPiece = Right . KeepAlive

instance ToHttpApiData V3 where toUrlPiece _ = "v3"
instance ToHttpApiData V2 where toUrlPiece _ = "v2"
instance ToHttpApiData V1 where toUrlPiece _ = "v1"
instance ToHttpApiData V0 where toUrlPiece _ = "v0"

instance FromHttpApiData V3 where parseUrlPiece = parseAPIVersion V3 "v3"
instance FromHttpApiData V2 where parseUrlPiece = parseAPIVersion V2 "v2"
instance FromHttpApiData V1 where parseUrlPiece = parseAPIVersion V1 "v1"
instance FromHttpApiData V0 where parseUrlPiece = parseAPIVersion V0 "v0"

parseAPIVersion :: v -> T.Text -> T.Text -> Either T.Text v
parseAPIVersion v need t
	| t == need = Right v
	| otherwise = Left "bad version"

instance ToHttpApiData B64Key where
	toUrlPiece (B64Key k) = encodeB64Text (serializeKey' k)

instance FromHttpApiData B64Key where
	parseUrlPiece t = case decodeB64Text t of
		Right b -> maybe (Left "key parse error") (Right . B64Key)
			(deserializeKey' b)
		Left err -> Left err

instance ToHttpApiData (B64UUID t) where
	toUrlPiece (B64UUID u) = encodeB64Text (fromUUID u)

instance FromHttpApiData (B64UUID t) where
	parseUrlPiece t = case decodeB64Text t of
		Right b -> case toUUID b of
			u@(UUID _) -> Right (B64UUID u)
			NoUUID -> Left "empty UUID"
		Left err -> Left err

instance ToHttpApiData B64FilePath where
	toUrlPiece (B64FilePath f) = encodeB64Text f

instance FromHttpApiData B64FilePath where
	parseUrlPiece t = case decodeB64Text t of
		Right b -> Right (B64FilePath b)
		Left err -> Left err

instance ToHttpApiData Offset where
	toUrlPiece (Offset (P2P.Offset n)) = T.pack (show n)

instance FromHttpApiData Offset where
	parseUrlPiece t = case readMaybe (T.unpack t) of
		Nothing -> Left "offset parse error"
		Just n -> Right (Offset (P2P.Offset n))

instance ToHttpApiData Timestamp where
	toUrlPiece (Timestamp (MonotonicTimestamp n)) = T.pack (show n)

instance FromHttpApiData Timestamp where
	parseUrlPiece t = case readMaybe (T.unpack t) of
		Nothing -> Left "timestamp parse error"
		Just n -> Right (Timestamp (MonotonicTimestamp n))

instance ToHttpApiData DataLength where
	toUrlPiece (DataLength n) = T.pack (show n)

instance FromHttpApiData DataLength where
	parseUrlPiece t = case readMaybe (T.unpack t) of
		Nothing -> Left "X-git-annex-data-length parse error"
		Just n -> Right (DataLength n)

instance ToJSON PutResult where
	toJSON (PutResult b) =
		object ["stored" .= b]

instance FromJSON PutResult where
	parseJSON = withObject "PutResult" $ \v -> PutResult
		<$> v .: "stored"

instance ToJSON PutResultPlus where
	toJSON (PutResultPlus b us) = object
		[ "stored" .= b
		, "plusuuids" .= plusList us
		]

instance FromJSON PutResultPlus where
	parseJSON = withObject "PutResultPlus" $ \v -> PutResultPlus
		<$> v .: "stored"
		<*> v .: "plusuuids"

instance ToJSON CheckPresentResult where
	toJSON (CheckPresentResult b) = object
		["present" .= b]

instance FromJSON CheckPresentResult where
	parseJSON = withObject "CheckPresentResult" $ \v -> CheckPresentResult
		<$> v .: "present"

instance ToJSON RemoveResult where
	toJSON (RemoveResult b) = object
		["removed" .= b]

instance FromJSON RemoveResult where
	parseJSON = withObject "RemoveResult" $ \v -> RemoveResult
		<$> v .: "removed"

instance ToJSON RemoveResultPlus where
	toJSON (RemoveResultPlus b us) = object
		[ "removed" .= b
		, "plusuuids" .= plusList us
		]

instance FromJSON RemoveResultPlus where
	parseJSON = withObject "RemoveResultPlus" $ \v -> RemoveResultPlus
		<$> v .: "removed"
		<*> v .: "plusuuids"

instance ToJSON GetTimestampResult where
	toJSON (GetTimestampResult (Timestamp (MonotonicTimestamp t))) = object
		["timestamp" .= t]

instance FromJSON GetTimestampResult where
	parseJSON = withObject "GetTimestampResult" $ \v ->
		GetTimestampResult . Timestamp . MonotonicTimestamp
			<$> v .: "timestamp"

instance ToJSON PutOffsetResult where
	toJSON (PutOffsetResult (Offset (P2P.Offset o))) = object
		["offset" .= o]
	toJSON PutOffsetResultAlreadyHave = object
		["alreadyhave" .= True]

instance FromJSON PutOffsetResult where
	parseJSON = withObject "PutOffsetResult" $ \v ->
		(PutOffsetResult
			<$> (Offset . P2P.Offset <$> v .: "offset"))
		<|> (mkalreadyhave
			<$> (v .: "alreadyhave"))
	  where
		mkalreadyhave :: Bool -> PutOffsetResult
		mkalreadyhave _ = PutOffsetResultAlreadyHave

instance ToJSON PutOffsetResultPlus where
	toJSON (PutOffsetResultPlus (Offset (P2P.Offset o))) = object
		[ "offset" .= o ]
	toJSON (PutOffsetResultAlreadyHavePlus us) = object
		[ "alreadyhave" .= True
		, "plusuuids" .= plusList us
		]

instance FromJSON PutOffsetResultPlus where
	parseJSON = withObject "PutOffsetResultPlus" $ \v ->
		(PutOffsetResultPlus
			<$> (Offset . P2P.Offset <$> v .: "offset"))
		<|> (mkalreadyhave
			<$> (v .: "alreadyhave")
			<*> (v .: "plusuuids"))
	  where
		mkalreadyhave :: Bool -> [B64UUID Plus] -> PutOffsetResultPlus
		mkalreadyhave _ us = PutOffsetResultAlreadyHavePlus us

instance FromJSON (B64UUID t) where
	parseJSON (String t) = case decodeB64Text t of
		Right s -> pure (B64UUID (toUUID s))
		Left _ -> mempty
	parseJSON _ = mempty

instance ToJSON LockResult where
	toJSON (LockResult v (Just (B64UUID lck))) = object
		[ "locked" .= v
		, "lockid" .= encodeB64Text (fromUUID lck)
		]
	toJSON (LockResult v Nothing) = object
		[ "locked" .= v
		]

instance FromJSON LockResult where
	parseJSON = withObject "LockResult" $ \v -> LockResult
		<$> v .: "locked"
		<*> v .:? "lockid"

instance ToJSON UnlockRequest where
	toJSON (UnlockRequest v) = object
		["unlock" .= v]

instance FromJSON UnlockRequest where
	parseJSON = withObject "UnlockRequest" $ \v -> UnlockRequest
		<$> v .: "unlock"

plusList :: [B64UUID Plus] -> [String]
plusList = map (\(B64UUID u) -> fromUUID u)

class PlusClass plus unplus where
	dePlus :: plus -> unplus
	plus :: unplus -> plus

instance PlusClass RemoveResultPlus RemoveResult where
	dePlus (RemoveResultPlus b _) = RemoveResult b
	plus (RemoveResult b) = RemoveResultPlus b mempty

instance PlusClass PutResultPlus PutResult where
	dePlus (PutResultPlus b _) = PutResult b
	plus (PutResult b) = PutResultPlus b mempty

instance PlusClass PutOffsetResultPlus PutOffsetResult where
	dePlus (PutOffsetResultPlus o) = PutOffsetResult o
	dePlus (PutOffsetResultAlreadyHavePlus _) = PutOffsetResultAlreadyHave
	plus (PutOffsetResult o) = PutOffsetResultPlus o
	plus PutOffsetResultAlreadyHave = PutOffsetResultAlreadyHavePlus []

#endif