snix/users/Profpatsch/whatcd-resolver/src/WhatcdResolver.hs
Profpatsch cb5a745233 fix(users/Profpatsch/whatcd-resolver): speed up artist_ids lookups
We need `artist_ids` in most of the queries, so let’s make them a
generated column and put an index on them.

This reduces times for getArtistNameById from ~300ms to ~20ms and for
the main “latest” table from ~250ms to ~60ms.

The `artist_has_been_snatched` logic moves into the torrent filter, so
we don’t have to work on such large records further down.

Change-Id: I5a1929bd9c81ea0031e512d01897c0e460ded077
Reviewed-on: https://cl.tvl.fyi/c/depot/+/13240
Tested-by: BuildkiteCI
Reviewed-by: Profpatsch <mail@profpatsch.de>
2025-03-10 16:55:15 +00:00

1598 lines
62 KiB
Haskell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{-# 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|<h1>Search results for <pre>{dat.queryArgs.searchstr}</pre></h1>{table}|],
uniqueRunId,
searchFieldContent = dat.queryArgs.searchstr & bytesToTextUtf8Lenient,
settings
}
)
)
),
( "snips/redacted/torrentDataJson",
Html $ \span -> do
dat <- torrentIdMp span
Html.mkVal <$> (runTransaction $ getTorrentById dat)
),
( "snips/redacted/getTorrentFile",
HtmlOrReferer $ \span -> do
dat <- torrentIdMp span
runTransaction $ do
settings <- getSettings
inserted <- redactedGetTorrentFileAndInsert (T2 dat (getLabel @"useFreeleechTokens" settings))
running <-
lift @Transaction $
doTransmissionRequest' (transmissionRequestAddTorrent inserted)
updateTransmissionTorrentHashById
( T2
(getLabel @"torrentHash" running)
(getLabel @"torrentId" dat)
)
pure $
everySecond
"snips/transmission/getTorrentState"
(Enc.object [("torrent-hash", Enc.text running.torrentHash)])
"Starting"
),
-- TODO: this is bad duplication??
( "snips/redacted/startTorrentFile",
Html $ \span -> do
dat <- torrentIdMp span
runTransaction $ do
file <-
getTorrentFileById dat
<&> annotate [fmt|No torrent file for torrentId "{dat.torrentId}"|]
>>= orAppThrow span
running <-
lift @Transaction $
doTransmissionRequest' (transmissionRequestAddTorrent file)
updateTransmissionTorrentHashById
( T2
(getLabel @"torrentHash" running)
(getLabel @"torrentId" dat)
)
pure $
everySecond
"snips/transmission/getTorrentState"
(Enc.object [("torrent-hash", Enc.text running.torrentHash)])
"Starting"
),
( "snips/transmission/getTorrentState",
Html $ \span -> do
dat <- parseMultipartOrThrow span req $ label @"torrentHash" <$> Multipart.field "torrent-hash" Field.utf8
status <-
doTransmissionRequest'
( transmissionRequestListOnlyTorrents
( T2
(label @"ids" [label @"torrentHash" dat.torrentHash])
(label @"fields" ["hashString"])
)
(Json.keyLabel @"torrentHash" "hashString" Json.asText)
)
<&> List.find (\torrent -> torrent.torrentHash == dat.torrentHash)
pure $
case status of
Nothing -> [hsx|ERROR unknown|]
Just _torrent -> [hsx|Running|]
),
( "snips/jsonld/render",
do
HtmlWithQueryArgs
( label @"target"
<$> ( (singleQueryArgument "target" Field.utf8 >>> textToURI)
& Parse.andParse uriToHttpClientRequest
)
)
( \dat _span -> do
jsonld <- httpGetJsonLd (dat.queryArgs.target)
pure $ renderJsonld jsonld
)
),
("counter", counterHandler),
( "settings",
PostAndRedirect
( do
settings <- runTransaction getSettings
pure $ do
returnTo <- Multipart.fieldLabel @"returnTo" "returnTo" Field.utf8
parsed <- label @"settings" <$> settingsMultipartParser settings
pure $ T2 returnTo parsed
)
$ \_span (s :: T2 "returnTo" Text "settings" Settings) -> do
let Settings {useFreeleechTokens} = s.settings
runTransaction $ do
_ <-
writeSettings
[ T2
(label @"key" "useFreeleechTokens")
(label @"val" $ Json.Bool useFreeleechTokens)
]
pure $ label @"redirectTo" (s.returnTo & textToBytesUtf8)
),
( "artist",
do
HtmlStream
( label @"artistRedactedId"
<$> ( singleQueryArgument
"redacted_id"
( Field.utf8
>>> (Field.decimalNatural <&> toInteger)
>>> (Field.bounded @Int "Int")
)
)
)
$ \dat _span ->
( do
runTransaction $ do
(artistName, _) <-
concurrentlyTraced
( inSpan' "finding artist name" $ \span -> do
addAttribute span "artist-redacted-id" (dat.queryArgs.artistRedactedId, intDecimalT)
mArtistName <- getArtistNameById (lbl #artistId dat.queryArgs.artistRedactedId)
let pageTitle = case mArtistName of
Nothing -> "whatcd-resolver"
Just a -> [fmt|{a} - Artist Page - whatcd-resolver|]
pure $ htmlPageChrome ourHtmlIntegrities pageTitle
)
( do
execute [sql|INSERT INTO redacted.artist_favourites (artist_id) VALUES (?) ON CONFLICT DO NOTHING|] (Only (dat.queryArgs.artistRedactedId :: Int))
)
pure artistName,
do
artistPage (T2 dat.queryArgs (label @"uniqueRunId" uniqueRunId))
)
),
( "artist/refresh",
HtmlOrRedirect $
\span -> do
dat <-
parseMultipartOrThrow
span
req
( label @"artistId"
<$> Multipart.field
"artist-id"
( Field.utf8
>>> (Field.decimalNatural <&> toInteger)
>>> (Field.bounded @Int "Int")
)
)
t <- redactedRefreshArtist dat
runTransaction $ do
t
pure $ E22 (label @"redirectTo" $ textToBytesUtf8 $ mkArtistLink dat)
),
( "autorefresh",
Plain $ do
qry <-
parseQueryArgsNewSpan
"Autorefresh Query Parse"
( label @"hasItBeenRestarted"
<$> singleQueryArgument "hasItBeenRestarted" Field.utf8
)
pure $
Wai.responseLBS
Http.ok200
( [("Content-Type", "text/html")]
<> if uniqueRunId /= qry.hasItBeenRestarted
then -- cause the client side to refresh
[("HX-Refresh", "true")]
else []
)
""
)
]
runInIO $
runHandlers
( Html $ \span -> do
counterHtml <- counterHtmlM
mainHtml counterHtml uniqueRunId span
)
handlers
req
respondOrig
where
everySecond :: Text -> Enc -> Html -> Html
everySecond call extraData innerHtml = [hsx|<div hx-trigger="every 1s" hx-swap="outerHTML" hx-post={call} hx-vals={Enc.encToBytesUtf8 extraData}>{innerHtml}</div>|]
mainHtml :: Html -> Text -> Otel.Span -> AppT IO Html
mainHtml counterHtml uniqueRunId _span = runTransaction $ do
-- jsonld <-
-- httpGetJsonLd
-- ( URI.parseURI "https://musicbrainz.org/work/92000fd4-d304-406d-aeb4-6bdbeed318ec" & annotate "not an URI" & unwrapError,
-- "https://musicbrainz.org/work/92000fd4-d304-406d-aeb4-6bdbeed318ec"
-- )
-- <&> renderJsonld
(bestTorrentsTable, settings) <-
concurrentlyTraced
( do
d <-
getBestTorrentsData
( BestTorrentsData
{ limitResults = Just 100,
ordering = ByLastReleases,
onlyFavourites = True,
disallowedReleaseTypes =
[ releaseTypeBootleg,
releaseTypeGuestAppearance,
releaseTypeRemix
],
..
}
)
Nothing
pure $ case d & nonEmpty of
Nothing -> [hsx|<h1>Latest Releases</h1><p>No torrents found</p>|]
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}
<form action="redacted-search">
<label for="redacted-search-input">Redacted Search</label>
<input
id="redacted-search-input"
type="text"
name="searchstr"
value={dat.searchFieldContent} />
<button type="submit" hx-disabled-elt="this">Search</button>
<div class="htmx-indicator">Search running!</div>
</form>
<div>
{dat.mainContent}
</div>
<!-- refresh the page if the uniqueRunId is different -->
<!-- <input
hidden
type="text"
id="autorefresh"
name="hasItBeenRestarted"
value={dat.uniqueRunId}
hx-get="/autorefresh"
hx-trigger="every 5s"
hx-swap="none"
/> -->
|]
withAsyncTraced :: (MonadUnliftIO m) => m a -> (Async a -> m b) -> m b
withAsyncTraced act f = do
ctx <- Otel.getContext
withAsync
( do
_old <- Otel.attachContext ctx
act
)
f
-- | Run two actions concurrently, and add them to the current Otel trace
concurrentlyTraced :: (MonadUnliftIO m) => m a -> m b -> m (a, b)
concurrentlyTraced act1 act2 = do
ctx <- Otel.getContext
concurrently
( do
_old <- Otel.attachContext ctx
act1
)
( do
_old <- Otel.attachContext ctx
act2
)
mapConcurrentlyTraced :: (MonadUnliftIO m, Traversable t) => (a -> m b) -> t a -> m (t b)
mapConcurrentlyTraced f t = do
ctx <- Otel.getContext
mapConcurrently
( \a -> do
_old <- Otel.attachContext ctx
f a
)
t
parseMultipartOrThrow :: (MonadLogger m, MonadIO m, MonadThrow m) => Otel.Span -> Wai.Request -> Multipart.MultipartParseT m a -> m a
parseMultipartOrThrow span req parser =
Multipart.parseMultipartOrThrow
(appThrow span . AppExceptionTree)
parser
req
-- | Reload the current page (via the Referer header) if the browser has Javascript disabled (and thus htmx does not work). This should make post requests work out of the box.
htmxOrReferer :: Wai.Request -> Wai.Response -> Wai.Response
htmxOrReferer req resp = do
let fnd h = req & Wai.requestHeaders & List.find (\(hdr, _) -> hdr == h)
let referer = fnd "Referer"
if
| Just _ <- fnd "Hx-Request" -> resp
| Nothing <- referer -> resp
| Just (_, rfr) <- referer -> do
Wai.responseLBS seeOther303 [("Location", rfr)] ""
-- | Redirect to the given page, if the browser has Javascript enabled use HTMX client side redirect, otherwise use a normal HTTP redirect.
redirectOrFallback :: ByteString -> (Status -> (CI ByteString, ByteString) -> Wai.Response) -> Wai.Request -> Wai.Response
redirectOrFallback target responseFn req = do
let fnd h = req & Wai.requestHeaders & List.find (\(hdr, _) -> hdr == h)
case fnd "Hx-Request" of
Just _ -> responseFn Http.ok200 ("Hx-Redirect", target)
Nothing -> responseFn Http.seeOther303 ("Location", target)
htmlPageChrome :: OurHtmlIntegrities m -> Text -> HtmlHead
htmlPageChrome integrities title =
HtmlHead
{ title,
headContent =
[hsx|
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
prevent favicon request, based on answers in
https://stackoverflow.com/questions/1321878/how-to-prevent-favicon-ico-requests
TODO: create favicon
-->
<link rel="icon" href="data:,">
{integrities.html}
<style>
dl {
margin: 1em;
padding: 0.5em 1em;
border: thin solid;
}
</style>
|]
}
data OurHtmlIntegrities m = OurHtmlIntegrities
{ html :: Html,
handlers :: [(Text, HandlerResponse m)]
}
prefetchHtmlIntegrities :: (MonadOtel m, MonadThrow m) => m (OurHtmlIntegrities m)
prefetchHtmlIntegrities = do
let resources =
[ HtmlIntegrity
{ integrityName = "Bootstrap CSS",
integrityUrl = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css",
integrityHash = "sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM",
localPath = "resources/bootstrap.min.css",
provideSourceMap = True,
isTag = E21 (label @"link" ())
},
HtmlIntegrity
{ integrityName = "Bootstrap JS",
integrityUrl = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js",
integrityHash = "sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz",
localPath = "resources/bootstrap.bundle.min.js",
provideSourceMap = True,
isTag = E22 (label @"script" ())
},
HtmlIntegrity
{ integrityName = "htmx",
integrityUrl = "https://unpkg.com/htmx.org@1.9.2",
integrityHash = "sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h",
localPath = "resources/htmx.js",
provideSourceMap = False,
isTag = E22 (label @"script" ())
}
]
resources
& mapConcurrentlyTraced
( \r ->
prefetchResourceIntegrity r <&> \(html, handler) ->
( html,
[(r.localPath, handler (Arg @"giveSourceMap" False))]
-- a little hacky, we provide an extra handler if there is a source map
<> ifTrue
(r.provideSourceMap)
[(r.localPath <> ".map", handler (Arg @"giveSourceMap" True))]
)
)
<&> fold
<&> \(html, handlers) -> OurHtmlIntegrities {..}
artistPage ::
( HasField "artistRedactedId" dat Int,
HasField "uniqueRunId" dat Text,
MonadPostgres m,
MonadOtel m,
MonadLogger m,
MonadThrow m,
MonadTransmission m
) =>
dat ->
m Html
artistPage dat = runTransaction $ do
(fresh, settings) <-
concurrentlyTraced
( getBestTorrentsData
bestTorrentsDataDefault
(Just $ E22 (getLabel @"artistRedactedId" dat))
)
(getSettings)
let torrents = mkBestTorrentsTableByReleaseType fresh
let returnUrl =
textToBytesUtf8 $
mkArtistLink (label @"artistId" (dat.artistRedactedId))
let mainContent =
[hsx|
<div id="artist-torrents">
{torrents}
</div>
<form method="post" action="artist/refresh" hx-post="artist/refresh">
<input
hidden
type="text"
name="artist-id"
value={dat.artistRedactedId & buildText intDecimalT}
/>
<button type="submit" hx-disabled-elt="this">Refresh Artist Page</button>
<div class="htmx-indicator">Refreshing!</div>
</form>
|]
pure $
mainHtml'
( MainHtml
{ -- pageTitle,
returnUrl,
counterHtml = "",
mainContent,
uniqueRunId = dat.uniqueRunId,
searchFieldContent = "",
settings
}
)
type Handlers m = Map Text (HandlerResponse m)
data QueryArgsDat a = QueryArgsDat
{ queryArgs :: a,
returnUrl :: ByteString
}
data HtmlHead = HtmlHead
{ title :: Text,
headContent :: Html
}
data HandlerResponse m where
-- | render html
Html :: (Otel.Span -> m Html) -> HandlerResponse m
-- | either render html or redirect to another page
HtmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> HandlerResponse m
-- | render html after parsing some query arguments
HtmlWithQueryArgs :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> m Html) -> HandlerResponse m
-- | render html or reload the page via the Referer header if no htmx
HtmlOrReferer :: (Otel.Span -> m Html) -> HandlerResponse m
-- | render html and stream the head before even doing any work in the handler
HtmlStream :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> (m HtmlHead, m Html)) -> HandlerResponse m
-- | parse the request as POST submission, then redirect to the given endpoint
PostAndRedirect ::
m (MultipartParseT m dat) ->
(Otel.Span -> dat -> m (Label "redirectTo" ByteString)) ->
HandlerResponse m
-- | render a plain wai response
Plain :: m Wai.Response -> HandlerResponse m
runHandlers ::
forall m.
(MonadOtel m, MonadLogger m, MonadThrow m) =>
(HandlerResponse m) ->
(Map Text (HandlerResponse m)) ->
Wai.Request ->
(Wai.Response -> IO ResponseReceived) ->
m ResponseReceived
runHandlers defaultHandler handlers req respond = withRunInIO $ \runInIO -> do
let path = req & Wai.pathInfo & Text.intercalate "/"
let inRouteSpan =
Otel.inSpan'
[fmt|Route /{path}|]
( Otel.defaultSpanArguments
{ Otel.attributes =
HashMap.fromList
[ ("_.server.path", Otel.toAttribute @Text path),
("_.server.query_args", Otel.toAttribute @Text (req.rawQueryString & bytesToTextUtf8Lenient))
]
}
)
let html' resp act =
inRouteSpan
( \span -> do
res <- act span <&> (\h -> label @"html" h)
addEventSimple span "Got Html result, rendering…"
liftIO $ respond (resp res)
)
let htmlResp res = Wai.responseLBS Http.ok200 ([("Content-Type", "text/html")]) . Html.renderHtml $ res.html
let html = html' htmlResp
let htmlOrReferer = html' $ \res -> htmxOrReferer req (htmlResp res)
let htmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> m ResponseReceived
htmlOrRedirect = html' $ \res -> case res.html of
E21 h -> htmlResp (label @"html" h.respond)
E22 r ->
redirectOrFallback
r.redirectTo
(\status header -> Wai.responseLBS status [header] "")
req
let postAndRedirect ::
MultipartParseT m dat ->
(Otel.Span -> dat -> m (Label "redirectTo" ByteString)) ->
m ResponseReceived
postAndRedirect parser act = inRouteSpan $ \span -> do
if (req & Wai.requestMethod) == "POST"
then do
dat <- parseMultipartOrThrow span req parser
res <- act span dat
liftIO $ respond (Wai.responseLBS Http.seeOther303 [("Location", res.redirectTo)] "")
else do
liftIO $ respond (Wai.responseLBS Http.methodNotAllowed405 [] "")
let htmlWithQueryArgs' parser =
case req & Parse.runParse "Unable to find the right request query arguments" (lmap Wai.queryString parser) of
Right queryArgs -> Right $ QueryArgsDat {queryArgs, returnUrl = (req & Wai.rawPathInfo) <> (req & Wai.rawQueryString)}
Left err ->
Left
( \span -> do
recordException
span
( T2
(label @"type_" "Query Parse Exception")
(label @"message" (prettyErrorTree err))
)
pure
[hsx|
<h1>Error:</h1>
<pre>{err & prettyErrorTree}</pre>
|]
)
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 <head>" $ do
htmlHead <- mkHead
liftIO $ do
send "<!DOCTYPE html>\n"
send "<html>\n"
send $
Html.renderHtmlBuilder $
[hsx|
<head>
<title>{htmlHead.title}</title>
{htmlHead.headContent}
</head>
|]
flush
htmlBody <- liftIO $ wait bodyAsync
send "<body>\n"
send $ Html.renderHtmlBuilder htmlBody
send "</body>\n"
send "</html>\n"
flush
let handler =
handlers
& Map.lookup path
& fromMaybe defaultHandler
& \case
Html act -> html act
HtmlOrRedirect act -> htmlOrRedirect act
HtmlWithQueryArgs parser act -> htmlWithQueryArgs parser act
HtmlOrReferer act -> htmlOrReferer act
HtmlStream parser act -> htmlStream parser act
PostAndRedirect mParser act -> mParser >>= \parser -> postAndRedirect parser act
Plain act -> liftIO $ runInIO act >>= respond
runInIO handler
singleQueryArgument :: Text -> FieldParser ByteString to -> Parse Http.Query to
singleQueryArgument field inner =
Parse.mkParsePushContext
field
( \(ctx, qry) -> case qry
& mapMaybe
( \(k, v) ->
if k == (field & textToBytesUtf8)
then Just v
else Nothing
) of
[] -> Left [fmt|No such query argument "{field}", at {ctx & Parse.showContext}|]
[Nothing] -> Left [fmt|Expected one query argument with a value, but "{field}" was a query flag|]
[Just one] -> Right one
more -> Left [fmt|More than one value for query argument "{field}": {show more}, at {ctx & Parse.showContext}|]
)
>>> Parse.fieldParser inner
singleQueryArgumentMay :: Text -> FieldParser ByteString to -> Parse Http.Query (Maybe to)
singleQueryArgumentMay field inner =
Parse.mkParsePushContext
field
( \(ctx, qry) -> case qry
& mapMaybe
( \(k, v) ->
if k == (field & textToBytesUtf8)
then Just v
else Nothing
) of
[] -> Right Nothing
[Nothing] -> Left [fmt|Expected one query argument with a value, but "{field}" was a query flag|]
[Just one] -> Right (Just one)
more -> Left [fmt|More than one value for query argument "{field}": {show more}, at {ctx & Parse.showContext}|]
)
>>> Parse.maybe (Parse.fieldParser inner)
data ArtistFilter = ArtistFilter
{ onlyArtist :: Maybe (Label "artistId" Text)
}
doIfJust :: (Applicative f) => (a -> f ()) -> Maybe a -> f ()
doIfJust = traverse_
data BestTorrentsData = BestTorrentsData
{ limitResults :: Maybe Natural,
ordering :: BestTorrentsOrdering,
disallowedReleaseTypes :: [ReleaseType],
onlyFavourites :: Bool
}
bestTorrentsDataDefault :: BestTorrentsData
bestTorrentsDataDefault =
BestTorrentsData
{ limitResults = Nothing,
ordering = BySeedingWeight,
disallowedReleaseTypes = [],
onlyFavourites = False
}
getBestTorrentsData ::
( MonadTransmission m,
MonadThrow m,
MonadLogger m,
MonadPostgres m,
MonadOtel m
) =>
BestTorrentsData ->
Maybe (E2 "onlyTheseTorrents" [Label "torrentId" Int] "artistRedactedId" Int) ->
Transaction m [TorrentData (Label "percentDone" Percentage)]
getBestTorrentsData opts filters = inSpan' "get torrents table data" $ \span -> do
let onlyArtist = label @"artistRedactedId" <$> (filters >>= getE22 @"artistRedactedId")
onlyArtist & doIfJust (\a -> addAttribute span "artist-filter.redacted-id" (a.artistRedactedId, intDecimalT))
let onlyTheseTorrents = filters >>= getE21 @"onlyTheseTorrents"
onlyTheseTorrents & doIfJust (\a -> addAttribute span "torrent-filter.ids" (a <&> (getLabel @"torrentId") & showToText & Otel.toAttribute))
let limitResults = getField @"limitResults" opts
let ordering = opts.ordering
let disallowedReleaseTypes = opts.disallowedReleaseTypes
let onlyFavourites = opts.onlyFavourites
let getBest = getBestTorrents GetBestTorrentsFilter {..}
bestStale :: [TorrentData ()] <- getBest
(statusInfo, transmissionStatus) <-
getAndUpdateTransmissionTorrentsStatus
( bestStale
& mapMaybe
( \td -> case td.torrentStatus of
InTransmission h -> Just (getLabel @"torrentHash" h, td)
_ -> Nothing
)
& Map.fromList
)
bestBest <-
-- Instead of serving a stale table when a torrent gets deleted, fetch
-- the whole view again. This is a little wasteful, but torrents
-- shouldnt get deleted very often, so its fine.
-- Re-evaluate invariant if this happens too often.
if statusInfo.knownTorrentsStale
then inSpan' "Fetch torrents table data again" $
\span' -> do
addEventSimple span' "The transmission torrent list was out of date, refetching torrent list."
getBest
else pure bestStale
pure $
bestBest
-- filter out some kinds we dont really care about
& filter
( \td ->
td.releaseType
`List.notElem` [ releaseTypeCompilation,
releaseTypeDJMix,
releaseTypeMixtape,
releaseTypeRemix
]
)
-- we have to update the status of every torrent thats not in tranmission anymore
-- TODO I feel like its easier (& more correct?) to just do the database request again …
<&> ( \td -> case td.torrentStatus of
InTransmission info ->
case transmissionStatus & Map.lookup (getLabel @"torrentHash" info) of
-- TODO this is also pretty dumb, cause it assumes that we have the torrent file if it was in transmission before,
-- which is an internal factum that is established in getBestTorrents (and might change later)
Nothing -> td {torrentStatus = NotInTransmissionYet}
Just transmissionInfo -> td {torrentStatus = InTransmission (T2 (getLabel @"torrentHash" info) (label @"transmissionInfo" transmissionInfo))}
NotInTransmissionYet -> td {torrentStatus = NotInTransmissionYet}
NoTorrentFileYet -> td {torrentStatus = NoTorrentFileYet}
)
mkBestTorrentsTableByReleaseType ::
[TorrentData (Label "percentDone" Percentage)] ->
Html
mkBestTorrentsTableByReleaseType fresh =
fresh
& toList
& groupAllWithComparison ((.releaseType) >$< releaseTypeComparison)
& foldMap
( \ts -> do
let releaseType = ts & NonEmpty.head & (.releaseType.stringKey)
mkBestTorrentsTableSection (lbl #sectionName [fmt|{releaseType}s|]) ts
)
mkBestTorrentsTableSection ::
(HasField "sectionName" opts Text) =>
opts ->
NonEmpty (TorrentData (Label "percentDone" Percentage)) ->
Html
mkBestTorrentsTableSection opts torrents = do
let localTorrent b = case b.torrentStatus of
NoTorrentFileYet ->
[hsx|
<form method="post">
<input type="hidden" name="torrent-id" value={b.torrentId & show} />
<button
formaction="snips/redacted/getTorrentFile"
hx-post="snips/redacted/getTorrentFile"
hx-swap="outerHTML"
hx-vals={Enc.encToBytesUtf8 $ Enc.object [("torrent-id", Enc.int b.torrentId)]}>Upload Torrent</button>
</form>
|]
InTransmission info -> [hsx|{info.transmissionInfo.percentDone.unPercentage}% done|]
NotInTransmissionYet -> [hsx|<button hx-post="snips/redacted/startTorrentFile" hx-swap="outerHTML" hx-vals={Enc.encToBytesUtf8 $ Enc.object [("torrent-id", Enc.int b.torrentId)]}>Start Torrent</button>|]
let bestRows :: NonEmpty (TorrentData (Label "percentDone" Percentage)) -> Html
bestRows rowData =
rowData
& foldMap
( \b -> do
let torrentPosition :: Text = [fmt|torrent-{b.torrentId}|]
let artists =
b.artists
<&> ( \a ->
T2
(label @"url" $ mkArtistLink a)
(label @"content" $ Html.toHtml @Text a.artistName)
)
& mkLinkList
let releaseTypeTooltip rt = [fmt|{rt.stringKey} (Release type ID: {rt.intKey})|] :: Text
[hsx|
<tr id={torrentPosition}>
<td>{localTorrent b}</td>
<td>{Html.toHtml @Int b.groupId}</td>
<td>
{artists}
</td>
<td>
<a href={mkRedactedTorrentLink (Arg b.groupId)} target="_blank">
{Html.toHtml @Text b.torrentGroupJson.groupName}
</a>
</td>
<td title={releaseTypeTooltip b.releaseType}>{Html.toHtml @Text b.releaseType.stringKey}</td>
<td>{Html.toHtml @Natural b.torrentGroupJson.groupYear}</td>
<td>{Html.toHtml @Int b.seedingWeight}</td>
<td>{Html.toHtml @Text b.torrentFormat}</td>
<td><details hx-trigger="toggle once" hx-post="snips/redacted/torrentDataJson" hx-vals={Enc.encToBytesUtf8 $ Enc.object [("torrent-id", Enc.int b.torrentId)]}></details></td>
</tr>
|]
)
[hsx|
<h2>{opts.sectionName}</h2>
<table class="table">
<thead>
<tr>
<th>Local</th>
<th>Group ID</th>
<th>Artist</th>
<th>Name</th>
<th>Type</th>
<th>Year</th>
<th>Weight</th>
<th>Format</th>
<th>Torrent</th>
</tr>
</thead>
<tbody>
{bestRows torrents}
</tbody>
</table>
|]
mkLinkList :: [T2 "url" Text "content" Html] -> Html
mkLinkList xs =
xs
<&> ( \x -> do
[hsx|<a href={x.url}>{x.content}</a>|]
)
& List.intersperse ", "
& mconcat
mkArtistLink :: (HasField "artistId" r Int) => r -> Text
mkArtistLink a = [fmt|/artist?redacted_id={a.artistId}|]
getTransmissionTorrentsTable ::
(MonadTransmission m, MonadThrow m, MonadLogger m, MonadOtel m) => m Html
getTransmissionTorrentsTable = do
let fields =
[ "hashString",
"name",
"percentDone",
"percentComplete",
"downloadDir",
"files"
]
doTransmissionRequest'
( transmissionRequestListAllTorrents fields $ do
Json.asObject <&> KeyMap.toMapText
)
<&> \resp ->
Html.toTable
( resp
& List.sortOn (\m -> m & Map.lookup "percentDone" & fromMaybe (Json.Number 0))
<&> Map.toList
-- TODO
& List.take 100
)
unzip3PGArray :: [(a1, a2, a3)] -> (PGArray a1, PGArray a2, PGArray a3)
unzip3PGArray xs = xs & unzip3 & \(a, b, c) -> (PGArray a, PGArray b, PGArray c)
assertOneUpdated ::
(HasField "numberOfRowsAffected" r Natural, MonadThrow m, MonadIO m) =>
Otel.Span ->
Text ->
r ->
m ()
assertOneUpdated span name x = case x.numberOfRowsAffected of
1 -> pure ()
n -> appThrow span ([fmt|{name :: Text}: Expected to update exactly one row, but updated {n :: Natural} row(s)|])
migrate ::
( MonadPostgres m,
MonadOtel m
) =>
Transaction m (Label "numberOfRowsAffected" Natural)
migrate = inSpan "Database Migration" $ do
execute
[sql|
CREATE SCHEMA IF NOT EXISTS redacted;
CREATE TABLE IF NOT EXISTS redacted.settings (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value JSONB
);
CREATE TABLE IF NOT EXISTS redacted.torrent_groups (
id SERIAL PRIMARY KEY,
group_id INTEGER,
group_name TEXT,
full_json_result JSONB,
UNIQUE(group_id)
);
CREATE TABLE IF NOT EXISTS redacted.torrents_json (
id SERIAL PRIMARY KEY,
torrent_id INTEGER,
torrent_group SERIAL NOT NULL REFERENCES redacted.torrent_groups(id) ON DELETE CASCADE,
full_json_result JSONB,
UNIQUE(torrent_id)
);
CREATE INDEX IF NOT EXISTS redacted_torrents_json_torrent_group_fk ON redacted.torrents_json (torrent_group);
ALTER TABLE redacted.torrents_json
ADD COLUMN IF NOT EXISTS torrent_file bytea NULL;
ALTER TABLE redacted.torrents_json
ADD COLUMN IF NOT EXISTS transmission_torrent_hash text NULL;
-- the seeding weight is used to find the best torrent in a group.
CREATE OR REPLACE FUNCTION calc_seeding_weight(full_json_result jsonb) RETURNS int AS $$
BEGIN
RETURN
-- three times seeders plus one times snatches
(3 * (full_json_result->'seeders')::integer
+ (full_json_result->'snatches')::integer
)
-- prefer remasters by multiplying them with 3
* (CASE
WHEN full_json_result->>'remasterTitle' ILIKE '%remaster%'
THEN 3
ELSE 1
END)
-- slightly push mp3 V0, to make sure its preferred over 320 CBR
* (CASE
WHEN full_json_result->>'encoding' ILIKE '%v0%'
THEN 2
ELSE 1
END)
-- remove 24bit torrents from the result (wayyy too big)
* (CASE
WHEN full_json_result->>'encoding' ILIKE '%24bit%'
THEN 0
ELSE 1
END)
-- discount FLACS, so we only use them when theres no mp3 alternative (to save space)
/ (CASE
WHEN full_json_result->>'encoding' ILIKE '%lossless%'
THEN 5
ELSE 1
END)
;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
ALTER TABLE redacted.torrents_json
ADD COLUMN IF NOT EXISTS seeding_weight int NOT NULL GENERATED ALWAYS AS (calc_seeding_weight(full_json_result)) STORED;
CREATE OR REPLACE FUNCTION artist_record_to_id(artists jsonb) RETURNS int[]
as $$
SELECT array_agg(x::int) from jsonb_path_query(artists, '$[*].id') j(x);
$$ LANGUAGE sql IMMUTABLE;
ALTER TABLE redacted.torrents_json
ADD COLUMN IF NOT EXISTS artist_ids int[] NOT NULL GENERATED ALWAYS AS (COALESCE(artist_record_to_id(full_json_result->'artists'), ARRAY[]::int[])) STORED;
ALTER TABLE redacted.torrent_groups
ADD COLUMN IF NOT EXISTS artist_ids int[] NOT NULL GENERATED ALWAYS AS (COALESCE(artist_record_to_id(full_json_result->'artists'), ARRAY[]::int[])) STORED;
CREATE INDEX IF NOT EXISTS torrents_json_artist_ids ON redacted.torrents_json USING GIN (artist_ids);
CREATE INDEX IF NOT EXISTS torrent_groups_artist_ids ON redacted.torrent_groups USING GIN (artist_ids);
-- inflect out values of the full json
CREATE OR REPLACE VIEW redacted.torrents AS
SELECT
t.id,
t.torrent_id,
t.torrent_group,
-- the seeding weight is used to find the best torrent in a group.
t.seeding_weight,
t.full_json_result,
t.torrent_file,
t.transmission_torrent_hash,
t.artist_ids
FROM redacted.torrents_json t;
CREATE INDEX IF NOT EXISTS torrents_json_seeding ON redacted.torrents_json(((full_json_result->'seeding')::integer));
CREATE INDEX IF NOT EXISTS torrents_json_snatches ON redacted.torrents_json(((full_json_result->'snatches')::integer));
CREATE TABLE IF NOT EXISTS redacted.artist_favourites (
id SERIAL PRIMARY KEY,
artist_id INTEGER NOT NULL,
UNIQUE(artist_id)
);
-- for easier query lookup, a mapping from artist ids to names
CREATE OR REPLACE VIEW redacted.artist_names AS
SELECT
t.artist_id, x.name as artist_name
FROM
(SELECT unnest(artist_ids) as artist_id, * FROM redacted.torrents t) as t
join LATERAL
jsonb_to_recordset(full_json_result->'artists') as x(id int, name text)
ON x.id = t.artist_id
WHERE x.id = t.artist_id
UNION ALL
SELECT
t.artist_id, x.name as artist_name
FROM
(SELECT unnest(artist_ids) as artist_id, * FROM redacted.torrent_groups t) as t
join LATERAL
jsonb_to_recordset(full_json_result->'artists') as x(id int, name text)
ON x.id = t.artist_id
WHERE x.id = t.artist_id;
|]
()
runAppWith :: AppT IO a -> IO (Either TmpPg.StartError a)
runAppWith appT = withTracer $ \tracer -> withDb $ \db -> do
tool <- readTools (label @"toolsEnvVar" "WHATCD_RESOLVER_TOOLS") (readTool "pg_format")
prettyPrintDatabaseQueries <-
Env.lookupEnv "WHATCD_RESOLVER_PRETTY_PRINT_DATABASE_QUERIES" >>= \case
Nothing -> pure DontPrettyPrintDatabaseQueries
Just _ -> do
pgFormat <- initPgFormatPool (label @"pgFormat" tool)
pure $ PrettyPrintDatabaseQueries pgFormat
let pgConfig =
T2
(label @"logDatabaseQueries" LogDatabaseQueries)
(label @"prettyPrintDatabaseQueries" prettyPrintDatabaseQueries)
pgConnPool <-
Pool.newPool $
Pool.defaultPoolConfig
{- resource init action -} (Postgres.connectPostgreSQL (db & TmpPg.toConnectionString))
{- resource destruction -} Postgres.close
{- unusedResourceOpenTime -} 10
{- max resources across all stripes -} 20
transmissionSessionId <- newIORef Nothing
redactedApiKey <-
Env.lookupEnv "WHATCD_RESOLVER_REDACTED_API_KEY" >>= \case
Just k -> pure (k & stringToBytesUtf8)
Nothing -> runStderrLoggingT $ do
logInfo "WHATCD_RESOLVER_REDACTED_API_KEY was not set, trying pass"
runCommandExpect0 "pass" ["internet/redacted/api-keys/whatcd-resolver"]
let newAppT = do
logInfo [fmt|Running with config: {showPretty pgConfig}|]
logInfo [fmt|Connected to database at {db & TmpPg.toDataDirectory} on socket {db & TmpPg.toConnectionString}|]
appT
runReaderT newAppT.unAppT Context {..}
`catch` ( \case
AppExceptionPretty p -> throwM $ EscapedException (p & Pretty.prettyErrs)
AppExceptionTree t -> throwM $ EscapedException (t & prettyErrorTree & textToString)
AppExceptionEnc e -> throwM $ EscapedException (e & Enc.encToTextPrettyColored & textToString)
)
-- | Just a silly wrapper so that correctly format any 'AppException' that would escape the runAppWith scope.
newtype EscapedException = EscapedException String
deriving anyclass (Exception)
instance Show EscapedException where
show (EscapedException s) = s
withTracer :: (Otel.Tracer -> IO c) -> IO c
withTracer f = do
setDefaultEnv "OTEL_SERVICE_NAME" "whatcd-resolver"
bracket
-- Install the SDK, pulling configuration from the environment
( do
(processors, opts) <- Otel.getTracerProviderInitializationOptions
tp <-
Otel.createTracerProvider
processors
-- workaround the attribute length bug https://github.com/iand675/hs-opentelemetry/issues/113
( opts
{ Otel.tracerProviderOptionsAttributeLimits =
opts.tracerProviderOptionsAttributeLimits
{ Otel.attributeCountLimit = Just 65_000
}
}
)
Otel.setGlobalTracerProvider tp
pure tp
)
-- Ensure that any spans that haven't been exported yet are flushed
Otel.shutdownTracerProvider
-- Get a tracer so you can create spans
(\tracerProvider -> f $ Otel.makeTracer tracerProvider "whatcd-resolver" Otel.tracerOptions)
setDefaultEnv :: String -> String -> IO ()
setDefaultEnv envName defaultValue = do
Env.lookupEnv envName >>= \case
Just _env -> pure ()
Nothing -> Env.setEnv envName defaultValue
withDb :: (TmpPg.DB -> IO a) -> IO (Either TmpPg.StartError a)
withDb act = do
dataDir <- Xdg.getXdgDirectory Xdg.XdgData "whatcd-resolver"
let databaseDir = dataDir </> "database"
let socketDir = dataDir </> "database-socket"
Dir.createDirectoryIfMissing True socketDir
initDbConfig <-
Dir.doesDirectoryExist databaseDir >>= \case
True -> pure TmpPg.Zlich
False -> do
putStderrLn [fmt|Database does not exist yet, creating in "{databaseDir}"|]
Dir.createDirectoryIfMissing True databaseDir
pure TmpPg.DontCare
let cfg =
mempty
{ TmpPg.dataDirectory = TmpPg.Permanent (databaseDir),
TmpPg.socketDirectory = TmpPg.Permanent socketDir,
TmpPg.port = pure $ Just 5431,
TmpPg.initDbConfig
}
TmpPg.withConfig cfg $ \db -> do
-- print [fmt|data dir: {db & TmpPg.toDataDirectory}|]
-- print [fmt|conn string: {db & TmpPg.toConnectionString}|]
act db
data Settings = Settings
{ useFreeleechTokens :: Bool
}
deriving stock (Generic)
settingFreeleechToken :: Bool -> Settings
settingFreeleechToken b = Settings {useFreeleechTokens = b}
instance Semigroup Settings where
a <> b = Settings {useFreeleechTokens = a.useFreeleechTokens || b.useFreeleechTokens}
instance Monoid Settings where
mempty = Settings {useFreeleechTokens = False}
submitSettingForm :: (HasField "returnUrl" r ByteString, ToHtml a) => r -> a -> Html
submitSettingForm opts inputs =
[hsx|
<form
method="post"
action="/settings"
>
<input type="hidden" name="returnTo" value={opts.returnUrl} />
{inputs}
</form>
|]
settingButtons :: (HasField "returnUrl" opts ByteString, HasField "settings" opts Settings) => opts -> Html
settingButtons opts =
if opts.settings.useFreeleechTokens
then
submitSettingForm
opts
[hsx|<p>Using freeleech tokens! <input type="submit" name="useFreeleechTokensOFF" value="Turn off" /></p>|]
else
submitSettingForm
opts
[hsx|<p>Not using freeleech tokens <input type="submit" name="useFreeleechTokensON" value="Turn on" /></p>|]
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|
<div hx-get={opts.endpoint} hx-trigger="every 1s" hx-swap="outerHTML">
{inner :: Html}
</div>
|]
currentHtml <- newIORef $! htmlPolling init'
collectorHandle <- Async.async $ do
liftIO $ putStderrLn "spawned async collector"
lastVal <-
conduit
.| Conduit.mapMC
( \html -> do
atomicWriteIORef currentHtml $! (htmlPolling html)
pure html
)
.| Conduit.lastDefC init'
& Conduit.runConduit
-- when the original conduit finishes, we stop polling for updates.
atomicWriteIORef currentHtml $! [hsx|<div>{lastVal}</div>|]
let handler = Html $ \_span -> do
-- TODO: can we use Etags here and return 304 instead?
readIORef currentHtml
pure (readIORef currentHtml, handler, collectorHandle)
testCounter ::
(HasField "endpoint" opts Text, MonadUnliftIO m) =>
opts ->
m (m Html, HandlerResponse m, Async ())
testCounter opts = conduitToHtmx opts [hsx|<p>0</p>|] counterConduit
counterConduit :: (MonadIO m) => ConduitT i Html m ()
counterConduit =
Conduit.yieldMany [0 .. 100]
.| Conduit.awaitForever
( \(i :: Int) -> do
threadDelay 300_000
Conduit.yield [hsx|<p>{i}</p>|]
)
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 @<link>@ or @<script>@ tag?
isTag :: E2 "link" () "script" ()
}
-- | Fetch a resource, calculate its integrity hash, and return a html @<link>@ snippet and a handler to return the resource.
prefetchResourceIntegrity :: forall m. (MonadOtel m, MonadThrow m) => HtmlIntegrity -> m (Html, (Arg "giveSourceMap" Bool) -> HandlerResponse m)
prefetchResourceIntegrity dat = inSpan' [fmt|prefetching resource {dat.integrityName}|] $ \span -> do
let x =
dat.integrityUrl
& Parse.runParse "Failed to parse URI" (textToURI >>> uriToHttpClientRequest)
& unwrapErrorTree
resp <- Http.httpBS x
let !statusCode = resp & Http.responseStatus & (.statusCode)
let !mContentType =
resp
& Http.responseHeaders
& List.lookup "content-type"
<&> parseContentType
<&> (\(!ct, _mimeAttributes) -> ct)
let !bodyStrict = resp & Http.responseBody
let !bodyLength = bodyStrict & ByteString.length
if
| statusCode == 200 -> do
let tagMatch prx1 val1 prx2 val2 =
dat.isTag
& caseE2
( t2
prx1
(\() -> val1)
prx2
(\() -> val2)
)
mSourceMap <-
if
| dat.provideSourceMap -> do
inSpan' [fmt|Get Source Map for {dat.integrityName}|] $ \span' -> do
let sourceMapUrl = dat.integrityUrl <> ".map"
let x' =
sourceMapUrl
& Parse.runParse "Failed to parse URI" (textToURI >>> uriToHttpClientRequest)
& unwrapErrorTree
resp' <- Http.httpBS x'
let !statusCode' = resp' & Http.responseStatus & (.statusCode)
if
| statusCode' == 200 -> do
pure $ Just <$> resp' & Http.responseBody
-- if it does not exist, lets 404 as well
| statusCode' == 404 -> do
pure Nothing
| otherwise -> do
appThrow span' $ AppExceptionPretty [[fmt|Failed to fetch source map, got status code {statusCode'}|]]
| otherwise -> pure Nothing
pure
( tagMatch
#link
[hsx|<link rel="stylesheet" href={dat.localPath} integrity={dat.integrityHash} crossorigin="anonymous">|]
#script
[hsx|<script src={dat.localPath} integrity={dat.integrityHash} crossorigin="anonymous"></script>|],
\(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