summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorTom Smeding <tom@tomsmeding.com>2026-03-29 23:25:10 +0200
committerTom Smeding <tom@tomsmeding.com>2026-03-29 23:25:10 +0200
commitf21dcde54b09913550036e6501cca935278597d9 (patch)
tree505f373b1bce690f0bafc2038636721126d9bcad /src
Initial
Diffstat (limited to 'src')
-rw-r--r--src/Cache.hs87
-rw-r--r--src/Config.hs125
-rw-r--r--src/Index.hs193
-rw-r--r--src/Main.hs188
-rw-r--r--src/Mmap.hs50
-rw-r--r--src/Util.hs31
-rw-r--r--src/ZNC.hs104
7 files changed, 778 insertions, 0 deletions
diff --git a/src/Cache.hs b/src/Cache.hs
new file mode 100644
index 0000000..53d2185
--- /dev/null
+++ b/src/Cache.hs
@@ -0,0 +1,87 @@
+module Cache where
+
+import Control.Concurrent.STM
+import Control.Monad (forM_)
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+
+import ZNC
+
+
+-- value, previous (more recently accessed), next (less recently accessed)
+data LRUNode a = LRUNode a (TVar (Maybe (LRUNode a))) (TVar (Maybe (LRUNode a)))
+
+-- head (most recent), last (least recent)
+data LRUChain a = LRUChain (TVar (Maybe (LRUNode a))) (TVar (Maybe (LRUNode a)))
+
+lruchainNew :: IO (LRUChain a)
+lruchainNew = LRUChain <$> newTVarIO Nothing <*> newTVarIO Nothing
+
+lruchainAdd :: LRUChain a -> a -> STM (LRUNode a)
+lruchainAdd (LRUChain hd tl) x = do
+ mhdnode <- readTVar hd
+ case mhdnode of
+ Nothing -> do -- chain empty
+ node <- LRUNode x <$> newTVar Nothing <*> newTVar Nothing
+ writeTVar hd (Just node)
+ writeTVar tl (Just node)
+ return node
+ Just hdnode -> do
+ node <- LRUNode x <$> newTVar Nothing <*> newTVar (Just hdnode)
+ writeTVar hd (Just node)
+ return node
+
+lruchainBump :: LRUChain a -> LRUNode a -> STM ()
+lruchainBump (LRUChain hd _) node@(LRUNode _ prevvar nextvar) = do
+ mprev <- readTVar prevvar
+ case mprev of
+ Nothing -> return () -- already most recent
+ Just (LRUNode _ _ prevnextvar) -> do
+ -- remove node from chain where it is now
+ mnext <- readTVar nextvar
+ writeTVar prevnextvar mnext
+ case mnext of
+ Nothing -> return ()
+ Just (LRUNode _ nextprevvar _) -> writeTVar nextprevvar mprev
+ -- add node to chain at head
+ writeTVar nextvar =<< readTVar hd
+ writeTVar hd (Just node)
+
+lruchainEvict :: LRUChain a -> STM (Maybe a)
+lruchainEvict (LRUChain _ tl) = do
+ mtlnode <- readTVar tl
+ case mtlnode of
+ Nothing -> return Nothing
+ Just (LRUNode x prevvar _) -> do
+ mprev <- readTVar prevvar
+ writeTVar tl mprev
+ case mprev of
+ Nothing -> return ()
+ Just (LRUNode _ _ prevnextvar) -> writeTVar prevnextvar Nothing
+ return (Just x)
+
+-- maxsize, elements in cache, linked list
+data LRU a = LRU Int (TVar (Map a (LRUNode a))) (LRUChain a)
+
+lruNew :: Ord a => Int -> IO (LRU a)
+lruNew maxsize = LRU maxsize <$> newTVarIO mempty <*> lruchainNew
+
+-- | If a value got evicted, it is returned
+lruBump :: Ord a => LRU a -> a -> IO (Maybe a)
+lruBump (LRU maxsize mapvar chain) value = atomically $ do
+ mp <- readTVar mapvar
+ case Map.lookup value mp of
+ Nothing -> do
+ node <- lruchainAdd chain value
+ writeTVar mapvar $! Map.insert value node mp
+ if Map.size mp >= maxsize
+ then do
+ mret <- lruchainEvict chain
+ forM_ mret $ \ret -> writeTVar mapvar $! Map.delete ret mp
+ return mret
+ else return Nothing
+ Just node -> do
+ lruchainBump chain node
+ return Nothing
diff --git a/src/Config.hs b/src/Config.hs
new file mode 100644
index 0000000..7fe9e6a
--- /dev/null
+++ b/src/Config.hs
@@ -0,0 +1,125 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TypeData #-}
+{-# LANGUAGE TypeFamilies #-}
+module Config (
+ Config, Config'(..), ConfigStage(..), IfFull,
+ Channel(..),
+ readConfig, enrichConfig,
+) where
+
+import Data.Char (isSpace)
+import Data.List (foldl')
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as T
+import Text.Read (readMaybe)
+
+import Util
+
+
+-- Example config file:
+--
+-- ```
+-- port 8080
+-- logs /var/lib/znc/users/ircbrowse/moddata/log
+-- channel freenode #haskell fn-haskell
+-- channel liberachat #haskell haskell
+-- channel liberachat #xmonad xmonad
+-- ```
+--
+-- Lines starting with '#' are ignored.
+
+
+type Config = Config' Full
+
+-- | Whether a 'Config' is straight from the 'User', or enriched with more
+-- information ('Full').
+type data ConfigStage = User | Full
+
+data Config' (stage :: ConfigStage) = Config
+ { uconfChannels :: IfUser stage (Snoc (Text, Text, Text))
+ -- ^ (network, channel, alias); channel includes '#'. Alias is used for
+ -- URLs and must be globally unique.
+
+ , confLogsDir :: FilePath
+ , confPort :: Int
+ -- ^ Port at which to host the HTTP server
+
+ , econfChannels :: IfFull stage [Channel]
+ -- ^ Network-channel pairs, in user-specified order
+ , econfChan2Alias :: IfFull stage (Map Channel Text)
+ -- ^ The URL alias of a network-channel pair
+ , econfAlias2Chan :: IfFull stage (Map Text Channel)
+ -- ^ The network-channel pair corresponding to each alias
+ }
+
+deriving instance Show (Config' User)
+deriving instance Show (Config' Full)
+
+type family IfUser stage a where
+ IfUser User a = a
+ IfUser Full a = ()
+
+type family IfFull stage a where
+ IfFull User a = ()
+ IfFull Full a = a
+
+-- | network, channelname
+data Channel = Channel { chanNetwork :: Text, chanChannel :: Text }
+ deriving (Show, Eq, Ord)
+
+readConfig :: FilePath -> IO (Config' User)
+readConfig path = foldl' parseLine initConfig . lines <$> readFile path
+
+enrichConfig :: Config' User -> Config
+enrichConfig conf = conf
+ { uconfChannels = ()
+ , econfChannels = [Channel nw ch | (nw, ch, _) <- uchannels]
+ , econfChan2Alias = Map.fromList [(Channel nw ch, alias) | (nw, ch, alias) <- uchannels]
+ , econfAlias2Chan = Map.fromList [(alias, Channel nw ch) | (nw, ch, alias) <- uchannels]
+ }
+ where
+ uchannels = toList (uconfChannels conf)
+
+initConfig :: Config' User
+initConfig = Config
+ { uconfChannels = SnocNil
+ , confLogsDir = error "config: Log directory not set with 'logs'"
+ , confPort = 8000
+ , econfChannels = ()
+ , econfChan2Alias = ()
+ , econfAlias2Chan = ()
+ }
+
+parseLine :: Config' User -> String -> Config' User
+parseLine conf line -- skip empty lines and comments
+ | case dropWhile isSpace line of
+ [] -> True
+ '#':_ -> True
+ _ -> False
+ = conf
+parseLine conf line =
+ let (cmd, rest) = break (== ' ') line
+ in case cmd of
+ "port" ->
+ case readMaybe (trim rest) of
+ Just port | 0 <= port, port < 65536 -> conf { confPort = port }
+ _ -> error "config: Invalid port number"
+ "logs" ->
+ let dir = dropWhile isSpace rest
+ in if null dir
+ then error "config: Empty 'logs' directory"
+ else conf { confLogsDir = dropWhile isSpace rest }
+ "channel" ->
+ case words (trim rest) of
+ [network, chan, alias]
+ | all (not . null) [network, chan, alias]
+ -> conf { uconfChannels = uconfChannels conf `Snoc` (T.pack network, T.pack chan, T.pack alias) }
+ _ -> error $ "config: Invalid channel spec: " ++ trim rest
+ _ -> error $ "config: Invalid line with command '" ++ cmd ++ "'"
+ where
+ trim :: String -> String
+ trim = dropWhile isSpace . reverse . dropWhile isSpace . reverse
diff --git a/src/Index.hs b/src/Index.hs
new file mode 100644
index 0000000..4de4c6c
--- /dev/null
+++ b/src/Index.hs
@@ -0,0 +1,193 @@
+module Index (
+ Index,
+ initIndex,
+ indexGetEventsLinear,
+ indexNumEvents,
+) where
+
+import Chronos hiding (day)
+import Control.Monad (forM)
+import Data.ByteString qualified as BS
+import Data.Char (isDigit)
+import Data.List (sort, scanl')
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as T
+import Data.Vector.Storable qualified as VS
+import System.Directory
+import System.FilePath
+import Text.Read (readMaybe)
+import Torsor (add, difference)
+
+import Debug.Trace
+
+import Config (Channel(..))
+import Mmap
+import Util
+import ZNC
+
+
+data ChanIndex = ChanIndex
+ { ciStartDay :: Day
+ , ciEndDay :: Day
+ , ciCountUntil :: VS.Vector Int -- ^ number of events up to and /including/ this day
+ , ciTotal :: Int }
+ deriving (Show)
+
+data Index = Index FilePath (Map Channel ChanIndex)
+ deriving (Show)
+
+-- init
+
+initIndex :: FilePath -> [Channel] -> IO Index
+initIndex basedir toimport = do
+ items <-
+ fmap concat . forM (map chanNetwork toimport) $ \nwT -> do
+ let nw = T.unpack nwT
+ forM [ch | Channel nwT' ch <- toimport, nwT' == nwT] $ \chT -> do
+ let ch = T.unpack chT
+ files <- listDirectory (basedir </> nw </> ch)
+ days <- fmap sort . forM files $ \fn -> do
+ let path = basedir </> nw </> ch </> fn
+ -- putStrLn $ "Parsing " ++ path ++ " (" ++ show (parseFileName fn) ++ " -> " ++ show (dateToDay (parseFileName fn)) ++ ")"
+ events <- parseLog <$> BS.readFile path
+ return (dateToDay (parseFileName fn), length events)
+ let minday = minimum (map fst days)
+ maxday = maximum (map fst days)
+ ndays = difference maxday minday + 1
+ -- traceM $ nw ++ "/" ++ ch ++ ": days = " ++ show [(toFileName (dayToDate d), i) | (d, i) <- days]
+ let countScan = VS.fromListN ndays (drop 1 $ scanl' (+) 0 (makeCounts [minday..maxday] days))
+ let ntotal = sum (map snd days)
+ return (Channel nwT chT
+ ,ChanIndex
+ { ciStartDay = minday
+ , ciEndDay = maxday
+ , ciCountUntil = countScan
+ , ciTotal = ntotal})
+ return (Index basedir (Map.fromList items))
+
+makeCounts :: [Day] -> [(Day, Int)] -> [Int]
+makeCounts [] [] = []
+makeCounts [] _ = error "makeCounts: more entries than days in range"
+makeCounts (d:ds) ents@((d',n):ents')
+ | d == d' = n : makeCounts ds ents'
+ | d < d' = 0 : makeCounts ds ents
+ | otherwise = error $ "makeCounts: duplicate entry? " ++ show (d, dayToDate d, d', dayToDate d')
+makeCounts (_:ds) [] = 0 : makeCounts ds []
+
+-- search
+
+-- | Returns proper lazy list of events. Reading the files happens strictly,
+-- but parsing the events happens lazily.
+indexGetEventsLinear :: Index -> Channel -> Int -> Int -> IO [(YMDHMS, Event)]
+indexGetEventsLinear (Index basedir mp) chan@(Channel network channel) from count
+ | from + count < 0 = return []
+ | from >= ciTotal ci = return []
+ | otherwise = do
+ let scan = ciCountUntil ci
+ day1idx = binSearch scan from
+ day1 = day1idx `add` ciStartDay ci
+ neventBeforeDay1 | day1idx == 0 = 0
+ | otherwise = scan VS.! (day1idx - 1)
+ neventInclDay1 = scan VS.! day1idx
+ neventOnDay1 = neventInclDay1 - neventBeforeDay1
+ off1 = from - neventBeforeDay1
+ -- day2 is inclusive, off2 is exclusive
+ (day2, off2)
+ | from + count <= neventInclDay1 = (day1, off1 + count)
+ | day1idx == VS.length scan - 1 = (day1, neventOnDay1)
+ | otherwise =
+ let loop day2idx nbefore nseen
+ | nseen + nOnDay2 >= count =
+ (day2idx `add` ciStartDay ci, count - nseen)
+ | day2idx == VS.length scan - 1 =
+ (day2idx `add` ciStartDay ci, nOnDay2)
+ | otherwise =
+ loop (day2idx + 1) (scan VS.! day2idx) (nseen + nOnDay2)
+ where
+ nOnDay2 = scan VS.! day2idx - nbefore
+ in loop (day1idx + 1) (neventBeforeDay1 + neventOnDay1) (neventOnDay1 - off1)
+
+ -- traceM ("ci = " ++ show ci)
+ -- traceM ("binSearch " ++ show from ++ " =")
+ -- traceM (" " ++ show day1idx)
+ -- traceM ("day1 = " ++ show day1)
+ -- traceM ("off1 = " ++ show off1)
+ -- traceM ("neventOnDay1 = " ++ show neventOnDay1)
+ -- traceM ("count = " ++ show count)
+ -- traceM ("day2 = " ++ show day2)
+ -- traceM ("off2 = " ++ show off2)
+ evs <- forM (zip [day1 .. day2] [day1idx..]) $ \(day, dayidx) ->
+ let neventsOnDay | dayidx == 0 = scan VS.! 0
+ | otherwise = scan VS.! dayidx - scan VS.! (dayidx - 1)
+ parse
+ | day == day1 =
+ if day1 == day2
+ then parseLogRange (off1, Just (off2 - off1))
+ else parseLogRange (off1, Nothing)
+ | day == day2 = parseLogRange (0, Just off2)
+ | otherwise = parseLog
+ Date (Year y) (Month monthm1) (DayOfMonth d) = dayToDate day
+ fixDate = map $ \(tod, ev) ->
+ (YMDHMS (YMD y (fromIntegral monthm1 + 1) (fromIntegral d)) tod
+ ,ev)
+ in if neventsOnDay > 0
+ then fixDate . parse <$>
+ mapFile (basedir </> T.unpack network </> T.unpack channel </> toFileName (dayToDate day))
+ else return []
+
+ return (concat evs)
+ where
+ ci = mp Map.! chan
+
+-- | The vector must be sorted.
+-- Returns index of the first element x such that needle < x. If there is no
+-- such element (i.e. needle is greater-equal the last element of vec), returns
+-- the length of vec.
+--
+-- TODO: proportional binary search
+binSearch :: VS.Vector Int -> Int -> Int
+binSearch vec needle
+ | veclen == 0 || vec VS.! (veclen - 1) < needle = veclen
+ | needle < vec VS.! 0 = 0
+ | otherwise = go 0 (veclen - 1)
+ where
+ veclen = VS.length vec
+
+ -- Invariant: vec[lo] <= needle < vec[hi]
+ go :: Int -> Int -> Int
+ go lo hi
+ | lo + 1 == hi = hi
+ | otherwise =
+ let mid = lo + (hi - lo) `div` 2
+ in if vec VS.! mid <= needle
+ then go mid hi else go lo mid
+
+-- other methods
+
+indexNumEvents :: Index -> Channel -> Int
+indexNumEvents (Index _ mp) chan = ciTotal (mp Map.! chan)
+
+-- utilities
+
+parseFileName :: String -> Date
+parseFileName name
+ | (y, '-' : s1) <- splitAt 4 name
+ , (m, '-' : s2) <- splitAt 2 s1
+ , (d, ".log") <- splitAt 2 s2
+ , all isDigit y, all isDigit m, all isDigit d
+ , let y' = read' y ; m' = read' m - 1 ; d' = read' d
+ , 0 <= m', m' < 12
+ , 1 <= d', d' <= daysInMonth (isLeapYear (Year y')) (Month m')
+ = Date (Year y') (Month m') (DayOfMonth d')
+ | otherwise
+ = error $ "Invalid ZNC log file name: " ++ name
+ where
+ read' :: Read a => String -> a
+ read' s = case readMaybe s of
+ Just r -> r
+ Nothing -> error $ "No read: " ++ show s
+
+toFileName :: Date -> String
+toFileName (Date (Year y) (Month m) (DayOfMonth d)) =
+ pad '0' 4 y ++ '-' : pad '0' 2 (m + 1) ++ '-' : pad '0' 2 d ++ ".log"
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
index 0000000..75b9a96
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,188 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ViewPatterns #-}
+module Main (main) where
+
+import Control.Exception (mask_)
+import Control.Monad (when)
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as BS8
+import Data.ByteString.Lazy.Char8 qualified as BSL8
+import Data.Function (on, (&))
+import Data.IORef
+import Data.List.NonEmpty (NonEmpty((:|)), groupBy)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+import Data.Text.IO qualified as T
+import Network.Wai
+import Network.HTTP.Types
+import Network.Wai.Handler.Warp (runSettings, defaultSettings, setFork, setPort)
+import Network.Wai.Handler.Warp.Internal (Settings(..))
+import System.Environment
+import System.Exit (die)
+import System.Posix.Resource
+import Text.Mustache (Template, compileTemplate, substituteValue, (~>))
+import Text.Mustache qualified as M
+import Text.Mustache.Types (Value(..))
+import Text.Read (readMaybe)
+
+import Debug.Trace
+
+import Config
+import Index
+import Util
+import ZNC
+
+
+newtype Pages = Pages (Map String Template)
+
+getPage :: Pages -> String -> Template
+getPage (Pages mp) name =
+ case Map.lookup name mp of
+ Just tpl -> tpl
+ Nothing -> error $ "Not a page: " ++ name
+
+sendPage200 :: Pages -> String -> Value -> Response
+sendPage200 pages name sub =
+ responseBuilder
+ status200
+ [("Content-Type", "text/html")]
+ (TE.encodeUtf8Builder (substituteValue (getPage pages name) sub))
+
+pageIndex :: Config -> Pages -> IO Response
+pageIndex conf pages =
+ return $ sendPage200 pages "index" $ M.object
+ ["networks" ~>
+ [M.object
+ ["name" ~> nw
+ ,"channels" ~>
+ [M.object ["name" ~> ch, "alias" ~> econfChan2Alias conf Map.! Channel nw ch]
+ | ch <- chs]]
+ | (nw, chs) <-
+ [(nw, ch : map chanChannel chs)
+ | Channel nw ch :| chs <- groupBy ((==) `on` chanNetwork) (econfChannels conf)]]]
+
+pageLog :: Config -> Pages -> Index -> Request -> Text -> IO Response
+pageLog conf pages index req alias =
+ case econfAlias2Chan conf Map.!? alias of
+ Nothing -> page404 "Channel"
+ Just chan -> do
+ let npages = (indexNumEvents index chan + numPerPage - 1) `div` numPerPage
+ curpage | Just (readMaybe . BS8.unpack -> Just pg) <- query "page" = min npages (max 1 pg)
+ | otherwise = npages
+ ntoleft = min 5 (curpage - 1)
+ ntoright = min 5 (npages - curpage)
+ -- traceShowM (indexNumEvents index chan, npages, curpage, ntoleft, ntoright)
+ events <- indexGetEventsLinear index chan ((curpage - 1) * numPerPage) numPerPage
+ return $ sendPage200 pages "log" $ M.object
+ ["network" ~> chanNetwork chan
+ ,"channel" ~> chanChannel chan
+ ,"alias" ~> alias
+ ,"totalevents" ~> indexNumEvents index chan
+ ,"picker" ~> M.object
+ ["prevpage" ~> if curpage > 1 then Just (curpage - 1) else Nothing
+ ,"leftnums" ~> [curpage - ntoleft .. curpage - 1]
+ ,"curnum" ~> curpage
+ ,"rightnums" ~> [curpage + 1 .. curpage + ntoright]
+ ,"nextpage" ~> if curpage < npages then Just (curpage + 1) else Nothing
+ ,"npages" ~> npages]
+ ,"events" ~> [M.object
+ ["classlist" ~> classlist
+ ,"datetime" ~> let YMDHMS (YMD y mo d) (HMS h mi s) = time
+ in pad '0' 4 y ++ '-' : pad '0' 2 mo ++ '-' : pad '0' 2 d ++ ' ' :
+ pad '0' 2 h ++ ':' : pad '0' 2 mi ++ ':' : pad '0' 2 s
+ ,"nickwrap1" ~> nickw1
+ ,"nick" ~> nick
+ ,"nickwrap2" ~> nickw2
+ ,"message" ~> msg]
+ | (time, ev) <- events
+ , let (classlist, (nickw1, nick, nickw2), msg) = renderEvent ev]]
+ where
+ numPerPage = 100
+
+ query :: ByteString -> Maybe ByteString
+ query key = case lookup key (queryString req) of
+ Nothing -> Nothing
+ Just Nothing -> Nothing -- given but empty; treat as not given
+ Just (Just value) -> Just value
+
+ renderEvent :: Event -> (Maybe Text, (Maybe Text, Text, Maybe Text), Text)
+ renderEvent = \case
+ Join n addr -> (j "ev-meta", (no, n, j " →"), "Joins (" <> addr <> ")")
+ Part n addr reas -> (j "ev-meta", (no, n, j " ←"), "Parts (" <> addr <> ") (" <> reas <> ")")
+ Quit n addr reas -> (j "ev-meta", (no, n, j " ×"), "Quits (" <> addr <> ") (" <> reas <> ")")
+ ReNick n n' -> (j "ev-meta", (no, n, no), "is now known as " <> n')
+ Talk n m -> (no, (j "<", n, j ">"), m)
+ Notice n m -> (j "ev-notice", (j "-", n, j "-"), m)
+ Act n m -> (j "ev-act", (no, n, no), m)
+ Kick n by reas -> (j "ev-meta", (no, n, no), "is kicked by " <> by <> " (" <> reas <> ")")
+ Mode n m -> (j "ev-meta", (no, n, no), "sets mode " <> m)
+ Topic n m -> (j "ev-meta", (no, n, no), "sets topic to \"" <> m <> "\"")
+ ParseError -> (j "ev-parseerror", (no, "", no), "<parse error>")
+ where no = Nothing; j = Just
+
+page404 :: String -> IO Response
+page404 thing = return $ responseLBS
+ status404
+ [("Content-Type", "text/plain")]
+ (BSL8.pack thing <> " not found")
+
+getUlimitFiles :: IO Int
+getUlimitFiles = do
+ limits <- getResourceLimit ResourceOpenFiles
+ let infer ResourceLimitInfinity = maxBound
+ infer ResourceLimitUnknown = maxBound
+ infer (ResourceLimit n) = fromIntegral n
+ return (infer (softLimit limits) `min` infer (hardLimit limits))
+
+mainServe :: FilePath -> IO ()
+mainServe confpath = do
+ config <- enrichConfig <$> readConfig confpath
+
+ index <- initIndex (confLogsDir config) (econfChannels config)
+
+ pages <- Pages . Map.fromList <$> sequence
+ [do src <- T.readFile ("pages/" ++ name ++ ".mustache")
+ case compileTemplate name src of
+ Right tpl -> return (name, tpl)
+ Left err -> die (show err)
+ | name <- ["index", "log"]]
+
+ let staticFiles = ["style.css"]
+
+ ulimitFiles <- getUlimitFiles
+ counter <- newIORef 0
+ let connectionLimit = max 2 (ulimitFiles - 100)
+ checkedFork :: ((forall a. IO a -> IO a) -> IO ()) -> IO ()
+ checkedFork k = mask_ $ do
+ count <- atomicModifyIORef' counter (\i -> (i + 1, i + 1))
+ print (connectionLimit, count)
+ when (count <= connectionLimit) $
+ settingsFork defaultSettings k
+ atomicModifyIORef' counter (\i -> (i - 1, ()))
+ settings = defaultSettings & setFork checkedFork
+ & setPort (confPort config)
+
+ putStrLn $ "Listening on port " ++ show (confPort config)
+ runSettings settings $ \req respond -> case pathInfo req of
+ [] ->
+ respond =<< pageIndex config pages
+ ["log", alias] ->
+ respond =<< pageLog config pages index req alias
+ [fname] | fname `elem` staticFiles ->
+ respond $ responseFile status200 [] ("pages/" ++ T.unpack fname) Nothing
+ _ ->
+ respond =<< page404 "URL"
+
+testParseLog :: FilePath -> IO ()
+testParseLog fname = print =<< parseLog <$> BS.readFile fname
+
+main :: IO ()
+main = getArgs >>= \case
+ ["test", "parselog", fname] -> testParseLog fname
+ ["serve", fname] -> mainServe fname
+ _ -> die "Expected command-line argument (see Main.hs)"
diff --git a/src/Mmap.hs b/src/Mmap.hs
new file mode 100644
index 0000000..bfe6042
--- /dev/null
+++ b/src/Mmap.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+module Mmap where
+
+import Control.Exception
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Unsafe as BS
+import Foreign
+import Foreign.C.Types
+import System.Posix.IO
+import System.Posix.Types
+
+foreign import ccall "ircbrowse_mmap"
+ c_mmap :: CInt -> Ptr CSize -> IO (Ptr Word8)
+ -- fd out length
+
+foreign import ccall "ircbrowse_munmap"
+ c_munmap :: Ptr Word8 -> CSize -> IO ()
+ -- addr length
+
+mapFile :: FilePath -> IO ByteString
+mapFile path = mask_ $ do
+ -- open can fail without repercussions
+ Fd fd <- openFd path ReadOnly defaultFileFlags
+
+ -- do the mmap; if it fails, close the file, ignoring exceptions there
+ (addr, filelen) <-
+ catchNoPropagate @SomeException
+ (alloca $ \lengthp -> do
+ addr <- c_mmap fd lengthp
+ lengthval <- peek lengthp
+ return (addr, lengthval))
+ (\e -> do
+ catch @SomeException (closeFd (Fd fd))
+ (\_ -> return ())
+ rethrowIO e)
+
+ -- mmap succeeded, close the file
+ catchNoPropagate @SomeException (closeFd (Fd fd))
+ (\e -> do
+ -- putStrLn ("[munmap " ++ show addr ++ " as close(2) handler]")
+ c_munmap addr filelen
+ rethrowIO e)
+
+ -- close succeeded, we're safe now since bytestring construction will not
+ -- fail (and no exceptions are coming from outside as we're masked)
+ if addr == nullPtr
+ then fail "mapFile: could not mmap"
+ else BS.unsafePackCStringFinalizer addr (fromIntegral @CSize @Int filelen)
+ (do -- putStrLn ("[munmap " ++ show addr ++ "]")
+ c_munmap addr filelen)
diff --git a/src/Util.hs b/src/Util.hs
new file mode 100644
index 0000000..ca31258
--- /dev/null
+++ b/src/Util.hs
@@ -0,0 +1,31 @@
+{-# LANGUAGE DeriveFoldable #-}
+module Util (module Util, toList) where
+
+import Data.Foldable (toList)
+import Data.Word (Word8)
+
+
+data Snoc a = SnocNil | Snoc (Snoc a) a
+ deriving (Show, Foldable)
+
+-- | Time-of-day, in unspecified time zone
+data YMDHMS = YMDHMS {-# UNPACK #-} !YMD
+ {-# UNPACK #-} !HMS
+ deriving (Show)
+
+-- | Calendar day
+data YMD = YMD {-# UNPACK #-} !Int
+ {-# UNPACK #-} !Word8 -- ^ 1-based
+ {-# UNPACK #-} !Word8
+ deriving (Show)
+
+-- | Time-of-day in seconds, in unspecified time zone
+data HMS = HMS {-# UNPACK #-} !Word8
+ {-# UNPACK #-} !Word8
+ {-# UNPACK #-} !Word8
+ deriving (Show)
+
+pad :: Show a => Char -> Int -> a -> String
+pad c w val =
+ let s = show val
+ in replicate (w - length s) c ++ s
diff --git a/src/ZNC.hs b/src/ZNC.hs
new file mode 100644
index 0000000..35eafe0
--- /dev/null
+++ b/src/ZNC.hs
@@ -0,0 +1,104 @@
+{-# LANGUAGE OverloadedStrings #-}
+module ZNC (
+ -- Log(..),
+ Nick, Event(..),
+ parseLog, parseLogRange,
+) where
+
+import Control.Applicative
+import Data.Attoparsec.ByteString.Char8 qualified as P
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as BS8
+import Data.Char (ord)
+import Data.Either (fromRight)
+import Data.Text (Text)
+import Data.Text.Encoding qualified as TE
+-- import Data.Vector qualified as V
+-- import Data.Vector (Vector)
+import Data.Word (Word8)
+
+import Util
+
+
+-- newtype Log = Log (Vector (TOD, Event))
+-- deriving (Show)
+
+type Nick = Text
+
+-- Adapted from clogparse by Keegan McAllister (BSD3) (https://hackage.haskell.org/package/clogparse).
+data Event
+ = Join Nick Text -- ^ User joined.
+ | Part Nick Text Text -- ^ User left the channel. (address, reason)
+ | Quit Nick Text Text -- ^ User quit the server. (address, reason)
+ | ReNick Nick Nick -- ^ User changed from one to another nick.
+ | Talk Nick Text -- ^ User spoke (@PRIVMSG@).
+ | Notice Nick Text -- ^ User spoke (@NOTICE@).
+ | Act Nick Text -- ^ User acted (@CTCP ACTION@).
+ | Kick Nick Nick Text -- ^ User was kicked by user. (kicked, kicker, reason)
+ | Mode Nick Text -- ^ User set mode on the channel.
+ | Topic Nick Text -- ^ Topic change.
+ | ParseError
+ deriving (Show)
+
+-- these INLINE/NOINLINE pragmas are optimisation without testing or profiling, have fun
+{-# INLINE parseLog #-}
+parseLog :: ByteString -> [(HMS, Event)]
+parseLog = parseLogRange (0, Nothing)
+
+-- (start line, number of lines (default to rest of file))
+{-# INLINE parseLogRange #-}
+parseLogRange :: (Int, Maybe Int) -> ByteString -> [(HMS, Event)]
+parseLogRange (startln, mnumln) =
+ -- Log . V.fromList .
+ map go . maybe id take mnumln . drop startln . BS8.lines
+ where
+ {-# NOINLINE go #-}
+ go = fromRight (HMS 0 0 0, ParseError) . P.parseOnly parseLine
+
+parseLine :: P.Parser (HMS, Event)
+parseLine = (,) <$> parseTOD <*> parseEvent
+
+parseTOD :: P.Parser HMS
+parseTOD = do
+ _ <- P.char '['
+ tod <- HMS <$> pTwoDigs <*> (P.char ':' >> pTwoDigs) <*> (P.char ':' >> pTwoDigs)
+ _ <- P.string "] "
+ return tod
+
+-- Adapted from clogparse by Keegan McAllister (BSD3) (https://hackage.haskell.org/package/clogparse).
+parseEvent :: P.Parser Event
+parseEvent = asum
+ [ P.string "*** " *> asum
+ [ userAct Join "Joins: "
+ , userAct' Part "Parts: "
+ , userAct' Quit "Quits: "
+ , ReNick <$> nick <*> (P.string " is now known as " *> nick)
+ , Mode <$> nick <*> (P.string " sets mode: " *> remaining)
+ , Kick <$> (nick <* P.string " was kicked by ") <*> nick <* P.char ' ' <*> encloseTail '(' ')'
+ , Topic <$> (nick <* P.string " changes topic to ") <*> encloseTail '\'' '\''
+ ]
+ , Talk <$ P.char '<' <*> nick <* P.string "> " <*> remaining
+ , Notice <$ P.char '-' <*> nick <*> remaining -- FIXME: parse host
+ , Act <$ P.string "* " <*> nick <* P.char ' ' <*> remaining
+ ] where
+ nick = utf8 <$> P.takeWhile (not . P.inClass " \n\r\t\v<>")
+ userAct f x = f <$ P.string x <*> nick <* P.char ' ' <*> parens
+ userAct' f x = f <$ P.string x <*> nick <* P.char ' ' <*> parens <* P.char ' ' <*> encloseTail '(' ')'
+ parens = P.char '(' >> (utf8 <$> P.takeWhile (/= ')')) <* P.char ')'
+ encloseTail c1 c2 = do _ <- P.char c1
+ bs <- P.takeByteString
+ case BS.unsnoc bs of
+ Just (s, c) | c == fromIntegral (ord c2) -> return (utf8 s)
+ _ -> fail "Wrong end char"
+ utf8 = TE.decodeUtf8Lenient
+ remaining = utf8 <$> P.takeByteString
+
+pTwoDigs :: P.Parser Word8
+pTwoDigs = do
+ let digit = do
+ c <- P.satisfy (\c -> '0' <= c && c <= '9')
+ return (fromIntegral (ord c - ord '0'))
+ c1 <- digit
+ c2 <- digit
+ return (10 * c1 + c2)