fedi/app/Main.hs
2024-12-17 10:46:59 +02:00

232 lines
7.1 KiB
Haskell

module Main where
import Data.SecureMem (secureMemFromByteString)
import Data.String (fromString)
import Database.Sqlite.Easy qualified as Sqlite
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.RequestLogger qualified as Logger
import System.Environment (getArgs)
import System.Environment (lookupEnv)
import Web.Twain qualified as Twain
import Fedi qualified as Fedi
import Data.Functor ((<&>))
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import qualified Data.Text as T
import System.IO.Unsafe (unsafePerformIO)
import Control.Monad.IO.Class (liftIO)
import Lucid qualified as H
import Html
import DB
data Command
= Serve
| Insert FilePath
main :: IO ()
main = do
command <- getArgs >>= \case
["insert", file] -> pure (Insert file)
["serve"] -> pure Serve
_ -> usageError
case command of
Insert file -> do
insertNoteFromFile file
Serve ->
serve
insertNoteFromFile :: FilePath -> IO ()
insertNoteFromFile file = do
connStr <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING"
content <- T.readFile file
detailsFile <- lookupEnv "FEDI_DETAILS"
<&> maybe (error "missing FEDI_DETAILS") id
details <- A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
db <- mkDB connStr details
note <- db.insertNote NoteEntry
{ content = content
, inReplyTo = Nothing
, name = Nothing
, url = Nothing
}
putStrLn "Inserted."
print note
serve :: IO ()
serve = do
auth <- fmap (T.splitOn "," . T.pack) <$> lookupEnv "FEDI_AUTH"
authMiddleware <-
case auth of
Nothing -> do
putStrLn "Starting server with authentication disabled."
pure id
Just [user, pass] -> do
putStrLn "Starting server with authentication enabled,"
let
username = secureMemFromByteString $ T.encodeUtf8 user
password = secureMemFromByteString $ T.encodeUtf8 pass
pure $ basicAuth
( \u p -> pure $
secureMemFromByteString u == username
&& secureMemFromByteString p == password
)
"My Fediserve"
Just{} -> usageError
fediPort <- maybe 3001 read <$> lookupEnv "FEDI_PORT"
conn <- maybe "/tmp/fediserve_sqlite_db.sqlite" fromString <$> lookupEnv "FEDI_CONN_STRING"
putStrLn $ "and with connection string " <> show (Sqlite.unConnectionString conn) <> "."
runServer fediPort authMiddleware =<< mkFediApp conn
usageError :: err
usageError =
errorWithoutStackTrace $ unlines
[ "Usage: fedi [ insert <FILE> | serve ]"
, "Env vars:"
, " - FEDI_PORT=<PORT>"
, " - FEDI_DETAILS=<FILE>"
, " - FEDI_CONN_STRING=<SQLITE_CONN_STR>"
, " - FEDI_AUTH=<user>,<password>"
]
-- | Run server at at specific port.
runServer :: Port -> Twain.Middleware -> Twain.Application -> IO ()
runServer port authMiddleware app = do
putStrLn $ unwords
[ "Running fedi at"
, "http://localhost:" <> show port
, "(ctrl-c to quit)"
]
auth <- Wai.routedMiddleware matchAdmin <$> pure authMiddleware
run port (Logger.logStdoutDev $ auth app)
matchAdmin :: [T.Text] -> Bool
matchAdmin = any (=="admin")
-- | Application description.
mkFediApp :: Sqlite.ConnectionString -> IO Twain.Application
mkFediApp connStr = do
detailsFile <- lookupEnv "FEDI_DETAILS"
<&> maybe (error "missing FEDI_DETAILS") id
details <- A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
db <- mkDB connStr details
pure $ foldr ($)
(Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
(routes db detailsFile)
-- * Routes
routes :: DB -> FilePath -> [Twain.Middleware]
routes db detailsFile =
[ -- Match actor
Twain.get (Fedi.matchUser $ unsafePerformIO $ fetchUserDetails detailsFile) do
request <- Twain.request
if Fedi.checkContentTypeAccept request
then do
details <- liftIO $ fetchUserDetails detailsFile
Fedi.handleUser details
else do
details <- liftIO $ fetchUserDetails detailsFile
notes <- liftIO db.getNotes
Twain.send $ Twain.html $ H.renderBS $ actorPage details notes
, -- Match outbox
Twain.get (Fedi.matchOutbox $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile
notes <- map noteToCreate <$> liftIO db.getNotes
Fedi.handleOutbox details notes
, -- Match Create object
Twain.get (Fedi.matchCreate $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile
notes <- map noteToCreate <$> liftIO db.getNotes
Fedi.handleCreate details notes
, -- Match Note object
Twain.get (Fedi.matchNote $ unsafePerformIO $ fetchUserDetails detailsFile) do
details <- liftIO $ fetchUserDetails detailsFile
notes <- liftIO db.getNotes
request <- Twain.request
if Fedi.checkContentTypeAccept request
then do
Fedi.handleNote details notes
else do
noteId <- Twain.param "note_id"
let
noteUrl =
"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
Twain.get Fedi.matchWebfinger do
details <- liftIO $ fetchUserDetails detailsFile
Fedi.handleWebfinger details
, -- Admin page
Twain.get (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin") do
details <- liftIO $ fetchUserDetails detailsFile
notes <- liftIO db.getNotes
Twain.send $ Twain.html $ H.renderBS $ adminPage details notes
, -- New post
Twain.post (fromString $ "/" <> (unsafePerformIO $ fetchUserDetails detailsFile).username <> "/admin/new") do
title <- Twain.param "title"
content <- Twain.param "content"
url <- Twain.param "url"
details <- liftIO $ fetchUserDetails detailsFile
noteid <-
liftIO $ db.insertNote NoteEntry
{ content = content
, inReplyTo = Nothing
, name = if trim title == "" then Nothing else Just title
, url = if trim url == "" then Nothing else Just url
}
Twain.send $ Twain.redirect302 (fromString ("/" <> details.username <> "/notes/" <> noteid))
]
trim :: String -> String
trim = unwords . words
fetchUserDetails :: FilePath -> IO Fedi.UserDetails
fetchUserDetails detailsFile =
A.eitherDecodeFileStrict detailsFile
<&> either (\err -> error $ "could not read file '" <> detailsFile <> "'.\n" <> err) id
noteToCreate :: Fedi.Note -> Fedi.Activity
noteToCreate note =
Fedi.Create
{ id =
note.id <> "/create"
, actor = note.actor
, object = Fedi.NoteObject note
}