diff --git a/.env.example b/.env.example index 7f1ab23..b5031eb 100644 --- a/.env.example +++ b/.env.example @@ -2,18 +2,20 @@ POSTGRES_USER= POSTGRES_PASSWORD= POSTGRES_PORT= POSTGRES_DB= -POSTGRES_HOST=db - +POSTGRES_HOST= YOUTUBE_KEY= WORKER_API_KEY= - +WORKER_API_URL= +WORKER_URL= DISCORD_CLIENT_ID= DISCORD_SECRET= GITHUB_CLIENT_ID= GITHUB_SECRET= -TWITTER_API_KEY= +TWITTER_CLIENT_ID= TWITTER_SECRET= GOOGLE_CLIENT_ID= GOOGLE_SECRET= SPOTIFY_CLIENT_ID= -SPOTIFY_SECRET= \ No newline at end of file +SPOTIFY_SECRET= +ANILIST_SECRET= +ANILIST_CLIENT_ID= diff --git a/README.md b/README.md index 1efdf33..5e8ac90 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,50 @@ # Professional, personnal action-reaction manager -## Introduction +## What is Aeris + +Aeris is an Action-Reaction system manager. It lets its users create *Pipelines*. + +Pipelines are triggered by an *Action* (for example, when a PR is open on some GitHub repository), and perfoms *Reactions* (for example, play a given song on Spotify). + +To manage your Pipeline, you can use the Android App, as well as the Web client. + +Aeris supports a variety of service, on which Pipelines are perfomed upon: + +- GitHub +- Spotify +- Youtube +- Discord +- Anilist +- Twitter + +## What do I need to use Aeris? + +Make sure the following softwares are installed on your machine: + +- Docker, (and Docker-Compose) + +To setup Aeris, you need to provide the building system some information: + +- Client ID and Client Secrets for EACH services (fill the `.env.example`provided at the root of the repository and rename it `.env`) +- A Host name, to let clients know how to call the API (usually localhost:8080) + +## How to install it ? + +To install Aeris, run the following commands at the root of the repository: + +- `docker-compose -f docker-compose.yml build` (grab a snack, it might take some time) +- `docker-compose -f docker-compose.yml up` + +## How to use it ? + +The Aeris server is accessible on the host's 8080 port + +You can access the Web client through port 8081 + +An Android APK can be downloaded via localhost:8081/client.apk + +## Why *Aeris* ? This project's name is inspired by [Aergia]( https://en.wikipedia.org/wiki/Aergia), the goddess of sloth. This tool is an automatisation tool. diff --git a/api/aeris.cabal b/api/aeris.cabal index f232871..c89886c 100644 --- a/api/aeris.cabal +++ b/api/aeris.cabal @@ -17,6 +17,10 @@ license-file: LICENSE build-type: Simple extra-source-files: README.md + services/discord.json + services/github.json + services/spotify.json + services/youtube.json source-repository head type: git @@ -29,6 +33,7 @@ library Api.Auth Api.OIDC Api.Pipeline + Api.Worker App Config Core.OIDC @@ -39,12 +44,6 @@ library Db.Reaction Db.User Lib - OIDC - OIDC.Discord - OIDC.Github - OIDC.Google - OIDC.Spotify - OIDC.Twitter Password Repository Repository.Pipeline @@ -59,10 +58,12 @@ library build-depends: aeson ==1.5.6.0 , base >=4.7 && <5 + , base64 , bytestring , containers , cryptonite , data-default + , file-embed , hasql , hasql-migration , hasql-pool @@ -77,6 +78,7 @@ library , servant , servant-auth , servant-auth-server + , servant-errors , servant-server , text , time @@ -98,10 +100,12 @@ executable aeris-exe aeris , aeson ==1.5.6.0 , base + , base64 , bytestring , containers , cryptonite , data-default + , file-embed , hasql , hasql-migration , hasql-pool @@ -116,6 +120,7 @@ executable aeris-exe , servant , servant-auth , servant-auth-server + , servant-errors , servant-server , text , time @@ -138,10 +143,12 @@ test-suite aeris-test aeris , aeson , base + , base64 , bytestring , containers , cryptonite , data-default + , file-embed , hasql , hasql-migration , hasql-pool @@ -159,6 +166,7 @@ test-suite aeris-test , servant , servant-auth , servant-auth-server + , servant-errors , servant-server , text , time diff --git a/api/app/Main.hs b/api/app/Main.hs index 6d415f7..ba72584 100644 --- a/api/app/Main.hs +++ b/api/app/Main.hs @@ -10,7 +10,7 @@ import Hasql.Transaction (Transaction, condemn, sql, statement) import Rel8 (each, insert, select) import Servant import Servant.Auth.Server (CookieSettings, JWTSettings, defaultCookieSettings, defaultJWTSettings, generateKey) - +import Network.Wai.Middleware.Servant.Errors import App import Config (dbConfigToConnSettings, getPostgresConfig) import qualified Hasql.Session as Session @@ -27,4 +27,4 @@ main = do appPort <- envAsInt "AERIS_BACK_PORT" 8080 let jwtCfg = defaultJWTSettings key pool <- acquire (3, 1, dbConfigToConnSettings dbConf) - run appPort $ app jwtCfg $ State pool + run appPort $ errorMwDefJson $ app jwtCfg $ State pool diff --git a/api/package.yaml b/api/package.yaml index 20cab05..1a37a24 100644 --- a/api/package.yaml +++ b/api/package.yaml @@ -8,6 +8,7 @@ copyright: "2022 Author name here" extra-source-files: - README.md +- services/*.json # Metadata used when publishing your package # synopsis: Short description of your package @@ -30,10 +31,13 @@ dependencies: - containers - warp - time +- servant-errors - data-default - bytestring - network +- base64 - text +- file-embed - hasql - hasql-transaction - hasql-migration diff --git a/api/src/Api.hs b/api/src/Api.hs index 8e01787..e70d662 100644 --- a/api/src/Api.hs +++ b/api/src/Api.hs @@ -19,11 +19,13 @@ import Api.Pipeline import qualified Api.Pipeline as Api import App import Control.Monad.Trans.Reader (ReaderT (runReaderT)) +import Api.Worker (WorkerAPI, workerHandler) data API mode = API { about :: mode :- "about.json" :> RemoteHost :> Get '[JSON] About , auth :: mode :- "auth" :> NamedRoutes AuthAPI , pipelines :: mode :- NamedRoutes PipelineAPI + , worker :: mode :- "worker" :> NamedRoutes WorkerAPI } deriving stock (Generic) @@ -35,6 +37,7 @@ server cs jwts = { Api.about = Api.About.about , Api.auth = Api.Auth.authHandler cs jwts , Api.pipelines = Api.Pipeline.pipelineHandler + , Api.worker = Api.Worker.workerHandler } nt :: State -> AppM a -> Handler a diff --git a/api/src/Api/About.hs b/api/src/Api/About.hs index e1a5081..cdf06a9 100644 --- a/api/src/Api/About.hs +++ b/api/src/Api/About.hs @@ -9,14 +9,17 @@ module Api.About where import App (AppM) import Control.Monad.IO.Class (liftIO) -import Data.Aeson (defaultOptions, eitherDecode) +import Data.Aeson (defaultOptions, eitherDecode, Object) import qualified Data.Aeson.Parser import Data.Aeson.TH (deriveJSON) -import qualified Data.ByteString.Lazy as B import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime) import GHC.Generics (Generic) import Network.Socket (SockAddr) -import Servant (Handler, RemoteHost) +import Servant (Handler, RemoteHost, throwError, err500) +import qualified Data.ByteString as S +import qualified Data.ByteString.Lazy as L + +import Data.FileEmbed (embedDir, makeRelativeToProject) data ClientAbout = ClientAbout { host :: String @@ -26,6 +29,8 @@ data ClientAbout = ClientAbout data ActionAbout = ActionAbout { name :: String , description :: String + , params :: [Object] + , returns :: [Object] } deriving (Eq, Show) @@ -54,11 +59,13 @@ $(deriveJSON defaultOptions ''ServicesAbout) $(deriveJSON defaultOptions ''ServerAbout) $(deriveJSON defaultOptions ''About) +servicesDir :: [(FilePath, S.ByteString)] +servicesDir = $(embedDir "./services/") + about :: SockAddr -> AppM About about host = do now <- liftIO getPOSIXTime - s <- liftIO (readFile "services.json") - d <- liftIO ((eitherDecode <$> B.readFile "services.json") :: IO (Either String [ServicesAbout])) + let d = traverse (eitherDecode . L.fromStrict . snd) servicesDir :: Either String [ServicesAbout] case d of - Left err -> return $ About (ClientAbout $ show host) (ServerAbout now []) + Left err -> throwError err500 Right services -> return $ About (ClientAbout $ show host) (ServerAbout now services) diff --git a/api/src/Api/OIDC.hs b/api/src/Api/OIDC.hs index 6a40da8..84f7617 100644 --- a/api/src/Api/OIDC.hs +++ b/api/src/Api/OIDC.hs @@ -8,19 +8,19 @@ import App (AppM) import Control.Monad.IO.Class (liftIO) import Core.User (ExternalToken (ExternalToken, service), Service (Github), UserId (UserId), User (User)) import Data.Text (pack) -import OIDC +import Core.OIDC ( getOauthTokens ) import Repository.User (updateTokens, getTokensByUserId) -import Servant (Capture, Get, GetNoContent, JSON, NoContent (NoContent), QueryParam, ServerT, err400, throwError, type (:<|>) ((:<|>)), type (:>), err401) +import Servant (Capture, Get, GetNoContent, JSON, NoContent (NoContent), QueryParam, ServerT, err400, throwError, type (:<|>) ((:<|>)), type (:>), err401, err403) import Servant.API.Generic (type (:-)) import Servant.Server.Generic (AsServerT) import Utils (UserAuth, AuthRes) import Servant.Auth.Server (AuthResult(Authenticated)) oauthHandler :: AuthRes -> Service -> Maybe String -> AppM NoContent -oauthHandler (Authenticated (User uid name slug)) service (Just code) = do +oauthHandler (Authenticated (User uid _ _)) service (Just code) = do tokens <- liftIO $ getOauthTokens service code case tokens of - Nothing -> throwError err400 + Nothing -> throwError err403 Just t -> do updateTokens uid t return NoContent diff --git a/api/src/Api/Pipeline.hs b/api/src/Api/Pipeline.hs index 1b4cf91..038c7cb 100644 --- a/api/src/Api/Pipeline.hs +++ b/api/src/Api/Pipeline.hs @@ -4,6 +4,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} @@ -18,7 +19,7 @@ import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode) import Data.Aeson.TH (deriveJSON) import Data.Functor.Identity (Identity) import Data.Int (Int64) -import Data.Text (Text) +import Data.Text (Text, pack) import Db.Pipeline (Pipeline (Pipeline, pipelineType, pipelineUserId, pipelineId, pipelineEnabled), PipelineId (PipelineId, toInt64), getPipelineById, insertPipeline, pipelineName, pipelineParams, pipelineSchema, pipelineId) import Db.Reaction (Reaction (Reaction, reactionOrder, reactionParams, reactionType), ReactionId (ReactionId), getReactionsByPipelineId, insertReaction) import GHC.Generics (Generic) @@ -30,16 +31,22 @@ import Repository getPipelineById', getPipelineByUser, createReaction, - getReactionsByPipelineId', getWorkflow', getWorkflowsByUser', getWorkflows', createReactions, putWorkflow, delWorkflow ) + getReactionsByPipelineId', getWorkflow', getWorkflowsByUser', getWorkflows', createReactions, putWorkflow, delWorkflow, getUserById' ) import Servant (Capture, Get, JSON, err401, throwError, type (:>), NoContent (NoContent), err400, err403, err500) import Servant.API (Delete, Post, Put, ReqBody, QueryParam) import Servant.API.Generic ((:-)) import Servant.Server.Generic (AsServerT) import Utils (mapInd, UserAuth, AuthRes) -import Core.User (UserId(UserId), User (User)) +import Core.User (UserId(UserId), User (User), ExternalToken) import Servant.Auth.Server (AuthResult(Authenticated)) +import Network.HTTP.Simple (setRequestBodyJSON, httpJSONEither, setRequestMethod, addRequestHeader, parseRequest, httpBS, setRequestPath) +import Network.HTTP.Client.Conduit (Request(requestBody), httpNoBody) +import Data.ByteString (ByteString) +import Data.Text.Encoding (encodeUtf8) import System.Environment.MrEnv (envAsString) import Data.Default (def) +import Db.User (UserDB(..)) +import Control.Applicative (Alternative((<|>))) data PipelineData = PipelineData { name :: Text @@ -75,8 +82,8 @@ data PipelineAPI mode = PipelineAPI Capture "id" PipelineId :> ReqBody '[JSON] PutPipelineData :> Put '[JSON] PutPipelineData , del :: mode :- "workflow" :> UserAuth :> Capture "id" PipelineId :> Delete '[JSON] Int64 - , all :: mode :- "workflows" :> UserAuth :> - QueryParam "API_KEY" String :>Get '[JSON] [GetPipelineResponse] + , all :: mode :- "workflows" :> UserAuth + :> Get '[JSON] [GetPipelineResponse] } deriving stock (Generic) @@ -87,10 +94,23 @@ formatGetPipelineResponse pipeline reactions = actionResult = PipelineData (pipelineName pipeline) (pipelineType pipeline) (pipelineParams pipeline) (pipelineId pipeline) (pipelineEnabled pipeline) reactionsResult = fmap (\x -> ReactionData (reactionType x) (reactionParams x)) reactions +informWorker :: ByteString -> PipelineId -> IO () +informWorker method id = + do + url <- envAsString "WORKER_URL" "worker/" + request <- parseRequest url + response <- httpBS + $ setRequestMethod method + $ addRequestHeader "Accept" "application/json" + $ setRequestPath (encodeUtf8 (pack $ "/worker/" <> show id)) + $ request + return () + <|> return () + getPipelineHandler :: AuthRes -> PipelineId -> AppM GetPipelineResponse getPipelineHandler (Authenticated (User uid _ _)) pipelineId = do - (pipeline, reactions) <- getWorkflow' pipelineId + (pipeline, reactions, _) <- getWorkflow' pipelineId if pipelineUserId pipeline == uid then return $ formatGetPipelineResponse pipeline reactions else @@ -108,6 +128,7 @@ postPipelineHandler (Authenticated (User uid _ _)) x = do , pipelineParams = pParams p , pipelineUserId = uid } actionId <- createPipeline newPipeline + liftIO $ informWorker "POST" actionId createReactions $ reactionDatasToReactions (reactions x) actionId where p = action x @@ -118,7 +139,8 @@ putPipelineHandler (Authenticated (User uid _ _)) pipelineId x = do oldPipeline <- getPipelineById' pipelineId if pipelineUserId oldPipeline == uid then do res <- putWorkflow pipelineId newPipeline r - if res > 0 then + if res > 0 then do + liftIO $ informWorker "PUT" pipelineId return x else throwError err500 @@ -138,19 +160,16 @@ delPipelineHandler :: AuthRes -> PipelineId -> AppM Int64 delPipelineHandler (Authenticated (User uid _ _)) pipelineId = do oldPipeline <- getPipelineById' pipelineId if pipelineUserId oldPipeline == uid then do + liftIO $ informWorker "DELETE" pipelineId delWorkflow pipelineId else throwError err403 delPipelineHandler _ _ = throwError err401 -allPipelineHandler :: AuthRes -> Maybe String -> AppM [GetPipelineResponse] -allPipelineHandler usr@(Authenticated (User uid _ _)) Nothing = do +allPipelineHandler :: AuthRes -> AppM [GetPipelineResponse] +allPipelineHandler usr@(Authenticated (User uid _ _)) = do workflows <- getWorkflowsByUser' uid return $ fmap (uncurry formatGetPipelineResponse) workflows -allPipelineHandler _ (Just key) = do - k <- liftIO $ envAsString "WORKER_API_KEY" "" - if k == key then do fmap (uncurry formatGetPipelineResponse) <$> getWorkflows' - else throwError err403 -allPipelineHandler _ _ = throwError err401 +allPipelineHandler _ = throwError err401 pipelineHandler :: PipelineAPI (AsServerT AppM) pipelineHandler = diff --git a/api/src/Api/Worker.hs b/api/src/Api/Worker.hs new file mode 100644 index 0000000..b17dbb1 --- /dev/null +++ b/api/src/Api/Worker.hs @@ -0,0 +1,108 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeOperators #-} + +module Api.Worker where + +import App (AppM) +import Servant (Capture, Get, JSON, err401, throwError, type (:>), NoContent (NoContent), err400, err403, err500) +import Servant.API (Delete, Post, Put, ReqBody, QueryParam) +import Servant.API.Generic ((:-)) +import Servant.Server.Generic (AsServerT) +import Db.Pipeline (PipelineId(PipelineId), Pipeline (pipelineUserId)) +import Utils (UserAuth, uncurry3) +import Api.Pipeline (GetPipelineResponse, formatGetPipelineResponse, allPipelineHandler) +import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode) +import Data.Aeson.TH (deriveJSON) +import Core.User (UserId(UserId), ExternalToken (ExternalToken)) +import Control.Monad.IO.Class (MonadIO(liftIO)) +import System.Environment.MrEnv (envAsString) +import Repository (getUserById', getWorkflow', getWorkflows', triggerPipeline', errorPipeline') +import Db.User (UserDB(userDBId, externalTokens)) +import Data.Functor.Identity (Identity) +import Db.Reaction (Reaction) +import GHC.Generics (Generic) +import Data.Text (Text) + + +data WorkerUserData = WorkerUserData + { userId :: UserId + , tokens :: [ExternalToken ] + } + +data GetPipelineWorkerResponse = GetPipelineWorkerResponse + { userData :: WorkerUserData + , res :: GetPipelineResponse + } + +newtype ErrorBody = ErrorBody { error :: Text } + +$(deriveJSON defaultOptions ''WorkerUserData) +$(deriveJSON defaultOptions ''GetPipelineWorkerResponse) +$(deriveJSON defaultOptions ''ErrorBody) + +data WorkerAPI mode = WorkerAPI + { get :: mode :- "workflow" :> Capture "id" PipelineId :> + QueryParam "WORKER_API_KEY" String :> Get '[JSON] GetPipelineWorkerResponse + , all :: mode :- "workflows" :> + QueryParam "WORKER_API_KEY" String :> Get '[JSON] [GetPipelineWorkerResponse] + , trigger :: mode :- "trigger" :> Capture "id" PipelineId :> + QueryParam "WORKER_API_KEY" String :> Get '[JSON] NoContent + , error :: mode :- "error" :> Capture "id" PipelineId :> + QueryParam "WORKER_API_KEY" String :> ReqBody '[JSON] ErrorBody :> Post '[JSON] NoContent + } + deriving stock (Generic) + +fmtWorkerResponse :: Pipeline Identity -> [Reaction Identity] -> UserDB Identity -> GetPipelineWorkerResponse +fmtWorkerResponse pipeline reactions user = + GetPipelineWorkerResponse userData res + where + userData = WorkerUserData (userDBId user) (externalTokens user) + res = formatGetPipelineResponse pipeline reactions + +getPipelineHandlerWorker :: PipelineId -> Maybe String -> AppM GetPipelineWorkerResponse +getPipelineHandlerWorker pId (Just key) = do + k <- liftIO $ envAsString "WORKER_API_KEY" "" + if k == key then uncurry3 fmtWorkerResponse <$> getWorkflow' pId + else throwError err403 +getPipelineHandlerWorker _ _ = throwError err403 + +allPipelineHandlerWorker :: Maybe String -> AppM [GetPipelineWorkerResponse] +allPipelineHandlerWorker (Just key) = do + k <- liftIO $ envAsString "WORKER_API_KEY" "" + if k == key then do fmap (uncurry3 fmtWorkerResponse) <$> getWorkflows' + else throwError err403 +allPipelineHandlerWorker _ = throwError err401 + +triggerHandler :: PipelineId -> Maybe String -> AppM NoContent +triggerHandler pId (Just key) = do + k <- liftIO $ envAsString "WORKER_API_KEY" "" + if k == key then do + triggerPipeline' pId + return NoContent + else throwError err403 +triggerHandler _ _ = throwError err403 + + +errorHandler :: PipelineId -> Maybe String -> ErrorBody -> AppM NoContent +errorHandler pId (Just key) (ErrorBody msg) = do + k <- liftIO $ envAsString "WORKER_API_KEY" "" + if k == key then do + errorPipeline' pId msg + return NoContent + else throwError err403 +errorHandler _ _ _ = throwError err403 + +workerHandler :: WorkerAPI (AsServerT AppM) +workerHandler = + WorkerAPI + { get = getPipelineHandlerWorker + , all = allPipelineHandlerWorker + , trigger = triggerHandler + , error = errorHandler + } \ No newline at end of file diff --git a/api/src/Core/OIDC.hs b/api/src/Core/OIDC.hs index 9d85fc8..0422049 100644 --- a/api/src/Core/OIDC.hs +++ b/api/src/Core/OIDC.hs @@ -2,21 +2,238 @@ module Core.OIDC where -import Data.ByteString.Lazy +import qualified Data.ByteString.Char8 as B8 +import qualified Data.HashMap.Strict as HM --- * OIDC - -data OIDCConf = OIDCConf - { redirectUri :: ByteString - , clientId :: ByteString - , clientPassword :: ByteString +import App (AppM) +import Core.User (ExternalToken (ExternalToken), Service (Github, Discord, Spotify, Google, Twitter, Anilist)) +import Data.Aeson.Types (Object, Value (String)) +import Data.Text (Text, pack, unpack) +import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString, setRequestBodyURLEncoded) +import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) +import Utils (lookupObjString) +import Data.ByteString.Base64 +data OAuth2Conf = OAuth2Conf + { oauthClientId :: String + , oauthClientSecret :: String + , oauthAccessTokenEndpoint :: String } deriving (Show, Eq) -oidcGoogleConf :: OIDCConf -oidcGoogleConf = - OIDCConf - { redirectUri = "http://localhost:8080/auth/login/google" - , clientId = "914790981890-qjn5qjq5qjqjqjqjqjqjqjqjqjqjqjq.apps.googleusercontent.com" - , clientPassword = "914790981890-qjn5qjq5qjqjqjqjqjqjqjqjqjqjqjqjq" - } +tokenEndpoint :: String -> OAuth2Conf -> String +tokenEndpoint code oc = + concat + [ oauthAccessTokenEndpoint oc + , "?client_id=" + , oauthClientId oc + , "&client_secret=" + , oauthClientSecret oc + , "&code=" + , code + ] + +-- GITHUB +getGithubConfig :: IO OAuth2Conf +getGithubConfig = + OAuth2Conf + <$> envAsString "GITHUB_CLIENT_ID" "" + <*> envAsString "GITHUB_SECRET" "" + <*> pure "https://github.com/login/oauth/access_token" + +getGithubTokens :: String -> IO (Maybe ExternalToken) +getGithubTokens code = do + gh <- getGithubConfig + print gh + let endpoint = tokenEndpoint code gh + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Accept" "application/json" $ + setRequestQueryString + [ ("client_id", Just . B8.pack . oauthClientId $ gh) + , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) + , ("code", Just . B8.pack $ code) + ] + request' + response <- httpJSONEither request + print response + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + +-- DISCORD +getDiscordConfig :: IO OAuth2Conf +getDiscordConfig = + OAuth2Conf + <$> envAsString "DISCORD_CLIENT_ID" "" + <*> envAsString "DISCORD_SECRET" "" + <*> pure "https://discord.com/api/oauth2/token" + +getDiscordTokens :: String -> IO (Maybe ExternalToken) +getDiscordTokens code = do + cfg <- getDiscordConfig + let endpoint = tokenEndpoint code cfg + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Accept" "application/json" $ + setRequestBodyURLEncoded + [ ("client_id", B8.pack . oauthClientId $ cfg) + , ("client_secret", B8.pack . oauthClientSecret $ cfg) + , ("code", B8.pack code) + , ("grant_type", "authorization_code") + , ("redirect_uri", "http://localhost:3000/authorization/discord") + ] + request' + response <- httpJSONEither request + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + +-- GOOGLE +getGoogleConfig :: IO OAuth2Conf +getGoogleConfig = + OAuth2Conf + <$> envAsString "GOOGLE_CLIENT_ID" "" + <*> envAsString "GOOGLE_SECRET" "" + <*> pure "https://oauth2.googleapis.com/token" + +getGoogleTokens :: String -> IO (Maybe ExternalToken) +getGoogleTokens code = do + cfg <- getGoogleConfig + let endpoint = tokenEndpoint code cfg + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Accept" "application/json" $ + setRequestBodyURLEncoded + [ ("client_id", B8.pack . oauthClientId $ cfg) + , ("client_secret", B8.pack . oauthClientSecret $ cfg) + , ("code", B8.pack code) + , ("grant_type", "authorization_code") + , ("redirect_uri", "http://localhost:3000/authorization/google") + ] + request' + response <- httpJSONEither request + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + +-- SPOTIFY +getSpotifyConfig :: IO OAuth2Conf +getSpotifyConfig = + OAuth2Conf + <$> envAsString "SPOTIFY_CLIENT_ID" "" + <*> envAsString "SPOTIFY_SECRET" "" + <*> pure "https://accounts.spotify.com/api/token" + +getSpotifyTokens :: String -> IO (Maybe ExternalToken) +getSpotifyTokens code = do + cfg <- getSpotifyConfig + + let basicAuth = encodeBase64 $ B8.pack $ "Basic " ++ oauthClientId cfg ++ ":" ++ oauthClientSecret cfg + let endpoint = tokenEndpoint code cfg + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Authorization" (B8.pack . unpack $ basicAuth) $ + addRequestHeader "Accept" "application/json" $ + setRequestBodyURLEncoded + [ ("code", B8.pack code) + , ("grant_type", "authorization_code") + , ("redirect_uri", "http://localhost:3000/authorization/google") + ] + request' + response <- httpJSONEither request + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + +-- TWITTER +getTwitterConfig :: IO OAuth2Conf +getTwitterConfig = + OAuth2Conf + <$> envAsString "TWITTER_CLIENT_ID" "" + <*> envAsString "TWITTER_SECRET" "" + <*> pure "https://api.twitter.com/2/oauth2/token" + +getTwitterTokens :: String -> IO (Maybe ExternalToken) +getTwitterTokens code = do + cfg <- getTwitterConfig + let basicAuth = encodeBase64 $ B8.pack $ "Basic " ++ oauthClientId cfg ++ ":" ++ oauthClientSecret cfg + let endpoint = tokenEndpoint code cfg + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Authorization" (B8.pack . unpack $ basicAuth) $ + addRequestHeader "Accept" "application/json" $ + setRequestBodyURLEncoded + [ ("code", B8.pack code) + , ("grant_type", "authorization_code") + , ("redirect_uri", "http://localhost:3000/authorization/twitter") + , ("code_verifier", "challenge") + ] + request' + response <- httpJSONEither request + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + +-- ANILIST +getAnilistConfig :: IO OAuth2Conf +getAnilistConfig = + OAuth2Conf + <$> envAsString "ANILIST_CLIENT_ID" "" + <*> envAsString "ANILIST_SECRET" "" + <*> pure "https://anilist.co/api/v2/oauth/token" + +getAnilistTokens :: String -> IO (Maybe ExternalToken) +getAnilistTokens code = do + cfg <- getAnilistConfig + let endpoint = tokenEndpoint code cfg + request' <- parseRequest endpoint + let request = + setRequestMethod "POST" $ + addRequestHeader "Accept" "application/json" $ + setRequestBodyURLEncoded + [ ("client_id", B8.pack . oauthClientId $ cfg) + , ("client_secret", B8.pack . oauthClientSecret $ cfg) + , ("code", B8.pack code) + , ("grant_type", "authorization_code") + , ("redirect_uri", "http://localhost:3000/authorization/anilist") + ] + request' + response <- httpJSONEither request + return $ case (getResponseBody response :: Either JSONException Object) of + Left _ -> Nothing + Right obj -> do + access <- lookupObjString obj "access_token" + refresh <- lookupObjString obj "refresh_token" + Just $ ExternalToken (pack access) (pack refresh) 0 Github + + + + +-- General +getOauthTokens :: Service -> String -> IO (Maybe ExternalToken) +getOauthTokens Github = getGithubTokens +getOauthTokens Discord = getDiscordTokens +getOauthTokens Spotify = getSpotifyTokens +getOauthTokens Google = getGoogleTokens +getOauthTokens Twitter = getTwitterTokens +getOauthTokens Anilist = getAnilistTokens diff --git a/api/src/Core/Pipeline.hs b/api/src/Core/Pipeline.hs index d598d5f..055f0f1 100644 --- a/api/src/Core/Pipeline.hs +++ b/api/src/Core/Pipeline.hs @@ -6,19 +6,30 @@ {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} module Core.Pipeline where -import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode) +import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode, Object) import Data.Aeson.TH (deriveJSON) import Data.Text (Text) import GHC.Generics (Generic) -import Rel8 (DBType, JSONBEncoded (JSONBEncoded), ReadShow (ReadShow)) +import Rel8 (DBType, JSONBEncoded (JSONBEncoded), ReadShow (ReadShow), DBEq) +import Servant (FromHttpApiData) +{-- data PipelineType = TwitterNewPost | TwitterNewFollower deriving stock (Generic, Read, Show) deriving (DBType) via ReadShow PipelineType deriving (FromJSON, ToJSON) +--} + +-- newtype PipelineType = PipelineType { toText :: Text } +-- deriving stock (Generic, Read, Show) +-- deriving (DBType) via ReadShow PipelineType +-- deriving (FromJSON, ToJSON) + +type PipelineType = Text data TwitterNewPostData = TwitterNewPostData { author :: Text @@ -34,9 +45,8 @@ data TwitterNewFollowerData = TwitterNewFollowerData $(deriveJSON defaultOptions ''TwitterNewFollowerData) -data PipelineParams - = TwitterNewPostP TwitterNewPostData - | TwitterNewFollowerP TwitterNewFollowerData - deriving stock (Generic, Show) - deriving anyclass (ToJSON, FromJSON) +newtype PipelineParams + = PipelineParams { toObject :: Object } + deriving stock (Generic) + deriving newtype (DBEq, Eq, Show, FromJSON, ToJSON) deriving (DBType) via JSONBEncoded PipelineParams diff --git a/api/src/Core/Reaction.hs b/api/src/Core/Reaction.hs index ad041c5..1e4d875 100644 --- a/api/src/Core/Reaction.hs +++ b/api/src/Core/Reaction.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingVia #-} @@ -5,19 +6,30 @@ {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} module Core.Reaction where -import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode) +import Data.Aeson (FromJSON, ToJSON, defaultOptions, eitherDecode, Object) import Data.Aeson.TH (deriveJSON) import Data.Text (Text) import GHC.Generics (Generic) -import Rel8 (DBType, JSONBEncoded (JSONBEncoded), ReadShow (ReadShow)) +import Rel8 (DBType, JSONBEncoded (JSONBEncoded), ReadShow (ReadShow), DBEq) +{-- data ReactionType = TwitterTweet | TwitterFollower deriving stock (Generic, Read, Show) deriving (DBType) via ReadShow ReactionType deriving (FromJSON, ToJSON) +--} + +-- newtype ReactionType = ReactionType { toText :: Text } +-- deriving stock (Generic, Read, Show) +-- deriving (DBType) via ReadShow PipelineType +-- deriving (FromJSON, ToJSON) + +type ReactionType = Text + data TwitterTweetData = TwitterTweetData { body :: Text } @@ -32,9 +44,8 @@ data TwitterFollowData = TwitterFollowData $(deriveJSON defaultOptions ''TwitterFollowData) -data ReactionParams - = TwitterTweetP TwitterTweetData - | TwitterFollowP TwitterFollowData - deriving stock (Generic, Show) - deriving anyclass (ToJSON, FromJSON) - deriving (DBType) via JSONBEncoded ReactionParams +newtype ReactionParams + = ReactionParams { toObject :: Object } + deriving stock (Generic) + deriving newtype (DBEq, Eq, Show, FromJSON, ToJSON) + deriving (DBType) via JSONBEncoded ReactionParams \ No newline at end of file diff --git a/api/src/Core/User.hs b/api/src/Core/User.hs index dfc8167..e79e327 100644 --- a/api/src/Core/User.hs +++ b/api/src/Core/User.hs @@ -25,7 +25,7 @@ newtype UserId = UserId {toInt64 :: Int64} deriving newtype (DBEq, DBType, Eq, Show, Num, FromJSON, ToJSON) deriving stock (Generic) -data Service = Github | Google | Spotify | Twitter | Discord +data Service = Github | Google | Spotify | Twitter | Discord | Anilist deriving (Eq, Show, Generic, ToJSON, FromJSON) instance FromHttpApiData Service where @@ -35,6 +35,7 @@ instance FromHttpApiData Service where parseUrlPiece "spotify" = Right Spotify parseUrlPiece "twitter" = Right Twitter parseUrlPiece "discord" = Right Discord + parseUrlPiece "anilist" = Right Anilist parseUrlPiece _ = Left "not a service" data ExternalToken = ExternalToken diff --git a/api/src/Db/Pipeline.hs b/api/src/Db/Pipeline.hs index cac8dcd..e4036ca 100644 --- a/api/src/Db/Pipeline.hs +++ b/api/src/Db/Pipeline.hs @@ -26,7 +26,9 @@ import Core.Pipeline ( PipelineParams, PipelineType ) import Data.Functor.Identity (Identity) import Servant (FromHttpApiData) import Core.User (UserId(UserId)) -import Data.Time (UTCTime (UTCTime), fromGregorian, secondsToDiffTime) +import Data.Time (UTCTime (UTCTime), fromGregorian, secondsToDiffTime, getCurrentTime) +import Rel8.Expr.Time (now) +import Control.Monad.IO.Class (MonadIO(liftIO)) newtype PipelineId = PipelineId {toInt64 :: Int64} deriving newtype (DBEq, DBType, Eq, Show, Num, FromJSON, ToJSON, FromHttpApiData) @@ -130,4 +132,34 @@ updatePipeline pId (Pipeline _ newName newType newParams _ newEnabled _ _ _) = pipelineName = newName , pipelineType = newType , pipelineParams = newParams + } + +triggerPipeline :: PipelineId -> UTCTime -> Update Int64 +triggerPipeline pId currTime = + Update + { target = pipelineSchema + , from = pure () + , updateWhere = \_ o -> pipelineId o ==. lit pId + , set = setter + , returning = NumberOfRowsAffected + } + where + setter = \from row -> row { + pipelineTriggerCount = pipelineTriggerCount row + 1 + , pipelineLastTrigger = lit $ Just currTime + , pipelineError = lit Nothing + } + +errorPipeline :: PipelineId -> Text -> Update Int64 +errorPipeline pId msg = + Update + { target = pipelineSchema + , from = pure () + , updateWhere = \_ o -> pipelineId o ==. lit pId + , set = setter + , returning = NumberOfRowsAffected + } + where + setter = \from row -> row { + pipelineError = lit $ Just msg } \ No newline at end of file diff --git a/api/src/Db/Reaction.hs b/api/src/Db/Reaction.hs index a6a61df..b06136d 100644 --- a/api/src/Db/Reaction.hs +++ b/api/src/Db/Reaction.hs @@ -47,8 +47,9 @@ import Rel8 import Core.Reaction import Data.Functor.Identity (Identity) -import Db.Pipeline (PipelineId (PipelineId), pipelineSchema, Pipeline (pipelineId), getPipelineById, getPipelineByUserId) -import Core.User (UserId) +import Db.Pipeline (PipelineId (PipelineId), pipelineSchema, Pipeline (pipelineId, pipelineUserId), getPipelineById, getPipelineByUserId) +import Core.User (UserId, User (User)) +import Db.User (getUserById, UserDB) newtype ReactionId = ReactionId {toInt64 :: Int64} deriving newtype (DBEq, DBType, Eq, Show, Num, FromJSON, ToJSON) @@ -114,17 +115,19 @@ reactionsForPipeline pipeline = do where_ $ reactionPipelineId reaction ==. pipelineId pipeline return reaction -getWorkflows :: Query (Pipeline Expr, ListTable Expr (Reaction Expr)) +getWorkflows :: Query (Pipeline Expr, ListTable Expr (Reaction Expr), UserDB Expr) getWorkflows = do pipeline <- each pipelineSchema reactions <- many $ reactionsForPipeline pipeline - return (pipeline, reactions) + user <- getUserById $ pipelineUserId pipeline + return (pipeline, reactions, user) -getWorkflow :: PipelineId -> Query (Pipeline Expr, ListTable Expr (Reaction Expr)) +getWorkflow :: PipelineId -> Query (Pipeline Expr, ListTable Expr (Reaction Expr), UserDB Expr) getWorkflow pId = do pipeline <- getPipelineById pId reactions <- many $ reactionsForPipeline pipeline - return (pipeline, reactions) + user <- getUserById $ pipelineUserId pipeline + return (pipeline, reactions, user) getWorkflowsByUser :: UserId -> Query (Pipeline Expr, ListTable Expr (Reaction Expr)) getWorkflowsByUser uid = do diff --git a/api/src/Db/User.hs b/api/src/Db/User.hs index 75cf47e..38179e3 100644 --- a/api/src/Db/User.hs +++ b/api/src/Db/User.hs @@ -63,10 +63,10 @@ userSchema = selectAllUser :: Query (UserDB Expr) selectAllUser = each userSchema -getUserById :: UserId -> Query (UserDB Expr) +getUserById :: Expr UserId -> Query (UserDB Expr) getUserById uid = do u <- selectAllUser - where_ $ userDBId u ==. lit uid + where_ $ userDBId u ==. uid return u getUserByName :: Text -> Query (UserDB Expr) @@ -100,7 +100,7 @@ insertUser (UserDB id name password slug _) = } getUserTokensById :: UserId -> Query (Expr [ExternalToken]) -getUserTokensById uid = externalTokens <$> getUserById uid +getUserTokensById uid = externalTokens <$> getUserById (lit uid) changeTokens :: [ExternalToken] -> ExternalToken -> [ExternalToken] changeTokens actual new = do diff --git a/api/src/OIDC.hs b/api/src/OIDC.hs deleted file mode 100644 index 1f98649..0000000 --- a/api/src/OIDC.hs +++ /dev/null @@ -1,11 +0,0 @@ -module OIDC ( - module OIDC.Github, - getOauthTokens, -) where - -import Core.User (ExternalToken, Service (Github)) -import OIDC.Github - -getOauthTokens :: Service -> String -> IO (Maybe ExternalToken) -getOauthTokens Github = getGithubTokens -getOauthTokens _ = \s -> return Nothing diff --git a/api/src/OIDC/Discord.hs b/api/src/OIDC/Discord.hs deleted file mode 100644 index ced720e..0000000 --- a/api/src/OIDC/Discord.hs +++ /dev/null @@ -1,83 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module OIDC.Discord where - -import qualified Data.ByteString.Char8 as B8 -import qualified Data.HashMap.Strict as HM -import qualified Data.Text as T - -import App (AppM) -import Core.User (ExternalToken (ExternalToken), Service (Github)) -import Data.Aeson.Types (Object, Value (String)) -import Data.Text (Text, pack) -import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString) -import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) -import Utils (lookupObj) - -data DiscordOAuth2 = DiscordOAuth2 - { oauthClientId :: String - , oauthClientSecret :: String - , oauthOAuthorizeEndpoint :: String - , oauthAccessTokenEndpoint :: String - , oauthCallback :: String - } - deriving (Show, Eq) - -getDiscordConfig :: IO DiscordOAuth2 -getDiscordConfig = - DiscordOAuth2 - <$> envAsString "DISCORD_CLIENT_ID" "" - <*> envAsString "DISCORD_SECRET" "" - <*> pure "https://github.com/login/oauth/authorize" - <*> pure "https://github.com/login/oauth/access_token" - <*> pure "http://localhost:8080/auth/github/token" - -githubAuthEndpoint :: DiscordOAuth2 -> String -githubAuthEndpoint oa = - concat - [ oauthOAuthorizeEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&response_type=" - , "code" - , "&redirect_uri=" - , oauthCallback oa - ] - -tokenEndpoint :: String -> DiscordOAuth2 -> String -tokenEndpoint code oa = - concat - [ oauthAccessTokenEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&client_secret=" - , oauthClientSecret oa - , "&code=" - , code - ] - -getGithubAuthEndpoint :: IO String -getGithubAuthEndpoint = githubAuthEndpoint <$> getDiscordConfig - --- Step 3. Exchange code for auth token -getGithubTokens :: String -> IO (Maybe ExternalToken) -getGithubTokens code = do - gh <- getDiscordConfig - let endpoint = tokenEndpoint code gh - request' <- parseRequest endpoint - let request = - setRequestMethod "POST" $ - addRequestHeader "Accept" "application/json" $ - setRequestQueryString - [ ("client_id", Just . B8.pack . oauthClientId $ gh) - , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) - , ("code", Just . B8.pack $ code) - ] - $ request' - response <- httpJSONEither request - return $ case (getResponseBody response :: Either JSONException Object) of - Left _ -> Nothing - Right obj -> do - access <- lookupObj obj "access_token" - refresh <- lookupObj obj "refresh_token" - Just $ ExternalToken (pack access) (pack refresh) 0 Github diff --git a/api/src/OIDC/Github.hs b/api/src/OIDC/Github.hs deleted file mode 100644 index 275229b..0000000 --- a/api/src/OIDC/Github.hs +++ /dev/null @@ -1,82 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module OIDC.Github where - -import qualified Data.ByteString.Char8 as B8 -import qualified Data.HashMap.Strict as HM - -import App (AppM) -import Core.User (ExternalToken (ExternalToken), Service (Github)) -import Data.Aeson.Types (Object, Value (String)) -import Data.Text (Text, pack) -import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString) -import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) -import Utils (lookupObj) - -data GithubOAuth2 = GithubOAuth2 - { oauthClientId :: String - , oauthClientSecret :: String - , oauthOAuthorizeEndpoint :: String - , oauthAccessTokenEndpoint :: String - , oauthCallback :: String - } - deriving (Show, Eq) - -getGithubConfig :: IO GithubOAuth2 -getGithubConfig = - GithubOAuth2 - <$> envAsString "GITHUB_CLIENT_ID" "" - <*> envAsString "GITHUB_SECRET" "" - <*> pure "https://github.com/login/oauth/authorize" - <*> pure "https://github.com/login/oauth/access_token" - <*> pure "http://localhost:8080/auth/github/token" - -githubAuthEndpoint :: GithubOAuth2 -> String -githubAuthEndpoint oa = - concat - [ oauthOAuthorizeEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&response_type=" - , "code" - , "&redirect_uri=" - , oauthCallback oa - ] - -tokenEndpoint :: String -> GithubOAuth2 -> String -tokenEndpoint code oa = - concat - [ oauthAccessTokenEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&client_secret=" - , oauthClientSecret oa - , "&code=" - , code - ] - -getGithubAuthEndpoint :: IO String -getGithubAuthEndpoint = githubAuthEndpoint <$> getGithubConfig - --- Step 3. Exchange code for auth token -getGithubTokens :: String -> IO (Maybe ExternalToken) -getGithubTokens code = do - gh <- getGithubConfig - let endpoint = tokenEndpoint code gh - request' <- parseRequest endpoint - let request = - setRequestMethod "POST" $ - addRequestHeader "Accept" "application/json" $ - setRequestQueryString - [ ("client_id", Just . B8.pack . oauthClientId $ gh) - , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) - , ("code", Just . B8.pack $ code) - ] - $ request' - response <- httpJSONEither request - return $ case (getResponseBody response :: Either JSONException Object) of - Left _ -> Nothing - Right obj -> do - access <- lookupObj obj "access_token" - refresh <- lookupObj obj "refresh_token" - Just $ ExternalToken (pack access) (pack refresh) 0 Github diff --git a/api/src/OIDC/Google.hs b/api/src/OIDC/Google.hs deleted file mode 100644 index 5f4803e..0000000 --- a/api/src/OIDC/Google.hs +++ /dev/null @@ -1,64 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module OIDC.Google where - -import qualified Data.ByteString.Char8 as B8 -import qualified Data.HashMap.Strict as HM -import qualified Data.Text as T - -import App (AppM) -import Core.User (ExternalToken (ExternalToken), Service (Github)) -import Data.Aeson.Types (Object, Value (String)) -import Data.Text (Text, pack) -import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString) -import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) -import Utils (lookupObj) - -data GoogleOAuth2 = GoogleOAuth2 - { oauthClientId :: String - , oauthClientSecret :: String - , oauthAccessTokenEndpoint :: String - } - deriving (Show, Eq) - -getGoogleConfig :: IO GoogleOAuth2 -getGoogleConfig = - GoogleOAuth2 - <$> envAsString "GOOGLE_CLIENT_ID" "" - <*> envAsString "GOOGLE_SECRET" "" - <*> pure "https://github.com/login/oauth/access_token" - -tokenEndpoint :: String -> GoogleOAuth2 -> String -tokenEndpoint code oa = - concat - [ oauthAccessTokenEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&client_secret=" - , oauthClientSecret oa - , "&code=" - , code - ] - --- Step 3. Exchange code for auth token -getGithubTokens :: String -> IO (Maybe ExternalToken) -getGithubTokens code = do - gh <- getGoogleConfig - let endpoint = tokenEndpoint code gh - request' <- parseRequest endpoint - let request = - setRequestMethod "POST" $ - addRequestHeader "Accept" "application/json" $ - setRequestQueryString - [ ("client_id", Just . B8.pack . oauthClientId $ gh) - , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) - , ("code", Just . B8.pack $ code) - ] - $ request' - response <- httpJSONEither request - return $ case (getResponseBody response :: Either JSONException Object) of - Left _ -> Nothing - Right obj -> do - access <- lookupObj obj "access_token" - refresh <- lookupObj obj "refresh_token" - Just $ ExternalToken (pack access) (pack refresh) 0 Github diff --git a/api/src/OIDC/Spotify.hs b/api/src/OIDC/Spotify.hs deleted file mode 100644 index 09f53d6..0000000 --- a/api/src/OIDC/Spotify.hs +++ /dev/null @@ -1,83 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module OIDC.Spotify where - -import qualified Data.ByteString.Char8 as B8 -import qualified Data.HashMap.Strict as HM -import qualified Data.Text as T - -import App (AppM) -import Core.User (ExternalToken (ExternalToken), Service (Github)) -import Data.Aeson.Types (Object, Value (String)) -import Data.Text (Text, pack) -import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString) -import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) -import Utils (lookupObj) - -data GithubOAuth2 = GithubOAuth2 - { oauthClientId :: String - , oauthClientSecret :: String - , oauthOAuthorizeEndpoint :: String - , oauthAccessTokenEndpoint :: String - , oauthCallback :: String - } - deriving (Show, Eq) - -getGithubConfig :: IO GithubOAuth2 -getGithubConfig = - GithubOAuth2 - <$> envAsString "GITHUB_CLIENT_ID" "" - <*> envAsString "GITHUB_SECRET" "" - <*> pure "https://github.com/login/oauth/authorize" - <*> pure "https://github.com/login/oauth/access_token" - <*> pure "http://localhost:8080/auth/github/token" - -githubAuthEndpoint :: GithubOAuth2 -> String -githubAuthEndpoint oa = - concat - [ oauthOAuthorizeEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&response_type=" - , "code" - , "&redirect_uri=" - , oauthCallback oa - ] - -tokenEndpoint :: String -> GithubOAuth2 -> String -tokenEndpoint code oa = - concat - [ oauthAccessTokenEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&client_secret=" - , oauthClientSecret oa - , "&code=" - , code - ] - -getGithubAuthEndpoint :: IO String -getGithubAuthEndpoint = githubAuthEndpoint <$> getGithubConfig - --- Step 3. Exchange code for auth token -getGithubTokens :: String -> IO (Maybe ExternalToken) -getGithubTokens code = do - gh <- getGithubConfig - let endpoint = tokenEndpoint code gh - request' <- parseRequest endpoint - let request = - setRequestMethod "POST" $ - addRequestHeader "Accept" "application/json" $ - setRequestQueryString - [ ("client_id", Just . B8.pack . oauthClientId $ gh) - , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) - , ("code", Just . B8.pack $ code) - ] - $ request' - response <- httpJSONEither request - return $ case (getResponseBody response :: Either JSONException Object) of - Left _ -> Nothing - Right obj -> do - access <- lookupObj obj "access_token" - refresh <- lookupObj obj "refresh_token" - Just $ ExternalToken (pack access) (pack refresh) 0 Github diff --git a/api/src/OIDC/Twitter.hs b/api/src/OIDC/Twitter.hs deleted file mode 100644 index bed9c8a..0000000 --- a/api/src/OIDC/Twitter.hs +++ /dev/null @@ -1,83 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} - -module OIDC.Twitter where - -import qualified Data.ByteString.Char8 as B8 -import qualified Data.HashMap.Strict as HM -import qualified Data.Text as T - -import App (AppM) -import Core.User (ExternalToken (ExternalToken), Service (Github)) -import Data.Aeson.Types (Object, Value (String)) -import Data.Text (Text, pack) -import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString) -import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString) -import Utils (lookupObj) - -data GithubOAuth2 = GithubOAuth2 - { oauthClientId :: String - , oauthClientSecret :: String - , oauthOAuthorizeEndpoint :: String - , oauthAccessTokenEndpoint :: String - , oauthCallback :: String - } - deriving (Show, Eq) - -getGithubConfig :: IO GithubOAuth2 -getGithubConfig = - GithubOAuth2 - <$> envAsString "GITHUB_CLIENT_ID" "" - <*> envAsString "GITHUB_SECRET" "" - <*> pure "https://github.com/login/oauth/authorize" - <*> pure "https://github.com/login/oauth/access_token" - <*> pure "http://localhost:8080/auth/github/token" - -githubAuthEndpoint :: GithubOAuth2 -> String -githubAuthEndpoint oa = - concat - [ oauthOAuthorizeEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&response_type=" - , "code" - , "&redirect_uri=" - , oauthCallback oa - ] - -tokenEndpoint :: String -> GithubOAuth2 -> String -tokenEndpoint code oa = - concat - [ oauthAccessTokenEndpoint oa - , "?client_id=" - , oauthClientId oa - , "&client_secret=" - , oauthClientSecret oa - , "&code=" - , code - ] - -getGithubAuthEndpoint :: IO String -getGithubAuthEndpoint = githubAuthEndpoint <$> getGithubConfig - --- Step 3. Exchange code for auth token -getGithubTokens :: String -> IO (Maybe ExternalToken) -getGithubTokens code = do - gh <- getGithubConfig - let endpoint = tokenEndpoint code gh - request' <- parseRequest endpoint - let request = - setRequestMethod "POST" $ - addRequestHeader "Accept" "application/json" $ - setRequestQueryString - [ ("client_id", Just . B8.pack . oauthClientId $ gh) - , ("client_secret", Just . B8.pack . oauthClientSecret $ gh) - , ("code", Just . B8.pack $ code) - ] - $ request' - response <- httpJSONEither request - return $ case (getResponseBody response :: Either JSONException Object) of - Left _ -> Nothing - Right obj -> do - access <- lookupObj obj "access_token" - refresh <- lookupObj obj "refresh_token" - Just $ ExternalToken (pack access) (pack refresh) 0 Github diff --git a/api/src/Repository/Pipeline.hs b/api/src/Repository/Pipeline.hs index 861ce24..6d54ea0 100644 --- a/api/src/Repository/Pipeline.hs +++ b/api/src/Repository/Pipeline.hs @@ -4,10 +4,14 @@ module Repository.Pipeline where import App (AppM) import Data.Functor.Identity (Identity) -import Db.Pipeline (Pipeline (Pipeline), PipelineId, getPipelineById, insertPipeline, getPipelineByUserId, selectAllPipelines) -import Rel8 (insert, limit, select) +import Db.Pipeline (Pipeline (Pipeline), PipelineId, getPipelineById, insertPipeline, getPipelineByUserId, selectAllPipelines, triggerPipeline, errorPipeline) +import Rel8 (insert, limit, select, update, lit) import Repository.Utils (runQuery) import Core.User (UserId(UserId)) +import Data.Int (Int64) +import Control.Monad.IO.Class (MonadIO(liftIO)) +import Data.Time (getCurrentTime) +import Data.Text (Text) getPipelineById' :: PipelineId -> AppM (Pipeline Identity) getPipelineById' pId = do @@ -21,3 +25,11 @@ createPipeline :: Pipeline Identity -> AppM PipelineId createPipeline pipeline = do res <- runQuery (insert $ insertPipeline pipeline) return $ head res + +triggerPipeline' :: PipelineId -> AppM Int64 +triggerPipeline' pId = do + currTime <- liftIO getCurrentTime + runQuery $ update $ triggerPipeline pId currTime + +errorPipeline' :: PipelineId -> Text -> AppM Int64 +errorPipeline' pId msg = runQuery $ update $ errorPipeline pId msg \ No newline at end of file diff --git a/api/src/Repository/Reaction.hs b/api/src/Repository/Reaction.hs index d0c1ad1..7663ada 100644 --- a/api/src/Repository/Reaction.hs +++ b/api/src/Repository/Reaction.hs @@ -14,6 +14,7 @@ import Rel8 (asc, insert, orderBy, select, limit, update, Expr, delete) import Repository.Utils (runQuery) import Core.User (UserId(UserId)) import Data.Int (Int64) +import Db.User (UserDB) createReaction :: Reaction Identity -> AppM ReactionId createReaction reaction = do @@ -23,12 +24,12 @@ createReaction reaction = do getReactionsByPipelineId' :: PipelineId -> AppM [Reaction Identity] getReactionsByPipelineId' pId = runQuery (select $ orderBy (reactionOrder >$< asc) $ getReactionsByPipelineId pId) -getWorkflow' :: PipelineId -> AppM (Pipeline Identity, [Reaction Identity]) +getWorkflow' :: PipelineId -> AppM (Pipeline Identity, [Reaction Identity], UserDB Identity) getWorkflow' pId = do res <- runQuery $ select $ limit 1 $ getWorkflow pId return $ head res -getWorkflows' :: AppM [(Pipeline Identity, [Reaction Identity])] +getWorkflows' :: AppM [(Pipeline Identity, [Reaction Identity], UserDB Identity)] getWorkflows' = runQuery $ select getWorkflows getWorkflowsByUser' :: UserId -> AppM [(Pipeline Identity, [Reaction Identity])] diff --git a/api/src/Repository/User.hs b/api/src/Repository/User.hs index a7f3634..4786462 100644 --- a/api/src/Repository/User.hs +++ b/api/src/Repository/User.hs @@ -3,8 +3,8 @@ module Repository.User where import App (AppM) import Core.User (ExternalToken, UserId) import Data.Text (Text) -import Db.User (User', getUserByName, getUserTokensById, insertUser, selectAllUser, updateUserTokens) -import Rel8 (insert, select, update) +import Db.User (User', getUserByName, getUserTokensById, insertUser, selectAllUser, updateUserTokens, getUserById) +import Rel8 (insert, select, update, limit, lit) import Repository.Utils (runQuery) users :: AppM [User'] @@ -13,6 +13,11 @@ users = runQuery (select selectAllUser) getUserByName' :: Text -> AppM [User'] getUserByName' name = runQuery (select $ getUserByName name) +getUserById' :: UserId -> AppM User' +getUserById' uid = do + res <- runQuery (select $ limit 1 $ getUserById (lit uid)) + return $ head res + createUser :: User' -> AppM [UserId] createUser user = runQuery (insert $ insertUser user) diff --git a/api/src/Utils.hs b/api/src/Utils.hs index 872e018..174ca84 100644 --- a/api/src/Utils.hs +++ b/api/src/Utils.hs @@ -12,29 +12,33 @@ import Db.User (User') import qualified Servant.Auth.Server import Core.User (User, UserId (UserId)) import Data.Text (Text, unpack) -import Data.HashMap.Strict (HashMap, lookup) +import Data.HashMap.Strict (HashMap, lookup, empty) import Data.Functor.Identity (Identity) import Db.Pipeline (Pipeline (Pipeline), PipelineId (PipelineId), pipelineLastTrigger, pipelineTriggerCount, pipelineError, pipelineEnabled, pipelineUserId, pipelineParams, pipelineType, pipelineName, pipelineId) -import Core.Pipeline (PipelineType(TwitterNewPost), PipelineParams (TwitterNewPostP), TwitterNewPostData (TwitterNewPostData)) +import Core.Pipeline (PipelineParams (PipelineParams)) import Data.Time (UTCTime (UTCTime), fromGregorian, secondsToDiffTime) -import Data.Default (Default) +import Data.Default (Default, def) +import Data.Aeson (Value(Number, Object), decode) mapInd :: (a -> Int -> b) -> [a] -> [b] mapInd f l = zipWith f l [0 ..] -lookupObj :: Object -> Text -> Maybe String -lookupObj obj key = case Data.HashMap.Strict.lookup key obj of +lookupObjString :: Object -> Text -> Maybe String +lookupObjString obj key = case Data.HashMap.Strict.lookup key obj of Just (String x) -> Just . unpack $ x _ -> Nothing -defaultPipelineParams :: PipelineParams -defaultPipelineParams = TwitterNewPostP (TwitterNewPostData "") +uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d +uncurry3 f (a, b, c) = f a b c + +instance Default PipelineParams where + def = PipelineParams empty instance Default (Pipeline Identity) where def = Pipeline { pipelineId = PipelineId 1 , pipelineName = "" - , pipelineType = TwitterNewPost - , pipelineParams = defaultPipelineParams + , pipelineType = "" + , pipelineParams = def , pipelineUserId = UserId 1 , pipelineEnabled = True , pipelineError = Nothing diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ac1224e..defde01 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,24 +23,27 @@ services: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_PORT=${POSTGRES_PORT} - WORKER_API_KEY=${WORKER_API_KEY} + - WORKER_URL=${WORKER_URL} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DISCORD_SECRET=${DISCORD_SECRET} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_SECRET=${GITHUB_SECRET} - - TWITTER_API_KEY=${TWITTER_API_KEY} + - TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID} - TWITTER_SECRET=${TWITTER_SECRET} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_SECRET=${GOOGLE_SECRET} - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} - SPOTIFY_SECRET=${SPOTIFY_SECRET} - worker: - build: ./worker - ports: - - "3001:8999" - depends_on: - - "api" - environment: - - YOUTUBE_KEY=${YOUTUBE_KEY} + worker: + build: ./worker + ports: + - "3001:8999" + depends_on: + - "api" + environment: + - YOUTUBE_KEY=${YOUTUBE_KEY} + - WORKER_API_URL=${WORKER_API_URL} + - WORKER_API_KEY=${WORKER_API_KEY} volumes: apk: diff --git a/docker-compose.yml b/docker-compose.yml index 1253e6b..a24586b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,15 +15,26 @@ services: volumes: - apk:/dist front: - build: ./web-app + build: + context: ./web-app + dockerfile: Dockerfile + args: + - API_ROUTE=/api + - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} + - TWITTER_API_KEY=${TWITTER_API_KEY} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} ports: - - "80:80" + - "8081:80" depends_on: - "mobile" + volumes: + - apk:/dist api: build: ./api ports: - - "81:8080" + - "8080:8080" depends_on: - "db" environment: @@ -33,11 +44,12 @@ services: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_PORT=${POSTGRES_PORT} - WORKER_API_KEY=${WORKER_API_KEY} + - WORKER_URL=${WORKER_URL} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DISCORD_SECRET=${DISCORD_SECRET} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_SECRET=${GITHUB_SECRET} - - TWITTER_API_KEY=${TWITTER_API_KEY} + - TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID} - TWITTER_SECRET=${TWITTER_SECRET} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_SECRET=${GOOGLE_SECRET} @@ -51,6 +63,8 @@ services: - "api" environment: - YOUTUBE_KEY=${YOUTUBE_KEY} + - WORKER_API_URL=${WORKER_API_URL} + - WORKER_API_KEY=${WORKER_API_KEY} volumes: apk: diff --git a/web-app/.dockerignore b/web-app/.dockerignore index 40b878d..e3f4a47 100644 --- a/web-app/.dockerignore +++ b/web-app/.dockerignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +Dockerfile \ No newline at end of file diff --git a/web-app/Dockerfile b/web-app/Dockerfile index b5046f7..f58dd20 100644 --- a/web-app/Dockerfile +++ b/web-app/Dockerfile @@ -3,9 +3,28 @@ WORKDIR /webapp COPY package.json . COPY package-lock.json . RUN npm ci + +ARG API_ROUTE +ENV REACT_APP_API_ROUTE=$API_ROUTE + +ARG DISCORD_CLIENT_ID +ENV REACT_APP_DISCORD_CLIENT_ID=$DISCORD_CLIENT_ID + +ARG GITHUB_CLIENT_ID +ENV REACT_APP_GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID + +ARG TWITTER_API_KEY +ENV REACT_APP_TWITTER_API_KEY=$TWITTER_API_KEY + +ARG GOOGLE_CLIENT_ID +ENV REACT_APP_GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID + +ARG SPOTIFY_CLIENT_ID +ENV REACT_APP_SPOTIFY_CLIENT_ID=$SPOTIFY_CLIENT_ID COPY . . RUN npm run build FROM nginx:1.21 COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /webapp/build/ /usr/share/nginx/html \ No newline at end of file +COPY --from=builder /webapp/build/ /usr/share/nginx/html +CMD cp /dist/aeris_android.apk /usr/share/nginx/html/client.apk; nginx -g "daemon off;" \ No newline at end of file diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 23fb7b5..6edba1b 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -26,7 +26,10 @@ "dotenv": "^16.0.0", "http-proxy-middleware": "^2.0.3", "react": "^17.0.2", + "react-dnd": "^15.1.1", + "react-dnd-html5-backend": "^15.1.2", "react-dom": "^17.0.2", + "react-i18next": "^11.15.5", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", "typescript": "^4.5.4", @@ -38,7 +41,6 @@ "@typescript-eslint/parser": "^5.12.1", "eslint": "^8.9.0", "eslint-plugin-only-warn": "^1.0.3", - "prettier": "2.5.1", "ts-loader": "^9.2.6" } }, @@ -3479,6 +3481,21 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "node_modules/@react-dnd/invariant": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.0.tgz", + "integrity": "sha512-keberJRIqPX15IK3SWS/iO1t/kGETiL1oczKrDitAaMnQ+kpHf81l3MrRmFjvfqcnApE+izEvwM6GsyoIcpsVA==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.0.tgz", + "integrity": "sha512-1ELWQdJB2UrCXTKK5cCD9uGLLIwECLIEdttKA255owdpchtXohIjZBTlFJszwYi2ZKe2Do+QvUzsGyGCMNwbdw==" + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", @@ -7159,6 +7176,16 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dnd-core": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.1.tgz", + "integrity": "sha512-Mtj/Sltcx7stVXzeDg4g7roTe/AmzRuIf/FYOxX6F8gULbY54w066BlErBOzQfn9RIJ3gAYLGX7wvVvoBSq7ig==", + "dependencies": { + "@react-dnd/asap": "4.0.0", + "@react-dnd/invariant": "3.0.0", + "redux": "^4.1.1" + } + }, "node_modules/dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -9160,6 +9187,14 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -9300,6 +9335,29 @@ "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", "peer": true }, + "node_modules/i18next": { + "version": "21.6.12", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.12.tgz", + "integrity": "sha512-xlGTPdu2g5PZEUIE6TA1mQ9EIAAv9nMFONzgwAIrKL/KTmYYWufQNGgOmp5Og1PvgUji+6i1whz0rMdsz1qaKw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13963,18 +14021,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", - "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -14344,6 +14390,43 @@ "node": ">=8" } }, + "node_modules/react-dnd": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.1.tgz", + "integrity": "sha512-QLrHtPU08U4c5zop0ANeqrHXaQw2EWLMn8DQoN6/e4eSN/UbB84P49/80Qg0MEF29VLB5vikSoiFh9N8ASNmpQ==", + "dependencies": { + "@react-dnd/invariant": "3.0.0", + "@react-dnd/shallowequal": "3.0.0", + "dnd-core": "15.1.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.2.tgz", + "integrity": "sha512-mem9QbutUF+aA2YC1y47G3ECjnYV/sCYKSnu5Jd7cbg3fLMPAwbnTf/JayYdnCH5l3eg9akD9dQt+cD0UdF8QQ==", + "dependencies": { + "dnd-core": "15.1.1" + } + }, "node_modules/react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -14362,6 +14445,28 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.10.tgz", "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==" }, + "node_modules/react-i18next": { + "version": "11.15.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.15.5.tgz", + "integrity": "sha512-vBWuVEQgrhZrGKpyv8FmJ7Zs5jRQWl794Tte7yzJ0okZqqi3jd6j2pLYNg441WcREsbIOvWdiDXbY7W6E93p1A==", + "dependencies": { + "@babel/runtime": "^7.14.5", + "html-escaper": "^2.0.2", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -14541,6 +14646,14 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -16472,6 +16585,14 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -19675,6 +19796,21 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" }, + "@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "@react-dnd/invariant": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.0.tgz", + "integrity": "sha512-keberJRIqPX15IK3SWS/iO1t/kGETiL1oczKrDitAaMnQ+kpHf81l3MrRmFjvfqcnApE+izEvwM6GsyoIcpsVA==" + }, + "@react-dnd/shallowequal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.0.tgz", + "integrity": "sha512-1ELWQdJB2UrCXTKK5cCD9uGLLIwECLIEdttKA255owdpchtXohIjZBTlFJszwYi2ZKe2Do+QvUzsGyGCMNwbdw==" + }, "@rollup/plugin-babel": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", @@ -22384,6 +22520,16 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "dnd-core": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.1.tgz", + "integrity": "sha512-Mtj/Sltcx7stVXzeDg4g7roTe/AmzRuIf/FYOxX6F8gULbY54w066BlErBOzQfn9RIJ3gAYLGX7wvVvoBSq7ig==", + "requires": { + "@react-dnd/asap": "4.0.0", + "@react-dnd/invariant": "3.0.0", + "redux": "^4.1.1" + } + }, "dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -23853,6 +23999,14 @@ "terser": "^5.10.0" } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, "html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -23950,6 +24104,15 @@ "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", "peer": true }, + "i18next": { + "version": "21.6.12", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.12.tgz", + "integrity": "sha512-xlGTPdu2g5PZEUIE6TA1mQ9EIAAv9nMFONzgwAIrKL/KTmYYWufQNGgOmp5Og1PvgUji+6i1whz0rMdsz1qaKw==", + "peer": true, + "requires": { + "@babel/runtime": "^7.12.0" + } + }, "iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -27210,12 +27373,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, - "prettier": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", - "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "dev": true - }, "pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -27487,6 +27644,26 @@ } } }, + "react-dnd": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.1.tgz", + "integrity": "sha512-QLrHtPU08U4c5zop0ANeqrHXaQw2EWLMn8DQoN6/e4eSN/UbB84P49/80Qg0MEF29VLB5vikSoiFh9N8ASNmpQ==", + "requires": { + "@react-dnd/invariant": "3.0.0", + "@react-dnd/shallowequal": "3.0.0", + "dnd-core": "15.1.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.2.tgz", + "integrity": "sha512-mem9QbutUF+aA2YC1y47G3ECjnYV/sCYKSnu5Jd7cbg3fLMPAwbnTf/JayYdnCH5l3eg9akD9dQt+cD0UdF8QQ==", + "requires": { + "dnd-core": "15.1.1" + } + }, "react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -27502,6 +27679,16 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.10.tgz", "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==" }, + "react-i18next": { + "version": "11.15.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.15.5.tgz", + "integrity": "sha512-vBWuVEQgrhZrGKpyv8FmJ7Zs5jRQWl794Tte7yzJ0okZqqi3jd6j2pLYNg441WcREsbIOvWdiDXbY7W6E93p1A==", + "requires": { + "@babel/runtime": "^7.14.5", + "html-escaper": "^2.0.2", + "html-parse-stringify": "^3.0.1" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -27637,6 +27824,14 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -29062,6 +29257,11 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/web-app/package.json b/web-app/package.json index ba6d26b..f9df674 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -22,7 +22,10 @@ "dotenv": "^16.0.0", "http-proxy-middleware": "^2.0.3", "react": "^17.0.2", + "react-dnd": "^15.1.1", + "react-dnd-html5-backend": "^15.1.2", "react-dom": "^17.0.2", + "react-i18next": "^11.15.5", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", "typescript": "^4.5.4", diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index ef39555..7f353d4 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -1,93 +1,8 @@ -import type { PipelineBoxProps } from "./components/Pipelines/PipelineBox"; -import { GenericButtonProps } from "./components/GenericButton"; -import { Typography, Box, Button } from "@mui/material"; -import type { ImageProps } from "./components/types"; -import "./App.css"; +import { Typography, Box, Button, ButtonGroup } from "@mui/material"; import { useNavigate } from "react-router-dom"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; export default function App() { - let svc: ImageProps = { - altText: "youTube", - imageSrc: "https://upload.wikimedia.org/wikipedia/commons/0/09/YouTube_full-color_icon_%282017%29.svg", - }; - let svc2: ImageProps = { - altText: "Spotify", - imageSrc: "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg", - }; - - let data: Array = [ - { - title: "My super action", - statusText: "Last: 2d ago", - service1: svc, - service2: svc2, - }, - { - title: "Lorem ipsum behm uit's long", - statusText: - "Lego Star Wars: The Skywalker Saga is an upcoming Lego-themed action-adventure game developed by Traveller's Tales and published by Warner Bros. Interactive Entertainment. It will be the sixth entry in TT Games' Lego Star Wars series of video games and the successor to Lego Star Wars: The Force", - service1: svc2, - service2: svc, - }, - { - title: "My super action", - statusText: "Last: 2d ago", - service1: svc, - service2: svc2, - }, - { - title: "Lorem ipsum behm uit's long", - statusText: "Vive la france !", - service1: svc2, - service2: svc, - }, - { - title: "My super action", - statusText: "Last: 2d ago", - service1: svc, - service2: svc2, - }, - { - title: "Lorem ipsum behm uit's long", - statusText: "Vive la france !", - service1: svc2, - service2: svc, - }, - { - title: "My super action", - statusText: "Last: 2d ago", - service1: svc, - service2: svc2, - }, - { - title: "Lorem ipsum behm uit's long", - statusText: "Vive la france !", - service1: svc2, - service2: svc, - }, - { - title: "My super action", - statusText: "Last: 2d ago", - service1: svc, - service2: svc2, - }, - ]; - - let actions: Array = [ - { - title: "Une vidéo à été rg erg ergr rgrg publiée", - service: svc, - trailingIcon: , - }, - { - title: "Riz aux oignons", - service: svc2, - trailingIcon: , - }, - ]; - const navigate = useNavigate(); const pushToLogin = () => { @@ -110,14 +25,12 @@ export default function App() { Professional, personnal action-reaction manager
- + diff --git a/web-app/src/components/AREACard.tsx b/web-app/src/components/AREACard.tsx new file mode 100644 index 0000000..7dd407d --- /dev/null +++ b/web-app/src/components/AREACard.tsx @@ -0,0 +1,54 @@ +import { Card, Chip, CardActionArea, CardContent, CardHeader, Typography, Avatar, Grid } from "@mui/material"; + +import { AppAREAType } from "../utils/types"; + +export interface AREACardProps { + AREA: AppAREAType; + onClick?: React.MouseEventHandler; +} + +export const AREACard = ({ AREA, onClick }: AREACardProps) => { + return ( + + + + } + title={{AREA.type}} + subheader={AREA.description} + /> + {Object.keys(AREA.params.contents).length > 0 || Object.keys(AREA.returns).length > 0 ? ( + + + {Object.entries(AREA.params.contents).map((el, idx) => { + return ( + + + + ); + })} + + + + {Object.entries(AREA.returns).map((el, idx) => { + return ( + + + + ); + })} + + + ) : ( +
+ )} +
+
+ ); +}; diff --git a/web-app/src/components/AppBar.tsx b/web-app/src/components/AppBar.tsx index de4dcdb..1470de7 100644 --- a/web-app/src/components/AppBar.tsx +++ b/web-app/src/components/AppBar.tsx @@ -1,28 +1,53 @@ import ElectricalServicesIcon from "@mui/icons-material/ElectricalServices"; +import RefreshIcon from '@mui/icons-material/Refresh'; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; -import Cached from "@mui/icons-material/Cached"; +import Logout from "@mui/icons-material/Logout"; +import Divider from "@mui/material/Divider"; import Toolbar from "@mui/material/Toolbar"; import AppBar from "@mui/material/AppBar"; import Box from "@mui/material/Box"; +import { Button } from "@mui/material"; import React from "react"; +import {Tooltip} from "@mui/material"; +import {useNavigate} from "react-router-dom"; interface AppBarProps { username: string; onClickOnServices?: React.MouseEventHandler; + onClickRefresh?: React.MouseEventHandler; } export type { AppBarProps }; -export default function AerisAppbar({ username, onClickOnServices }: AppBarProps) { +export default function AerisAppbar({ username, onClickOnServices, onClickRefresh }: AppBarProps) { + const navigate = useNavigate(); + return ( - - - + + + + + + + + + + + + + { + document.cookie = "aeris_jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + navigate('/auth'); + }}> + + + + {username} diff --git a/web-app/src/components/DraggableItem.tsx b/web-app/src/components/DraggableItem.tsx new file mode 100644 index 0000000..e1f7301 --- /dev/null +++ b/web-app/src/components/DraggableItem.tsx @@ -0,0 +1,54 @@ +import React, { useRef } from "react"; + +export interface DraggableItemProps { + index: number; + moveItem: any; + children: React.ReactNode; +} + +/* + +export const DraggableItem = ({ children, index, moveItem }: DraggableItemProps) => { + // useDrag - the list item is draggable + const [{ isDragging }, dragRef] = useDrag({ + type: "item", + item: { index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + // useDrop - the list item is also a drop area + const [spec, dropRef] = useDrop({ + accept: "item", + hover: (item, monitor) => { + const dragIndex = item.index; + const hoverIndex = index; + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const hoverActualY = monitor.getClientOffset().y - hoverBoundingRect.top; + + // if dragging down, continue only when hover is smaller than middle Y + if (dragIndex < hoverIndex && hoverActualY < hoverMiddleY) return; + // if dragging up, continue only when hover is bigger than middle Y + if (dragIndex > hoverIndex && hoverActualY > hoverMiddleY) return; + + moveListItem(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + + // Join the 2 refs together into one (both draggable and can be dropped on) + const ref = useRef(null); + const dragDropRef = dragRef(dropRef(ref)); + + // Make items being dragged transparent, so it's easier to see where we drop them + const opacity = isDragging ? 0 : 1; + return ( +
+ {text} +
+ ); +}; + +*/ \ No newline at end of file diff --git a/web-app/src/components/PipelineAREACard.tsx b/web-app/src/components/PipelineAREACard.tsx new file mode 100644 index 0000000..7586331 --- /dev/null +++ b/web-app/src/components/PipelineAREACard.tsx @@ -0,0 +1,124 @@ +import { + CardMedia, + Card, + CardHeader, + CardActionArea, + CardContent, + Avatar, + CardActions, + IconButton, + Chip, + TextField, + Grid, + Divider, + Collapse, +} from "@mui/material"; +import { IconButtonProps } from "@mui/material/IconButton"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import { styled } from "@mui/material/styles"; + +import { AppAREAType } from "../utils/types"; +import { useState } from "react"; + +interface ExpandMoreProps extends IconButtonProps { + expand: boolean; +} + +const ExpandMore = styled((props: ExpandMoreProps) => { + const { expand, ...other } = props; + return ; +})(({ theme, expand }) => ({ + transform: !expand ? "rotate(0deg)" : "rotate(180deg)", + marginLeft: "auto", + transition: theme.transitions.create("transform", { + duration: theme.transitions.duration.shortest, + }), +})); + +export interface PipelineAREACardProps { + AREA: AppAREAType; + order: number; + style?: any; + canBeRemoved: boolean; + handleEdit: () => any; + handleDelete: () => any; + onClick: React.MouseEventHandler; +} + +export const PipelineAREACard = ({ + AREA, + order, + handleDelete, + handleEdit, + style, + onClick, + canBeRemoved, +}: PipelineAREACardProps) => { + const [expanded, setExpanded] = useState(false); + + return ( + + + } + action={ + setExpanded(!expanded)} + aria-expanded={expanded} + aria-label="show more"> + + + } + title={AREA.type} + subheader={"#" + order} + /> + + + + + + + + + + + + {Object.entries(AREA.params.contents).map((el, idx) => { + return ( + + + {el[1].value} + + ); + })} + + + + {Object.entries(AREA.returns).map((el, idx) => { + return ( + + + + ); + })} + + + + + ); +}; diff --git a/web-app/src/index.tsx b/web-app/src/index.tsx index 7191a2d..5fee5f8 100644 --- a/web-app/src/index.tsx +++ b/web-app/src/index.tsx @@ -15,7 +15,7 @@ import { ThemeProvider } from "@mui/material"; import theme from "./Aeris.theme"; -export const API_ROUTE = process.env.API_ROUTE ?? ""; +export const API_ROUTE = process.env.REACT_APP_API_ROUTE ?? ""; /** * Creates the routing tree. diff --git a/web-app/src/pages/HomePage.tsx b/web-app/src/pages/HomePage.tsx index ccea0ad..0aec332 100644 --- a/web-app/src/pages/HomePage.tsx +++ b/web-app/src/pages/HomePage.tsx @@ -10,19 +10,13 @@ import { API_ROUTE } from "../"; import { makeStyles } from "@material-ui/core/styles"; import { useState } from "react"; -import { getCookie } from "../utils/utils"; -import { requestCreatePipeline, deletePipeline } from "../utils/CRUDPipeline"; -import { AppPipelineType, ActionTypeEnum, ReactionTypeEnum, AppAREAType } from "../utils/types"; +import { getCookie, deSerializeServices } from "../utils/utils"; +import { requestCreatePipeline, deletePipeline, getAboutJson } from "../utils/CRUDPipeline"; +import { AppAREAType, AppPipelineType } from "../utils/types"; import ServiceSetupModal from "./ServiceSetup"; -import { - AppServices, - ServiceActions, - AppServicesLogos, - AppListActions, - AppListReactions, - AppListPipelines, -} from "../utils/globals"; +import { AppServices, ServiceActions, AppServicesLogos, AppListPipelines, NoAREA } from "../utils/globals"; import AerisAppbar from "../components/AppBar"; +import MenuItem from "@mui/material/MenuItem"; const useStyles = makeStyles((theme) => ({ divHomePage: { @@ -54,20 +48,35 @@ const getUserName = async (): Promise => { return ""; }; +const fetchWorkflows = async (): Promise => { + const response = await fetch(API_ROUTE + 'workflows', { + method: 'GET', + headers: { + Accept: 'application/json', + "Content-Type": 'application/json', + Authorization: 'Bearer ' + getCookie('aeris_jwt') + } + }); + + if (response.ok) { + let json = await response.json(); + return json; + } + console.error("Can't fetch newer workflows"); + return null; +} + export default function HomePage() { const classes = useStyles(); + const [AREAs, setAREAs] = useState>>([]); const [username, setUsername] = useState(""); const [modalMode, setModalMode] = useState(ModalSelection.None); const [pipelineData, setPipelineData] = useState(AppListPipelines[0]); - const [handleSavePipeline, setHandleSavePipeline] = useState(() => {}); - - const homePagePipeLineSave = async (pD: AppPipelineType, creation: boolean) => { - if (await requestCreatePipeline(pD, creation)) { - return setModalMode(ModalSelection.None); - } - }; - - const data: Array = [ + const [handleSavePipeline, setHandleSavePipeline] = useState<(pD: AppPipelineType) => any>( + () => (t: AppPipelineType) => {} + ); + const [pipelineDeletion, setPipelineDeletion] = useState(true); + const [data, setWorkflowsDatas] = useState>(() => [ { title: "My super action", statusText: "Last: 2d ago", @@ -75,32 +84,110 @@ export default function HomePage() { service2: AppServicesLogos["twitter"], onClickCallback: () => { setPipelineData({ + id: 2, name: "louis", - action: AppListActions[0], - reactions: AppListReactions, + action: NoAREA, + reactions: [], data: { enabled: true, error: false, status: "mdr", }, } as AppPipelineType); - setHandleSavePipeline((pD: AppPipelineType) => homePagePipeLineSave(pD, false)); + setHandleSavePipeline(() => (pD: AppPipelineType) => homePagePipeLineSave(pD, false)); setModalMode(ModalSelection.PipelineEdit); + setPipelineDeletion(true); }, }, { title: "Lorem ipsum behm uit's long", statusText: "Lego Star Wars: The Skywalker Saga is an upcoming Lego-themed action-adventure game developed by Traveller's Tales and published by Warner Bros.", - service1: AppServicesLogos["gmail"], + service1: AppServicesLogos["anilist"], service2: AppServicesLogos["twitter"], onClickCallback: () => { setPipelineData(AppListPipelines[0]); - setHandleSavePipeline((pD: AppPipelineType) => homePagePipeLineSave(pD, false)); + setHandleSavePipeline(() => (pD: AppPipelineType) => homePagePipeLineSave(pD, false)); setModalMode(ModalSelection.PipelineEdit); + setPipelineDeletion(true); }, }, - ]; + ]); + + const homePagePipeLineSave = async (pD: AppPipelineType, creation: boolean) => { + if (await requestCreatePipeline(pD, creation)) { + return setModalMode(ModalSelection.None); + } + }; + useEffect(() => { + getAboutJson().then((aboutInfoParam) => { + setAREAs(deSerializeServices(aboutInfoParam?.server?.services ?? [], AppServices)); + }).catch((error) => { + console.warn(error); + setAREAs([[], []]); + }); + }, []); + + const jsonToPipelineData = (data: any): PipelineBoxProps => { + let reactionList:AppAREAType[] = []; + + for (const reaction of data.reactions) { + let newReaction:AppAREAType = { + type: reaction.rType, + params: { + contents: reaction.rParams.contents + }, + returns: {}, + description: '', + service: AppServices[0] //TODO => Get App Service Logo from request + }; + reactionList.push(newReaction); + } + + let pipelineData = { + title: data['action']['name'], + statusText: 'Refresh API Test Workflow', + service1: AppServicesLogos[data['action']['pType'].replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(' ')[0]], + service2: AppServicesLogos['twitter'], //TODO => Fetch service name in reaction[...][rType] for reactions + onClickCallback: () => { + setPipelineData({ + id: data['action']['id'], + name: data['action']['name'], + action: { + type: data['action']['pType'], + params: { + contents: data['action']['pParams']['contents'] + }, + returns: {}, + description: 'Something must have been done.', + service: AppServices[3] //TODO => Make service enum + }, + reactions: reactionList, + data: { + enabled: true, + error: false, + status: "mdr", //TODO => Change status from request + } + } as AppPipelineType); + setHandleSavePipeline(() => (pD: AppPipelineType) => homePagePipeLineSave(pD, false)); + setModalMode(ModalSelection.PipelineEdit); + setPipelineDeletion(true); + } + } as PipelineBoxProps; + + return pipelineData; + } + + const refreshWorkflows = () => { + let workflowArray = fetchWorkflows().then((res) => { + if (res !== null) { + for (const workflow of res) { + let newWorkflow = jsonToPipelineData(workflow); + setWorkflowsDatas((oldArray) => [...oldArray, newWorkflow]); + } + }} + ); + }; useEffect(() => { getUserName().then((username) => { @@ -115,6 +202,7 @@ export default function HomePage() { onClickOnServices={() => { setModalMode(ModalSelection.ServiceSetup); }} + onClickRefresh={refreshWorkflows} /> @@ -122,11 +210,12 @@ export default function HomePage() { isOpen={modalMode === ModalSelection.PipelineEdit} handleClose={() => setModalMode(ModalSelection.None)}> deletePipeline(pD)} handleQuit={() => setModalMode(ModalSelection.None)} /> @@ -147,8 +236,9 @@ export default function HomePage() { }}> { + setPipelineDeletion(false); setPipelineData(AppListPipelines[1]); - setHandleSavePipeline((pD: AppPipelineType) => homePagePipeLineSave(pD, true)); + setHandleSavePipeline(() => (pD: AppPipelineType) => homePagePipeLineSave(pD, true)); setModalMode(ModalSelection.PipelineEdit); }} size="medium" diff --git a/web-app/src/pages/Login/LoginPage.tsx b/web-app/src/pages/Login/LoginPage.tsx index c7b78df..e76dff2 100644 --- a/web-app/src/pages/Login/LoginPage.tsx +++ b/web-app/src/pages/Login/LoginPage.tsx @@ -112,7 +112,8 @@ export default function AuthComponent() { setAuthData((prevState) => { return { ...prevState, isError: false, helperText: "Login successful!" }; }); - navigate("/pipelines"); + window.location.href = "/pipelines"; + //navigate("/pipelines"); } else { setAuthData((prevState) => { return { ...prevState, isError: true, helperText: "Incorrect username or password!" }; diff --git a/web-app/src/pages/PipelineEdit/PipelineEditAREA.tsx b/web-app/src/pages/PipelineEdit/PipelineEditAREA.tsx index 8de1ed7..d14b755 100644 --- a/web-app/src/pages/PipelineEdit/PipelineEditAREA.tsx +++ b/web-app/src/pages/PipelineEdit/PipelineEditAREA.tsx @@ -1,4 +1,4 @@ -import { InputLabel, FormHelperText, Button } from "@mui/material"; +import { InputLabel, FormHelperText, Button, SelectChangeEvent, Divider } from "@mui/material"; import Typography from "@mui/material/Typography"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; @@ -6,63 +6,65 @@ import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { AppServiceType, AppAREAType, AppPipelineType } from "../../utils/types"; -import GenericButton, { GenericButtonProps } from "../../components/GenericButton"; -import PipelineModal from "../../components/Pipelines/PipelineModal"; import PipelineEditParams from "./PipelineEditParams"; import { useState } from "react"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { PipelineEditMode } from "./PipelineEditPage"; +import { AREACard } from "../../components/AREACard"; export interface PipelineEditAREAProps { pipelineData: AppPipelineType; services: Array; AREAs: Array; - setEditMode: any; - setAREA: any; + isActions: boolean; + selectedAREA?: AppAREAType; + setEditMode: (mode: PipelineEditMode) => any; + setAREA: (AREA: AppAREAType) => any; } export default function PipelineEditAREA({ pipelineData, services, AREAs, + selectedAREA, + isActions, setEditMode, setAREA, }: PipelineEditAREAProps) { - const [serviceToShow, setServiceToShow] = useState(services[0].uid); - const [isOPenParamsModal, setIsOpenParamsModal] = useState(false); + const [serviceToShow, setServiceToShow] = useState(selectedAREA?.service.uid ?? services[0].uid); let filteredElements = AREAs.filter((el) => el.service.uid === serviceToShow); - const [AREAData, setAREAData] = useState(); + const [AREAData, setAREAData] = useState(selectedAREA ?? null); return (
- Setup {AREAs[0].isAction ? "Action" : "Réaction"} : + Setup {isActions ? "Action" : "Réaction"} : Service - {filteredElements.length} actions disponibles + + {filteredElements.length} {isActions ? "actions" : "réactions"} disponibles + - - {filteredElements.map((el, elIndex) => { - return ( - - } - onClickCallback={() => { - setAREAData(el); - setIsOpenParamsModal(true); - }} - /> - - ); - })} - +
+ + {filteredElements.map((el, elIndex) => { + return ( + + { + setAREAData(el); + }} + /> + + ); + })} + +
+ + {AREAData === null ? ( + + Sélectionnez une {isActions ? "Action" : "Réaction"} + + ) : ( +
+ {}} + setParams={setAREA} + /> +
+ )}
- - {isOPenParamsModal ? ( - setIsOpenParamsModal(false)}> - setIsOpenParamsModal(false)} - setParams={(AREA: AppAREAType) => setAREA(AREA)} - /> - - ) : ( -
- )}
); } diff --git a/web-app/src/pages/PipelineEdit/PipelineEditPage.tsx b/web-app/src/pages/PipelineEdit/PipelineEditPage.tsx index 779a504..b33095f 100644 --- a/web-app/src/pages/PipelineEdit/PipelineEditPage.tsx +++ b/web-app/src/pages/PipelineEdit/PipelineEditPage.tsx @@ -17,18 +17,22 @@ import PipelineEditAREA from "./PipelineEditAREA"; interface PipelineEditProps { pipelineData: AppPipelineType; - handleSave: any; - handleDelete: any; + handleSave: (pD: AppPipelineType) => any; + handleDelete: (pD: AppPipelineType) => any; services: Array; + disableDeletion: boolean; actions: Array; reactions: Array; - handleQuit: any; + + handleQuit: () => void; } export enum PipelineEditMode { Pipeline, Action, Reactions, + EditAction, + EditReaction, } export default function PipelineEditPage({ @@ -38,12 +42,13 @@ export default function PipelineEditPage({ services, actions, reactions, + disableDeletion, handleQuit, }: PipelineEditProps) { const [mode, setMode] = useState(PipelineEditMode.Pipeline); const [editPipelineData, setEditPipelineData] = useState(pipelineData); const [editActionData, setEditActionData] = useState(pipelineData.action); - const [editReactionsData, setEditReactionsData] = useState>(pipelineData.reactions); + const [editReactionData, setEditReactionData] = useState(); const [editReactionIndex, setEditReactionIndex] = useState(0); switch (mode) { @@ -51,7 +56,40 @@ export default function PipelineEditPage({ case PipelineEditMode.Pipeline: return ( setEditPipelineData({ + ...editPipelineData, + name: newTtitle + })} + handleEditPipelineMetaData={(name, enabled) => { + setEditPipelineData({ + ...editPipelineData, + name: name, + data: { + ...editPipelineData.data, + enabled: enabled + } + }) + } + } + handleEditAction={(action) => { + setEditActionData(action); + setMode(PipelineEditMode.EditAction); + }} + handleEditReaction={(reaction, index) => { + setEditReactionIndex(index); + setEditReactionData(reaction); + setMode(PipelineEditMode.EditReaction); + }} + handleDeleteReaction={(reaction, index) => { + let reactionsTmp = editPipelineData.reactions; + reactionsTmp.splice(index, 1); + setEditPipelineData({ + ...editPipelineData, + reactions: reactionsTmp, + }); + }} setEditMode={setMode} handleSave={handleSave} handleDelete={handleDelete} @@ -63,6 +101,7 @@ export default function PipelineEditPage({ { setEditPipelineData({ ...editPipelineData, @@ -79,6 +118,7 @@ export default function PipelineEditPage({ { let reactionsTmp = editPipelineData.reactions; reactionsTmp[editReactionIndex] = AREA; @@ -92,5 +132,43 @@ export default function PipelineEditPage({ AREAs={reactions} /> ); + case PipelineEditMode.EditAction: + return ( + { + setEditPipelineData({ + ...editPipelineData, + action: AREA, + }); + setMode(PipelineEditMode.Pipeline); + }} + selectedAREA={editActionData} + services={services} + AREAs={actions} + /> + ); + case PipelineEditMode.EditReaction: + return ( + { + let reactionsTmp = editPipelineData.reactions; + reactionsTmp[editReactionIndex] = AREA; + setEditPipelineData({ + ...editPipelineData, + reactions: reactionsTmp, + }); + setMode(PipelineEditMode.Pipeline); + }} + selectedAREA={editReactionData} + services={services} + AREAs={reactions} + /> + ); } } diff --git a/web-app/src/pages/PipelineEdit/PipelineEditParams.tsx b/web-app/src/pages/PipelineEdit/PipelineEditParams.tsx index a9b9515..7b42066 100644 --- a/web-app/src/pages/PipelineEdit/PipelineEditParams.tsx +++ b/web-app/src/pages/PipelineEdit/PipelineEditParams.tsx @@ -1,45 +1,51 @@ import LoadingButton from "@mui/lab/LoadingButton"; -import Typography from "@mui/material/Typography"; -import { Grid, TextField } from "@mui/material"; +import { Grid, TextField, Typography, Stack } from "@mui/material"; import { Save } from "@mui/icons-material"; import Box from "@mui/material/Box"; -import { AppPipelineType, AppAREAType } from "../../utils/types"; +import { AppPipelineType, AppAREAType, ParamsType } from "../../utils/types"; +import { useState } from "react"; interface PipelineEditParamsProps { pipelineData: AppPipelineType; AREA: AppAREAType; - setParams: any; - handleQuit: any; + setParams: (area: AppAREAType) => any; + handleQuit: () => any; } export default function PipelineEditParams({ pipelineData, AREA, setParams }: PipelineEditParamsProps) { + const [formData, setFormData] = useState<{ [key: string]: ParamsType }>({}); + return (
- - - '{AREA.type}' Parameters - - - - {Object.entries(AREA.params.contents).map((param) => { + + '{AREA.type}' Parameters + + + {Object.entries(AREA.params.contents).map((param, key) => { return ( { + let paramToSave = formData; + + paramToSave[param[0]] = { + ...AREA.params.contents[param[0]], + value: e.target.value, + }; + setFormData(paramToSave); + }} variant="standard" /> ); })} - + + setParams({ ...AREA, + params: { + contents: formData, + }, }) } variant="contained"> Save - +
); } diff --git a/web-app/src/pages/PipelineEdit/PipelineEditPipeline.tsx b/web-app/src/pages/PipelineEdit/PipelineEditPipeline.tsx index 7b7c0bd..0450413 100644 --- a/web-app/src/pages/PipelineEdit/PipelineEditPipeline.tsx +++ b/web-app/src/pages/PipelineEdit/PipelineEditPipeline.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { AppPipelineType } from "../../utils/types"; +import { AppAREAType, AppPipelineType } from "../../utils/types"; import { Box, Switch, @@ -9,42 +9,60 @@ import { FormGroup, FormControlLabel, Button, + Tooltip, ButtonGroup, + IconButton, + TextField, } from "@mui/material"; -import GenericButton, { GenericButtonProps } from "../../components/GenericButton"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import AddBoxIcon from "@mui/icons-material/AddBox"; import DeleteIcon from "@mui/icons-material/Delete"; +import CloseIcon from "@mui/icons-material/Close"; +import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; import LoadingButton from "@mui/lab/LoadingButton"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { PipelineEditMode } from "./PipelineEditPage"; -import { getCookie, PipeLineHostToApi } from "../../utils/utils"; -import { API_ROUTE } from "../.."; -import { Keyboard } from "@mui/icons-material"; +import { PipelineAREACard } from "../../components/PipelineAREACard"; +import { NoAREA } from "../../utils/globals"; +import { title } from "process"; interface PipelineEditPipelineProps { pipelineData: AppPipelineType; - handleDelete: any; - handleSave: any; - setEditMode: any; + handleEditPipelineMetaData: (name: string, enblaed: boolean) => any; + handleEditAction: (action: AppAREAType) => any; + handleEditReaction: (reaction: AppAREAType, index: number) => any; + handleDeleteReaction: (reaction: AppAREAType, index: number) => any; + handleDelete: (pD: AppPipelineType) => any; + handleSave: (pD: AppPipelineType) => any; + handleEditPipelineTitle: (newTtitle: string) => any; + setEditMode: (mode: PipelineEditMode) => any; setEditReactionIndex: any; + disableDeletion: boolean; } export default function PipelineEditPipeline({ pipelineData, + handleEditReaction, + handleEditPipelineMetaData, + handleEditAction, + handleDeleteReaction, + handleEditPipelineTitle, handleDelete, handleSave, setEditMode, + disableDeletion, setEditReactionIndex, }: PipelineEditPipelineProps) { + const [titleEditMode, setTitleEditMode] = useState(false); + const [titlePipelineEditValue, setTitlePipelineEditValue] = useState(pipelineData.name); return (
- - {pipelineData.name} - +
+ setTitleEditMode(!titleEditMode)}> + {titleEditMode ? : } + + {titleEditMode ? ( + setTitlePipelineEditValue(e.target.value)} + onKeyPress={(e) => { + if (e.key === "Enter") { + handleEditPipelineTitle(titlePipelineEditValue); + setTitleEditMode(false); + } + }} + variant="standard" + defaultValue={pipelineData.name} + /> + ) : ( + + {pipelineData.name} + + )} +
- } color="secondary" label="Activée" /> + handleEditPipelineMetaData(pipelineData.name, e.target.checked)} + /> + } + label="Activée" + /> @@ -84,12 +128,29 @@ export default function PipelineEditPipeline({ justifyContent="flex-start" alignItems="flex-start"> - setEditMode(PipelineEditMode.Action)} - trailingIcon={} - /> + {pipelineData.action.type === NoAREA.type ? ( + + + + ) : ( + { + setEditMode(PipelineEditMode.Action); + }} + handleDelete={() => {}} + AREA={pipelineData.action} + style={{ width: "25vw" }} + order={0} + onClick={() => {}} + /> + )} @@ -98,38 +159,62 @@ export default function PipelineEditPipeline({
- {pipelineData.reactions.map((el, index) => ( - - { + {pipelineData.reactions.length === 0 && ( + + + + )} + {pipelineData.reactions.map((el, index, arr) => ( + + 1} + handleEdit={() => { + setEditMode(PipelineEditMode.EditReaction); + handleEditReaction(el, index); }} - trailingIcon={} + handleDelete={() => { + handleDeleteReaction(el, index); + }} + AREA={el} + order={index + 1} + onClick={() => {}} /> ))}
- } - variant="contained"> - Ajouter une réaction - + {pipelineData.reactions.length !== 0 && ( + { + setEditMode(PipelineEditMode.Reactions); + setEditReactionIndex(pipelineData.reactions.length); + }} + startIcon={} + variant="contained"> + Ajouter une réaction + + )} } loadingPosition="start" onClick={() => handleDelete(pipelineData)} + disabled={disableDeletion} loading={false}> Supprimer la pipeline @@ -146,6 +232,7 @@ export default function PipelineEditPipeline({