initial commit
This commit is contained in:
commit
a60daddd0c
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
*
|
||||||
|
!/.gitignore
|
||||||
|
|
||||||
|
!/*.cabal
|
||||||
|
!/src
|
||||||
|
!/src/**
|
||||||
|
|
||||||
|
!/*.nix
|
30
blog-proxy.cabal
Normal file
30
blog-proxy.cabal
Normal file
|
@ -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,
|
5
default.nix
Normal file
5
default.nix
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
nix-gitignore,
|
||||||
|
haskell,
|
||||||
|
haskellPackages,
|
||||||
|
}: haskellPackages.callCabal2nix "blog-proxy" (nix-gitignore.gitignoreSource [] ./.) {}
|
23
flake.nix
Normal file
23
flake.nix
Normal file
|
@ -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;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
7
shell.nix
Normal file
7
shell.nix
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
pkgs ? import <nixpkgs> {},
|
||||||
|
blog-proxy ? pkgs.callPackage ./default.nix {},
|
||||||
|
}: pkgs.haskellPackages.shellFor {
|
||||||
|
packages = _: [ blog-proxy ];
|
||||||
|
buildInputs = [ pkgs.haskellPackages.cabal-install ];
|
||||||
|
}
|
25
src/Config.hs
Normal file
25
src/Config.hs
Normal file
|
@ -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
|
25
src/Main.hs
Normal file
25
src/Main.hs
Normal file
|
@ -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
|
79
src/PlausibleQuery.hs
Normal file
79
src/PlausibleQuery.hs
Normal file
|
@ -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
|
44
src/Server.hs
Normal file
44
src/Server.hs
Normal file
|
@ -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
|
16
src/TemplateLoader.hs
Normal file
16
src/TemplateLoader.hs
Normal file
|
@ -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
|
71
src/TemplatePage.hs
Normal file
71
src/TemplatePage.hs
Normal file
|
@ -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
|
Loading…
Reference in a new issue