commit a60daddd0cf9794ed93f89ef07823f676351ddce Author: outfoxxed <outfoxxed@outfoxxed.me> Date: Mon Feb 10 20:03:25 2025 -0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d1b19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +* +!/.gitignore + +!/*.cabal +!/src +!/src/** + +!/*.nix \ No newline at end of file diff --git a/blog-proxy.cabal b/blog-proxy.cabal new file mode 100644 index 0000000..dc1a7e4 --- /dev/null +++ b/blog-proxy.cabal @@ -0,0 +1,30 @@ +cabal-version: 3.0 +name: blog-proxy +version: 0.1.0.0 +author: outfoxxed +build-type: Simple + +common warnings + ghc-options: -Wall + +executable blog-proxy + import: warnings + main-is: Main.hs + other-modules: Config, TemplatePage, TemplateLoader, PlausibleQuery, Server + hs-source-dirs: src + default-language: Haskell2010 + + build-depends: + base ^>= 4.18.2.0, + bytestring ^>= 0.11.5.0, + utf8-string ^>= 1.0.0, + text ^>= 2.0.0, + split ^>= 0.2.5, + containers ^>= 0.6.7, + filepath ^>= 1.4.300.0, + directory ^>= 1.3.8.0, + aeson ^>= 2.1.2.0, + http-types ^>= 0.12.4, + http-conduit ^>= 2.3.9.0, + wai ^>= 3.2.4, + warp ^>= 3.3.31, diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..15ebab4 --- /dev/null +++ b/default.nix @@ -0,0 +1,5 @@ +{ + nix-gitignore, + haskell, + haskellPackages, +}: haskellPackages.callCabal2nix "blog-proxy" (nix-gitignore.gitignoreSource [] ./.) {} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..37faa16 --- /dev/null +++ b/flake.nix @@ -0,0 +1,23 @@ +{ + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: let + forEachSystem = fn: nixpkgs.lib.genAttrs + [ "x86_64-linux" "aarch64-linux" ] + (system: fn system nixpkgs.legacyPackages.${system}); + in { + packages = forEachSystem (system: pkgs: rec { + blog-proxy = pkgs.callPackage ./default.nix {}; + default = blog-proxy; + }); + + devShells = forEachSystem (system: pkgs: rec { + default = import ./shell.nix { + inherit pkgs; + inherit (self.packages.${system}) blog-proxy; + }; + }); + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..65e70b9 --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +{ + pkgs ? import <nixpkgs> {}, + blog-proxy ? pkgs.callPackage ./default.nix {}, +}: pkgs.haskellPackages.shellFor { + packages = _: [ blog-proxy ]; + buildInputs = [ pkgs.haskellPackages.cabal-install ]; +} diff --git a/src/Config.hs b/src/Config.hs new file mode 100644 index 0000000..010399b --- /dev/null +++ b/src/Config.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE DeriveGeneric #-} + +module Config ( + loadConfig, + Config (serve, plausible), + Serve (port, baseUrl, templateDir), + Plausible (queryUrl, token, siteId), +) where + +import GHC.Generics (Generic) +import Data.Aeson (FromJSON, eitherDecodeFileStrict) + +data Config = Config { serve :: !Serve, plausible :: !Plausible } deriving (Generic) +data Serve = Serve { port :: !Int, baseUrl :: !String, templateDir :: !String } deriving (Generic) +data Plausible = Plausible { queryUrl :: !String, token :: !String, siteId :: !String } deriving (Generic) + +instance FromJSON Config where +instance FromJSON Serve where +instance FromJSON Plausible where + +loadConfig :: FilePath -> IO Config +loadConfig path = loadConfig' =<< eitherDecodeFileStrict path + where loadConfig' :: Either String Config -> IO Config + loadConfig' (Left err) = fail err + loadConfig' (Right config) = return config diff --git a/src/Main.hs b/src/Main.hs new file mode 100644 index 0000000..99c07e3 --- /dev/null +++ b/src/Main.hs @@ -0,0 +1,25 @@ +module Main where + +import Control.Concurrent (forkIO, newMVar, newEmptyMVar) +import qualified Data.Map as Map +import PlausibleQuery (queryPageviewsLoop) +import Server (servePages) +import TemplateLoader (loadTemplates) +import TemplatePage (PageContext (PageContext, ctxPageviews)) +import System.Environment (getArgs) +import qualified Config as C + +main :: IO () +main = do + (configFile:_) <- getArgs + config <- C.loadConfig configFile + let cServe = C.serve config + + templates <- loadTemplates (C.baseUrl cServe) (C.templateDir cServe) + putStrLn $ "Found templates: " ++ show (map fst templates) + + context <- newMVar $ PageContext {ctxPageviews = Map.empty} + updateNotify <- newEmptyMVar + + _ <- forkIO $ queryPageviewsLoop config context updateNotify + servePages (C.port cServe) (Map.fromList templates) context updateNotify diff --git a/src/PlausibleQuery.hs b/src/PlausibleQuery.hs new file mode 100644 index 0000000..293978b --- /dev/null +++ b/src/PlausibleQuery.hs @@ -0,0 +1,79 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} + +module PlausibleQuery (queryPageviewsLoop) where + +import GHC.Generics (Generic) +import Data.Map (Map) +import qualified Data.ByteString.UTF8 as BS.UTF8 +import qualified Data.Map as Map +import qualified Data.Aeson as JSON +import Data.Aeson (FromJSON, (.=)) +import qualified Network.HTTP.Simple as H +import qualified Network.HTTP.Types.Header as H +import Control.Concurrent (MVar, threadDelay, putMVar) +import Control.Concurrent.MVar (takeMVar) +import Control.Exception (Exception (displayException), catch, SomeException) + +import TemplatePage (PageContext (ctxPageviews)) +import qualified Config as C + +newtype MetricsResponse = MetricsResponse { results :: [MRResult] } deriving (Generic, Show) +data MRResult = MRResult { metrics :: [Int], dimensions :: [String] } deriving (Generic, Show) + +instance FromJSON MetricsResponse where +instance FromJSON MRResult where + +parseMetricsResponse :: MetricsResponse -> Either String (Map String Int) +parseMetricsResponse MetricsResponse { results = r } = Map.fromList <$> mapM parseResult r + where parseResult :: MRResult -> Either String (String, Int) + parseResult MRResult { metrics = [count], dimensions = [path] } = Right (removeTrailingSlash path, count) + parseResult result = Left $ "Could not parse result: " ++ show result + + removeTrailingSlash "" = "" + removeTrailingSlash "/" = "" + removeTrailingSlash (x:xs) = x : removeTrailingSlash xs + +queryPageviews :: C.Config -> IO (Either String (Map String Int)) +queryPageviews config = do + request' <- H.parseRequest $ "POST " ++ C.queryUrl (C.plausible config) + let request + = H.addRequestHeader H.hAuthorization (BS.UTF8.fromString $ "Bearer " ++ C.token (C.plausible config)) + $ H.setRequestBodyJSON + (JSON.object [ + "site_id" .= C.siteId (C.plausible config), + "metrics" .= ["visitors" :: JSON.Value], + "dimensions" .= ["event:page" :: JSON.Value], + "filters" .= [[ + "matches" :: JSON.Value, + "event:page" :: JSON.Value, + JSON.toJSON ['^' : C.baseUrl (C.serve config) ++ "/.*$"] + ]], + "date_range" .= ("all" :: JSON.Value) + ]) + $ request' + + response <- H.httpJSON request + return $ parseMetricsResponse $ H.getResponseBody response + +queryPageviewsLoop :: C.Config -> MVar PageContext -> MVar () -> IO () +queryPageviewsLoop config context updateNotify = loop + where loop = do + putStrLn "Querying pageviews from analytics..." + pageviewResult <- queryPageviews config `catch` \e -> return $ Left $ displayException (e :: SomeException) + case pageviewResult of + Left e -> do + putStrLn $ "Failed to query pageviews: " ++ e ++ "\nRetrying in 10 seconds." + threadDelay $ 10 * 1000000 + loop + Right pageviews -> do + context' <- takeMVar context + let context'' = context' { ctxPageviews = pageviews } + putMVar context context'' + putStrLn $ "Updated pageview cache: " ++ show (Map.toList pageviews) + + threadDelay $ 20 * 1000000 + -- Don't make another query until a page is queried, as the stats shouldn't change without a pageview. + takeMVar updateNotify + + loop diff --git a/src/Server.hs b/src/Server.hs new file mode 100644 index 0000000..50a5a4d --- /dev/null +++ b/src/Server.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Server ( + servePages +) where + +import Control.Applicative ((<|>)) +import Network.Wai (Application, Response, responseLBS, Request (pathInfo)) +import Network.Wai.Handler.Warp (run) +import qualified Network.HTTP.Types as H +import qualified Data.Map as Map +import Data.Map (Map) +import qualified Data.Text as T + +import TemplatePage (PageContext, PageTemplate, buildPage) + +import qualified Data.ByteString as BS +import Data.ByteString (ByteString) +import Control.Concurrent (MVar, readMVar, tryPutMVar) + +app :: Map String PageTemplate -> MVar PageContext -> MVar () -> Application +app templates context updateNotify request respond = do + let path = T.unpack $ T.intercalate "/" $ filter (not . T.null) $ pathInfo request + slashPath = if path /= "" then '/' : path else path + indexPath = slashPath ++ "/index.html" + template = Map.lookup path templates <|> Map.lookup indexPath templates + + putStrLn $ "Page requested: " ++ slashPath + context' <- readMVar context + + -- Unblock pageview cache if waiting for a request. + _ <- tryPutMVar updateNotify () + + respond $ respMaybe $ buildPage context' <$> template + +respMaybe :: Maybe ByteString -> Response +respMaybe Nothing = notFound +respMaybe (Just text) = responseLBS H.status200 [("content-type", "text/html")] $ BS.fromStrict text + +notFound :: Response +notFound = responseLBS H.status404 [(H.hContentType, "text/plain")] "404 - File not found" + +servePages :: Int -> Map String PageTemplate -> MVar PageContext -> MVar () -> IO () +servePages port templates context updateNotify = run port $ app templates context updateNotify diff --git a/src/TemplateLoader.hs b/src/TemplateLoader.hs new file mode 100644 index 0000000..2502a5c --- /dev/null +++ b/src/TemplateLoader.hs @@ -0,0 +1,16 @@ +module TemplateLoader (loadTemplates) where + +import Data.Bool (bool) +import System.FilePath ((</>)) +import System.Directory (listDirectory, doesDirectoryExist) +import qualified Data.ByteString as BS +import TemplatePage (PageTemplate, parsePage, isUntemplated) + +walkFiles :: FilePath -> IO [FilePath] +walkFiles path = foldr (mappend . forEntry . (path </>)) mempty =<< listDirectory path + where forEntry path' = bool (pure [path']) (walkFiles path') =<< doesDirectoryExist path' + +loadTemplates :: String -> FilePath -> IO [(String, PageTemplate)] +loadTemplates baseUrl basePath = filter (not . isUntemplated . snd) <$> (mapM loadTemplate =<< walkFiles basePath) + where pathToUrl path = baseUrl ++ drop (length basePath) path + loadTemplate path = (\d -> (pathToUrl path, parsePage d)) <$> BS.readFile path diff --git a/src/TemplatePage.hs b/src/TemplatePage.hs new file mode 100644 index 0000000..45ce76d --- /dev/null +++ b/src/TemplatePage.hs @@ -0,0 +1,71 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} + +module TemplatePage ( + PageContext (PageContext, ctxPageviews), + PageTemplate, + parsePage, + buildPage, + isUntemplated, +) where + +import GHC.Generics (Generic) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as LBS +import qualified Data.ByteString.UTF8 as BS.UTF8 +import Data.ByteString (ByteString) +import Data.List.Split (splitOn) +import qualified Data.Aeson as JSON +import Data.Aeson ((.:)) +import Data.Aeson.Types (FromJSON) +import qualified Data.Map as Map +import Data.Map (Map) + +newtype PageContext = PageContext { ctxPageviews :: Map String Int } + +data PageTemplatePart = PTString !ByteString | PTComputed !(PageContext -> ByteString) +type PageTemplate = [PageTemplatePart] + +isUntemplated :: PageTemplate -> Bool +isUntemplated [PTString _] = True +isUntemplated _ = False + +buildPage :: PageContext -> PageTemplate -> ByteString +buildPage context template = BS.concat $ map handlePart template + where handlePart (PTString p) = p + handlePart (PTComputed p) = p context + +parsePage :: ByteString -> PageTemplate +parsePage = parseSplit . splitOn "\\\\\\\\$" . BS.UTF8.toString + where parseSplit :: [String] -> [PageTemplatePart] + parseSplit [] = [] + parseSplit [part] = [PTString $ BS.UTF8.fromString part] + parseSplit (first:rest) = PTString (BS.UTF8.fromString first) : concatMap parseSegment rest + + parseSegment :: String -> [PageTemplatePart] + parseSegment segment = + let (numStr, next) = break (== '$') segment + (templatePart, rawPart) = splitAt (read numStr) (drop 1 next) + in [parseTemplate $ BS.UTF8.fromString templatePart, PTString $ BS.UTF8.fromString rawPart] + + parseTemplate :: ByteString -> PageTemplatePart + parseTemplate = parseTemplate' . JSON.decode . LBS.fromStrict + --parseTemplate v = PTString $ BS.UTF8.fromString $ "[-" ++ BS.UTF8.toString v ++ "-]" + + parseTemplate' :: Maybe JsonTemplate -> PageTemplatePart + parseTemplate' Nothing = PTString "{parse error}" + parseTemplate' (Just jt) = PTComputed $ jsonTemplateFn jt + +newtype JsonTemplate = TViewCount { jtPage :: String } deriving (Generic, Show) + +instance FromJSON JsonTemplate where + parseJSON = JSON.withObject "JsonTemplate" $ \o -> o .: "t" + >>= \t -> case (t :: String) of + "pageviews" -> TViewCount <$> o .: "page" + other -> fail $ "Unexpected t: " ++ other + +jsonTemplateFn :: JsonTemplate -> (PageContext -> ByteString) +jsonTemplateFn TViewCount { jtPage = page } PageContext { ctxPageviews = pageviews } + = BS.UTF8.fromString $ viewStr $ Map.lookup page pageviews + where viewStr Nothing = "{unk}" + viewStr (Just viewcount) = show viewcount