{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE QuasiQuotes #-} {-# OPTIONS_GHC -Wno-orphans #-} module WhatcdResolver where import AppT import Arg import Builder import Comparison import Conduit (ConduitT) import Conduit qualified import Control.Category qualified as Cat import Control.Monad.Logger.CallStack import Control.Monad.Reader import Data.Aeson qualified as Json import Data.Aeson.BetterErrors qualified as Json import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString qualified as ByteString import Data.CaseInsensitive (CI) import Data.Conduit ((.|)) import Data.Error.Tree import Data.HashMap.Strict qualified as HashMap import Data.List qualified as List import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict qualified as Map import Data.Pool qualified as Pool import Data.Text qualified as Text import Database.PostgreSQL.Simple qualified as Postgres import Database.PostgreSQL.Simple.Types (Only (..), PGArray (PGArray)) import Database.Postgres.Temp qualified as TmpPg import FieldParser (FieldParser) import FieldParser qualified as Field import GHC.OverloadedLabels (IsLabel (fromLabel)) import GHC.Records (HasField (..)) import GHC.TypeLits (Symbol) import Html qualified import Http import IHP.HSX.QQ (hsx) import IHP.HSX.ToHtml (ToHtml) import Json qualified import Json.Enc (Enc) import Json.Enc qualified as Enc import JsonLd import Label import Multipart2 (MultipartParseT) import Multipart2 qualified as Multipart import MyPrelude import Network.HTTP.Client.Conduit qualified as Http import Network.HTTP.Simple qualified as Http import Network.HTTP.Types import Network.HTTP.Types qualified as Http import Network.Wai (ResponseReceived) import Network.Wai qualified as Wai import Network.Wai.Handler.Warp qualified as Warp import Network.Wai.Parse (parseContentType) import OpenTelemetry.Attributes qualified as Otel import OpenTelemetry.Context.ThreadLocal qualified as Otel import OpenTelemetry.Trace qualified as Otel hiding (getTracer, inSpan, inSpan') import OpenTelemetry.Trace.Monad qualified as Otel import Parse (Parse, showContext) import Parse qualified import Postgres.Decoder qualified as Dec import Postgres.MonadPostgres import Pretty import Redacted import RunCommand (runCommandExpect0) import System.Directory qualified as Dir import System.Directory qualified as Xdg import System.Environment qualified as Env import System.FilePath ((>)) import Text.Blaze.Html (Html) import Text.Blaze.Html.Renderer.Utf8 qualified as Html import Text.Blaze.Html5 qualified as Html import Tool (readTool, readTools) import Transmission import UnliftIO hiding (Handler) import UnliftIO.Async qualified as Async import UnliftIO.Concurrent (threadDelay) import Prelude hiding (span) main :: IO () main = runAppWith ( do -- todo: trace that to the init functions as well Otel.inSpan "whatcd-resolver main function" Otel.defaultSpanArguments $ do _ <- runTransaction migrate htmlUi ) <&> first showToError >>= expectIOError "could not start whatcd-resolver" htmlUi :: AppT IO () htmlUi = do uniqueRunId <- runTransaction $ querySingleRowWith [sql| SELECT gen_random_uuid()::text |] () (Dec.fromField @Text) ourHtmlIntegrities <- prefetchHtmlIntegrities (counterHtmlM, counterHandler, _counterAsync) <- testCounter (label @"endpoint" "counter") withRunInIO $ \runInIO -> Warp.run 9093 $ \req respondOrig -> do let catchAppException act = try act >>= \case Right a -> pure a Left (AppExceptionTree err) -> do runInIO (logError (prettyErrorTree err)) respondOrig (Wai.responseLBS Http.status500 [] "") Left (AppExceptionPretty err) -> do runInIO (logError (err & Pretty.prettyErrsNoColor & stringToText)) respondOrig (Wai.responseLBS Http.status500 [] "") Left (AppExceptionEnc err) -> do runInIO (logError (Enc.encToTextPrettyColored err)) respondOrig (Wai.responseLBS Http.status500 [] "") catchAppException $ do let torrentIdMp span = parseMultipartOrThrow span req ( do label @"torrentId" <$> Multipart.field "torrent-id" ((Field.utf8 >>> Field.signedDecimal >>> Field.bounded @Int "int")) ) let parseQueryArgsNewSpan spanName parser = Parse.runParse "Unable to find the right request query arguments" (lmap Wai.queryString parser) req & assertMNewSpan spanName (first AppExceptionTree) let handlers :: Handlers (AppT IO) handlers = Map.fromList $ ourHtmlIntegrities.handlers <> [ ( "", HtmlStream (pure ()) $ \_dat span -> ( pure $ htmlPageChrome ourHtmlIntegrities "whatcd-resolver", do counterHtml <- counterHtmlM mainHtml counterHtml uniqueRunId span ) ), ( "redacted-search", HtmlStream (label @"searchstr" <$> singleQueryArgument "searchstr" Cat.id) $ \dat _span -> ( pure $ htmlPageChrome ourHtmlIntegrities [fmt|whatcd-resolver – Search – {dat.queryArgs.searchstr & bytesToTextUtf8Lenient}|], do t <- redactedSearchAndInsert [("searchstr", dat.queryArgs.searchstr)] runTransaction $ do res <- t (table, settings) <- concurrentlyTraced ( do d <- getBestTorrentsData bestTorrentsDataDefault ( Just ( E21 (label @"onlyTheseTorrents" res.newTorrents) ) :: Maybe ( E2 "onlyTheseTorrents" [Label "torrentId" Int] "artistRedactedId" Int ) ) pure $ mkBestTorrentsTableByReleaseType d ) (getSettings) pure $ mainHtml' ( MainHtml { returnUrl = dat.returnUrl, counterHtml = "", mainContent = [hsx|
{dat.queryArgs.searchstr}No torrents found
|] Just d' -> mkBestTorrentsTableSection (lbl #sectionName "Last Releases") d' ) (getSettings) -- transmissionTorrentsTable <- lift @Transaction getTransmissionTorrentsTable pure $ mainHtml' ( MainHtml { returnUrl = "/", counterHtml, mainContent = bestTorrentsTable, uniqueRunId, settings, searchFieldContent = "" } ) data MainHtml = MainHtml { returnUrl :: ByteString, counterHtml :: Html, mainContent :: Html, searchFieldContent :: Text, uniqueRunId :: Text, settings :: Settings } mainHtml' :: MainHtml -> Html mainHtml' dat = do [hsx| {dat.counterHtml} {settingButtons dat}{err & prettyErrorTree}
|]
)
let htmlWithQueryArgs parser act = case htmlWithQueryArgs' parser of
Right dat -> html (act dat)
Left act' -> html act'
let htmlStream :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> (m HtmlHead, m Html)) -> m ResponseReceived
htmlStream parser act = inRouteSpan $ \span -> do
case htmlWithQueryArgs' parser of
Left act' -> html act'
Right dat -> do
let (mkHead, mkBody) = act dat span
-- start the body work (heh) immediately, but stream the head first
withAsyncTraced mkBody $ \bodyAsync -> do
withRunInIO $ \runInIO' -> respond $ Wai.responseStream Http.ok200 [("Content-Type", "text/html")] $ \send flush -> do
runInIO' $ inSpan "sending " $ do
htmlHead <- mkHead
liftIO $ do
send "\n"
send "\n"
send $
Html.renderHtmlBuilder $
[hsx|
| Local | Group ID | Artist | Name | Type | Year | Weight | Format | Torrent |
|---|
Using freeleech tokens!
|] else submitSettingForm opts [hsx|Not using freeleech tokens
|] settingsMultipartParser :: (Applicative m) => Settings -> MultipartParseT m Settings settingsMultipartParser old = do useFreeleechTokens <- do on <- Multipart.fieldMay "useFreeleechTokensON" (cconst $ True) off <- Multipart.fieldMay "useFreeleechTokensOFF" (cconst $ False) pure $ (on <|> off) & fromMaybe old.useFreeleechTokens pure $ Settings {..} getSettings :: (MonadPostgres m, MonadOtel m) => Transaction m Settings getSettings = inSpan' "Get Settings" $ \span -> do res <- foldRowsWithMonoid [sql| SELECT key, value FROM redacted.settings |] () ( do key <- Dec.text Dec.jsonMay ( case key of "useFreeleechTokens" -> settingFreeleechToken <$> Json.asBool _ -> pure mempty ) <&> fromMaybe mempty ) lift $ addAttribute span "settings" (toOtelAttrGenericStruct res) pure res writeSettings :: (MonadPostgres m, MonadOtel m) => [T2 "key" Text "val" Json.Value] -> Transaction m (Label "numberOfRowsAffected" Natural) writeSettings settings = inSpan' "Write Settings" $ \span -> do addAttribute span "settings" ( toOtelJsonAttr $ Enc.list (\s -> Enc.tuple2 Enc.text Enc.value (s.key, s.val)) settings ) execute [sql| INSERT INTO redacted.settings (key, value) SELECT * FROM UNNEST(?::text[], ?::jsonb[]) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value |] (settings & unzipPGArray @"key" @Text @"val" @Json.Value) -- | Given a conduit that produces Html, -- return a htmx html snippet which will regularly poll for new results in the conduit, -- and a handler endpoint that returns the newest output when it happens. conduitToHtmx :: (HasField "endpoint" opts Text, MonadUnliftIO m) => opts -> -- | initial inner html Html -> ConduitT () Html m () -> m (m Html, HandlerResponse m, Async.Async ()) conduitToHtmx opts init' conduit = do let htmlPolling inner = [hsx|0
|] counterConduit counterConduit :: (MonadIO m) => ConduitT i Html m () counterConduit = Conduit.yieldMany [0 .. 100] .| Conduit.awaitForever ( \(i :: Int) -> do threadDelay 300_000 Conduit.yield [hsx|{i}
|] ) data HtmlIntegrity = HtmlIntegrity { -- | The name of the resource, for debugging purposes integrityName :: Text, -- | The URL of the resource content integrityUrl :: Text, -- | The integrity hash of the resource integrityHash :: Text, -- | The local url path to fetch the cached resource from the frontend localPath :: Text, -- | Whether there is a resource map at the URL + `.map` provideSourceMap :: Bool, -- | is @@ or @|], \(Arg giveSourceMap) -> Plain $ do if | giveSourceMap, Just sourceMap <- mSourceMap -> do pure $ Wai.responseLBS Http.ok200 [ ( "Content-Type", "application/json" ), ("Content-Length", buildBytes intDecimalB (ByteString.length sourceMap)) ] (toLazyBytes sourceMap) | giveSourceMap -> do pure $ Wai.responseLBS Http.notFound404 [] "" | otherwise -> do pure $ Wai.responseLBS Http.ok200 [ ( "Content-Type", mContentType & fromMaybe ( tagMatch #script "text/javascript; charset=UTF-8" #link "text/css; charset=UTF-8" ) ), ("Content-Length", buildBytes intDecimalB bodyLength) ] (toLazyBytes $ bodyStrict) ) | code <- statusCode -> appThrow span $ AppExceptionPretty [[fmt|Server returned an non-200 error code, code {code}:|], pretty resp] -- case-match on an e2 with a t2 that provides the relevant functions caseE2 :: forall l1 t1 l2 t2 matcher r. ( HasField l1 matcher (t1 -> r), HasField l2 matcher (t2 -> r) ) => matcher -> E2 l1 t1 l2 t2 -> r {-# INLINE caseE2 #-} caseE2 m e2 = do let f1 = getField @l1 m let f2 = getField @l2 m case e2 of E21 a -> f1 $ getField @l1 a E22 b -> f2 $ getField @l2 b t2 :: forall l1 t1 l2 t2. LabelPrx l1 -> t1 -> LabelPrx l2 -> t2 -> T2 l1 t1 l2 t2 {-# INLINE t2 #-} t2 LabelPrx a LabelPrx b = T2 (label @l1 a) (label @l2 b) t3 :: forall l1 t1 l2 t2 l3 t3. LabelPrx l1 -> t1 -> LabelPrx l2 -> t2 -> LabelPrx l3 -> t3 -> T3 l1 t1 l2 t2 l3 t3 {-# INLINE t3 #-} t3 LabelPrx a LabelPrx b LabelPrx c = T3 (label @l1 a) (label @l2 b) (label @l3 c) lbl :: forall l t. LabelPrx l -> t -> Label l t {-# INLINE lbl #-} lbl LabelPrx a = label @l a data LabelPrx (l :: Symbol) = LabelPrx instance (l ~ l') => IsLabel l (LabelPrx l') where fromLabel = LabelPrx instance (t ~ t') => IsLabel l (t -> (Label l t')) where fromLabel = label @l