#!/usr/bin/env stack > -- stack script --resolver nightly-2025-12-01 --package text --package bytestring --package directory --package filepath --package time --package liquidhaskell --package http-client --package http-client-tls --package http-types --package network interlog: interactive logs for gopherspace ========================================== A Venusia applet port of [interlog][repo]. Append-only logs, one per thread, stored as plain text files; the gopher response is always a gophermap so menu clients render it as a navigable forum. No daemon, no database, no schema — just a directory of `.txt` files and one literate-Haskell script handling index / create / append. The original was a separate CLI binary (`/usr/bin/interlog`) called by four distinct `[[gateway]]` routes; this single .lhs replaces all of them using Venusia 0.8.0.0's path-info dispatch. [repo]: https://github.com/someodd/interlog URL design ---------- Below, `$SCRIPT` is whatever `routes.toml` mounts this file at (e.g. `/applets/interlog/interlog.lhs`). The script reads its own mount-point from `$selector` at request time, so the layout works under any `[[files]]` selector. $SCRIPT → index page (threads newest first; each is a link — title, timestamp, size in KB — with a head/tail content preview and an "append" prompt; "create new" search prompt; "browse raw" link) $SCRIPT/new + text → create a new thread; the search text becomes the opening message and the first line becomes the (slugified) title. Creating a thread past the 'maxLogs' cap prunes the oldest. $SCRIPT/append/ + text → append `text` as a new line to the thread with id ``, unless the thread has reached its 'maxLogSizeKb' cap, in which case it's closed to replies. Raw log content is served by Venusia's existing `[[files]]` block, not by this script — clicking a thread title in the index goes to `logs/` under this directory and Venusia returns the file verbatim. Log files live at `./logs/` (relative to the script's directory, which Venusia sets as the working directory at exec time). They're named `__.txt`; the directory is created on first run if it doesn't already exist. The slug is ASCII-only and underscore-separated (see 'slugify'); since the `id` and `epoch` fields never contain `_`, 'splitFilename' still recovers the title even though the slug shares that field separator. Verifying this applet --------------------- Two complementary halves, exactly as the gopher-applet spec lays out: * `doctest-lhs interlog.lhs` runs the `>>>` examples on every pure helper below (slugging, KB rendering, filename parsing, line extraction, gophermap row builders). It reuses this file's own `-- stack` directive and language pragmas. * The LiquidHaskell GHC plugin (enabled via the `OPTIONS_GHC` pragma) checks the refinement types inline at *compile* time — so `run-cached-lhs` and the shebang path both exercise it on every cache miss, no separate command. The refinements here are targeted, not a totality proof: the two `--no-*` LIQUID options switch off LH's "everything must be proven total and terminating" defaults so the recursive directory walk and the argv cons-pattern don't need re-architecting, and what's left are the specific invariants we *do* want checked: - 'maxLogs' and 'maxSlugLen' carry verified @>= 1@ refinements — drop either to zero and LH refuses to compile (no logs allowed / every slug collapses to "untitled"). 'maxSubmissionKb' goes further with a *cross-constant* refinement, @1 <= v < maxLogSizeKb@: LH @inline@s 'maxLogSizeKb' into its logic, so the per-submission cap is proven both positive and strictly below the whole-log cap (above which it could never fire). Set either cap to a nonsense value and the compile fails. - 'asGopherFields' carries a *verified* @len == 4@ refinement on its @Just@ result (no @assume@): LH derives it from the @length fs == 4@ guard via the @length@/@len@ spec, so the four-field gophermap-pass-through match in 'isGophermapRow' is total by construction. - 'sanitize' is *assumed* to produce 'SafeText' (text with no CR/LF/TAB row-break bytes). LH trusts the body; it's six characters of `T.map`, audited by the three doctests on it. - 'kbOf' is *assumed* to return a non-negative count. Per the spec, no LTS-22/23/24 snapshot ships `liquidhaskell`, so this file pins `nightly-2025-12-01` on its `-- stack` line; the other applets in this directory keep their own resolvers. Module header and imports ------------------------- > {-# LANGUAGE OverloadedStrings #-} > {-# OPTIONS_GHC -fplugin=LiquidHaskell #-} > > {-@ LIQUID "--no-totality" @-} > {-@ LIQUID "--no-termination" @-} > > module Main (main) where > > import Control.Exception (IOException, SomeException, try) > import qualified Data.ByteString as BS > import qualified Data.ByteString.Char8 as BSC > import Data.Char (isAsciiLower, isAsciiUpper, isDigit, > isSpace, toLower) > import Data.List (intercalate, isPrefixOf, isSuffixOf, > sortOn) > import qualified Data.Text as T > import qualified Data.Text.Encoding as TE > import qualified Data.Text.Encoding.Error as TEE > import qualified Data.Text.IO as TIO > import Data.Time (UTCTime, defaultTimeLocale, formatTime) > import Data.Time.Clock.POSIX (getPOSIXTime) > import System.Directory (createDirectoryIfMissing, doesDirectoryExist, > doesFileExist, getFileSize, > getModificationTime, listDirectory, > removeFile) > import System.Environment (getArgs, lookupEnv) > import System.FilePath ((), takeExtension) > import System.IO (BufferMode (..), IOMode (..), > hPutStr, hSetBuffering, hSetEncoding, > stdout, utf8, withFile) > import Data.Bits ((.&.), (.|.), shiftL, shiftR) > import Data.Word (Word8, Word16) > import Text.Read (readMaybe) > import qualified Network.HTTP.Client as H > import qualified Network.HTTP.Client.TLS as HTLS > import Network.HTTP.Types.Status (statusCode) > import Network.HTTP.Types.Header (hContentType, hContentLength) > import Network.Socket (AddrInfo (..), SockAddr (..), > getAddrInfo, hostAddressToTuple, > hostAddress6ToTuple) > import System.Timeout (timeout) > import Data.IORef (newIORef, readIORef, writeIORef) Constants --------- `GOPHER_HOST` / `GOPHER_PORT` env vars override the defaults so the same script works on staging and production without editing. The two operator knobs are 'maxLogs' (how many threads exist at once) and 'maxLogSizeKb' (how big any one thread may grow); everything else is derived. The original took limits via env vars (`LOG_MAX_SIZE` etc.), but for an applet the config-via-env path is awkward — tweak the constants here instead. > defaultHost, defaultPort :: T.Text > defaultHost = "gopher.someodd.zip" > defaultPort = "70" > logsDir :: FilePath > logsDir = "logs" -- relative to script's CWD (set by Venusia) The board holds at most 'maxLogs' threads. Creating the (maxLogs+1)th thread prunes the oldest by mtime — a ring buffer of conversations, sized to keep the index navigable and the directory bounded. > -- | Maximum number of threads kept at once; the oldest is pruned > -- when a new thread pushes the count past it. The @>= 1@ refinement > -- is verified by LH: a board that can hold zero threads is useless, > -- so dropping this to 0 fails the compile. > {-@ maxLogs :: {v:Int | v >= 1} @-} > maxLogs :: Int > maxLogs = 64 > -- | Per-log size ceiling, in kilobytes. A log at or above this size > -- is closed to replies (see 'appendToLog'). It's @inline@d into LH's > -- logic so 'maxSubmissionKb' can be refined against its value — that > -- cross-check (@maxSubmissionKb < maxLogSizeKb@) is where this > -- constant's sanity is enforced: set it to 0 and the submission-cap > -- refinement fails to compile. > {-@ inline maxLogSizeKb @-} > maxLogSizeKb :: Int > maxLogSizeKb = 1024 -- 1 MiB > -- | The per-thread ceiling in bytes, derived from 'maxLogSizeKb'. > maxLogSizeBytes :: Int > maxLogSizeBytes = maxLogSizeKb * 1024 > -- | Per-submission size ceiling, in kilobytes — the cap on a single > -- create or append. Gopher (RFC 1436) puts no limit on the query a > -- client may send: it's a bare @CR LF@-terminated string, and any > -- ceiling is the daemon's read buffer, not a guarantee we can lean > -- on or get a clean error from. So we enforce our own and reject an > -- over-long submission with a clear message. It also stops one post > -- from filling a whole log, since a post is now one line on disk. > -- > -- The refinement is the cross-constant one: LH *verifies* > -- @1 <= v < maxLogSizeKb@ against the @inline@d 'maxLogSizeKb', so a > -- per-post cap that's non-positive, or that meets/exceeds the > -- whole-log cap (where it could never actually fire), fails to > -- compile. > {-@ maxSubmissionKb :: {v:Int | 1 <= v && v < maxLogSizeKb} @-} > maxSubmissionKb :: Int > maxSubmissionKb = 64 > -- | The per-submission ceiling in bytes, derived from 'maxSubmissionKb'. > maxSubmissionBytes :: Int > maxSubmissionBytes = maxSubmissionKb * 1024 The index shows a content preview of each thread, by *whole lines* so no post is ever cut mid-line. A thread of at most 'previewHeadLines' + 'previewTailLines' lines is shown whole; a longer one shows its first 'previewHeadLines' lines, a marker naming how many lines (and bytes) were skipped, then its last 'previewTailLines' lines. > previewHeadLines, previewTailLines :: Int > previewHeadLines = 5 -- preview: whole lines shown from the start > previewTailLines = 5 -- preview: whole lines shown from the end Request parsing --------------- Three argv from Venusia: selector, search, path-info. Cons-pattern parsing ignores extras and aborts loudly on empty argv (the script can't invent its own mount-point selector — see figlet.lhs's fallback-selector commentary for the same reasoning). > data Req = Req > { reqSel :: T.Text -- $selector > , reqQ :: T.Text -- $search > , reqP :: T.Text -- $pathinfo > } deriving Show The argv catch-all calls `error`, which LH's totality checker would flag even with `--no-totality` off in some configurations; it's pure plumbing with nothing to refine, so we `ignore` it outright (same as grep.lhs does for its own `parseArgs`). > {-@ ignore parseArgs @-} > parseArgs :: [String] -> Req > parseArgs (s:q:p:_) = Req (T.pack s) (T.pack q) (T.pack p) > parseArgs (s:q:_) = Req (T.pack s) (T.pack q) T.empty > parseArgs [s] = Req (T.pack s) T.empty T.empty > parseArgs [] = error > "interlog.lhs: missing argv[0] (gopher selector). Run via Venusia, \ > \or for manual testing pass selector + search + path-info explicitly." > data Ctx = Ctx > { ctxScriptSel :: T.Text -- selector path to this script, no path-info > , ctxHost :: T.Text > , ctxPort :: T.Text > } Main dispatch ------------- Split path-info into segments and route. Search text is the message body for create / append; empty search just re-prompts. Top-level `main` wraps the real work in `try` so an uncaught exception (permission denied, IO error, anything else) becomes a visible type-3 gophermap response instead of a blank body — blank output is the worst possible failure mode for a gopher client to present. `hSetEncoding stdout utf8` forces UTF-8 regardless of the daemon's locale, so the menu rows that contain Unicode (em-dashes, arrows) never silently fail mid-write. > main :: IO () > main = do > hSetBuffering stdout NoBuffering > hSetEncoding stdout utf8 > createDirectoryIfMissing True logsDir > result <- try mainBody :: IO (Either SomeException ()) > case result of > Right () -> pure () > Left e -> mapM_ putLine > [ errorItem ("interlog crashed: " <> T.pack (show e)) > , terminator > ] > mainBody :: IO () > mainBody = do > req <- parseArgs <$> getArgs > host <- maybe defaultHost T.pack <$> lookupEnv "GOPHER_HOST" > port <- maybe defaultPort T.pack <$> lookupEnv "GOPHER_PORT" > let pinfo = reqP req > scriptSel = T.dropEnd (T.length pinfo) (reqSel req) > ctx = Ctx scriptSel host port > segs = filter (not . T.null) . T.splitOn "/" $ pinfo > msg = T.strip (reqQ req) > hasMsg = not (T.null msg) > case T.stripPrefix "/extimg/" pinfo of > Just rest -> emitExtImg ctx (decodeExtImgPath rest) > Nothing -> case (segs, hasMsg) of > ([], _ ) -> emitIndex ctx > (["new"], False) -> emitNewPrompt ctx > (["new"], True ) -> handleNew ctx msg > (["append", lid], False) -> emitAppendPrompt ctx lid > (["append", lid], True ) -> handleAppend ctx lid msg > (["view", lid], _ ) -> emitThread ctx lid > (["img", lid, n], _ ) -> emitImage ctx lid n > (["faq"], _ ) -> emitFaq ctx > _ -> emitPathError ctx pinfo Gophermap line builders ----------------------- Same conventions as the other applets in this directory. Every row ends with `\r\n`; every display field passes through `sanitize`. > putLine :: T.Text -> IO () > putLine t = TIO.putStr (t <> "\r\n") > -- | >>> infoLine "hello" > -- "ihello\tnull\terror.host\t1" > infoLine :: T.Text -> T.Text > infoLine msg = "i" <> sanitize msg <> "\tnull\terror.host\t1" > -- | >>> menuLine "home" "/" "host" "70" > -- "1home\t/\thost\t70" > menuLine :: T.Text -> T.Text -> T.Text -> T.Text -> T.Text > menuLine display sel host port = > "1" <> sanitize display <> "\t" <> sel <> "\t" <> host <> "\t" <> port > -- | >>> textLine "doc" "/d" "host" "70" > -- "0doc\t/d\thost\t70" > textLine :: T.Text -> T.Text -> T.Text -> T.Text -> T.Text > textLine display sel host port = > "0" <> sanitize display <> "\t" <> sel <> "\t" <> host <> "\t" <> port > -- | >>> searchLine "find" "/q" "host" "70" > -- "7find\t/q\thost\t70" > searchLine :: T.Text -> T.Text -> T.Text -> T.Text -> T.Text > searchLine display sel host port = > "7" <> sanitize display <> "\t" <> sel <> "\t" <> host <> "\t" <> port > -- | A type-h "URL" line — clients with web support open it in a > -- browser; gopher-only clients fall back gracefully. > -- > -- >>> urlLine "Source" "https://example.com" "host" "70" > -- "hSource\tURL:https://example.com\thost\t70" > urlLine :: T.Text -> T.Text -> T.Text -> T.Text -> T.Text > urlLine display url host port = > "h" <> sanitize display <> "\tURL:" <> url <> "\t" <> host <> "\t" <> port > -- | An image menu row (item type @I@). The selector points at this > -- script's @/img//@ endpoint, which serves the decoded bytes. > -- > -- >>> imageLine "pic" "/applets/interlog/interlog.lhs/img/abc/0" "host" "70" > -- "Ipic\t/applets/interlog/interlog.lhs/img/abc/0\thost\t70" > imageLine :: T.Text -> T.Text -> T.Text -> T.Text -> T.Text > imageLine display sel host port = > "I" <> sanitize display <> "\t" <> sel <> "\t" <> host <> "\t" <> port > -- | A menu row of an arbitrary gopher item type — used when forwarding > -- a @gopher://host[:port]/@ URL as a cross-server > -- link, so the row carries the same item type the URI declared. > -- > -- >>> typedLine '0' "Notes" "/notes.txt" "host" "70" > -- "0Notes\t/notes.txt\thost\t70" > typedLine :: Char -> T.Text -> T.Text -> T.Text -> T.Text -> T.Text > typedLine t display sel host port = > T.singleton t <> sanitize display <> "\t" <> sel <> "\t" <> host <> "\t" <> port > -- | >>> errorItem "nope" > -- "3nope\tnull\terror.host\t1" > errorItem :: T.Text -> T.Text > errorItem msg = "3" <> sanitize msg <> "\tnull\terror.host\t1" > terminator :: T.Text > terminator = "." Gophermap pass-through ---------------------- A stored line that is *itself* a syntactically valid gophermap row is emitted to the menu verbatim, so a user can post a real link or menu entry into a thread. The shape is: