XXPS2UOWSWD2YXHHY2IDBR6KJ7WGG7XNTSLWUDCIF5CAP2EIAJNQC -----BEGIN PRIVATE KEY-----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-----END PRIVATE KEY-----
import Test.Hspecimport qualified Signaturesmain :: IO ()main = hspec $ doSignatures.spec
-- | Tests HTTP signatures.module Signatures (spec) whereimport Data.Time (UTCTime (..))import Effectfulimport Effectful.Fail (runFailIO)import Puppy.Crypto.RSA (readKeyFile)import Puppy.Protocol.ActivityStreams (Id (..))import Puppy.Protocol.HTTP.Signatureimport Test.Hspec-- TODO: Make tests with official test suite https://github.com/w3c-ccg/http-signatures-test-suitespec :: Specspec = dolet unwrap :: Either String a -> IO aunwrap = either fail pureit "generates a valid HTTP signature" $ do-- Test case validated through https://dinochiesa.github.io/httpsig/testKey <- runEff $ runFailIO $ readKeyFile "test/testKey"let date = UTCTime (read "2137-12-13") 0params = GET { host = "example.com", path = "/.well-known/fwibble", date }signer = Signer (Id "https://example.com/key") testKeysig <- unwrap (makeSignature signer params)fmtSignature sig `shouldBe` "keyId=\"https://example.com/key\", algorithm=\"rsa-sha256\", headers=\"(request-target) date host\", signature=\"fafUl+kuJItuFlrqRpzfqAB9Dxf/eLZY6i/jUBlME7nSb4ZRZ6EM0/3/wY1F+JThCUxJTNlW1/tnLzvB4lY33sEFjFQhCe6dKhLL8a3crMec0PDhRADW3kmivzGV+e4snMUG8R9+gQECzaBFSYpB1BE+yVYHHaBiHpqok7P35yEJ9ZJicaOT4bhv8nCMzVau8lj6e2nJra9ucGqIph5sX0wMVxwzlt4rlKUctFsvyjqNKI2Os/OwTrV1VhcvylXiUxWWztJiguQf9wOLgCG1G3O4PKpbinJ7bLkKqSSPJZUeWhCmHNhMYLXBEg0NR7OnDSOAHaNJ3/83EYhY1G3YlmJZ2SZnaGFqUVVySKec2IkbWCzoMeuxLYP16jcQvBU/sCiuSnBQTpdUh0uZsk75+HDz9uFosi8j/e+FZxKEk1ljWt6osUqq/jLjVKPecYBnUSNkhjEUPv4K/UnaV2uLtRvBfqiF/5nKLDtTp/rRdaDxQCdVTGnryVJ0lqsjgZ06SmRuqNJQf274OoEZJSQixulji+0ikpXBp/PeoFnfipEcSC68sKokAFAs9WjF8kq3CsoxkyQsMLziQTSalSseGJP107H7Pqw1fNlaR4zGL8lJo+TNHS4gkKglaMjfoAEMGZKziOQ/vCbvFFIsSqvbuvlF5hRmP1EvNb/mHvr3YJxIuznRkTshMSQ86L+307bxgN7+/05RPWVbWi3FZ5fEKmf8zR7pI2KqY6xdYIV93Cn1AVOLNluHluzd3p2fnlxp2iPu33hB1p1O6xvrE/p5sAA79h+S0f5N2P1bt2jIMGMv345IF6Ra68ep646eNXgyrVNgp2ke+oDdemCzFSDp5Dxsw/16MUbESpwMXPpwRuxD+LdNRrNQFxTIXxrDC2/4LlcEWNGbEOGR2K/xVp3ObZfFYWstX9qOLBsVUCZPOCBHbKKECq81539ePuHm++3A9UNUy8hIfvldRd9Hy977invihhMl77Y699mrcf1zyrES0XNyYsmhlqWJBkO/ELpzL3h28wiL90WhZXNlo/t4U2RDA2b6iQhepDZgqtX9RiLFEhdi7lMYMKSEJc3T7Ia0IuW98eJwYeFL9/2t//BLXUe7ASfLL9nrlcWbBbgQ2CEpQXiO78ypllWf8l03Ki+GT/ptLj1nZSeSMvuLYYwDPEwiw/7Sc3JTnNxhcZVG70f1GjRbLJTI+VdydAbhtdKk6IB/yH57+Q/r/NJof+bmHb+FOD5Z8EQql3MQAQ8zfuaQra2XklH2DgfwMun3++fvh4uAfFUSKbBywHgmzKIVDw1HB9NRx4WfRNlXFVzK5BWAsmM9hHASC4w7vV9wWL19ayGvi7aOALgsHeWCacyOaw==\""
{-# LANGUAGE NoFieldSelectors #-}{-# LANGUAGE StrictData #-}-- | Internal data types for representing local data in the database.module Puppy.Types (Channel (..),Post (..),User (..),ChannelSettings (..)) whereimport Data.UUID (UUID)import Data.Text (Text)import qualified Crypto.PubKey.RSA as RSAdata User= User {userId :: UUID,userName :: Text,mainChannel :: UUID}data Channel= Channel {channelId :: UUID,linkedActorId :: Text,privateKeyPem :: RSA.PrivateKey,settings :: ChannelSettings}data Post= Post {postId :: UUID,linkedDocumentId :: Text}data ChannelSettings= ChannelSettings {autoAcceptFollows :: Bool}
{-# LANGUAGE TemplateHaskell #-}-- | An effect for {en,de}queueing tasks of a particular type-- through concurrent channels.module Puppy.TaskQueue (-- * A basic effect for talking to other threadsTaskQueue,enqueue,dequeue,runTaskQueue,-- * Task typesPerformTask (..),DeliverTask (..),) whereimport Effectfulimport Effectful.Concurrent.Chanimport Effectful.Dispatch.Dynamicimport Effectful.TH (makeEffect)import Puppy.Types (Channel)import qualified Puppy.Protocol.ActivityStreams as AS-- | Schedule work through a task queue.data TaskQueue work :: Effect whereEnqueue :: work -> TaskQueue work m ()Dequeue :: TaskQueue work m workmakeEffect ''TaskQueuerunTaskQueue:: (Concurrent :> es)=> Chan task-> Eff (TaskQueue task : es) a-> Eff es arunTaskQueue chan = interpret $ \_ -> \caseEnqueue v -> writeChan chan vDequeue -> readChan chandata PerformTask= PerformTask (AS.Activity AS.Object) Channeldata DeliverTask= DeliverTask (AS.Activity AS.Object) Channel
{-# LANGUAGE DerivingStrategies #-}-- | Code and utilities related to the WebFinger protocol.module Puppy.Protocol.WebFinger (JRD,Handle (..),parseHandle,lookupLocal,queryRemote,) whereimport Data.Aeson ((.=))import Data.Text (Text)import Effectfulimport Puppy.Loggingimport qualified Data.Aeson as JSONimport qualified Data.Text as T-- | A JSON Resource Descriptor.newtype JRD = JRD JSON.Valuederiving newtype (JSON.FromJSON, JSON.ToJSON)data Handle= Handle { actorName :: Text, nodeName :: Text }parseHandle :: Text -> Maybe HandleparseHandle = check . build . preprocesswherecheck h = case h ofHandle "" _ -> NothingHandle _ "" -> Nothinghandle -> Just handlepreprocess = T.dropWhile (== '@')build = Handle<$> T.takeWhile (/= '@')<*> T.takeWhileEnd (/= '@')lookupLocal:: (Log :> es)=> Handle-> Eff es (Maybe JRD)lookupLocal (Handle actorName nodeName) = scope "lookupLocal" $ dopure (Just $ JRD $ JSON.object ["subject" .= JSON.String ("acct:" <> actorName <> "@" <> nodeName),"links" .= [JSON.object ["rel" .= JSON.String "self","type" .= JSON.String "application/activity+json","href" .= JSON.String ("https://" <> nodeName <> "/ap/a/" <> actorName)]]])queryRemote :: Handle -> Eff es (Maybe JRD)queryRemote = undefined
module Puppy.Protocol.HTTP (HTTP,get,post,setKey,Response (..),runHTTP) whereimport Control.Exception (catch)import Control.Monad (when)import Data.Functor ((<&>))import Data.Maybe (fromJust)import Data.Text (Text)import Data.Time.Clock (getCurrentTime)import Effectfulimport Effectful.Dispatch.Dynamicimport Effectful.Fail (Fail)import Effectful.Reader.Static (Reader, ask, runReader, local)import Network.HTTP.Conduit (httpLbs, responseBody, Manager, Request (..), RequestBody (RequestBodyBS), responseStatus, parseRequest, HttpException)import Puppy.Config (version)import Puppy.Loggingimport qualified Data.Aeson as JSONimport qualified Data.ByteString as BSimport qualified Data.ByteString.Lazy as LBSimport qualified Data.Text as Timport qualified Data.Text.Encoding as Timport qualified Puppy.Protocol.HTTP.Signature as Sdata Response= Response {statusCode :: Int,body :: BS.ByteString}-- * Effect-- | An effect for performing actions related to the HTTP protocol, such as executing-- requests.data HTTP :: Effect whereSetKey :: S.Signer -> m a -> HTTP m aPost :: LBS.ByteString -> Text -> HTTP m ResponseGet :: Text -> HTTP m Responsetype instance DispatchOf HTTP = 'Dynamic-- * Actions-- | POST a JSON payload to a URL.post :: (HTTP :> es, JSON.ToJSON body) => body -> Text -> Eff es Responsepost body = send . Post (JSON.encode body)-- | GET a response from a URL.get :: (HTTP :> es) => Text -> Eff es Responseget = send . GetsetKey :: (S.KeySource s, HTTP :> es) => s -> Eff es a -> Eff es asetKey key rest = send (SetKey (S.toSigner key) rest)-- * HandlersrunHTTP:: (IOE :> es, Log :> es, Fail :> es, Reader Manager :> es)=> (S.KeySource k)=> k-> Eff (HTTP : es) a-> Eff es arunHTTP src = reinterpret (runReader (S.toSigner src)) $ \env -> \caseSetKey key rest -> localSeqUnlift env $ \unlift ->local (const key) (unlift rest)Post payload url -> dosigner <- askreq <- withSeqEffToIO (parse url)ask >>= execute signer(req { method = "POST" })(Just $ BS.toStrict payload)Get url -> dosigner <- askreq <- withSeqEffToIO (parse url)ask >>= execute signer req Nothingwhereparse :: (IOE :> xs, Fail :> xs)=> Text-> (forall r. Eff xs r -> IO r)-> IO Requestparse url unlift = catch(parseRequest (T.unpack url))(\(exc :: HttpException) -> unlift (fail ("HTTP exception: " <> show exc)))-- * Helpers-- Actually execute an HTTP request.execute:: (IOE :> es, Log :> es, Fail :> es)=> S.Signer-> Request-- ^ URL-> Maybe BS.ByteString-- ^ Request body-> Manager-- ^ HTTP client-> Eff es Responseexecute signer raw body client = donow <- liftIO getCurrentTimelet attachBody r bytes = r {requestBody = RequestBodyBS bytes,requestHeaders = [("content-type", "application/activity+json"),("digest", S.makeDigestHeader bytes)]}unsigned | Nothing <- body = raw| Just bytes <- body = raw `attachBody` bytes-- Propagate a failureunwrap = either fail puremkParams | "POST" <- raw.method = S.POST (fromJust body)| otherwise = S.GETparams = mkParams raw.host raw.path nowreq <- unwrap $ S.makeSignature signer params <&> \sig ->(unsigned {requestHeaders= ("accept", "application/activity+json"): ("date", S.fmtDateHeader now): ("signature", S.fmtSignature sig): ("user-agent", "ActivityPuppy/" <> version): unsigned.requestHeaders})res <- httpLbs req clientwhen (fromEnum (responseStatus res) `elem` [400 .. 599]) $ dowarn ("Error response code: " <> T.pack (show (responseStatus res)))debug ("Error response body: " <> T.decodeUtf8 (BS.toStrict (responseBody res)))return (Response {body = BS.toStrict (responseBody res),statusCode = fromEnum (responseStatus res)})
{-# LANGUAGE DerivingStrategies #-}{-# LANGUAGE DuplicateRecordFields #-}{-# LANGUAGE NoFieldSelectors #-}{-# LANGUAGE StrictData #-}{-# OPTIONS_GHC -Wno-type-defaults #-}-- | Implementation of the HTTP Signatures specification for providing cryptographic-- authentication of network requests.module Puppy.Protocol.HTTP.Signature (KeySource (..),makeSignature,makeDigestHeader,fmtDateHeader,fmtSignature,Server (..),Signer (..),Signature (..),Algorithm (..),Request (..),) whereimport Crypto.Hash.Algorithms (SHA256(..))import Data.Bifunctor (Bifunctor(..))import Data.ByteString.Base64 (encodeBase64)import Data.Foldable (Foldable(..))import Data.Function ((&))import Data.Functor ((<&>))import Data.List (intersperse)import Data.String (IsString (..))import Data.Time.Formatimport Data.Time (UTCTime)import Puppy.Config (ServerConfig (..))import Puppy.Cryptoimport Puppy.Protocol.ActivityStreams (Id (..))import Puppy.Types (Channel (..))import qualified Crypto.PubKey.RSA as RSAimport qualified Crypto.PubKey.RSA.PKCS15 as PKCS15import qualified Data.ByteString as BSimport qualified Data.Text as Timport qualified Data.Text.Encoding as T-- | A simplified representation of a network request containing *just* the information-- we need to generate a signature header.data Request= POST {body :: BS.ByteString,host :: BS.ByteString,path :: BS.ByteString,date :: UTCTime}| GET {host :: BS.ByteString,path :: BS.ByteString,date :: UTCTime}-- | The result of signing a `Request`. Use `fmtSignature` to turn it into a header value.data Signature= Signature {-- | Where can we find the public key?keyId :: Id,-- | The signing algorithm used to construct the signaturealgorithm :: Algorithm,-- | The names of the components of the signing stringcomponents :: [BS.ByteString],-- | Base64-encoded cryptographic signaturesignature :: T.Text}data Algorithm= RSA_SHA256| HS2019instance Show Algorithm whereshow RSA_SHA256 = "rsa-sha256"show HS2019 = "hs2019"makeSignature :: Signer -> Request -> Either String SignaturemakeSignature (Signer { privateKey, keyId }) req =let -- Components that are used either waycommon = [ ("(request-target)", fmtTarget req),("date", fmtDateHeader req.date),("host", req.host) ]-- The final set of components.components-- In the case of a POST request, many implementations require a digest of-- the request body to be included in the signature string.| POST { body } <- req = common <> [("digest", makeDigestHeader body)]-- In any other case, signing extra stuff is not required.| otherwise = commonin generateSignature components privateKey keyId-- `first` converts the RSA error to a string to satisfy the API& first showwherefmtTarget (POST { path }) = "post " <> pathfmtTarget (GET { path }) = "get " <> pathgenerateSignature:: [(BS.ByteString, BS.ByteString)]-> RSA.PrivateKey-> Id-> Either RSA.Error SignaturegenerateSignature components key keyId =let -- This is the string we're gonna end up signing. Every component needs to be-- lowercased and separated by newlines.signatureString :: BS.ByteStringsignatureString = [ fold [k, ": ", v] | (k,v) <- components ]& intersperse "\n"& foldsign = PKCS15.signNothing -- The blinder, empty here because it's not needed (afaik)(Just SHA256) -- Hashing algorithm, SHA-256 should be pretty widely supportedkeysignatureStringstructure signature = Signature { signature, algorithm = RSA_SHA256, components = map fst components, keyId }in sign <&> encodeBase64<&> structuremakeDigestHeader :: BS.ByteString -> BS.ByteStringmakeDigestHeader = ("sha-256=" <>) . base64 . sha256-- | Format a date header from a time.fmtDateHeader :: UTCTime -> BS.ByteStringfmtDateHeader now -- RFC-822 formatting, forcing GMT time zone= formatTime defaultTimeLocale "%a, %d %b %Y %H:%M:%S GMT" now& T.pack& T.encodeUtf8-- | Format an HTTP signature so it can be stuck in a headerfmtSignature :: Signature -> BS.ByteStringfmtSignature sig =-- Note: we use `show` here because it adds the quotes around each `show`n-- component, which is what we want here.fromString $ fold $ intersperse ", " ["keyId=" <> show sig.keyId.url,"algorithm=" <> show (show sig.algorithm),"headers=" <> show (fold $ intersperse " " sig.components),"signature=" <> show sig.signature]class KeySource s wheretoSigner :: s -> Signerdata Signer= Signer {keyId :: Id,privateKey :: RSA.PrivateKey}newtype Server = Server ServerConfiginstance KeySource Server wheretoSigner (Server c) = Signer {keyId = Id ("https://" <> c.name <> "/ap/a/server#key"),privateKey = c.serverKey}instance KeySource Channel wheretoSigner c = Signer {keyId = Id (c.linkedActorId <> "#key"),privateKey = c.privateKeyPem}instance KeySource Signer wheretoSigner = id
{-# LANGUAGE DeriveAnyClass #-}{-# LANGUAGE DerivingStrategies #-}{-# LANGUAGE DuplicateRecordFields #-}{-# LANGUAGE NoFieldSelectors #-}{-# LANGUAGE StrictData #-}-- | An incomplete-but-good-enough implementation of the ActivityStreams vocabulary.---- This module contains only what is needed for the ActivityPub implementation (which-- is also technically incomplete).module Puppy.Protocol.ActivityStreams (Activity (..),Actor (..),Document (..),Id (..),Inbox (..),Object,PublicKey (..),Subtype (..),IsObject (..),idToInbox,inboxToId,object,subtype,) whereimport Data.Aeson (FromJSON, ToJSON (..), (.=))import Data.Functor ((<&>), ($>))import Data.Maybe (fromMaybe)import Data.String (IsString)import Data.Text (Text)import Data.Time (UTCTime)import GHC.Generics (Generic)import GHC.Records (HasField (..))import Puppy.Crypto.RSA (encodePublicKey)import qualified Crypto.PubKey.RSA as RSAimport qualified Data.Aeson.Types as JSONimport qualified Data.Text.Encoding as Timport qualified Data.Vector as Vecimport Unsafe.Coerce (unsafeCoerce)newtype Id = Id { url :: Text }deriving newtype (Eq, IsString, ToJSON, FromJSON)newtype Inbox = Inbox { url :: Text }deriving newtype (Eq, IsString, ToJSON, FromJSON)inboxToId :: Inbox -> IdinboxToId = Id . (.url)idToInbox :: Id -> InboxidToInbox = Inbox . (.url)data Activity o= Activity {id :: Id,actor :: Id,object :: o,to :: [Inbox],cc :: [Inbox],time :: Maybe UTCTime,subtype :: Subtype (Activity o)}instance Functor Activity wherefmap f x = x { object = f x.object, subtype = unsafeCoerce x.subtype }data Document= Document {id :: Id,content :: Maybe Text,summary :: Maybe Text,subtype :: Subtype Document}data Actor= Actor {id :: Id,inbox :: Maybe Inbox,outbox :: Maybe Id,followers :: Maybe Id,following :: Maybe Id,accountName :: Text,displayName :: Maybe Text,summary :: Maybe Text,publicKey :: PublicKey,locked :: Bool,subtype :: Subtype Actor}data PublicKey= PublicKey {id :: Id,owner :: Id,publicKeyPem :: RSA.PublicKey}data Object= ActivityO (Activity Object)| DocumentO Document| ActorO Actorclass IsObject x wheretoObject :: x -> ObjectfromObject :: Object -> Maybe xinstance (IsObject o) => IsObject (Activity o) wheretoObject x = ActivityO (x <&> toObject)fromObject = \case(ActivityO x) -> (fromObject x.object) <&> (x $>)_ -> Nothinginstance IsObject Actor wheretoObject = ActorOfromObject = \caseActorO o -> Just o_ -> Nothinginstance IsObject Document wheretoObject = DocumentOfromObject = \caseDocumentO o -> Just o_ -> Nothinginstance IsObject Object wheretoObject = idfromObject = pure-- * Subtypingclass Subtyped a wheredata Subtype asubtype :: a -> Subtype ainstance forall o. Subtyped (Activity o) wheresubtype = (.subtype)data Subtype (Activity o)= Follow| Accept| Reject| Create| Delete| Announce| Like| Undoderiving (Show, Generic, FromJSON, ToJSON)instance Subtyped Document wheresubtype = (.subtype)data Subtype Document= Article| Audio| Video| Image| Notederiving (Show, Generic, FromJSON, ToJSON)instance Subtyped Actor wheresubtype = (.subtype)data Subtype Actor= Person| Service| Application| Organization| Groupderiving (Show, Generic, FromJSON, ToJSON)instance Subtyped Object wheresubtype = \caseActivityO o -> ActivityT o.subtypeDocumentO o -> DocumentT o.subtypeActorO o -> ActorT o.subtypedata Subtype Object= ActivityT (Subtype (Activity Object))| DocumentT (Subtype Document)| ActorT (Subtype Actor)deriving (Show)instance HasField "id" Object Id wheregetField = object (.id) (.id) (.id)instance (ToJSON o) => ToJSON (Activity o) wheretoJSON o = JSON.object (filterNull ["@context" .= context,"id" .= o.id,"to" .= o.to,"cc" .= o.cc,"actor" .= o.actor,"object" .= o.object,"published" .= o.time,"type" .= o.subtype])instance ToJSON Document wheretoJSON o = JSON.object (filterNull ["@context" .= context,"id" .= o.id,"summary" .= o.summary,"content" .= o.content,"type" .= o.subtype])instance ToJSON Actor wheretoJSON o = JSON.object (filterNull ["@context" .= context,"id" .= o.id,"inbox" .= o.inbox,"outbox" .= o.outbox,"followers" .= o.followers,"following" .= o.following,"publicKey" .= o.publicKey,"name" .= fromMaybe o.accountName o.displayName,"summary" .= o.summary,"type" .= o.subtype,"preferredUsername".= o.accountName,"manuallyApprovesFollowers".= o.locked])instance ToJSON PublicKey wheretoJSON o = JSON.object ["id" .= o.id,"publicKeyPem" .= T.decodeUtf8 (encodePublicKey o.publicKeyPem),"owner" .= o.owner]instance ToJSON Object wheretoJSON = object toJSON toJSON toJSONfilterNull :: [JSON.Pair] -> [JSON.Pair]filterNull = filter isNotNullwhereisNotNull (_, JSON.Null) = FalseisNotNull _ = True-- | Case analysis on an `Object`.object :: (Activity Object -> a) -> (Document -> a) -> (Actor -> a) -> Object -> aobject activity document actor = \caseActivityO x -> activity xDocumentO x -> document xActorO x -> actor xcontext :: JSON.Valuecontext = JSON.Array (Vec.fromList [JSON.String "https://www.w3.org/ns/activitystreams"])
{-# LANGUAGE DisambiguateRecordFields #-}-- | Exposes an effect for actions involving the ActivityPub protocol.module Puppy.Protocol.ActivityPub (module Puppy.Protocol.ActivityStreams,ActivityPub,runActivityPub,dereference,perform,deliver,genId,doDeliverTask,doPerformTask,) whereimport Data.Functor ((<&>))import Effectfulimport Effectful.Concurrent.Async (forConcurrently_, Concurrent)import Effectful.Dispatch.Dynamicimport Effectful.Fail (Fail, runFail)import Puppy.Contextimport Puppy.Crypto.RNG (RNG, genBytes)import Puppy.Database (DB)import Puppy.Loggingimport Puppy.Protocol.ActivityStreamsimport Puppy.Protocol.HTTP (HTTP, post, get, Response (..))import Puppy.TaskQueueimport Puppy.Types (Channel (..), ChannelSettings (..))import qualified Data.Aeson as JSONimport qualified Data.ByteString as BSimport qualified Data.ByteString.Base64.URL as BSimport qualified Puppy.Database as DBimport qualified Puppy.Protocol.ActivityPub.Fetch as F-- | An effect for talking to other nodes over ActivityPub.data ActivityPub :: Effect whereResolve :: forall obj m. (F.Dereference obj) => Either JSON.Value Id -> ActivityPub m objPerform :: Activity Object -> Channel -> ActivityPub m ()Deliver :: Activity Object -> Channel -> ActivityPub m ()type instance DispatchOf ActivityPub = 'Dynamic-- | Resolve an `Id` to an ActivityStreams object.resolve :: (ActivityPub :> es, F.Dereference obj) => Id -> Eff es objresolve = send . Resolve . Right-- | Assume that the given `Value` is the JSON representation of `obj` and attempt to parse it.dereference :: (ActivityPub :> es, F.Dereference obj) => JSON.Value -> Eff es objdereference = send . Resolve . Left-- | Schedule a perform task for the given activity, using the given channel for request-- signatures.perform :: (ActivityPub :> es) => Activity Object -> Channel -> Eff es ()perform activity channel = send (Perform activity channel)-- | Schedule a deliver task for the given activity, using the given channel for request-- signatures.deliver :: (ActivityPub :> es) => Activity Object -> Channel -> Eff es ()deliver activity channel = send (Deliver activity channel)-- | Generate an ActivityPub ID.genId :: (ServerInfo :> es, RNG :> es) => Eff es IdgenId = dosuffix <- BS.encodeBase64 <$> genBytes 32localId ("/ap/o/" <> suffix)-- | Run the ActivityPub effect. Object resolution goes through HTTP, whereas the tasks for performing and-- delivering activities are sent to their associated worker threads.runActivityPub:: (TaskQueue DeliverTask :> es,TaskQueue PerformTask :> es,Fail :> es,HTTP :> es,Log :> es) => Eff (ActivityPub : es) a-> Eff es arunActivityPub = interpret $ \_ -> \caseResolve (Left json) -> F.runFetch json F.dereferencerResolve (Right (Id url)) -> dojson <- get url >>= parseJSONF.runFetch json F.dereferencerPerform activity channel -> enqueue (PerformTask activity channel)Deliver activity channel -> enqueue (DeliverTask activity channel)whereparseJSON :: (Fail :> es) => Response -> Eff es JSON.ValueparseJSON (Response { body }) =case JSON.decode (BS.fromStrict body) ofJust value -> pure valueNothing -> fail "Couldn't decode body"doDeliverTask:: (Concurrent :> es,HTTP :> es,Log :> es) => Activity Object-> Eff es ()doDeliverTask activity = scope "deliver" $ dolet payload = preparePayload activity-- TODO: Add retry mechanismforConcurrently_ (activity.to <> activity.cc) $ \(Inbox inbox) -> dodebug ("Delivering to: " <> inbox)runFail (post payload inbox) >>= \caseLeft _ -> warn ("Failed to deliver to " <> inbox)Right resp| resp.statusCode > 299 -> warn "Encountered non-200 status code"| otherwise -> debug "Delivery OK"where-- Prepare a payload by folding the `object` field into just the id of the-- object instead of the entire thing, and then converting the entire thing-- to a JSON value.preparePayload :: Activity Object -> JSON.ValuepreparePayload aty = JSON.toJSON $ aty <&> (.id)doPerformTask:: (ActivityPub :> es,Log :> es,DB :> es,Fail :> es,RNG :> es,ServerInfo :> es) => Activity Object-> Eff es ()doPerformTask a = scope "perform" $ doDB.storeActivity acase a.subtype of-- Handle requests to follow between actors.Follow | Just (followee :: Actor) <- fromObject a.object-> doDB.insertFollowRequest (a.actor, followee.id)DB.getChannelByActorId followee.id >>= \case-- If the channel has follow autoaccept turned on, proceed to automatically accept-- the follow request.Just channel | channel.settings.autoAcceptFollows -> acceptFollow channelJust _ -> debug "Not automatically accepting follow request due to channel settings"Nothing -> debug "Follow request does not target a channel, not doing anything"Accept | Just o@Activity { subtype = Follow } <- fromObject a.object,Just (followee :: Actor) <- fromObject o.object-> dodebug ("Accepting follow request: " <> o.actor.url <> " to " <> followee.id.url)DB.acceptFollowRequest (o.actor, followee.id)Undo | Just o@Activity { subtype = Follow } <- fromObject a.object,Just (followee :: Actor) <- fromObject o.object-> dodebug "Cancelling follow request"DB.cancelFollowRequest (o.actor, followee.id)ty -> dofail ("Combination of activity type " <> show ty <> " and object type unknown")where-- | Autoaccept a follow request targeting `channel`.acceptFollow channel = dodebug "Automatically accepting follow request for channel"reply <- doactivityId <- genIdfollower :: Actor <- resolve a.actortarget <- case follower.inbox ofJust inbox -> pure inboxNothing -> fail "No inbox available for follow requester"pure (Activity {id = activityId,actor = Id channel.linkedActorId,object = toObject a,to = [target],cc = [],time = Nothing,subtype = Accept})deliver reply channelperform reply channel
{-# LANGUAGE DerivingStrategies #-}-- | Dereferencingmodule Puppy.Protocol.ActivityPub.Fetch (Dereference (..),Fetch,runFetch,) whereimport Control.Applicative (Alternative (..), optional)import Control.Monad (when)import Data.Aeson (Key, FromJSON)import Data.Aeson.KeyMap ((!?))import Data.ByteString.Lazy (fromStrict)import Data.Function ((&))import Data.Maybe (fromJust)import Data.Text (Text)import Effectfulimport Effectful.Dispatch.Dynamic (interpret)import Effectful.Fail (Fail (..))import Effectful.NonDet (NonDet, runNonDet, OnEmptyPolicy (..))import Effectful.Reader.Staticimport Puppy.Crypto.RSAimport Puppy.Protocol.ActivityStreamsimport Puppy.Protocol.HTTPimport qualified Data.Aeson as JSONimport qualified Data.Aeson.Types as JSONimport qualified Data.Text.Encoding as T-- | A restricted monad for writing dereferencing "scripts".newtype Fetch o= Fetch { eff :: Eff '[Reader State, NonDet, HTTP, Fail] o }deriving newtype (Functor, Applicative, Alternative, Monad, MonadFail)data State = State { value :: JSON.Value, depth :: Int }runFetch:: (HTTP :> es, Fail :> es)=> JSON.Value-> Fetch o-> Eff es orunFetch json (Fetch eff) = do-- `inject` narrows the list of effects expected by `runNonDet`-- to the restricted subset of `eff` (see the `toPoly` example-- in the haddocs for `inject`)result <- inject eff& runBacktrack-- Keeping the state is more efficient according to the docs& runNonDet OnEmptyKeep& runReader (State json 0)case result ofLeft _ -> fail "Failed to fetch"Right ok -> do-- TODO: Add caching herepure okwhere-- | Backtrack on failures by triggering NonDet.runBacktrack = interpret $ \_ (Fail _) -> emptyclass Dereference o wheredereferencer :: Fetch o-- | Depth limit to prevent attacks against the dereferencer.limit :: Intlimit = 10-- | Fetch a URL from the interwebs.fetch :: (FromJSON o) => Text -> Fetch ofetch url = Fetch $ doresponse <- get urlcase JSON.eitherDecode (fromStrict response.body) ofLeft err -> fail ("Error with decoding response: " <> err <> "; data: " <> show response.body)Right value -> pure valuederef :: (Dereference o) => Key -> Fetch oderef key = Fetch $ do-- Increment the depth so we don't end up in a dereferencing looplocal (\st -> st { depth = st.depth + 1 }) $ do-- Check depth limitercurrent <- asks depthwhen (current >= limit) $ dofail "Depth limit exceeded"-- Extract the url to request the data fromval <- (parse key).effresult <- case val ofJSON.String url -> (fetch url).effJSON.Object _ -> pure val_ -> fail ("No url at key " <> show key)-- Execute the dereferencerrunFetch result dereferencerparse :: (FromJSON o) => Key -> Fetch oparse key = Fetch $ doval <- asks valuecase extract val >>= decoder ofJust x -> pure xNothing -> fail ("Failed to parse field " <> show key)wheredecoder = JSON.parseMaybe JSON.parseJSONextract = \case { JSON.Object obj -> obj !? key; _ -> Nothing }focus :: Key -> Fetch o -> Fetch ofocus key action = Fetch $ doval <- asks valueobj <- case val ofJSON.Object obj -> case obj !? key ofJust value -> pure valueNothing -> fail ("Expected " <> show key <> " to be present")-- TODO: catch this case earlier_ -> fail "Expected an object"local(\st -> st { value = obj })action.effinstance Dereference o => Dereference (Activity o) wheredereferencer = Activity<$> parse "id"<*> parse "actor"<*> deref "object"<*> (parse "to" <|> pure [])<*> (parse "cc" <|> pure [])<*> optional (parse "published")<*> parse "type"instance Dereference Actor wheredereferencer = dopublicKey <- focus "publicKey" $ doPublicKey <$> parse "id"<*> parse "owner"<*> (fromJust . decodePublicKey . T.encodeUtf8 <$> parse "publicKeyPem")Actor<$> parse "id"<*> optional (parse "inbox")<*> optional (parse "outbox")<*> optional (parse "followers")<*> optional (parse "following")<*> parse "preferredUsername"<*> optional (parse "name")<*> optional (parse "summary")<*> return publicKey<*> (parse "manuallyApprovesFollowers" <|> pure True)<*> parse "type"instance Dereference Document wheredereferencer = Document<$> parse "id"<*> optional (parse "content")<*> optional (parse "summary")<*> (parse "type" <|> pure Note)instance Dereference Object wheredereferencer =(toObject <$> (dereferencer :: Fetch Actor)) <|>(toObject <$> (dereferencer :: Fetch (Activity Object))) <|>(toObject <$> (dereferencer :: Fetch Document))
{-# LANGUAGE IncoherentInstances #-}{-# LANGUAGE TemplateHaskell #-}module Puppy.Logging (LogContext (..),genTracer,Level (..),Tracer,-- * EffectLog,-- * Actions-- | Get the current logging context.getContext,-- | Override the current logging context.setContext,-- | Push a scope segment to the stack.scope,debug,info,warn,-- * HandlersrunLog,) whereimport Control.Monad (when)import Crypto.Randomimport Data.Functor ((<&>))import Data.Text (Text, justifyRight)import Effectfulimport Effectful.Dispatch.Dynamicimport Effectful.Reader.Dynamicimport Effectful.TH (makeEffect)import qualified Data.ByteString as BSimport qualified Data.ByteString.Base64 as BSimport qualified Data.Text.IO as Tdata LogContext= LogRequest { scopeLabel :: Text, tracer :: Tracer }| LogGeneral { scopeLabel :: Text }newtype Tracer= Tracer { getTracerBytes :: BS.ByteString }genTracer :: (MonadIO m) => m TracergenTracer = Tracer <$> liftIO (getRandomBytes 8)data Level = Debug | Info | Warn deriving (Enum, Eq, Ord)data Log :: Effect whereGetContext :: Log m LogContextSetContext :: LogContext -> m a -> Log m aDebugMessage :: Text -> Log m ()InfoMessage :: Text -> Log m ()WarnMessage :: Text -> Log m ()Scope :: Text -> m a -> Log m amakeEffect ''Log-- | Insert a message into the log.debug, info, warn :: (Log :> es) => Text -> Eff es ()debug = debugMessageinfo = infoMessagewarn = warnMessage-- | Run the logging effect.runLog:: (IOE :> es)=> LogContext-> Level-> Eff (Log : es) a-> Eff es arunLog logContext lvl = reinterpret (runReader logContext) $ \env x -> dolabel <- asks scopeLabeltrace <- ask <&> \caseLogRequest { tracer } -> " [" <> BS.encodeBase64 (getTracerBytes tracer) <> "]"_ -> ""let writeMessage :: IOE :> es => Text -> Text -> Eff es ()writeMessage level msg = liftIO (T.putStrLn (justifyRight 9 ' ' ("[" <> level <> "]") <> trace <> " (" <> label <> "): " <> msg))case x ofGetContext -> askSetContext ctx rest -> localSeqUnlift env $ \unlift -> dolocal (const ctx) (unlift rest)-- Modify the context labelScope name rest -> localSeqUnlift env $ \unlift -> dolocal (\c -> c { scopeLabel = c.scopeLabel <> "::" <> name }) $ unlift rest-- Print messages at different severitiesDebugMessage msg -> when (lvl <= Debug) (writeMessage "debug" msg)InfoMessage msg -> when (lvl <= Info) (writeMessage "info" msg)WarnMessage msg -> when (lvl <= Warn) (writeMessage "warning" msg)
{-# LANGUAGE TemplateHaskell #-}-- | Allows access to files relevant to the server only.module Puppy.Files whereimport Effectfulimport Effectful.TH (makeEffect)import qualified Data.ByteString.Lazy as LBSimport qualified Data.Aeson as JSONimport Effectful.Dispatch.Dynamicimport System.Directory (doesFileExist)data Files :: Effect whereReadConfigFile :: Files m (Maybe JSON.Object)ReadFavicon :: Files m LBS.ByteStringReadServerKey :: Files m (Maybe LBS.ByteString)WriteConfigFile :: JSON.Object -> Files m ()WriteServerKey :: LBS.ByteString -> Files m ()makeEffect ''FilesrunFiles:: (IOE :> es)=> FilePath-- ^ The path to the state directory-> FilePath-- ^ The path to the static resources directory-> Eff (Files : es) a-> Eff es arunFiles stateMnt resMnt = interpret $ \_ -> liftIO . \caseReadConfigFile -> dolet fp = stateMnt <> "/config.json"doesFileExist fp >>= \caseTrue -> JSON.decode <$> LBS.readFile fpFalse -> return NothingReadFavicon -> LBS.readFile (resMnt <> "/favicon.png")ReadServerKey -> dolet fp = stateMnt <> "/serverKey"doesFileExist fp >>= \caseTrue -> Just <$> LBS.readFile fpFalse -> return NothingWriteConfigFile cfg -> LBS.writeFile (stateMnt <> "/config.json") (JSON.encode cfg)WriteServerKey key -> LBS.writeFile (stateMnt <> "/serverKey") key
{-# OPTIONS_GHC -Wno-orphans #-}{-# OPTIONS_GHC -Wno-name-shadowing #-}{-# LANGUAGE ApplicativeDo #-}{-# LANGUAGE DuplicateRecordFields #-}{-# LANGUAGE RecordWildCards #-}{-# LANGUAGE TemplateHaskell #-}-- | Querying and manipulating the database.module Puppy.Database (connect,Connection,-- * EffectDB,runDB,-- * Utilstransaction,-- * QueriesgetActorById,getChannelByActorId,getActivityById,getObjectById,getUserByName,-- * ActionsinsertNewUser,insertNewChannel,insertNewActor,insertNewActivity,insertFollowRequest,acceptFollowRequest,cancelFollowRequest,storeActivity,) whereimport Control.Monad ((>=>))import Database.SQLite.Simple (Connection, query, Only (..), execute, ToRow (..), withTransaction, open)import Database.SQLite.Simple.FromField (FromField (..))import Database.SQLite.Simple.FromRowimport Database.SQLite.Simple.ToField (ToField (..))import Data.Functor ((<&>), ($>))import Data.Maybe (fromJust)import Data.Text (Text)import Data.Timeimport Effectfulimport Effectful.Dispatch.Dynamicimport Effectful.Fail (Fail)import Effectful.TH (makeEffect)import Prelude hiding (id)import Puppy.Protocol.ActivityStreamsimport Puppy.Typesimport qualified Data.Aeson as JSONimport qualified Data.UUID as UUIDimport qualified Puppy.Crypto.RSA as RSAdata DB :: Effect whereGetActorById :: Id -> DB m (Maybe Actor)GetChannelByActorId :: Id -> DB m (Maybe Channel)GetUserByName :: Text -> DB m (Maybe User)GetActivityById :: Id -> DB m (Maybe (Activity Id))InsertNewUser :: User -> DB m ()InsertNewChannel :: Channel -> DB m ()InsertNewActor :: Actor -> DB m ()InsertNewActivity :: Activity Id -> DB m ()InsertNewDocument :: Document -> DB m ()Transaction :: m a -> DB m aInsertFollowRequest :: (Id, Id) -> DB m ()AcceptFollowRequest :: (Id, Id) -> DB m ()CancelFollowRequest :: (Id, Id) -> DB m ()makeEffect ''DBgetObjectById :: DB :> es => Id -> Eff es (Maybe Object)getObjectById objId = getActivityById objId >>= \case-- TODO: Add branch for DocumentJust activity -> getObjectById activity.object <&> fmap(\(obj :: Object) -> toObject (activity $> obj))Nothing -> getActorById objId <&> fmap toObject-- | Store the object, and then store the activity itself.storeActivity :: (DB :> es) => Activity Object -> Eff es ()storeActivity a =storeObject a.object >> insertNewActivity (a <&> (.id))wherestoreObject obj| Just o <- fromObject obj = insertNewActor o| Just o <- fromObject obj = insertNewDocument o| Just o <- fromObject obj = storeActivity o| otherwise = error "impossible!"connect :: (IOE :> es) => Eff es Connectionconnect = liftIO $ open ".state/db.sqlite"runDB:: (IOE :> es, Fail :> es)=> Connection-> Eff (DB : es) a-> Eff es arunDB conn = interpret $ \env a -> dolocalSeqUnliftIO env $ \unlift -> case a ofGetActorById actorId -> just <$> query conn"select id, inbox, outbox, followers, following, accountName, displayName, bio, keyId, publicKeyPem, locked, type from actors where id = ?"(Only actorId)GetChannelByActorId actorId -> just <$> query conn"select id, linkedActorId, privateKeyPem from channels where linkedActorId = ?"(Only actorId)GetUserByName userName -> just <$> query conn"select id, userName from users where userName = ?"(Only userName)GetActivityById activityId -> just <$> query conn"select id, actor, object, audienceTo, audienceCc, time, type from activities where id = ?"(Only activityId)InsertNewUser (User { userId, userName, mainChannel }) -> execute conn"insert into users (id, userName, mainChannel) values (?, ?, ?)"(userId, userName, mainChannel)InsertNewChannel (Channel { channelId, linkedActorId, privateKeyPem }) -> execute conn"insert into channels (id, linkedActorId, privateKeyPem) values (?, ?, ?)"(channelId, linkedActorId, RSA.encodePrivateKey privateKeyPem)InsertNewActor actor -> execute conn"insert into actors (id, inbox, outbox, followers, following, accountName, displayName, bio, keyId, publicKeyPem, locked, type) values (?,?,?,?,?,?,?,?,?,?,?,?) on conflict do nothing"actorInsertNewDocument _ -> pure () -- TODOInsertNewActivity activity -> execute conn"insert into activities (id, actor, object, audienceTo, audienceCc, time, type) values (?, ?, ?, ?, ?, ?, ?) on conflict do nothing"activityTransaction actions -> withTransaction conn (unlift actions)InsertFollowRequest (follower, followee) -> execute conn"insert into follows (follower, followee) values (?, ?)"(follower, followee)AcceptFollowRequest (follower, followee) -> do -- NOTE: Applicative dorecord <- just <$> query conn"select acceptTime, rejectTime, follower, followee from follows where follower = ? and followee = ?"(follower, followee)now <- getCurrentTimecase record ofJust (rec :: FollowRecord) -> execute conn"update follows set acceptTime = ? where follower = ? and followee = ?"(now, rec.follower, rec.followee)Nothing -> execute conn"insert into follows (follower, followee, acceptTime) values (?, ?, ?)"(follower, followee, now)CancelFollowRequest inputs -> execute conn"delete from follows where follower = ? and followee = ?"inputswherejust [a] = Just ajust _ = Nothinginstance FromRow Actor wherefromRow = do -- NOTE: Applicative do-- NOTE: Order of `field`s matters here!id <- field!inbox <- field!outbox <- field!followers <- field!following <- field!accountName <- field!displayName <- field!summary <- field!publicKeyId <- field!publicKeyPem <- field <&> fromJust . RSA.decodePublicKey!locked <- field!subtype <- fieldpure (Actor {publicKey = PublicKey {id = publicKeyId,owner = id,publicKeyPem},..})instance ToRow Actor wheretoRow a = [toField a.id,toField a.inbox,toField a.outbox,toField a.followers,toField a.following,toField a.accountName,toField a.displayName,toField a.summary,toField a.publicKey.id,toField (RSA.encodePublicKey a.publicKey.publicKeyPem),toField a.locked,toField a.subtype]instance FromRow Channel wherefromRow = do -- NOTE: Applicative do-- NOTE: Order of `field`s matters here!channelId <- field!linkedActorId <- field!privateKeyPem <- field <&> fromJust . RSA.decodePrivateKeylet settings = ChannelSettings { autoAcceptFollows = True }pure (Channel { .. })instance FromRow User where-- NOTE: Order of `field`s matters herefromRow = User <$> field<*> field<*> fieldinstance FromRow (Activity Id) wherefromRow = do -- NOTE: Applicative do-- NOTE: Order of `field`s matters here!id <- field!actor <- field!object <- field!to <- field <&> fromJust . JSON.decode!cc <- field <&> fromJust . JSON.decode!time <- field!subtype <- fieldpure (Activity { .. })instance ToRow (Activity Id) wheretoRow a = [toField a.id,toField a.actor,toField a.object,toField (JSON.encode a.to),toField (JSON.encode a.cc),toField a.time,toField a.subtype]data FollowRecord= FollowRecord {acceptTime :: Maybe UTCTime,rejectTime :: Maybe UTCTime,follower :: Id,followee :: Id}instance FromRow FollowRecord wherefromRow = do -- NOTE: Applicative do-- NOTE: Order of `field`s matters here!acceptTime <- field!rejectTime <- field!follower <- field!followee <- fieldpure (FollowRecord { .. })instance ToRow FollowRecord wheretoRow a = [toField a.acceptTime,toField a.rejectTime,toField a.follower,toField a.followee]instance FromField Id wherefromField = fmap Id . fromFieldinstance ToField Id wheretoField = toField . (.url)instance FromField Inbox wherefromField = fmap Inbox . fromFieldinstance ToField Inbox wheretoField = toField . (.url)instance FromField UUID.UUID wherefromField = fmap (fromJust . UUID.fromByteString). fromFieldinstance ToField UUID.UUID wheretoField = toField . UUID.toByteStringinstance forall o. FromField (Subtype (Activity o)) wherefromField = fromField >=> \case"Follow" -> pure Follow"Accept" -> pure Accept"Reject" -> pure Reject"Create" -> pure Create"Delete" -> pure Delete"Announce"-> pure Announce"Like" -> pure Like"Undo" -> pure Undo(str :: Text) -> fail ("Bad subtype of Activity: " <> show str)instance forall o. ToField (Subtype (Activity o)) wheretoField Follow = toField ("Follow" :: Text)toField Accept = toField ("Accept" :: Text)toField Reject = toField ("Reject" :: Text)toField Create = toField ("Create" :: Text)toField Delete = toField ("Delete" :: Text)toField Announce = toField ("Announce" :: Text)toField Like = toField ("Like" :: Text)toField Undo = toField ("Undo" :: Text)instance FromField (Subtype Actor) wherefromField = fromField >=> \case"Person" -> pure Person"Service" -> pure Service"Application" -> pure Application"Organization" -> pure Organization"Group" -> pure Group(str :: Text) -> fail ("Bad subtype of Actor: " <> show str)instance ToField (Subtype Actor) wheretoField Person = toField ("Person" :: Text)toField Service = toField ("Service" :: Text)toField Application = toField ("Application" :: Text)toField Organization = toField ("Organization" :: Text)toField Group = toField ("Group" :: Text)
-- | Cryptography utilities.module Puppy.Crypto (sha256, base64) whereimport Crypto.Hash (Digest, SHA256, hash)import Data.ByteString.Base64 (encodeBase64')import qualified Data.ByteArray as BAimport qualified Data.ByteString as BSclass Base64 x where-- | Base64-encode somethingbase64 :: x -> xinstance Base64 BS.ByteString wherebase64 = encodeBase64'-- | Hash a ByteString with SHA-256sha256 :: BS.ByteString -> BS.ByteStringsha256 = BA.pack. BA.unpack. (hash :: BS.ByteString -> Digest SHA256)
module Puppy.Crypto.RSA whereimport Data.List (singleton)import Effectfulimport Effectful.Failimport qualified Crypto.PubKey.RSA as RSAimport qualified Crypto.Store.PKCS8 as PKCS8import qualified Crypto.Store.X509 as X509import qualified Data.ByteString as BSimport qualified Data.X509 as X509encodePrivateKey :: RSA.PrivateKey -> BS.ByteStringencodePrivateKey= PKCS8.writeKeyFileToMemory PKCS8.PKCS8Format. singleton. X509.PrivKeyRSAdecodePrivateKey :: BS.ByteString -> Maybe RSA.PrivateKeydecodePrivateKey= extract. PKCS8.readKeyFileFromMemorywhereextract ((PKCS8.Unprotected (X509.PrivKeyRSA x)):_) = Just xextract (_ : rest) = extract restextract _ = NothingencodePublicKey :: RSA.PublicKey -> BS.ByteStringencodePublicKey= X509.writePubKeyFileToMemory. singleton. X509.PubKeyRSAdecodePublicKey :: BS.ByteString -> Maybe RSA.PublicKeydecodePublicKey= extract. X509.readPubKeyFileFromMemorywhereextract ((X509.PubKeyRSA x):_) = Just xextract (_ : rest) = extract restextract _ = NothingreadKeyFile :: (IOE :> es, Fail :> es) => FilePath -> Eff es RSA.PrivateKeyreadKeyFile path = liftIO (decodePrivateKey <$> BS.readFile path) >>= \caseJust x -> return xNothing -> fail "Bad key file"
{-# LANGUAGE TemplateHaskell #-}-- | An effect for generating random values.module Puppy.Crypto.RNG (-- * EffectRNG,-- * HandlerrunRNG,-- * ActionsgenUUID,genRSA,genBytes,) whereimport Effectfulimport Effectful.Dispatch.Dynamicimport Effectful.TH (makeEffect)import Crypto.Random (MonadRandom (..))import Data.UUID (UUID)import qualified Crypto.PubKey.RSA as RSAimport qualified Data.ByteString as BSimport qualified Data.UUID.V4 as UUID-- | An effect for generating random values.data RNG :: Effect whereGenRSA :: RNG m (RSA.PublicKey, RSA.PrivateKey)GenUUID :: RNG m UUIDGenBytes :: Int -> RNG m BS.ByteStringtype instance DispatchOf RNG = 'Dynamic-- | A handler for the `RNG` effect.runRNG:: (IOE :> es)=> Eff (RNG : es) a-> Eff es arunRNG = interpret $ \_ -> \caseGenRSA -> liftIO (RSA.generate 1024 65537)GenUUID -> liftIO UUID.nextRandomGenBytes len -> liftIO (getRandomBytes len)makeEffect ''RNG
{-# LANGUAGE TemplateHaskell #-}{-# LANGUAGE DisambiguateRecordFields #-}module Puppy.Context (-- * EffectServerInfo,-- * ActionslocalActorId,localUrl,localId,serverActor,nodeName,-- * HandlersrunServerInfo) whereimport Crypto.PubKey.RSA (PrivateKey (..))import Data.Text (Text)import Effectfulimport Effectful.Dispatch.Dynamicimport Effectful.TH (makeEffect)import Puppy.Configimport Puppy.Protocol.ActivityStreams (Id (..), Actor (..), PublicKey (..), Subtype (..), Inbox (..))-- | Query information about the server.data ServerInfo :: Effect whereNodeName :: ServerInfo m TextServerActor :: ServerInfo m ActormakeEffect ''ServerInfo-- | Create an `Id` for a local actor given only a name.localActorId:: (ServerInfo :> es)=> Text-> Eff es IdlocalActorId actorName = localId ("/ap/a/" <> actorName)localUrl :: (ServerInfo :> es) => Text -> Eff es TextlocalUrl suffix = dous <- nodeNamereturn ("https://" <> us <> suffix)localId :: (ServerInfo :> es) => Text -> Eff es IdlocalId = fmap Id . localUrlrunServerInfo :: (Config :> es) => Eff (ServerInfo : es) a -> Eff es arunServerInfo = interpret $ \_ -> \caseNodeName -> getsConfig nameServerActor -> doconfig <- getConfiglet actorId = Id ("https://" <> config.name <> "/ap/a/server")return (Actor {id = actorId,accountName = "server",publicKey = PublicKey {id = Id (actorId.url <> "#key"),publicKeyPem = config.serverKey.private_pub,owner = actorId},inbox = Just (Inbox (actorId.url <> "/inbox")),outbox = Nothing,followers = Nothing,following = Nothing,displayName = Nothing,summary = Nothing,locked = True,subtype = Service})
{-# LANGUAGE TemplateHaskell #-}{-# LANGUAGE RecordWildCards #-}module Puppy.Config whereimport Data.Functor ((<&>), ($>))import Data.String (IsString)import Data.Text (Text)import Effectfulimport Effectful.Dispatch.Dynamic (interpret)import Effectful.Fail (Fail)import Effectful.NonDetimport Effectful.THimport Puppy.Crypto.RNGimport Puppy.Crypto.RSAimport Puppy.Filesimport Puppy.Loggingimport qualified Crypto.PubKey.RSA as RSAimport qualified Data.Aeson.KeyMap as JSONimport qualified Data.Aeson.Types as JSONimport qualified Data.ByteString as BSimport System.Environment (lookupEnv)version :: IsString s => sversion = "0.1.0"getLogLevel :: MonadIO m => m LevelgetLogLevel = liftIO $ dolookupEnv "KAOS_LOG_LEVEL" <&> \caseJust "debug" -> DebugJust "info" -> InfoJust "warn" -> Warn_ -> Infodata ServerConfig= ServerConfig {port :: Int,name :: Text,serverKey :: RSA.PrivateKey,logLevel :: Level}data Config :: Effect whereGetConfig :: Config m ServerConfigGetsConfig :: (ServerConfig -> a) -> Config m amakeEffect ''ConfigrunConfig:: ServerConfig-> Eff (Config : es) a-> Eff es arunConfig config = interpret $ \_ -> \caseGetConfig -> pure configGetsConfig f -> pure (f config)loadConfig:: (Files :> es, RNG :> es, Fail :> es, IOE :> es)=> Eff es ServerConfigloadConfig = docfg <- require ".state/config.json" =<< readConfigFilelogLevel <- getLogLevel-- Attempt to load the key file, and generate a key if one doesn't exist yet.serverKey <- loadServerKeyres <- runNonDet OnEmptyKeep $ doport <- require "'port'"(parse cfg "port" <|> pure 1312)name <- require "'name'"(parse cfg "name")pure (ServerConfig { .. })either (const (fail "boo")) return reswhereparse :: (JSON.FromJSON v) => JSON.Object -> JSON.Key -> Maybe vparse obj key = JSON.lookup key obj >>= mayb . JSON.fromJSONmayb = \case { JSON.Error _ -> Nothing; JSON.Success a -> Just a }require :: (Fail :> xs) => String -> Maybe e -> Eff xs erequire msg = \caseJust v -> pure vNothing -> fail ("Requires " <> msg <> " but it is missing")loadServerKey = readServerKey >>= \caseJust key -> case decodePrivateKey (BS.toStrict key) ofJust k -> return kNothing -> fail "Bad private key!"Nothing -> do(_, key) <- genRSAwriteServerKey (BS.fromStrict $ encodePrivateKey key) $> key
module Router (dispatch) whereimport Data.Functor ((<&>))import Data.Resultimport Effectfulimport Effectful.Fail (Fail)import Network.Wai (Request (..))import Puppy.Contextimport Puppy.Database (DB)import Puppy.Files (Files, readFavicon)import Puppy.Loggingimport Puppy.Protocol.ActivityPub (ActivityPub)import Puppy.Protocol.ActivityStreams (Id(..))import qualified API.ActivityPubimport qualified API.WebFingerimport qualified Data.ByteString.Lazy as LBSimport qualified Data.Text as Timport qualified Data.Text.Encoding as Tdispatch:: (DB :> es,Fail :> es,Files :> es,Log :> es,ActivityPub :> es,ServerInfo :> es) => Request-> LBS.ByteString-> Eff es Resultdispatch req body = dotarget <- localUrl (T.decodeUtf8 req.rawPathInfo)let method = req.requestMethodpath = req.pathInfoinfo (T.unwords ["Handling", T.decodeUtf8 method, "'/" <> T.intercalate "/" path <> "'"])case (method, path) of("GET", [".well-known", "webfinger"]) ->-- Forward the request to the WebFinger handlerAPI.WebFinger.handleQuery req("GET", ["ap", "a", actorName])| actorName == "server" -> API.ActivityPub.serveServerActor| otherwise -> API.ActivityPub.serveObject (Id target)("GET", ["ap", "o", _]) ->API.ActivityPub.serveObject (Id target)("POST", ["ap", "a", actorName, "inbox"]) ->API.ActivityPub.handleInbox actorName body("GET", []) ->return (Bytes { contentType = "text/plain", body = "Hello :)" })("GET", ["favicon.ico"]) ->readFavicon <&> Bytes "image/png"_ -> doinfo "Rejected: unknown path"return (Error { statusCode = 404, errorMessage = "Unknown path" })
{-# OPTIONS_GHC -Wno-partial-type-signatures #-}{-# LANGUAGE PartialTypeSignatures #-}{-# LANGUAGE NoFieldSelectors #-}module Main whereimport Control.Exception.Base (handle, SomeException (..))import Control.Monad ((>=>), forever)import Data.Functor (void, ($>))import Data.Resultimport Data.String (IsString (..))import Effectfulimport Effectful.Concurrent.Async (runConcurrent, async, waitAnyCancel)import Effectful.Concurrent.Chan (Chan, newChan)import Effectful.Fail (runFailIO, runFail, Fail)import Effectful.Reader.Static (runReader, Reader)import Network.HTTP.Conduit (tlsManagerSettings, newManager, Manager)import Network.Wai.Handler.Warp (run)import Puppy.Configimport Puppy.Contextimport Puppy.Crypto.RNG (runRNG)import Puppy.Database (runDB)import Puppy.Files (runFiles, Files)import Puppy.Loggingimport Puppy.Protocol.ActivityPub (runActivityPub)import Puppy.Protocol.ActivityStreams (Id (..))import Puppy.Protocol.HTTP (runHTTP)import Puppy.Protocol.HTTP.Signature (Server (..))import Puppy.TaskQueueimport qualified Data.Text as Timport qualified Network.Wai as WAIimport qualified Puppy.Database as DBimport qualified Puppy.Protocol.ActivityPub as APimport qualified Routerimport System.Directory (createDirectoryIfMissing)main :: IO ()main = runEff . runRNG . runFiles ".state" "res" $ do-- Load the initial configurationconfig <- runFailIO $ doliftIO $ createDirectoryIfMissing False ".state"loadConfig-- Initialize the global HTTP managermanager <- liftIO (newManager tlsManagerSettings)-- Handle some root effects that require shared context that would-- be really annoying to thread down to where it needs to go.let handler = runConfig config. runServerInfo-- Share a single HTTP client between all threads using the HTTP effect.-- We don't evaluate the HTTP effect in the global handler, because we-- don't want failures to propagate to the main thread. This handler must-- not fail unless there is something catastrophically wrong.. runReader manager-- Initialize the logging root here. runLog (LogGeneral { scopeLabel = "main" }) config.logLevel-- Need concurrency past this point. runConcurrent-- Set up the task queue for delivering activities. (\x -> (newChan :: Eff _ (Chan DeliverTask)) >>= \q -> runTaskQueue q x)-- Set up the task queue for performing activities. (\x -> (newChan :: Eff _ (Chan PerformTask)) >>= \q -> runTaskQueue q x)-- Actually run the applicationhandler $ doinfo "Configuration loaded"info "Starting core tasks"-- Launch the tasksrespondTask <- async respondperformTask <- async performdeliverTask <- async deliver-- Block until any task returns (which should be never)void $ waitAnyCancel [respondTask,performTask,deliverTask]info "Server terminated. See ya!"where-- Take tasks from the queue and handle basic failures by logging their message and then-- proceeding to the next task.-- NOTE: Does not stop RTS exceptions from taking down the loop-- TODO: Add some kind of retry mechanism?-- TODO: Add exception handlingloop :: (TaskQueue task :> es, Log :> es) => (task -> Eff (Fail : es) ()) -> Eff es ()loop f = void $ forever $ logFail (dequeue >>= f)wherelogFail :: Log :> es => Eff (Fail : es) () -> Eff es ()logFail = runFail >=> \caseLeft msg -> warn ("Failed: " <> T.pack msg)Right _ -> debug "Finished"named = setContext . LogGeneral-- | The delivery worker task is responsible for delivering activities-- to remote instances.deliver = named "task/deliver" $ doinfo "Hello!"loop $ \(DeliverTask activity channel) -> dolet handler = scope activity.id.url-- Set the request signer for HTTP requests to be the channel-- through which the activity is sent.-- HTTP handler needs to be set within the loop, because-- it is fallible, so we need the `Fail` handler that is-- built into `loop`.. runHTTP channelhandler $ doAP.doDeliverTask activityinfo "Stopped"-- | The worker task responsible for executing the side effects of-- an activity.perform = named "task/perform" $ doinfo "Hello!"conn <- DB.connectloop $ \(PerformTask activity channel) -> dolet handler = scope activity.id.url-- HTTP handler needs to be set within the loop, because-- it is fallible, so we need the `Fail` handler that is-- built into `loop`.. runHTTP channel. runActivityPub. runDB connhandler $ doAP.doPerformTask activityinfo "Stopped"-- | The server task responsible for accepting requests and responding to-- them, as well as delegating to the other two tasks.respond = named "task/respond" $ doinfo "Hello!"cfg <- getConfigconn <- DB.connectinfo (T.unwords ["Starting node", cfg.name, "on port", T.pack (show cfg.port)])withConcEffToIO Ephemeral Unlimited $ \unlift -> dorun cfg.port (app unlift conn)info "Stopped"app:: (Config :> es,Files :> es,IOE :> es,Log :> es,Reader Manager :> es,ServerInfo :> es,TaskQueue DeliverTask :> es,TaskQueue PerformTask :> es) => (forall r. Eff es r -> IO r)-> DB.Connection-> WAI.Applicationapp unlift conn request respond = doresult <- unlift $ doconfig <- getConfigtracer <- genTracerlet context = LogRequest { scopeLabel = "handle", tracer }handler = setContext context-- Translates `fail` calls into `Result`s describing the error.. handleFail-- Initialize dependencies for AP ID resolution-- TODO: maybe use a connection pool here. can be an effect. runDB conn-- Set up the HTTP effect handler so failures are caught by `handleFail`. runHTTP (Server config). runActivityPub-- Doing some unlifting magic here to make the exception handling happen with the-- logging context that includes the tracer (as opposed to just using `unlift`,-- which would make it so that we lose the context of which request triggered-- the exception).handler $ withEffToIO $ \effToIO -> doeffToIO $ handleExceptions effToIO $ dobody <- liftIO (WAI.strictRequestBody request)-- TODO: verify request signature hereRouter.dispatch request bodyrespond (toResponse result)wherehandleFail :: (Log :> es) => Eff (Fail : es) Result -> Eff es ResulthandleFail = runFail >=> \caseLeft msg-> warn ("Uncaught failure: " <> T.pack msg)$> Error 500 (fromString msg)Right res-> debug ("Finished, status: " <> status res)$> reshandleExceptions :: (forall r. Eff _ r -> IO r) -> Eff _ Result -> Eff _ ResulthandleExceptions localUnlift= liftIO. handle (\ex -> localUnlift (warn ("Exception: " <> T.pack (show ex)) $> genResponse ex)). localUnliftgenResponse :: SomeException -> ResultgenResponse _ = Error 500 "Internal error"status = T.pack . show . getStatusCode
module Data.Result whereimport Data.Aeson (ToJSON, encode, (.=), object)import Network.Wai (Response, responseLBS)import qualified Data.ByteString as BSimport qualified Data.ByteString.Lazy as LBSimport qualified Data.Text.Encoding as Tdata Result= forall j. ToJSON j => Value j| Bytes { contentType :: BS.ByteString, body :: LBS.ByteString }| Error { statusCode :: Int, errorMessage :: LBS.ByteString }| EmptytoResponse :: Result -> ResponsetoResponse = \caseValue json -> responseLBS(toEnum 200)[("content-type", "application/activity+json")](encode json)Bytes contentType body -> responseLBS(toEnum 200)[("content-type", contentType)]bodyError code body -> responseLBS(toEnum code)[("content-type", "application/json")](encode $ object [ "error" .= T.decodeUtf8 (BS.toStrict body) ])Empty -> responseLBS (toEnum 202) [] ""getStatusCode :: Result -> IntgetStatusCode = \caseBytes _ _ -> 200Empty -> 202Error code _ -> codeValue _ -> 200
module API.WebFinger (handleQuery) whereimport Control.Monad (join)import Data.Functor ((<&>))import Data.Resultimport Effectfulimport Network.Wai (Request (..))import Puppy.Logging (Log)import qualified Data.ByteString as BSimport qualified Data.Text.Encoding as Timport qualified Puppy.Protocol.WebFinger as WebFingerhandleQuery:: (Log :> es)=> Request-> Eff es ResulthandleQuery req = dolet resource =join (lookup "resource" (queryString req))>>= BS.stripPrefix "acct:">>= WebFinger.parseHandle . T.decodeUtf8case resource ofJust res -> WebFinger.lookupLocal res <&> maybe(Error 404 "No such resource")ValueNothing -> return (Error 400 "Bad resource")
{-# LANGUAGE DisambiguateRecordFields #-}module API.User whereimport Data.Text (Text)import Data.UUID (UUID)import Effectfulimport Puppy.Contextimport Puppy.Crypto.RNG (genRSA, genUUID, RNG)import Puppy.Databaseimport Puppy.Protocol.ActivityStreamsimport Puppy.Typesimport qualified Crypto.PubKey.RSA as RSAcreateUser:: (ServerInfo :> es, DB :> es, RNG :> es)=> Text-> Eff es ()createUser userName = transaction $ do-- Generate the user's IDuserId <- genUUID-- Create default channelmainChannel <- createChannel userNameinsertNewUser (User {userId,userName,mainChannel})createChannel:: (ServerInfo :> es, DB :> es, RNG :> es)=> Text-> Eff es UUIDcreateChannel accountName = do(publicKey, privateKey) <- genRSAactorId <- createActor accountName publicKeychannelId <- genUUIDinsertNewChannel (Channel {linkedActorId = actorId,privateKeyPem = privateKey,channelId,settings = (ChannelSettings { autoAcceptFollows = True })})return channelIdcreateActor:: (ServerInfo :> es, DB :> es)=> Text-> RSA.PublicKey-> Eff es TextcreateActor accountName publicKeyPem = doId actorId <- localActorId accountNameinsertNewActor (Actor {id = Id actorId,inbox = Just $ Inbox (actorId <> "/inbox"),outbox = Just $ Id (actorId <> "/outbox"),followers = Just $ Id (actorId <> "/followers"),following = Just $ Id (actorId <> "/following"),accountName,displayName = Nothing,summary = Nothing,publicKey = PublicKey {id = Id (actorId <> "#key"),owner = Id actorId,publicKeyPem},locked = True,subtype = Person})return actorId
module API.ActivityPub whereimport Data.Functor ((<&>))import Data.Resultimport Data.Text (Text)import Effectfulimport Effectful.Failimport Puppy.Contextimport Puppy.Database (DB)import Puppy.Loggingimport Puppy.Protocol.ActivityStreams (Id (..))import qualified Data.Aeson as JSONimport qualified Data.ByteString.Lazy as LBSimport qualified Data.Text.Encoding as Timport qualified Puppy.Database as DBimport qualified Puppy.Protocol.ActivityPub as APimport qualified Puppy.Protocol.ActivityStreams as ASserveObject :: (DB :> es) => Id -> Eff es ResultserveObject objectId = DB.getObjectById objectId<&> maybe (Error 404 "No such object") fixupwherefixup = AS.object (Value . fmap (.id)) Value ValuehandleInbox:: (AP.ActivityPub :> es,DB :> es,Fail :> es,Log :> es,ServerInfo :> es) => Text-> LBS.ByteString-> Eff es ResulthandleInbox actorName body = scope "handleInbox" $ do-- TODO: verify that the activity actually involves the actorId actorId <- localActorId actorNamedebug ("Processing: " <> T.decodeUtf8 (LBS.toStrict body))debug ("Targeted: " <> actorId)case JSON.decode body ofJust json -> doactivity <- AP.dereference jsonDB.getChannelByActorId (Id actorId) >>= \caseJust channel -> doAP.perform activity channeldebug "Submitted activity for execution"return EmptyNothing -> return (Error 404 "Targeted actor does not exist")Nothing -> return (Error 400 "Could not decode payload")serveServerActor:: (ServerInfo :> es)=> Eff es ResultserveServerActor = Value <$> serverActor
# `src/`This directory contains the Haskell source code for the ActivityPub server and the accompanyingCLI controller under `app/`, along with the shared library on which they both depend under`lib/`.
CREATE TABLE activities (id TEXT PRIMARY KEY,actor TEXT NOT NULL,object TEXT NOT NULL,audienceTo BLOB NOT NULL,audienceCc BLOB NOT NULL,type TEXT NOT NULL,time INTEGER,FOREIGN KEY (actor) REFERENCES actors (id));CREATE TABLE follows (follower TEXT NOT NULL,followee TEXT NOT NULL,acceptTime INTEGER,rejectTime INTEGER,PRIMARY KEY (follower, followee),FOREIGN KEY (follower) REFERENCES actors (id),FOREIGN KEY (followee) REFERENCES actors (id));
DROP TABLE activities;DROP TABLE follows;
CREATE TABLE users (id BLOB PRIMARY KEY,userName TEXT NOT NULL,mainChannel BLOB NOT NULL,FOREIGN KEY (mainChannel) REFERENCES channels (id));CREATE TABLE actors (id TEXT PRIMARY KEY,inbox TEXT,outbox TEXT,followers TEXT,following TEXT,accountName TEXT NOT NULL,displayName TEXT,bio TEXT,keyId TEXT NOT NULL,publicKeyPem BLOB NOT NULL,locked BOOL NOT NULL,type TEXT NOT NULL);CREATE TABLE channels (id BLOB PRIMARY KEY,linkedActorId TEXT NOT NULL,privateKeyPem BLOB NOT NULL,FOREIGN KEY (linkedActorId) REFERENCES actors (id));CREATE TABLE channel_owners (channelId BLOB NOT NULL,userId BLOB NOT NULL,FOREIGN KEY (channelId) REFERENCES channels (id),FOREIGN KEY (userId) REFERENCES users (id));
DROP TABLE users;DROP TABLE actors;DROP TABLE channels;DROP TABLE channel_owners;
{ mkDerivation,lib,# Haskell dependenciesaeson,base64,bytestring,cryptonite,cryptostore,effectful,effectful-th,hspec,http-conduit,QuickCheck,sqlite-simple,text,uuid,wai,warp}:mkDerivation {pname = "activitypuppy";version = "0.1.0";src = ./.;isExecutable = true;executableHaskellDepends = [aesonbase64bytestringcryptonitecryptostoreeffectfuleffectful-thhspechttp-conduitQuickChecksqlite-simpletextuuidwaiwarp];license = lib.licenses.mit;}
{description = "Flake for ActivityPuppy";inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";flake-utils.url = "github:numtide/flake-utils";};outputs = { nixpkgs, flake-utils, ... }:flake-utils.lib.eachDefaultSystem (system: letpkgs = import nixpkgs { inherit system; };hs-env = pkgs.haskell.packages.ghc944.extend (final: prev: {});hs-ghc = hs-env.ghcWithPackages(hspkgs: with hspkgs; [aesonbase64bytestringcryptonitecryptostoreeffectfuleffectful-thhspechttp-conduitQuickChecksqlite-simpletextuuidwaiwarp]);in rec {packages.default = hs-env.callPackage ./package.nix {};apps.default = {type = "app";program = "${packages.default}/bin/kaos-api";};devShell = pkgs.mkShell {buildInputs = [hs-ghchs-env.cabal-installpkgs.haskell.packages.ghc944.haskell-language-serverpkgs.sqlite];};});}
{"nodes": {"flake-utils": {"inputs": {"systems": "systems"},"locked": {"lastModified": 1687709756,"narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=","owner": "numtide","repo": "flake-utils","rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7","type": "github"},"original": {"owner": "numtide","repo": "flake-utils","type": "github"}},"nixpkgs": {"locked": {"lastModified": 1688109178,"narHash": "sha256-BSdeYp331G4b1yc7GIRgAnfUyaktW2nl7k0C577Tttk=","owner": "NixOS","repo": "nixpkgs","rev": "b72aa95f7f096382bff3aea5f8fde645bca07422","type": "github"},"original": {"owner": "NixOS","ref": "nixos-23.05","repo": "nixpkgs","type": "github"}},"root": {"inputs": {"flake-utils": "flake-utils","nixpkgs": "nixpkgs"}},"systems": {"locked": {"lastModified": 1681028828,"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=","owner": "nix-systems","repo": "default","rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e","type": "github"},"original": {"owner": "nix-systems","repo": "default","type": "github"}}},"root": "root","version": 7}
# Revision history for kaos## 0.1.0.0 -- YYYY-mm-dd* First version. Released on an unsuspecting world.
cabal-version: 3.4name: activitypuppyversion: 0.1.0-- synopsis:-- description:license: MITlicense-file: LICENSEauthor: Riley Ezrimaintainer: me@riley.lgbt-- copyright:category: Webbuild-type: Simpleextra-doc-files: doc/CHANGELOG.md-- extra-source-files:common warningsghc-options: -Walllibraryimport: warningsexposed-modules:Puppy.ConfigPuppy.ContextPuppy.CryptoPuppy.Crypto.RNGPuppy.Crypto.RSAPuppy.DatabasePuppy.LoggingPuppy.Protocol.ActivityPubPuppy.Protocol.ActivityPub.FetchPuppy.Protocol.ActivityStreamsPuppy.Protocol.WebFingerPuppy.TaskQueuePuppy.FilesPuppy.Protocol.HTTPPuppy.Protocol.HTTP.SignaturePuppy.Typesbuild-depends:aeson,base ^>=4.17.0.0,base64,bytestring,cryptonite,cryptostore,directory,effectful,effectful-th,http-conduit,memory,sqlite-simple,text,time,uuid,vector,wai,warp,x509hs-source-dirs: src/libdefault-language: GHC2021default-extensions:DataKinds,TypeFamilies,LambdaCase,OverloadedRecordDot,OverloadedStringsexecutable puppy-apiimport: warningsmain-is: Main.hsdefault-extensions:DataKinds,TypeFamilies,LambdaCase,OverloadedRecordDot,OverloadedStringsother-modules:API.ActivityPubAPI.UserAPI.WebFingerData.ResultRouterbuild-depends:aeson,activitypuppy,base ^>=4.17.0.0,bytestring,cryptonite,cryptostore,directory,effectful,http-conduit,sqlite-simple,text,time,vector,uuid,warp,wai,x509hs-source-dirs: src/app/apidefault-language: GHC2021test-suite testimport: warningsmain-is: Spec.hsother-modules:Signatureshs-source-dirs: testtype: exitcode-stdio-1.0default-extensions:DataKinds,TypeFamilies,LambdaCase,OverloadedRecordDot,OverloadedStringsbuild-depends:activitypuppy,base,cryptonite,effectful,hspec,QuickCheck,text,timedefault-language: GHC2021
# ActivityPuppy ✨**Puppy, fetch my posts!**ActivityPuppy is a federated microblogging server designed for small self-organizing and self-moderatinggroups, collectives and organizations, built on open protocols and mutual trust.It is primarily intended to serve as a lightweight alternative to Mastodon, Akkoma and various forksof Misskey for small groups of friends/comrades who trust each other to keep their community safe byapplying anarchist principles to moderation and administration.## Project goalsActivityPuppy aims to be an ActivityPub project explicitly incorporating anti-hierarchical, anti-centralizationand self-organization practices.At time of writing, governance of the Mastodon project, which in some ways sets the de facto standardson the microbloggin fediverse, is centralized in such a way that it poses a threat to the safety ofmany marginalized users on the platform. This project aims to counter this by providing an alternativewhich takes decentralization as a tactic for preventing abuse seriously, and encourages the creation ofmany smaller-scale communities as opposed to a few large and hard-to-moderate servers.## Building and runningThis project uses Nix and Cabal as build tools. Cabal is used for everyday development tasks such as testing,whereas Nix is used to set up the development environment and package releases.You can acquire a development shell by running the following command:```nix develop```A development build can be done as such:```cabal build```In order to start a development build of the server, use cabal:```cabal run```### Database "migrations"Before you can do anything with the database, you should set it up using the `sqlite3` program:```sqlite3 .state/db.sqlite < sql/0000.up.sqlsqlite3 .state/db.sqlite < sql/0001.up.sql```
Copyright (c) 2023 Riley EzriPermission is hereby granted, free of charge, to any person obtaininga copy of this software and associated documentation files (the"Software"), to deal in the Software without restriction, includingwithout limitation the rights to use, copy, modify, merge, publish,distribute, sublicense, and/or sell copies of the Software, and topermit persons to whom the Software is furnished to do so, subject tothe following conditions:The above copyright notice and this permission notice shall be includedin all copies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OFMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANYCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THESOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
.git.DS_Store
dist-newstyle.state