Compare commits

..

No commits in common. "b04d4999e3e4bef729c3ad7408aede5a5d0c3291" and "5c9495bf0b0717ff17296371025645cb82a16b2a" have entirely different histories.

15 changed files with 561 additions and 930 deletions

View file

@ -4,8 +4,7 @@ import Data.Text qualified as T
import Text.RawString.QQ import Text.RawString.QQ
css :: T.Text css :: T.Text
css = css = [r|
[r|
body { body {
margin: 40px auto; margin: 40px auto;
max-width: 650px; max-width: 650px;

107
app/DB.hs
View file

@ -1,21 +1,19 @@
-- | Database interaction -- | Database interaction
module DB where module DB where
import GHC.Stack (HasCallStack)
import Data.Text qualified as T import Data.Text qualified as T
import Database.Sqlite.Easy qualified as DB import Database.Sqlite.Easy qualified as DB
import Fedi
import GHC.Stack (HasCallStack)
import Text.RawString.QQ import Text.RawString.QQ
import Fedi
----------------------- -----------------------
-- * Database handler API -- * Database handler API
data DB data DB
= DB = DB
{ getNotes :: IO [Note] { getNotes :: IO [Note]
, getNote :: DB.Int64 -> IO (Maybe Note) , insertNote :: NoteEntry -> IO NoteId
, insertNote :: NoteEntry -> IO ObjectId
} }
-- * Data types -- * Data types
@ -29,28 +27,23 @@ data NoteEntry
} }
----------------------- -----------------------
-- * Handler smart constructor -- * Handler smart constructor
mkDB :: DB.ConnectionString -> UserDetails -> IO DB mkDB :: DB.ConnectionString -> UserDetails -> IO DB
mkDB connstr details = do mkDB connstr details = do
pool <- DB.createSqlitePool connstr pool <- DB.createSqlitePool connstr
DB.withPool pool runMigrations DB.withPool pool runMigrations
pure pure DB
DB
{ getNotes = { getNotes =
DB.withPool pool (getNotesFromDb $ actorUrl details) DB.withPool pool (getNotesFromDb $ actorUrl details)
, getNote =
\noteid -> DB.withPool pool (getNoteFromDb noteid $ actorUrl details)
, insertNote = , insertNote =
\note -> DB.withPool pool (insertNoteToDb (actorUrl details) note) \note -> DB.withPool pool (insertNoteToDb (actorUrl details) note)
} }
----------------------- -----------------------
-- * Database migrations -- * Database migrations
runMigrations :: (HasCallStack) => DB.SQLite () runMigrations :: HasCallStack => DB.SQLite ()
runMigrations = DB.migrate migrations migrateUp migrateDown runMigrations = DB.migrate migrations migrateUp migrateDown
migrations :: [DB.MigrationName] migrations :: [DB.MigrationName]
@ -58,11 +51,10 @@ migrations =
[ "note" [ "note"
] ]
migrateUp :: (HasCallStack) => DB.MigrationName -> DB.SQLite () migrateUp :: HasCallStack => DB.MigrationName -> DB.SQLite ()
migrateUp = \case migrateUp = \case
"note" -> do "note" -> do
[] <- [] <- DB.run
DB.run
[r| create table note( [r| create table note(
id integer primary key autoincrement, id integer primary key autoincrement,
published datetime default (datetime('now')), published datetime default (datetime('now')),
@ -75,9 +67,10 @@ migrateUp = \case
|] |]
pure () pure ()
name -> error $ "unexpected migration: " <> show name name -> error $ "unexpected migration: " <> show name
migrateDown :: (HasCallStack) => DB.MigrationName -> DB.SQLite () migrateDown :: HasCallStack => DB.MigrationName -> DB.SQLite ()
migrateDown = \case migrateDown = \case
"notes" -> do "notes" -> do
[] <- DB.run "DROP TABLE note" [] <- DB.run "DROP TABLE note"
@ -85,19 +78,13 @@ migrateDown = \case
name -> error $ "unexpected migration: " <> show name name -> error $ "unexpected migration: " <> show name
----------------------- -----------------------
-- * Database actions -- * Database actions
getNotesFromDb :: Url -> DB.SQLite [Note] getNotesFromDb :: Url -> DB.SQLite [Note]
getNotesFromDb url = getNotesFromDb url =
map decodeNoteRow <$> uncurry DB.runWith (getNotesSQL url) map decodeNoteRow <$> uncurry DB.runWith (getNotesSQL url)
getNoteFromDb :: DB.Int64 -> Url -> DB.SQLite (Maybe Note) insertNoteToDb :: Url -> NoteEntry -> DB.SQLite NoteId
getNoteFromDb noteid url = do
n <- map decodeNoteRow <$> uncurry DB.runWith (getNoteSQL noteid url)
pure (listToMaybe n)
insertNoteToDb :: Url -> NoteEntry -> DB.SQLite ObjectId
insertNoteToDb actor note = do insertNoteToDb actor note = do
[n] <- map decodeNoteIdRow <$> uncurry DB.runWith (insertNoteSQL actor note) [n] <- map decodeNoteIdRow <$> uncurry DB.runWith (insertNoteSQL actor note)
pure n pure n
@ -116,30 +103,11 @@ getNotesSQL url =
inReplyTo, inReplyTo,
url url
FROM note FROM note
WHERE inReplyTo IS NULL
ORDER BY published DESC ORDER BY published DESC
|] |]
, [DB.SQLText $ T.pack url] , [DB.SQLText $ T.pack url]
) )
getNoteSQL :: DB.Int64 -> Url -> (DB.SQL, [DB.SQLData])
getNoteSQL noteid url =
( [r|
SELECT
? || '/notes/' || id,
published,
actor,
content,
name,
inReplyTo,
url
FROM note
WHERE note.id = ?
ORDER BY published DESC
|]
, [DB.SQLText $ T.pack url, DB.SQLInteger noteid]
)
insertNoteSQL :: Url -> NoteEntry -> (DB.SQL, [DB.SQLData]) insertNoteSQL :: Url -> NoteEntry -> (DB.SQL, [DB.SQLData])
insertNoteSQL actor note = insertNoteSQL actor note =
( [r| ( [r|
@ -147,8 +115,7 @@ insertNoteSQL actor note =
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
RETURNING cast(id as text) RETURNING cast(id as text)
|] |]
, , [ DB.SQLText (T.pack actor)
[ DB.SQLText (T.pack actor)
, toNullableString note.inReplyTo , toNullableString note.inReplyTo
, DB.SQLText note.content , DB.SQLText note.content
, toNullableString note.name , toNullableString note.name
@ -157,47 +124,39 @@ insertNoteSQL actor note =
) )
----------------------- -----------------------
-- ** Decode row -- ** Decode row
decodeNoteRow :: [DB.SQLData] -> Note decodeNoteRow :: [DB.SQLData] -> Note
decodeNoteRow = \case decodeNoteRow = \case
[ DB.SQLText noteid [ DB.SQLText noteid,
, DB.SQLText published DB.SQLText published,
, DB.SQLText actor DB.SQLText actor,
, DB.SQLText content DB.SQLText content,
, nullableString -> Just name nullableString -> Just name,
, nullableString -> Just inReplyTo nullableString -> Just inReplyTo,
, nullableString -> Just url nullableString -> Just url
] -> ] ->
let Note
emptyNote = emptyUserNote $ T.unpack actor { id = T.unpack noteid
in , published = read (T.unpack published)
emptyNote , actor = T.unpack actor
{ id = Just $ ObjectId $ T.unpack noteid , inReplyTo = inReplyTo
, published = Just $ read (T.unpack published) , content = content
, attributedTo = Just $ LLink $ Link $ T.unpack actor
, inReplyTo = LLink . Link <$> inReplyTo
, content = Just content
, url = url , url = url
, name = StringName <$> name , name = name
, otype = , replies = Collection
emptyNote.otype { id = T.unpack noteid <> "/replies"
{ likes = , summary = "Replies"
emptyNote.otype.likes , items = []
{ id = Just $ ObjectId $ T.unpack noteid <> "/likes" , first = Nothing
} , last = Nothing
, shares =
emptyNote.otype.shares
{ id = Just $ ObjectId $ T.unpack noteid <> "/shares"
}
} }
} }
row -> error $ "Couldn't decode row as Note: " <> show row row -> error $ "Couldn't decode row as Note: " <> show row
decodeNoteIdRow :: [DB.SQLData] -> ObjectId decodeNoteIdRow :: [DB.SQLData] -> NoteId
decodeNoteIdRow = \case decodeNoteIdRow = \case
[DB.SQLText noteid] -> ObjectId $ T.unpack noteid [ DB.SQLText noteid] -> T.unpack noteid
row -> error $ "Couldn't decode row as NoteId: " <> show row row -> error $ "Couldn't decode row as NoteId: " <> show row
nullableString :: DB.SQLData -> Maybe (Maybe String) nullableString :: DB.SQLData -> Maybe (Maybe String)

View file

@ -1,12 +1,13 @@
module Html where module Html where
import Css (css)
import Data.Char (isAlpha, ord)
import Data.String (fromString) import Data.String (fromString)
import Data.Char (ord, isAlpha)
import Data.Text qualified as T import Data.Text qualified as T
import Fedi qualified as Fedi
import Lucid qualified as H import Lucid qualified as H
import Fedi qualified as Fedi
import Css (css)
-- * HTML -- * HTML
type Html = H.Html () type Html = H.Html ()
@ -29,54 +30,54 @@ template :: T.Text -> Html -> Html
template title content = template title content =
H.doctypehtml_ $ do H.doctypehtml_ $ do
H.head_ $ do H.head_ $ do
H.meta_ [H.charset_ "utf-8"] H.meta_ [ H.charset_ "utf-8" ]
H.meta_ [H.name_ "viewport", H.content_ "width=device-width initial_scale=1.0"] H.meta_ [ H.name_ "viewport", H.content_ "width=device-width initial_scale=1.0" ]
H.title_ (H.toHtml $ "Fediserve - " <> title) H.title_ (H.toHtml $ "Fediserve - " <> title)
H.style_ css H.style_ css
H.body_ $ do H.body_ $ do
H.div_ [H.class_ "main"] $ do H.div_ [ H.class_ "main" ] $ do
content content
H.footer_ "" H.footer_ ""
userHtml :: Fedi.UserDetails -> Html userHtml :: Fedi.UserDetails -> Html
userHtml details = do userHtml details = do
H.div_ [H.class_ "user-details"] do H.div_ [ H.class_ "user-details" ] do
H.a_ [H.href_ (T.pack $ "/" <> details.username)] $ H.a_ [ H.href_ (T.pack $ "/" <> details.username) ] $
H.img_ [H.class_ "avatar", H.src_ (T.pack details.icon)] H.img_ [ H.class_ "avatar", H.src_ (T.pack details.icon) ]
H.div_ [H.class_ "user-details-details"] do H.div_ [ H.class_ "user-details-details" ] do
H.h2_ (fromString details.username) H.h2_ (fromString details.username)
H.a_ [H.href_ (T.pack $ Fedi.actorUrl details)] $ H.a_ [ H.href_ (T.pack $ Fedi.actorUrl details) ] $
H.p_ (fromString $ Fedi.fullmention details) H.p_ (fromString $ Fedi.fullmention details)
H.p_ (fromString details.summary) H.p_ (fromString details.summary)
notesHtml :: [Fedi.Note] -> Html notesHtml :: [Fedi.Note] -> Html
notesHtml notes = do notesHtml notes = do
H.div_ [H.class_ "notes"] $ mapM_ noteHtml notes H.div_ [ H.class_ "notes" ] $ mapM_ noteHtml notes
-- | A single post as HTML. -- | A single post as HTML.
noteHtml :: Fedi.Note -> Html noteHtml :: Fedi.Note -> Html
noteHtml note = do noteHtml note = do
H.div_ [H.class_ "note"] $ do H.div_ [ H.class_ "note" ] $ do
H.div_ [H.class_ "note-header"] $ do H.div_ [ H.class_ "note-header" ] $ do
case note.name of case note.name of
Just (Fedi.StringName title) -> Just title ->
H.h2_ [H.class_ (checkDirection $ T.pack title)] (fromString title) H.h2_ [ H.class_ (checkDirection $ T.pack title) ] (fromString title)
_ -> pure () Nothing -> pure ()
case note.url of case note.url of
Just url -> Just url ->
H.p_ $ H.a_ [H.href_ (T.pack url)] $ fromString url H.p_ $ H.a_ [ H.href_ (T.pack url) ] $ fromString url
Nothing -> pure () Nothing -> pure ()
H.a_ H.a_
[ H.href_ (T.pack (maybe "" (\i -> i.unwrap) note.id)) [ H.href_ (T.pack note.id)
, H.class_ "note-time" , H.class_ "note-time"
, H.title_ "See note page" , H.title_ "See note page"
] ]
(H.toHtml (T.pack (show note.published))) (H.toHtml (T.pack (show note.published)))
H.div_ [H.class_ $ "note-content " <> checkDirection (maybe "" id note.content)] $ do H.div_ [H.class_ $ "note-content " <> checkDirection note.content] $ do
H.toHtmlRaw (maybe "" id note.content) H.toHtmlRaw note.content
checkDirection :: T.Text -> T.Text checkDirection :: T.Text -> T.Text
checkDirection txt = checkDirection txt =
@ -94,8 +95,7 @@ newNoteHtml details = do
, H.class_ "new-note" , H.class_ "new-note"
] ]
( do ( do
H.div_ [H.class_ "new-note-div"] $ H.div_ [ H.class_ "new-note-div" ] $ H.input_
H.input_
[ H.class_ "new-note-text" [ H.class_ "new-note-text"
, H.autofocus_ , H.autofocus_
, H.type_ "text" , H.type_ "text"
@ -103,24 +103,20 @@ newNoteHtml details = do
, H.placeholder_ "A title (optional)" , H.placeholder_ "A title (optional)"
] ]
H.div_ [H.class_ "new-note-div"] $ H.div_ [ H.class_ "new-note-div" ] $ H.textarea_
H.textarea_
[ H.class_ "new-note-content" [ H.class_ "new-note-content"
, H.name_ "content" , H.name_ "content"
, H.placeholder_ "Yes?" , H.placeholder_ "Yes?"
] ] ""
""
H.div_ [H.class_ "new-note-div"] $ H.div_ [ H.class_ "new-note-div" ] $ H.input_
H.input_
[ H.class_ "new-note-text" [ H.class_ "new-note-text"
, H.type_ "url" , H.type_ "url"
, H.name_ "url" , H.name_ "url"
, H.placeholder_ "A URL this note should link to (optional)" , H.placeholder_ "A URL this note should link to (optional)"
] ]
H.div_ [H.class_ "new-note-div"] $ H.div_ [ H.class_ "new-note-div" ] $ H.input_
H.input_
[ H.class_ "new-note-submit" [ H.class_ "new-note-submit"
, H.type_ "submit" , H.type_ "submit"
, H.title_ "Add a new note" , H.title_ "Add a new note"

View file

@ -1,21 +1,23 @@
module Main where module Main where
import DB
import Data.Aeson qualified as A
import Data.Functor ((<&>))
import Data.SecureMem (secureMemFromByteString) import Data.SecureMem (secureMemFromByteString)
import Data.String (fromString) import Data.String (fromString)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Text.IO qualified as T
import Database.Sqlite.Easy qualified as Sqlite import Database.Sqlite.Easy qualified as Sqlite
import Network.Wai.Handler.Warp (Port, run) import Data.Aeson qualified as A
import Network.Wai.Handler.Warp (run, Port)
import Network.Wai.Middleware.Routed qualified as Wai
import Network.Wai.Middleware.HttpAuth (basicAuth) import Network.Wai.Middleware.HttpAuth (basicAuth)
import Network.Wai.Middleware.RequestLogger qualified as Logger import Network.Wai.Middleware.RequestLogger qualified as Logger
import Network.Wai.Middleware.Routed qualified as Wai import System.Environment (getArgs)
import Routes import System.Environment (lookupEnv)
import System.Environment (getArgs, lookupEnv)
import Web.Twain qualified as Twain import Web.Twain qualified as Twain
import Data.Functor ((<&>))
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import qualified Data.Text as T
import DB
import Routes
data Command data Command
= Serve = Serve
@ -23,8 +25,7 @@ data Command
main :: IO () main :: IO ()
main = do main = do
command <- command <- getArgs >>= \case
getArgs >>= \case
["insert", file] -> pure (Insert file) ["insert", file] -> pure (Insert file)
["serve"] -> pure Serve ["serve"] -> pure Serve
_ -> usageError _ -> usageError
@ -40,19 +41,15 @@ insertNoteFromFile file = do
connStr <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING" connStr <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING"
content <- T.readFile file content <- T.readFile file
detailsFile <- detailsFile <- lookupEnv "FEDI_DETAILS"
lookupEnv "FEDI_DETAILS"
<&> maybe (error "missing FEDI_DETAILS") id <&> maybe (error "missing FEDI_DETAILS") id
details <- details <- A.eitherDecodeFileStrict detailsFile
A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id <&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
db <- mkDB connStr details db <- mkDB connStr details
note <- note <- db.insertNote NoteEntry
db.insertNote
NoteEntry
{ content = content { content = content
, inReplyTo = Nothing , inReplyTo = Nothing
, name = Nothing , name = Nothing
@ -62,6 +59,7 @@ insertNoteFromFile file = do
putStrLn "Inserted." putStrLn "Inserted."
print note print note
serve :: IO () serve :: IO ()
serve = do serve = do
auth <- fmap (T.splitOn "," . T.pack) <$> lookupEnv "FEDI_AUTH" auth <- fmap (T.splitOn "," . T.pack) <$> lookupEnv "FEDI_AUTH"
@ -75,15 +73,13 @@ serve = do
let let
username = secureMemFromByteString $ T.encodeUtf8 user username = secureMemFromByteString $ T.encodeUtf8 user
password = secureMemFromByteString $ T.encodeUtf8 pass password = secureMemFromByteString $ T.encodeUtf8 pass
pure $ pure $ basicAuth
basicAuth ( \u p -> pure $
( \u p ->
pure $
secureMemFromByteString u == username secureMemFromByteString u == username
&& secureMemFromByteString p == password && secureMemFromByteString p == password
) )
"My Fediserve" "My Fediserve"
Just {} -> usageError Just{} -> usageError
fediPort <- maybe 3001 read <$> lookupEnv "FEDI_PORT" fediPort <- maybe 3001 read <$> lookupEnv "FEDI_PORT"
conn <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING" conn <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING"
@ -93,8 +89,7 @@ serve = do
usageError :: err usageError :: err
usageError = usageError =
errorWithoutStackTrace $ errorWithoutStackTrace $ unlines
unlines
[ "Usage: fedi [ insert <FILE> | serve ]" [ "Usage: fedi [ insert <FILE> | serve ]"
, "Env vars:" , "Env vars:"
, " - FEDI_PORT=<PORT>" , " - FEDI_PORT=<PORT>"
@ -106,8 +101,7 @@ usageError =
-- | Run server at at specific port. -- | Run server at at specific port.
runServer :: Port -> Twain.Middleware -> Twain.Application -> IO () runServer :: Port -> Twain.Middleware -> Twain.Application -> IO ()
runServer port authMiddleware app = do runServer port authMiddleware app = do
putStrLn $ putStrLn $ unwords
unwords
[ "Running fedi at" [ "Running fedi at"
, "http://localhost:" <> show port , "http://localhost:" <> show port
, "(ctrl-c to quit)" , "(ctrl-c to quit)"
@ -115,24 +109,21 @@ runServer port authMiddleware app = do
auth <- Wai.routedMiddleware matchAdmin <$> pure authMiddleware auth <- Wai.routedMiddleware matchAdmin <$> pure authMiddleware
run port (Logger.logStdoutDev $ auth app) run port (Logger.logStdoutDev $ auth app)
matchAdmin :: [T.Text] -> Bool matchAdmin :: [T.Text] -> Bool
matchAdmin = any (== "admin") matchAdmin = any (=="admin")
-- | Application description. -- | Application description.
mkFediApp :: Sqlite.ConnectionString -> IO Twain.Application mkFediApp :: Sqlite.ConnectionString -> IO Twain.Application
mkFediApp connStr = do mkFediApp connStr = do
detailsFile <- detailsFile <- lookupEnv "FEDI_DETAILS"
lookupEnv "FEDI_DETAILS"
<&> maybe (error "missing FEDI_DETAILS") id <&> maybe (error "missing FEDI_DETAILS") id
details <- details <- A.eitherDecodeFileStrict detailsFile
A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id <&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
db <- mkDB connStr details db <- mkDB connStr details
pure $ pure $ foldr ($)
foldr
($)
(Twain.notFound $ Twain.send $ Twain.text "Error: not found.") (Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
(routes db detailsFile) (routes db detailsFile)

View file

@ -1,16 +1,16 @@
module Routes where module Routes where
import Control.Monad.IO.Class (liftIO)
import DB
import Data.Aeson qualified as A
import Data.Functor ((<&>))
import Data.Maybe (maybeToList)
import Data.String (fromString) import Data.String (fromString)
import Fedi qualified as Fedi import Data.Aeson qualified as A
import Html
import Lucid qualified as H
import System.IO.Unsafe (unsafePerformIO)
import Web.Twain qualified as Twain import Web.Twain qualified as Twain
import Fedi qualified as Fedi
import Data.Functor ((<&>))
import System.IO.Unsafe (unsafePerformIO)
import Control.Monad.IO.Class (liftIO)
import Lucid qualified as H
import Html
import DB
routes :: DB -> FilePath -> [Twain.Middleware] routes :: DB -> FilePath -> [Twain.Middleware]
routes db detailsFile = routes db detailsFile =
@ -25,41 +25,54 @@ routes db detailsFile =
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
notes <- liftIO db.getNotes notes <- liftIO db.getNotes
Twain.send $ Twain.html $ H.renderBS $ actorPage details notes Twain.send $ Twain.html $ H.renderBS $ actorPage details notes
, -- Match outbox , -- Match outbox
Twain.get (Fedi.matchOutbox $ unsafePerformIO $ fetchUserDetails detailsFile) do Twain.get (Fedi.matchOutbox $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
notes <- map (Fedi.ActivityCreate . noteToCreate) <$> liftIO db.getNotes notes <- map noteToCreate <$> liftIO db.getNotes
Fedi.handleOutbox details notes Fedi.handleOutbox details notes
, -- Match Create object , -- Match Create object
Twain.get (Fedi.matchCreateNote $ unsafePerformIO $ fetchUserDetails detailsFile) do Twain.get (Fedi.matchCreate $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
notes <- map noteToCreate <$> liftIO db.getNotes notes <- map noteToCreate <$> liftIO db.getNotes
Fedi.handleCreateNote details notes Fedi.handleCreate details notes
, -- Match Note object , -- Match Note object
Twain.get (Fedi.matchNote $ unsafePerformIO $ fetchUserDetails detailsFile) do Twain.get (Fedi.matchNote $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
noteId <- Twain.param "note_id" notes <- liftIO db.getNotes
mnote <- liftIO $ db.getNote noteId
request <- Twain.request request <- Twain.request
if Fedi.checkContentTypeAccept request if Fedi.checkContentTypeAccept request
then do then do
Fedi.handleNote details (maybeToList mnote) Fedi.handleNote details notes
else do else do
case mnote of noteId <- Twain.param "note_id"
Nothing -> Twain.next let
Just thenote -> noteUrl =
Twain.send $ Twain.html $ H.renderBS $ actorPage details [thenote] "https://"
<> details.domain
<> "/"
<> details.username
<> "/notes/"
<> noteId
thenote = filter (\note -> note.id == noteUrl) notes
Twain.send $ Twain.html $ H.renderBS $ actorPage details thenote
, -- Match webfinger , -- Match webfinger
Twain.get Fedi.matchWebfinger do Twain.get Fedi.matchWebfinger do
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
Fedi.handleWebfinger details Fedi.handleWebfinger details
, -- Admin page , -- Admin page
Twain.get (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin") do Twain.get (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin") do
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
notes <- liftIO db.getNotes notes <- liftIO db.getNotes
Twain.send $ Twain.html $ H.renderBS $ adminPage details notes Twain.send $ Twain.html $ H.renderBS $ adminPage details notes
, -- New post , -- New post
Twain.post (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin/new") do Twain.post (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin/new") do
title <- Twain.param "title" title <- Twain.param "title"
@ -68,16 +81,14 @@ routes db detailsFile =
details <- liftIO $ fetchUserDetails detailsFile details <- liftIO $ fetchUserDetails detailsFile
noteid <- noteid <-
liftIO $ liftIO $ db.insertNote NoteEntry
db.insertNote
NoteEntry
{ content = content { content = content
, inReplyTo = Nothing , inReplyTo = Nothing
, name = if trim title == "" then Nothing else Just title , name = if trim title == "" then Nothing else Just title
, url = if trim url == "" then Nothing else Just url , url = if trim url == "" then Nothing else Just url
} }
Twain.send $ Twain.redirect302 (fromString ("/" <> details.username <> "/notes/" <> noteid.unwrap)) Twain.send $ Twain.redirect302 (fromString ("/" <> details.username <> "/notes/" <> noteid))
] ]
trim :: String -> String trim :: String -> String
@ -88,5 +99,11 @@ fetchUserDetails detailsFile =
A.eitherDecodeFileStrict detailsFile A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id <&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
noteToCreate :: Fedi.Note -> Fedi.Create noteToCreate :: Fedi.Note -> Fedi.Activity
noteToCreate note = Fedi.makeCreateNote note noteToCreate note =
Fedi.Create
{ id =
note.id <> "/create"
, actor = note.actor
, object = Fedi.NoteObject note
}

View file

@ -17,10 +17,10 @@ library
import: warnings import: warnings
exposed-modules: exposed-modules:
Fedi Fedi
Fedi.Helpers Fedi.Activity
Fedi.Actor
Fedi.Routes Fedi.Routes
Fedi.Types Fedi.Types
Fedi.UserDetails
Fedi.Webfinger Fedi.Webfinger
-- other-modules: -- other-modules:
-- other-extensions: -- other-extensions:

View file

@ -1,16 +0,0 @@
indentation: 2
column-limit: 150
function-arrows: leading
comma-style: leading
import-export-style: diff-friendly
indent-wheres: true
record-brace-space: true
newlines-between-decls: 1
haddock-style: single-line
haddock-style-module: single-line
let-style: newline
in-style: left-align
single-constraint-parens: always
unicode: never
respectful: false
fixities: []

View file

@ -1,7 +1,7 @@
module Fedi (module Export) where module Fedi (module Export) where
import Fedi.Helpers as Export import Fedi.Activity as Export
import Fedi.Actor as Export
import Fedi.Routes as Export import Fedi.Routes as Export
import Fedi.Types as Export import Fedi.Types as Export
import Fedi.UserDetails as Export
import Fedi.Webfinger as Export import Fedi.Webfinger as Export

168
src/Fedi/Activity.hs Normal file
View file

@ -0,0 +1,168 @@
module Fedi.Activity where
import Data.Aeson qualified as A
import Data.Text qualified as T
import Fedi.Types
import Fedi.Actor
import Data.Time (UTCTime)
data Activity
= Create
{ id :: ActivityUrl
, actor :: ActorId
, object :: Object
}
deriving Show
activityUrl :: Activity -> ActivityUrl
activityUrl = \case
create@Create{} -> create.id
type ActivityUrl = Url
data Object
= NoteObject Note
deriving Show
objectUrl :: Object -> Url
objectUrl = \case
NoteObject note -> note.id
data Note
= Note
{ id :: NoteId
, published :: UTCTime
, inReplyTo :: Maybe Url
, actor :: ActorId
, content :: T.Text
, name :: Maybe String
, url :: Maybe Url
, replies :: Collection Unordered Note
}
deriving Show
type NoteId = Url
type Followers = [Actor]
type Following = [Actor]
type Inbox = Collection Ordered Activity
type Outbox = Collection Ordered Activity
type OutboxPage = OrderedCollectionPage Activity
data Ordered
data Unordered
data OrderedCollectionPage a
= OrderedCollectionPage
{ id :: Url
, partOf :: Url
, orderedItems :: [a]
}
deriving Show
data Collection order a
= Collection
{ id :: Url
, summary :: String
, items :: [a]
, first :: Maybe Url
, last :: Maybe Url
}
deriving Show
instance A.ToJSON Note where
toJSON note =
A.object $
[ "@context" A..=
( "https://www.w3.org/ns/activitystreams" :: String
)
, "id" A..= note.id
, "type" A..= ("Note" :: String)
, "summary" A..= (Nothing :: Maybe String)
, "inReplyTo" A..= note.inReplyTo
, "published" A..= note.published
, "attributedTo" A..= note.actor
, "content" A..= note.content
, "name" A..= note.name
, "replies" A..= note.replies
, "sensitive" A..= False
, "tag" A..= ([] :: [String])
, "to" A..= [
"https://www.w3.org/ns/activitystreams#Public" :: String
]
, "cc" A..= [
note.actor <> "/followers" :: String
]
, "likes" A..=
( Collection
{ id = note.id <> "/likes"
, summary = "likes"
, items = []
, first = Nothing
, last = Nothing
} :: Collection Unordered Activity
)
, "shares" A..= (Nothing :: Maybe String)
]
<> [ "name" A..= name | Just name <- [note.name] ]
<> [ "url" A..= url | Just url <- [note.url] ]
instance A.ToJSON Object where
toJSON = \case
NoteObject note -> A.toJSON note
instance A.ToJSON Activity where
toJSON = \case
create@Create{} ->
A.object
[ "@context" A..=
( "https://www.w3.org/ns/activitystreams" :: String
)
, "type" A..= ("Create" :: String)
, "id" A..= create.id
, "actor" A..= create.actor
, "object" A..= create.object
]
instance A.ToJSON a => A.ToJSON (Collection Ordered a) where
toJSON collection =
A.object
[ "@context" A..=
( "https://www.w3.org/ns/activitystreams" :: String
)
, "id" A..= collection.id
, "type" A..= ("OrderedCollection" :: String)
, "summary" A..= collection.summary
, "totalItems" A..= length collection.items
, "orderedItems" A..= collection.items
, "first" A..= collection.first
, "last" A..= collection.last
]
instance A.ToJSON a => A.ToJSON (Collection Unordered a) where
toJSON collection =
A.object
[ "@context" A..=
( "https://www.w3.org/ns/activitystreams" :: String
)
, "id" A..= collection.id
, "type" A..= ("Collection" :: String)
, "summary" A..= collection.summary
, "totalItems" A..= length collection.items
, "items" A..= collection.items
, "first" A..= collection.first
, "last" A..= collection.last
]
instance A.ToJSON a => A.ToJSON (OrderedCollectionPage a) where
toJSON collection =
A.object
[ "@context" A..=
[ "https://www.w3.org/ns/activitystreams" :: String
]
, "type" A..= ("OrderedCollectionPage" :: String)
, "partOf" A..= collection.partOf
, "orderedItems" A..= collection.orderedItems
]

80
src/Fedi/Actor.hs Normal file
View file

@ -0,0 +1,80 @@
module Fedi.Actor where
import Data.Aeson qualified as A
import Fedi.Types
data Actor
= Actor
{ id :: Url
, name :: String
, preferredUsername :: String
, summary :: String
, icon :: Url
, publicKey :: PublicKey
}
deriving Show
type ActorId = Url
data ActorType
= Person
deriving Show
data PublicKey
= PublicKey
{ id :: Url
, owner :: Url
, publicKeyPem :: Pem
}
deriving Show
makeActor :: UserDetails -> Actor
makeActor details =
let
actor = actorUrl details
in Actor
{ id = actor
, name = details.name
, preferredUsername = details.username
, summary = details.summary
, icon = details.icon
, publicKey =
PublicKey
{ id = actor <> "#main-key"
, owner = actor
, publicKeyPem = details.publicPem
}
}
instance A.ToJSON Actor where
toJSON actor =
A.object
[ "@context" A..=
[ "https://www.w3.org/ns/activitystreams" :: String
, "https://w3id.org/security/v1"
]
, "id" A..= actor.id
, "type" A..= Person
, "name" A..= actor.name
, "preferredUsername" A..= actor.preferredUsername
, "summary" A..= actor.summary
, "icon" A..= A.object
[ "type" A..= ("Image" :: String)
, "mediaType" A..= ("image/png" :: String)
, "url" A..= actor.icon
]
, "inbox" A..= (actor.id <> "/inbox")
, "outbox" A..= (actor.id <> "/outbox")
, "publicKey" A..= actor.publicKey
]
instance A.ToJSON ActorType where
toJSON Person = A.String "Person"
instance A.ToJSON PublicKey where
toJSON pk =
A.object
[ "id" A..= pk.id
, "owner" A..= pk.owner
, "publicKeyPem" A..= pk.publicKeyPem
]

View file

@ -1,153 +0,0 @@
module Fedi.Helpers where
import Data.Text qualified as T
import Fedi.Types
import Fedi.UserDetails
-- | An empty activitypub Object.
emptyObject :: Object ()
emptyObject =
Object
{ id = Nothing
, otype = ()
, content = Nothing
, published = Nothing
, replies = Nothing
, attachment = Nothing
, attributedTo = Nothing
, tag = Nothing
, to = Nothing
, cc = Nothing
, inReplyTo = Nothing
, url = Nothing
, name = Nothing
, icon = Nothing
, image = Nothing
, preview = Nothing
, summary = Nothing
, updated = Nothing
, mediaType = Nothing
}
-- | Create an activitypub Actor.
makeActor :: UserDetails -> Actor
makeActor details =
let
actor = actorUrl details
in
ActorPerson $
emptyObject
{ id = Just $ ObjectId actor
, otype =
TypePerson
{ preferredUsername = details.username
, inbox = Link $ actor <> "/inbox"
, outbox = Link $ actor <> "/outbox"
, following = Link $ actor <> "/following"
, followers = Link $ actor <> "/followers"
, publicKey =
PublicKey
{ pkid = actor <> "#main-key"
, owner = actor
, publicKeyPem = details.publicPem
}
}
, url = Nothing -- details.url
, name = Just $ StringName details.name
, icon = Just $ makeImage details.icon
, image = Just $ makeImage details.image
, summary = Just $ T.pack details.summary
}
makeCreateNote :: Note -> Create
makeCreateNote note =
emptyObject
{ id = (\oid -> ObjectId $ oid.unwrap <> "/create") <$> note.id
, otype =
TypeActivity
{ actor = maybe (Link "") getAttributedTo note.attributedTo
, atype = TypeCreate note
, target = Nothing
, origin = Nothing
}
}
-- | Create an user's empty 'Note'.
emptyUserNote :: Url -> Note
emptyUserNote actor =
emptyObject
{ otype = emptyTypeNote
, attributedTo = Just (LLink $ Link actor)
, to = Just [Link "https://www.w3.org/ns/activitystreams#Public"]
, cc = Just [Link $ actor <> "/followers"]
}
-- | An empty 'Note'.
emptyTypeNote :: TypeNote
emptyTypeNote =
TypeNote
{ likes = emptyUnorderedCollection
, shares = emptyUnorderedCollection
, replies = emptyUnorderedCollection
, sensitive = False
}
-- | Create an activitypub Image.
makeImage :: Url -> Image
makeImage link =
emptyObject
{ otype = TypeImage
, mediaType = Just ("image/png" :: MediaType)
, url = Just link
}
-- | An empty 'Collection'.
emptyUnorderedCollection :: Collection a
emptyUnorderedCollection =
emptyObject
{ otype =
CollectionType
{ ctype =
UnorderedCollectionType
{ items = []
}
, first = Nothing
, last = Nothing
, current = Nothing
}
}
-- | An empty 'OrderedCollection'.
emptyOrderedCollection :: OrderedCollection a
emptyOrderedCollection =
emptyObject
{ otype =
CollectionType
{ ctype =
OrderedCollectionType
{ totalItems = 0
}
, first = Nothing
, last = Nothing
, current = Nothing
}
}
-- | Create an empty 'OrderedCollectionPage'.
emptyOrderedCollectionPage :: Url -> OrderedCollectionPage a
emptyOrderedCollectionPage url =
emptyObject
{ otype =
CollectionType
{ ctype =
OrderedCollectionPageType
{ partOf = url
, prev = Nothing
, next = Nothing
, orderedItems = []
}
, first = Nothing
, last = Nothing
, current = Nothing
}
}

View file

@ -1,14 +1,16 @@
module Fedi.Routes where module Fedi.Routes where
import Data.List (find)
import Web.Twain qualified as Twain
import Web.Twain.Types qualified as Twain
import Data.String (fromString)
import Data.Aeson qualified as A import Data.Aeson qualified as A
import Data.ByteString qualified as BS import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL import Data.ByteString.Lazy qualified as BSL
import Fedi.Helpers
import Fedi.Types import Fedi.Types
import Fedi.UserDetails import Fedi.Activity
import Fedi.Actor
import Fedi.Webfinger import Fedi.Webfinger
import Web.Twain qualified as Twain
import Web.Twain.Types qualified as Twain
-- * Routes -- * Routes
@ -16,16 +18,17 @@ routes :: UserDetails -> [Twain.Middleware]
routes details = routes details =
[ Twain.get (matchUser details) do [ Twain.get (matchUser details) do
handleUser details handleUser details
, Twain.get (matchOutbox details) do , Twain.get (matchOutbox details) do
handleOutbox details [] handleOutbox details []
, Twain.get (matchCreateNote details) do
handleCreateNote details []
, Twain.get (matchNote details) do
handleNote details []
, -- , Twain.post (matchInbox details) do
-- handleInbox details undefined
Twain.get matchWebfinger do , Twain.get (matchCreate details) do
handleUser details
, Twain.get (matchNote details) do
handleUser details
, Twain.get matchWebfinger do
handleWebfinger details handleWebfinger details
] ]
@ -37,11 +40,11 @@ jsonLD =
-- * Create -- * Create
matchCreateNote :: UserDetails -> Twain.PathPattern matchCreate :: UserDetails -> Twain.PathPattern
matchCreateNote details = fromString ("/" <> details.username <> "/notes/:note_id/create") matchCreate details = fromString ("/" <> details.username <> "/notes/:note_id/create")
handleCreateNote :: UserDetails -> [Create] -> Twain.ResponderM a handleCreate :: UserDetails -> [Activity] -> Twain.ResponderM a
handleCreateNote details items = do handleCreate details items = do
noteId <- Twain.param "note_id" noteId <- Twain.param "note_id"
let let
createUrl = createUrl =
@ -54,7 +57,7 @@ handleCreateNote details items = do
<> "/create" <> "/create"
let let
content = content =
find (\create -> create.id == Just (ObjectId createUrl)) items find (\create -> create.id == createUrl) items
Twain.send $ jsonLD (A.encode content) Twain.send $ jsonLD (A.encode content)
-- * Note -- * Note
@ -75,7 +78,7 @@ handleNote details items = do
<> noteId <> noteId
let let
content = content =
find (\note -> note.id == Just (ObjectId noteUrl)) items find (\note -> note.id == noteUrl) items
Twain.send $ jsonLD (A.encode content) Twain.send $ jsonLD (A.encode content)
-- * User -- * User
@ -85,8 +88,7 @@ matchUser details = fromString ("/" <> details.username)
handleUser :: UserDetails -> Twain.ResponderM a handleUser :: UserDetails -> Twain.ResponderM a
handleUser details = do handleUser details = do
let let content = makeActor details
content = makeActor details
Twain.send $ jsonLD (A.encode content) Twain.send $ jsonLD (A.encode content)
-- * Webfinger -- * Webfinger
@ -97,8 +99,7 @@ matchWebfinger = "/.well-known/webfinger"
handleWebfinger :: UserDetails -> Twain.ResponderM b handleWebfinger :: UserDetails -> Twain.ResponderM b
handleWebfinger details = do handleWebfinger details = do
resource <- Twain.param "resource" resource <- Twain.param "resource"
let let webfinger = makeWebfinger details
webfinger = makeWebfinger details
if resource == ppSubject webfinger.subject if resource == ppSubject webfinger.subject
then do then do
Twain.send $ jsonLD (A.encode webfinger) Twain.send $ jsonLD (A.encode webfinger)
@ -111,7 +112,7 @@ matchOutbox :: UserDetails -> Twain.PathPattern
matchOutbox details = matchOutbox details =
fromString ("/" <> details.username <> "/outbox") fromString ("/" <> details.username <> "/outbox")
handleOutbox :: UserDetails -> [AnyActivity] -> Twain.ResponderM b handleOutbox :: UserDetails -> [Activity] -> Twain.ResponderM b
handleOutbox details items = do handleOutbox details items = do
isPage <- Twain.queryParamMaybe "page" isPage <- Twain.queryParamMaybe "page"
let let
@ -125,56 +126,28 @@ handleOutbox details items = do
case isPage of case isPage of
Just True -> Just True ->
let let
empty = emptyOrderedCollectionPage outboxUrl
content :: OutboxPage content :: OutboxPage
content = content =
empty OrderedCollectionPage
{ id = Just $ ObjectId $ outboxUrl <> "?page=true" { id = outboxUrl <> "?page=true"
, otype = , partOf = outboxUrl
empty.otype
{ ctype =
empty.otype.ctype
{ partOf = outboxUrl
, orderedItems = items , orderedItems = items
} }
} in A.encode content
}
in
A.encode content
_ -> _ ->
let let
content :: Outbox content :: Outbox
content = content =
emptyOrderedCollection Collection
{ id = Just $ ObjectId outboxUrl { id = outboxUrl
, summary = Just $ fromString $ details.username <> "'s notes" , summary = details.username <> "'s notes"
, otype = , items = items
emptyOrderedCollection.otype
{ ctype =
emptyOrderedCollection.otype.ctype
{ totalItems = fromIntegral $ length items
}
, first = Just $ outboxUrl <> "?page=true" , first = Just $ outboxUrl <> "?page=true"
, last = Just $ outboxUrl <> "?page=true" , last = Just $ outboxUrl <> "?page=true"
} }
} in A.encode content
in
A.encode content
Twain.send $ jsonLD response Twain.send $ jsonLD response
-- * Inbox
-- matchInbox :: UserDetails -> Twain.PathPattern
-- matchInbox details =
-- fromString ("/" <> details.username <> "/inbox")
--
-- handleInbox :: UserDetails -> (Activity -> Twain.ResponderM b) -> Twain.ResponderM b
-- handleInbox _details _handle = do
-- let response = undefined
-- Twain.send $ jsonLD response
-- * Other stuff
checkContentTypeAccept :: Twain.Request -> Bool checkContentTypeAccept :: Twain.Request -> Bool
checkContentTypeAccept request = checkContentTypeAccept request =
case lookup Twain.hAccept request.requestHeaders of case lookup Twain.hAccept request.requestHeaders of

View file

@ -1,380 +1,48 @@
module Fedi.Types where module Fedi.Types where
import GHC.Generics (Generic)
import Data.Aeson qualified as A import Data.Aeson qualified as A
import Data.Aeson.Types qualified as A
import Data.Text qualified as T import Data.Text qualified as T
import Fedi.UserDetails
-- | An Object is what everything is here. data Rel = Self
-- <https://www.w3.org/TR/activitystreams-vocabulary/#object-types> deriving Show
data Object typ
= Object instance A.ToJSON Rel where
{ id :: Maybe ObjectId toJSON Self = A.String "self"
, otype :: typ
, content :: Maybe Content
, published :: Maybe UTCTime data LinkType = ActivityJson
, replies :: Maybe [Link] deriving Show
, attachment :: Maybe [AnyMedia]
, attributedTo :: Maybe (LinkOrObject Actor) instance A.ToJSON LinkType where
, -- , audience :: Maybe String toJSON ActivityJson = A.String "application/activity+json"
tag :: Maybe [Tag]
, to :: Maybe [Link] type Url = String
, cc :: Maybe [Link] type Domain = String
, inReplyTo :: Maybe (LinkOrObject Actor) type Username = String
, url :: Maybe Url -- revisit
, name :: Maybe Name newtype Pem = Pem T.Text
, icon :: Maybe Image deriving Show
, image :: Maybe Image deriving A.FromJSON via T.Text
, preview :: Maybe Preview
, summary :: Maybe T.Text instance A.ToJSON Pem where
, updated :: Maybe UTCTime toJSON (Pem pem) = A.String pem
, -- , bto :: Maybe String
-- , bcc :: Maybe String data UserDetails
mediaType :: Maybe MediaType = UserDetails
-- , duration :: Maybe String { domain :: Domain
, username :: String
, name :: String
, summary :: String
, icon :: Url
, publicPem :: Pem
, privatePem :: FilePath
} }
deriving (Show) deriving (Show, Generic, A.FromJSON)
class ToObject a where actorUrl :: UserDetails -> Url
toObject :: a -> [A.Pair] actorUrl details =
"https://" <> details.domain <> "/" <> details.username
instance (ToObject a) => A.ToJSON (Object a) where fullmention :: UserDetails -> String
toJSON = A.object . toObject fullmention details = "@" <> details.username <> "@" <> details.domain
instance (ToObject a) => ToObject (Object a) where
toObject object =
[ "@context"
A..= ("https://www.w3.org/ns/activitystreams" :: String)
]
<> toObject object.otype
<> [ assignment
| Just assignment <-
[ fmap ("id" A..=) object.id
, fmap ("content" A..=) object.content
, fmap ("attachement" A..=) object.attachment
, fmap ("attributedTo" A..=) object.attributedTo
, fmap ("published" A..=) object.published
, fmap ("inReplyTo" A..=) object.inReplyTo
, fmap ("url" A..=) object.url
, fmap ("name" A..=) object.name
, fmap ("icon" A..=) object.icon
, fmap ("image" A..=) object.image
, -- , fmap ("preview" A..= ) object.preview
fmap ("summary" A..=) object.summary
, fmap ("updated" A..=) object.updated
, fmap ("mediaType" A..=) object.mediaType
, fmap ("to" A..=) object.to
, fmap ("cc" A..=) object.cc
, fmap ("replies" A..=) object.replies
]
]
newtype ObjectId = ObjectId {unwrap :: String}
deriving (Show, Eq, A.FromJSON, A.ToJSON) via String
newtype Link = Link {unwrap :: Url}
deriving (Show, A.FromJSON, A.ToJSON) via Url
data LinkOrObject a
= LLink Link
| OObject (Object a)
| CCollection [LinkOrObject a]
deriving (Show)
getAttributedTo :: LinkOrObject a -> Link
getAttributedTo = \case
LLink link -> link
OObject obj -> Link (maybe (ObjectId "") id obj.id).unwrap
CCollection list ->
maybe (Link "") getAttributedTo (listToMaybe list)
instance (ToObject o) => A.ToJSON (LinkOrObject o) where
toJSON = \case
LLink link -> A.toJSON link
OObject ob -> A.toJSON ob
CCollection loos -> A.toJSON loos
data AnyMedia
= ImageMedia Image
deriving (Show)
instance A.ToJSON AnyMedia where
toJSON = \case
ImageMedia obj -> A.toJSON obj
type Image = Object TypeImage
data TypeImage = TypeImage deriving (Show)
instance ToObject TypeImage where
toObject TypeImage =
["type" A..= ("Image" :: String)]
data Name
= StringName String
| ObjectName (LinkOrObject Actor)
deriving (Show)
instance A.ToJSON Name where
toJSON = \case
StringName str -> A.toJSON str
ObjectName loo -> A.toJSON loo
type Content = T.Text
type MediaType = String
-- | A Note is an object that has the type 'Note'.
type Note = Object TypeNote
data TypeNote
= TypeNote
{ likes :: Collection Like
, shares :: Collection Share
, replies :: Collection Note
, sensitive :: Bool
}
deriving (Show)
instance ToObject TypeNote where
toObject note =
[ "type" A..= ("Note" :: String)
, "likes" A..= note.likes
, "shares" A..= note.shares
, "sensitive" A..= note.sensitive
]
type Tag = Object TypeTag
data TypeTag
= TypeTag
{ href :: Url
}
deriving (Show)
type Preview = Object TypePreview
data TypePreview = TypePreview
deriving (Show)
type Share = Object TypeShare
data TypeShare = TypeShare deriving (Show)
instance ToObject TypeShare where
toObject TypeShare =
[ "type" A..= ("Share" :: String)
]
-- * Activities
-- | An Activity is a superset of an Object with one of the following types,
-- <https://www.w3.org/TR/activitystreams-vocabulary/#activity-types>
-- and some additional fields.
type Activity t = Object (TypeActivity t)
data TypeActivity t
= TypeActivity
{ actor :: Link
, atype :: t
, target :: Maybe AnyActivity
, origin :: Maybe AnyActivity
-- , result :: Maybe String
-- , instrument :: Maybe String
}
deriving (Show)
instance (ToObject t) => ToObject (TypeActivity t) where
toObject activity =
[ "actor" A..= activity.actor
]
<> [ pair
| Just pair <-
[ fmap ("target" A..=) activity.target
, fmap ("origin" A..=) activity.origin
]
]
<> toObject activity.atype
-- type Announce = Object (TypeActivity TypeAnnounce)
-- data TypeAnnounce = TypeAnnounce deriving Show
-- instance ToObject TypeAnnounce where
-- toObject TypeAnnounce =
-- [ "type" A..= ("Announce" :: String)
-- ]
type Create = Activity TypeCreate
data TypeCreate
= TypeCreate
{ object :: Note
}
deriving (Show)
instance ToObject TypeCreate where
toObject create =
[ "type" A..= ("Create" :: String)
, "object" A..= create.object
]
-- type Follow = Object (TypeActivity TypeFollow)
-- data TypeFollow = TypeFollow deriving Show
-- instance ToObject TypeFollow where
-- toObject TypeFollow =
-- [ "type" A..= ("Follow" :: String)
-- ]
--
type Like = Object (TypeActivity TypeLike)
data TypeLike = TypeLike deriving (Show)
instance ToObject TypeLike where
toObject TypeLike =
[ "type" A..= ("Like" :: String)
]
data AnyActivity
= -- ActivityAnnounce Announce
ActivityCreate Create
-- | ActivityFollow Follow
-- | ActivityLike Like
deriving (Show)
instance A.ToJSON AnyActivity where
toJSON = \case
-- ActivityAnnounce obj -> A.toJSON obj
ActivityCreate obj -> A.toJSON obj
-- ActivityFollow obj -> A.toJSON obj
-- ActivityLike obj -> A.toJSON obj
-- * Actors
-- | An Actor is an object that has one of the following types.
-- <https://www.w3.org/TR/activitystreams-vocabulary/#actor-types>
data Actor = ActorPerson Person deriving (Show)
instance A.ToJSON Actor where
toJSON = \case
ActorPerson obj -> A.toJSON obj
instance ToObject Actor where
toObject = \case
ActorPerson obj -> toObject obj
-- | A Person is an object that has the type 'Person'.
type Person = Object TypePerson
data TypePerson
= TypePerson
{ preferredUsername :: String
, publicKey :: PublicKey
, inbox :: Link
, outbox :: Link
, following :: Link
, followers :: Link
}
deriving (Show)
instance ToObject TypePerson where
toObject person =
[ "type" A..= ("Person" :: String)
, "preferredUsername" A..= person.preferredUsername
, "publicKey" A..= person.publicKey
, "inbox" A..= person.inbox
, "outbox" A..= person.outbox
, "following" A..= person.following
, "followers" A..= person.followers
]
data PublicKey
= PublicKey
{ pkid :: Url
, owner :: Url
, publicKeyPem :: Pem
}
deriving (Show)
instance A.ToJSON PublicKey where
toJSON pk =
A.object
[ "id" A..= pk.pkid
, "owner" A..= pk.owner
, "publicKeyPem" A..= pk.publicKeyPem
]
-- * Collections
type Collection e = Object (CollectionType (Unordered e))
type OrderedCollection e = Object (CollectionType (Ordered e))
type OrderedCollectionPage e = Object (CollectionType (OrderedPage e))
type Outbox = OrderedCollection AnyActivity
type OutboxPage = OrderedCollectionPage AnyActivity
data CollectionType t
= CollectionType
{ ctype :: t
, first :: Maybe Url
, last :: Maybe Url
, current :: Maybe Url
}
deriving (Show)
instance (ToObject t) => ToObject (CollectionType t) where
toObject collection =
toObject collection.ctype
<> [ pair
| Just pair <-
[ fmap ("first" A..=) collection.first
, fmap ("last" A..=) collection.last
, fmap ("current" A..=) collection.current
]
]
data Unordered e
= UnorderedCollectionType
{ items :: [e]
}
deriving (Show)
instance (A.ToJSON e) => ToObject (Unordered e) where
toObject collection =
[ "type" A..= ("Collection" :: String)
, "totalItems" A..= length collection.items
, "items" A..= collection.items
]
data Ordered e
= OrderedCollectionType
{ totalItems :: Integer
}
deriving (Show)
instance (A.ToJSON e) => ToObject (Ordered e) where
toObject collection =
[ "type" A..= ("OrderedCollection" :: String)
, "totalItems" A..= collection.totalItems
]
data OrderedPage e
= OrderedCollectionPageType
{ partOf :: Url
, prev :: Maybe Url
, next :: Maybe Url
, orderedItems :: [e]
}
deriving (Show)
instance (A.ToJSON e) => ToObject (OrderedPage e) where
toObject page =
[ "type" A..= ("OrderedCollectionPage" :: String)
, "totalItems" A..= length page.orderedItems
, "orderedItems" A..= page.orderedItems
, "partOf" A..= page.partOf
, "prev" A..= page.prev
, "next" A..= page.next
]

View file

@ -1,46 +0,0 @@
module Fedi.UserDetails (
module Fedi.UserDetails,
module Export,
) where
import Data.Aeson qualified as A
import Data.List as Export (find)
import Data.Maybe as Export (fromMaybe, listToMaybe, maybeToList)
import Data.String as Export (fromString)
import Data.Text as Export (Text)
import Data.Text qualified as T
import Data.Time as Export (UTCTime)
import GHC.Generics as Export (Generic)
type Url = String
type Domain = String
type Username = String
newtype Pem = Pem T.Text
deriving (Show)
deriving (A.FromJSON) via T.Text
instance A.ToJSON Pem where
toJSON (Pem pem) = A.String pem
data UserDetails
= UserDetails
{ domain :: Domain
, username :: String
, name :: String
, summary :: String
, icon :: Url
, image :: Url
, publicPem :: Pem
, privatePem :: FilePath
}
deriving (Show, Generic, A.FromJSON)
actorUrl :: UserDetails -> Url
actorUrl details =
"https://" <> details.domain <> "/" <> details.username
fullmention :: UserDetails -> String
fullmention details = "@" <> details.username <> "@" <> details.domain

View file

@ -1,38 +1,34 @@
module Fedi.Webfinger where module Fedi.Webfinger where
import Data.String (fromString)
import Data.Aeson qualified as A import Data.Aeson qualified as A
import Fedi.UserDetails import Fedi.Types
data Webfinger data Webfinger
= Webfinger = Webfinger
{ subject :: Subject { subject :: Subject
, links :: [WfLink] , links :: [Link]
} }
deriving (Show) deriving Show
data Subject data Subject
= Subject = Subject
{ username :: Username { username :: Username
, domain :: Domain , domain :: Domain
} }
deriving (Show) deriving Show
ppSubject :: Subject -> String ppSubject :: Subject -> String
ppSubject subject = ppSubject subject =
"acct:" <> subject.username <> "@" <> subject.domain "acct:" <> subject.username <> "@" <> subject.domain
data WfLink data Link
= WfLink = Link
{ type_ :: WfLinkType { rel :: Rel
, type_ :: LinkType
, href :: Url , href :: Url
} }
deriving (Show) deriving Show
data WfLinkType = ActivityJson
deriving (Show)
instance A.ToJSON WfLinkType where
toJSON ActivityJson = A.String "application/activity+json"
makeWebfinger :: UserDetails -> Webfinger makeWebfinger :: UserDetails -> Webfinger
makeWebfinger details = makeWebfinger details =
@ -40,21 +36,20 @@ makeWebfinger details =
url = "https://" <> details.domain url = "https://" <> details.domain
in in
Webfinger Webfinger
{ subject = { subject = Subject
Subject
{ username = details.username { username = details.username
, domain = details.domain , domain = details.domain
} }
, links = , links =
[ WfLink [ Link
{ type_ = ActivityJson { rel = Self
, type_ = ActivityJson
, href = url <> "/" <> details.username , href = url <> "/" <> details.username
} }
] ]
} }
-- * ------------------------- -- * -------------------------
--- ---
instance A.ToJSON Webfinger where instance A.ToJSON Webfinger where
@ -68,10 +63,10 @@ instance A.ToJSON Subject where
toJSON subject = toJSON subject =
fromString $ ppSubject subject fromString $ ppSubject subject
instance A.ToJSON WfLink where instance A.ToJSON Link where
toJSON link = toJSON link =
A.object A.object
[ "rel" A..= ("self" :: String) [ "rel" A..= link.rel
, "type" A..= link.type_ , "type" A..= link.type_
, "href" A..= link.href , "href" A..= link.href
] ]