Skip to content

Commit ddf233e

Browse files
committed
Add Pinata ipfs upload API
1 parent 78a25ad commit ddf233e

File tree

8 files changed

+149
-13
lines changed

8 files changed

+149
-13
lines changed

govtool/backend/example-config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"password" : "postgres",
77
"port" : 5432
88
},
9+
"pinataapijwt": "",
910
"port" : 9999,
1011
"host" : "localhost",
1112
"cachedurationseconds": 20,

govtool/backend/src/VVA/API.hs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ import Control.Exception (throw, throwIO)
1313
import Control.Monad.Except (runExceptT, throwError)
1414
import Control.Monad.Reader
1515

16-
import Data.Aeson (Value(..), Array, decode, encode, ToJSON, toJSON)
16+
import Data.Aeson (Value(..), Array, decode, ToJSON, toJSON)
1717
import Data.Bool (Bool)
1818
import Data.List (sortOn, sort)
1919
import qualified Data.Map as Map
2020
import Data.Maybe (Maybe (Nothing), catMaybes, fromMaybe, mapMaybe)
2121
import Data.Ord (Down (..))
2222
import Data.Text hiding (any, drop, elem, filter, length, map, null, take)
2323
import qualified Data.Text as Text
24+
import qualified Data.Text.Lazy as TL
25+
import qualified Data.Text.Lazy.Encoding as TL
2426
import qualified Data.Vector as V
2527
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
2628

@@ -48,9 +50,14 @@ import VVA.Types (App, AppEnv (..),
4850
AppError (CriticalError, InternalError, ValidationError),
4951
CacheEnv (..))
5052
import Data.Time (TimeZone, localTimeToUTC)
53+
import qualified VVA.Ipfs as Ipfs
54+
import Data.ByteString.Lazy (ByteString)
55+
import qualified Data.ByteString.Lazy as BSL
5156

5257
type VVAApi =
53-
"drep" :> "list"
58+
"ipfs"
59+
:> "upload" :> QueryParam "fileName" Text :> ReqBody '[PlainText] Text :> Post '[JSON] UploadResponse
60+
:<|> "drep" :> "list"
5461
:> QueryParam "search" Text
5562
:> QueryParams "status" DRepStatus
5663
:> QueryParam "sort" DRepSortMode
@@ -89,7 +96,8 @@ type VVAApi =
8996
:<|> "account" :> Capture "stakeKey" HexText :> Get '[JSON] GetAccountInfoResponse
9097

9198
server :: App m => ServerT VVAApi m
92-
server = drepList
99+
server = upload
100+
:<|> drepList
93101
:<|> getVotingPower
94102
:<|> getVotes
95103
:<|> drepInfo
@@ -107,6 +115,19 @@ server = drepList
107115
:<|> getNetworkTotalStake
108116
:<|> getAccountInfo
109117

118+
upload :: App m => Maybe Text -> Text -> m UploadResponse
119+
upload mFileName fileContentText = do
120+
AppEnv {vvaConfig} <- ask
121+
let fileContent = TL.encodeUtf8 $ TL.fromStrict fileContentText
122+
vvaPinataJwt = pinataApiJwt vvaConfig
123+
fileName = fromMaybe "data.txt" mFileName -- Default to data.txt if no filename is provided
124+
when (BSL.length fileContent > 1024 * 512) $
125+
throwError $ ValidationError "The uploaded file is larger than 500Kb"
126+
eIpfsHash <- liftIO $ Ipfs.ipfsUpload vvaPinataJwt fileName fileContent
127+
case eIpfsHash of
128+
Left err -> throwError $ InternalError $ "IPFS upload failed: " <> pack err
129+
Right ipfsHash -> return $ UploadResponse ipfsHash
130+
110131
mapDRepType :: Types.DRepType -> DRepType
111132
mapDRepType Types.DRep = NormalDRep
112133
mapDRepType Types.SoleVoter = SoleVoter

govtool/backend/src/VVA/API/Types.hs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,14 @@ data GetAccountInfoResponse
11121112
}
11131113
deriving (Generic, Show)
11141114
deriveJSON (jsonOptions "getAccountInfoResponse") ''GetAccountInfoResponse
1115+
1116+
data UploadResponse
1117+
= UploadResponse
1118+
{ uploadResponseIpfsCid :: Text
1119+
}
1120+
deriving (Generic, Show)
1121+
deriveJSON (jsonOptions "uploadResponse") ''UploadResponse
1122+
11151123
exampleGetAccountInfoResponse :: Text
11161124
exampleGetAccountInfoResponse =
11171125
"{\"stakeKey\": \"stake1u9\","
@@ -1125,3 +1133,14 @@ instance ToSchema GetAccountInfoResponse where
11251133
& description ?~ "GetAccountInfoResponse"
11261134
& example
11271135
?~ toJSON exampleGetAccountInfoResponse
1136+
1137+
exampleUploadResponse :: Text
1138+
exampleUploadResponse =
1139+
"{\"ipfsHash\": \"QmZKLGf2D3Z3F2J2K5J2L5J2L5J2L5J2L5J2L5J2L5J2L5\"}"
1140+
1141+
instance ToSchema UploadResponse where
1142+
declareNamedSchema _ = pure $ NamedSchema (Just "UploadResponse") $ mempty
1143+
& type_ ?~ OpenApiObject
1144+
& description ?~ "UploadResponse"
1145+
& example
1146+
?~ toJSON exampleUploadResponse

govtool/backend/src/VVA/Config.hs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import qualified Conferer.Source.Env as Env
3232

3333
import Control.Monad.Reader
3434

35-
import Data.Aeson
35+
import Data.Aeson as Aeson
3636
import qualified Data.Aeson.Encode.Pretty as AP
3737
import Data.ByteString (ByteString, toStrict)
3838
import Data.Has (Has, getter)
@@ -58,7 +58,7 @@ data DBConfig
5858
-- | Port
5959
, dBConfigPort :: Int
6060
}
61-
deriving (FromConfig, Generic, Show)
61+
deriving (FromConfig, FromJSON, Generic, Show)
6262

6363
instance DefaultConfig DBConfig where
6464
configDef = DBConfig "localhost" "cexplorer" "postgres" "test" 9903
@@ -79,9 +79,12 @@ data VVAConfigInternal
7979
, vVAConfigInternalSentrydsn :: String
8080
-- | Sentry environment
8181
, vVAConfigInternalSentryEnv :: String
82+
-- | Pinata API JWT
83+
, vVAConfigInternalPinataApiJwt :: Maybe Text
8284
}
8385
deriving (FromConfig, Generic, Show)
8486

87+
8588
instance DefaultConfig VVAConfigInternal where
8689
configDef =
8790
VVAConfigInternal
@@ -90,7 +93,8 @@ instance DefaultConfig VVAConfigInternal where
9093
vVAConfigInternalHost = "localhost",
9194
vVaConfigInternalCacheDurationSeconds = 20,
9295
vVAConfigInternalSentrydsn = "https://username:[email protected]/id",
93-
vVAConfigInternalSentryEnv = "development"
96+
vVAConfigInternalSentryEnv = "development",
97+
vVAConfigInternalPinataApiJwt = Nothing
9498
}
9599

96100
-- | DEX configuration.
@@ -108,6 +112,8 @@ data VVAConfig
108112
, sentryDSN :: String
109113
-- | Sentry environment
110114
, sentryEnv :: String
115+
-- | Pinata API JWT
116+
, pinataApiJwt :: Maybe Text
111117
}
112118
deriving (Generic, Show, ToJSON)
113119

@@ -148,7 +154,8 @@ convertConfig VVAConfigInternal {..} =
148154
serverHost = vVAConfigInternalHost,
149155
cacheDurationSeconds = vVaConfigInternalCacheDurationSeconds,
150156
sentryDSN = vVAConfigInternalSentrydsn,
151-
sentryEnv = vVAConfigInternalSentryEnv
157+
sentryEnv = vVAConfigInternalSentryEnv,
158+
pinataApiJwt = vVAConfigInternalPinataApiJwt
152159
}
153160

154161
-- | Load configuration from a file specified on the command line. Load from
@@ -163,7 +170,7 @@ loadVVAConfig configFile = do
163170
where
164171
buildConfig :: IO Config
165172
buildConfig =
166-
Conferer.mkConfig'
173+
mkConfig'
167174
[]
168175
[ Env.fromConfig "vva",
169176
JSON.fromFilePath (fromMaybe "example-config.json" configFile)
@@ -185,4 +192,4 @@ getServerPort = asks (serverPort . getter)
185192
getServerHost ::
186193
(Has VVAConfig r, MonadReader r m) =>
187194
m Text
188-
getServerHost = asks (serverHost . getter)
195+
getServerHost = asks (serverHost . getter)

govtool/backend/src/VVA/Ipfs.hs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{-# LANGUAGE DeriveGeneric #-}
2+
{-# LANGUAGE OverloadedStrings #-}
3+
{-# LANGUAGE ScopedTypeVariables #-}
4+
5+
module VVA.Ipfs (ipfsUpload) where
6+
7+
import Control.Exception (SomeException, try)
8+
import Control.Monad.IO.Class (liftIO)
9+
import Data.Aeson (FromJSON(parseJSON), withObject, (.:), eitherDecode)
10+
import qualified Data.ByteString.Lazy as LBS
11+
import Data.Text (Text)
12+
import qualified Data.Text.Encoding as TE
13+
import GHC.Generics (Generic)
14+
import Network.HTTP.Client (newManager, parseRequest, httpLbs, method, requestHeaders, RequestBody(..), Request, responseBody, responseStatus)
15+
import Network.HTTP.Client.TLS (tlsManagerSettings)
16+
import Network.HTTP.Client.MultipartFormData (formDataBody, partBS, partFileRequestBody)
17+
import Network.HTTP.Types.Status (statusIsSuccessful)
18+
import qualified Data.ByteString.Lazy.Char8 as LBS8
19+
import qualified Data.Text.Lazy as TL
20+
import qualified Data.Text.Lazy.Encoding as TL
21+
import qualified Data.Text as T
22+
23+
24+
data PinataData = PinataData
25+
{ cid :: Text
26+
, size :: Int
27+
, created_at :: Text
28+
, isDuplicate :: Maybe Bool
29+
} deriving (Show, Generic)
30+
31+
instance FromJSON PinataData
32+
33+
data PinataSuccessResponse = PinataSuccessResponse
34+
{ pinataData :: PinataData
35+
} deriving (Show)
36+
37+
instance FromJSON PinataSuccessResponse where
38+
parseJSON = withObject "PinataSuccessResponse" $ \v -> PinataSuccessResponse
39+
<$> v .: "data"
40+
41+
ipfsUpload :: Maybe Text -> Text -> LBS.ByteString -> IO (Either String Text)
42+
ipfsUpload maybeJwt fileName fileContent =
43+
case maybeJwt of
44+
Nothing -> pure $ Left "Backend is not configured to support ipfs upload"
45+
Just "" -> pure $ Left "Backend is not configured to support ipfs upload"
46+
Just jwt -> do
47+
manager <- newManager tlsManagerSettings
48+
initialRequest <- parseRequest "https://uploads.pinata.cloud/v3/files"
49+
let req = initialRequest
50+
{ method = "POST"
51+
, requestHeaders = [("Authorization", "Bearer " <> TE.encodeUtf8 jwt)]
52+
}
53+
result <- try $ flip httpLbs manager =<< formDataBody
54+
[ partBS "network" "public"
55+
, partFileRequestBody "file" (T.unpack fileName) $ RequestBodyLBS fileContent
56+
]
57+
req
58+
59+
case result of
60+
Left (e :: SomeException) -> do
61+
let errMsg = show e
62+
liftIO $ putStrLn errMsg
63+
pure $ Left errMsg
64+
Right response -> do
65+
let body = responseBody response
66+
let status = responseStatus response
67+
if statusIsSuccessful status
68+
then case eitherDecode body of
69+
Left err -> do
70+
let errMsg = "Failed to decode Pinata API reponse: " <> err <> "\nResponse body: " <> LBS8.unpack body
71+
liftIO $ putStrLn errMsg
72+
pure $ Left errMsg
73+
Right (res :: PinataSuccessResponse) -> pure $ Right $ cid $ pinataData res
74+
else do
75+
let errMsg = "Pinata API request failed with status: " <> show status <> "\nResponse body: " <> LBS8.unpack body
76+
liftIO $ putStrLn errMsg
77+
pure $ Left errMsg

govtool/backend/stack.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ packages:
44

55
extra-deps:
66
- raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784
7+
- http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554

govtool/backend/stack.yaml.lock

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# This file was autogenerated by Stack.
22
# You should not edit this file by hand.
33
# For more information, please see the documentation at:
4-
# https://docs.haskellstack.org/en/stable/lock_files
4+
# https://docs.haskellstack.org/en/stable/topics/lock_files
55

66
packages:
77
- completed:
@@ -11,6 +11,13 @@ packages:
1111
size: 632
1212
original:
1313
hackage: raven-haskell-0.1.4.1@sha256:9187272adc064197528645b5ad9b89163b668f386f34016d97fa646d5c790784
14+
- completed:
15+
hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554
16+
pantry-tree:
17+
sha256: a35e249bf5a162c18e5fa2309c5cfcdaaead1d8fc914be029f3f1239102bd648
18+
size: 164
19+
original:
20+
hackage: http-client-multipart-0.3.0.0@sha256:d675f10cba69c98233467dd533ba46e64f34798fc2ea528efe662ad2ea6c89bf,554
1421
snapshots:
1522
- completed:
1623
sha256: e019cd29e3f7f9dbad500225829a3f7a50f73c674614f2f452e21bb8bf5d99ea

govtool/backend/vva-be.cabal

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ executable vva-be
4545
-- other-modules:
4646

4747
-- LANGUAGE extensions used by modules in this package.
48-
-- other-extensions:
49-
build-depends: base >=4.16 && <4.18
48+
-- other-extensions9
49+
build-depends: base >=4.16 && <4.19
5050
, vva-be
5151
, optparse-applicative
5252
, text
@@ -80,7 +80,7 @@ executable vva-be
8080

8181
library
8282
hs-source-dirs: src
83-
build-depends: base >=4.16 && <4.18
83+
build-depends: base >=4.16 && <4.19
8484
, servant-server
8585
, conferer
8686
, mtl
@@ -107,9 +107,11 @@ library
107107
, swagger2
108108
, http-client
109109
, http-client-tls
110+
, http-client-multipart
110111
, vector
111112
, async
112113
, random
114+
, http-types
113115

114116
exposed-modules: VVA.Config
115117
, VVA.CommandLine
@@ -126,4 +128,5 @@ library
126128
, VVA.Types
127129
, VVA.Network
128130
, VVA.Account
131+
, VVA.Ipfs
129132
ghc-options: -threaded

0 commit comments

Comments
 (0)