Stubbed out most of what is necessary for non-admin views
Getty Ritter
6 years ago
14 | 14 | hs-source-dirs: src |
15 | 15 | main-is: Main.hs |
16 | 16 | other-modules: Inf.Types |
17 | Inf.Templates | |
17 | 18 | Inf.DB |
19 | Inf.DB.Monad | |
20 | Inf.Feed | |
18 | 21 | default-language: Haskell2010 |
19 | 22 | default-extensions: ScopedTypeVariables |
20 | 23 | ghc-options: -Wall |
24 | 27 | , stache |
25 | 28 | , text |
26 | 29 | , time |
27 |
, |
|
30 | , http-types | |
31 | , sqlite-simple | |
32 | , aeson | |
33 | , filepath | |
34 | , pandoc | |
35 | , feed | |
36 | , xml-types | |
37 | , xml-conduit | |
38 | , bytestring⏎ |
1 | {-# LANGUAGE GADTs #-} | |
2 | {-# LANGUAGE RankNTypes #-} | |
3 | ||
4 | module Inf.DB.Monad | |
5 | ( DB(..) | |
6 | , DBException(..) | |
7 | , SQL.Only(..) | |
8 | , SQL.Connection | |
9 | , io | |
10 | , raise | |
11 | , f | |
12 | ||
13 | , query | |
14 | , queryMb | |
15 | , queryOne | |
16 | , execute | |
17 | , lastRow | |
18 | ||
19 | , SQL.open | |
20 | ) where | |
21 | ||
22 | import qualified Control.Exception as Exn | |
23 | import qualified Database.SQLite.Simple as SQL | |
24 | import qualified Database.SQLite.Simple.ToField as SQL | |
25 | ||
26 | import Inf.Types | |
27 | ||
28 | newtype DB r = DB { runDB :: SQL.Connection -> IO r } | |
29 | ||
30 | instance Functor DB where | |
31 | fmap fn (DB k) = DB (fmap fn . k) | |
32 | ||
33 | instance Applicative DB where | |
34 | pure x = DB (\ _ -> pure x) | |
35 | DB fn <*> DB x = | |
36 | DB (\ c -> fn c <*> x c) | |
37 | ||
38 | instance Monad DB where | |
39 | DB x >>= fn = DB $ \ c -> do | |
40 | x' <- x c | |
41 | runDB (fn x') c | |
42 | ||
43 | ||
44 | io :: IO a -> DB a | |
45 | io k = DB (\ _ -> k) | |
46 | ||
47 | raise :: DBException -> DB a | |
48 | raise e = DB (\ _ -> Exn.throwIO e) | |
49 | ||
50 | data Field where | |
51 | Field :: SQL.ToField r => r -> Field | |
52 | ||
53 | instance SQL.ToField Field where | |
54 | toField (Field x) = SQL.toField x | |
55 | ||
56 | f :: SQL.ToField r => r -> Field | |
57 | f = Field | |
58 | ||
59 | query :: SQL.FromRow r => SQL.Query -> [Field] -> DB [r] | |
60 | query q [] = DB $ \conn -> SQL.query_ conn q | |
61 | query q as = DB $ \conn -> SQL.query conn q as | |
62 | ||
63 | queryMb :: SQL.FromRow r => SQL.Query -> [Field] -> DB (Maybe r) | |
64 | queryMb q as = DB $ \conn -> do | |
65 | rs <- case as of | |
66 | [] -> SQL.query_ conn q | |
67 | _ -> SQL.query conn q as | |
68 | case rs of | |
69 | [] -> pure Nothing | |
70 | [x] -> pure (Just x) | |
71 | _ -> Exn.throwIO NonUniqueResult | |
72 | ||
73 | queryOne :: (SQL.FromRow r) => SQL.Query -> [Field] -> DB r | |
74 | queryOne q as = DB $ \conn -> do | |
75 | rs <- case as of | |
76 | [] -> SQL.query_ conn q | |
77 | _ -> SQL.query conn q as | |
78 | case rs of | |
79 | [x] -> pure x | |
80 | _ -> Exn.throwIO NonUniqueResult | |
81 | ||
82 | execute :: SQL.Query -> [Field] -> DB () | |
83 | execute q as = DB $ \conn -> do | |
84 | case as of | |
85 | [] -> SQL.execute_ conn q | |
86 | _ -> SQL.execute conn q as | |
87 | ||
88 | lastRow :: DB Int | |
89 | lastRow = DB $ \conn -> fromIntegral `fmap` SQL.lastInsertRowId conn |
2 | 2 | |
3 | 3 | module Inf.DB where |
4 | 4 | |
5 | import qualified Control.Exception as Exn | |
6 | import qualified Database.SQLite.Simple as SQL | |
5 | import Control.Monad (forM) | |
7 | 6 | import qualified Data.Text as T |
8 | 7 | import qualified Data.Time as Time |
9 | import Data.Typeable (Typeable) | |
10 | 8 | |
9 | import qualified Inf.DB.Monad as DB | |
11 | 10 | import Inf.Types |
12 | 11 | |
13 | ||
14 | type DB r = SQL.Connection -> IO r | |
15 | ||
16 | data DBException | |
17 | = MissingPost PostId | |
18 | deriving (Eq, Show, Typeable) | |
19 | ||
20 | instance Exn.Exception DBException where | |
21 | ||
22 | -- | A utility function for grabbing the only relevant value from a | |
23 | -- query | |
24 | unique :: [SQL.Only a] -> Maybe a | |
25 | unique (SQL.Only x:_) = Just x | |
26 | unique _ = Nothing | |
27 | ||
28 | submitPost :: User -> RawPost -> DB Bool | |
29 |
submitPost |
|
12 | submitPost :: User -> RawPost -> DB.DB () | |
13 | submitPost uname rp = case rpId rp of | |
30 | 14 | Just (PostId n) | uname == rpAuthor rp -> do |
31 | pure () | |
32 | SQL.execute c | |
15 | DB.execute | |
33 | 16 | "UPDATE posts \ |
34 | 17 | \ SET title = ?, author = ?, contents = ? \ |
35 | 18 | \ WHERE id = ?" |
36 | (rpTitle rp, userName (rpAuthor rp), rpContents rp, n) | |
37 | SQL.execute c | |
19 | [ DB.f (rpTitle rp), DB.f (userName (rpAuthor rp)), DB.f (rpContents rp), DB.f n] | |
20 | DB.execute | |
38 | 21 | "UPDATE lookup SET slug = ? WHERE post_id = ?" |
39 | (slugify (rpTitle rp), n) | |
40 | pure True | |
41 | ||
22 | [DB.f (slugify (rpTitle rp)), DB.f n] | |
23 | Just _ -> DB.raise DB.BadUserError | |
42 | 24 | Nothing -> do |
43 | now <- Time.getCurrentTime | |
44 | prev <- unique `fmap` SQL.query_ c | |
45 | "SELECT id FROM posts ORDER BY time DESC LIMIT 1" | |
46 | SQL.execute c | |
25 | now <- DB.io Time.getCurrentTime | |
26 | prev <- fmap (fmap DB.fromOnly) (DB.queryMb "SELECT id FROM posts ORDER BY time DESC LIMIT 1" []) | |
27 | DB.execute | |
47 | 28 | "INSERT INTO posts \ |
48 | 29 | \ (title, contents, author, time, next, prev) \ |
49 | 30 | \ VALUES (?, ?, ?, ?, ?, ?)" |
50 |
|
|
31 | [DB.f (rpTitle rp), DB.f (rpContents rp), DB.f (rpAuthor rp), DB.f now, DB.f (Nothing :: Maybe Int), DB.f (prev :: Maybe Int)] | |
51 | 32 | |
52 |
new <- |
|
33 | new <- DB.lastRow | |
53 | 34 | case prev of |
54 | Just p -> SQL.execute c | |
55 | "UPDATE posts SET next = ? WHERE id = ?" (new, p) | |
35 | Just p -> DB.execute | |
36 | "UPDATE posts SET next = ? WHERE id = ?" [DB.f new, DB.f p] | |
56 | 37 | Nothing -> pure () |
57 | 38 | |
58 | 39 | let (year, month, _) = Time.toGregorian (Time.utctDay now) |
59 |
|
|
40 | DB.execute | |
60 | 41 | "INSERT INTO lookup (year, month, time, slug, post_id) \ |
61 | 42 | \ VALUES (?, ?, ?, ?, ?)" |
62 | (year, month, now, slugify (rpTitle rp), new) | |
63 | pure True | |
43 | [DB.f year, DB.f month, DB.f now, DB.f (slugify (rpTitle rp)), DB.f new] | |
64 | 44 | |
65 | _ -> pure False | |
66 | ||
67 | deletePost :: RawPost -> DB Bool | |
68 | deletePost rp c | |
45 | deletePost :: RawPost -> DB.DB () | |
46 | deletePost rp | |
69 | 47 | | Just post <- rpId rp = do |
70 | vals <- SQL.query c | |
71 | "SELECT next, prev FROM posts WHERE id = ?" | |
72 |
|
|
48 | vals <- DB.query "SELECT next, prev FROM posts WHERE id = ?" [DB.f post] | |
73 | 49 | |
74 | 50 | (oldNext :: Maybe Int, oldPrev :: Maybe Int) <- case vals of |
75 |
[] -> |
|
51 | [] -> DB.raise (DB.MissingPost post) | |
76 | 52 | (x, y):_ -> pure (x, y) |
77 | 53 | |
78 | 54 | case oldPrev of |
79 | Just n -> SQL.execute c | |
80 | "UPDATE posts SET next = ? WHERE id = ?" (oldNext, n) | |
55 | Just n -> DB.execute | |
56 | "UPDATE posts SET next = ? WHERE id = ?" [DB.f oldNext, DB.f n] | |
81 | 57 | Nothing -> pure () |
82 | 58 | |
83 | 59 | case oldNext of |
84 | Just n -> SQL.execute c | |
85 | "UPDATE posts SET prev = ? WHERE id = ?" (oldPrev, n) | |
60 | Just n -> DB.execute | |
61 | "UPDATE posts SET prev = ? WHERE id = ?" [DB.f oldPrev, DB.f n] | |
86 | 62 | Nothing -> pure () |
87 | 63 | |
88 | SQL.execute c "DELETE FROM lookup WHERE post_id = ?" (SQL.Only post) | |
89 | SQL.execute c "DELETE FROM posts WHERE id = ?" (SQL.Only post) | |
90 | pure True | |
91 | deletePost _ _ = pure False | |
64 | DB.execute "DELETE FROM lookup WHERE post_id = ?" [DB.f post] | |
65 | DB.execute "DELETE FROM posts WHERE id = ?" [DB.f post] | |
66 | deletePost _ = DB.raise DB.NoSuchPost | |
67 | ||
68 | ||
69 | ||
70 | newestPost :: DB.DB (Maybe Post) | |
71 | newestPost = do | |
72 | values <- DB.queryMb "SELECT * FROM posts ORDER BY time DESC LIMIT 1" [] | |
73 | forM values $ \(pId, title, contents, author, date, next, prev) -> do | |
74 | nextRef <- mapM postRefById next | |
75 | prevRef <- mapM postRefById prev | |
76 | pure Post | |
77 | { postId = pId | |
78 | , postDate = date | |
79 | , postTitle = title | |
80 | , postContents = contents | |
81 | , postAuthor = author | |
82 | , postNext = nextRef | |
83 | , postPrev = prevRef | |
84 | } | |
85 | ||
86 | postRefById :: PostId -> DB.DB PostRef | |
87 | postRefById pId = do | |
88 | (year, month, slug, name, date) <- | |
89 | DB.queryOne "SELECT l.year, l.month, l.slug, p.title, p.time \ | |
90 | \ FROM lookup l, posts p \ | |
91 | \ WHERE l.post_id = p.id and p.id = ?" [DB.f pId] | |
92 | pure PostRef | |
93 | { prYear = year | |
94 | , prMonth = month | |
95 | , prSlug = slug | |
96 | , prName = name | |
97 | , prDate = date | |
98 | } | |
99 | ||
100 | ||
101 | newestPostRef :: DB.DB PostRef | |
102 | newestPostRef = do | |
103 | vals <- DB.queryOne "SELECT id FROM posts ORDER BY time DESC LIMIT 1" [] | |
104 | postRefById (DB.fromOnly vals) | |
105 | ||
106 | oldestPostRef :: DB.DB PostRef | |
107 | oldestPostRef = do | |
108 | vals <- DB.queryOne "SELECT id FROM posts ORDER BY time ASC LIMIT 1" [] | |
109 | postRefById (DB.fromOnly vals) | |
110 | ||
111 | staticPage :: T.Text -> DB.DB Page | |
112 | staticPage name = do | |
113 | pg <- DB.queryMb "SELECT title, contents FROM pages WHERE slug = ?" | |
114 | [DB.f name] | |
115 | case pg of | |
116 | Nothing -> DB.raise (DB.MissingPage name) | |
117 | Just x -> pure x | |
118 | ||
119 | allPosts :: DB.DB [Post] | |
120 | allPosts = do | |
121 | values <- DB.query "SELECT id, time, title, contents, author FROM posts" [] | |
122 | pure [ Post pId date title content author Nothing Nothing | |
123 | | (pId, date, title, content, author) <- values | |
124 | ] | |
125 | ||
126 | allPostRefs :: DB.DB [PostRef] | |
127 | allPostRefs = do | |
128 | posts <- DB.query "SELECT l.year, l.month, l.slug, p.title, p.time \ | |
129 | \ FROM lookup l, posts p WHERE p.id = l.post_id \ | |
130 | \ ORDER BY p.time DESC" [] | |
131 | pure [ PostRef | |
132 | { prYear = year | |
133 | , prMonth = month | |
134 | , prSlug = slug | |
135 | , prName = title | |
136 | , prDate = date | |
137 | } | |
138 | | (year, month, slug, title, date) <- posts | |
139 | ] | |
140 | ||
141 | postByDateAndSlug :: T.Text -> T.Text -> T.Text -> DB.DB Post | |
142 | postByDateAndSlug year month slug = do | |
143 | values <- DB.queryMb | |
144 | "SELECT post_id FROM lookup WHERE year = ? AND month = ? AND slug = ?" | |
145 | [DB.f year, DB.f month, DB.f slug] | |
146 | case values of | |
147 | Just p -> postById (DB.fromOnly p) | |
148 | Nothing -> DB.raise (DB.NoPostFound year month slug) | |
149 | ||
150 | postById :: PostId -> DB.DB Post | |
151 | postById pId = do | |
152 | (title, contents, author, date, next, prev) | |
153 | <- DB.queryOne "SELECT title, contents, author, time, next, prev \ | |
154 | \ FROM posts WHERE id = ?" [DB.f pId] | |
155 | nextRef <- mapM postRefById next | |
156 | prevRef <- mapM postRefById prev | |
157 | pure Post | |
158 | { postId = pId | |
159 | , postDate = date | |
160 | , postTitle = title | |
161 | , postContents = contents | |
162 | , postAuthor = author | |
163 | , postNext = nextRef | |
164 | , postPrev = prevRef | |
165 | } |
1 | {-# LANGUAGE OverloadedStrings #-} | |
2 | ||
3 | module Inf.Feed (renderFeed) where | |
4 | ||
5 | import Control.Monad (forM) | |
6 | import qualified Data.ByteString.Lazy as LBS | |
7 | import qualified Text.Atom.Feed as Atom | |
8 | import qualified Text.Atom.Feed.Export as Atom | |
9 | import qualified Text.XML as XML | |
10 | ||
11 | import qualified Inf.Templates as Template | |
12 | import Inf.Types | |
13 | ||
14 | renderFeed :: [Post] -> IO LBS.ByteString | |
15 | renderFeed posts = do | |
16 | renderedPosts <- forM posts $ \p -> do | |
17 | content <- Template.markdown (postContents p) | |
18 | pure p { postContents = content } | |
19 | let Right el = XML.fromXMLElement (Atom.xmlFeed (mkFeed renderedPosts)) | |
20 | doc = XML.Document (XML.Prologue [] Nothing []) el [] | |
21 | pure (XML.renderLBS XML.def doc) | |
22 | ||
23 | author :: Atom.Person | |
24 | author = Atom.Person | |
25 | { Atom.personName = "Getty Ritter" | |
26 | , Atom.personURI = Just "https://gdritter.com/" | |
27 | , Atom.personEmail = Nothing | |
28 | , Atom.personOther = [] | |
29 | } | |
30 | ||
31 | mkFeed :: [Post] -> Atom.Feed | |
32 | mkFeed posts = let postsRev = reverse posts in Atom.Feed | |
33 | { Atom.feedId = "https://blog.infinitenegativeutility.com/" | |
34 | , Atom.feedTitle = Atom.TextString "Infinite Negative Utility" | |
35 | , Atom.feedUpdated = postDateT (head postsRev) | |
36 | , Atom.feedAuthors = [author] | |
37 | , Atom.feedCategories = [] | |
38 | , Atom.feedContributors = [author] | |
39 | , Atom.feedGenerator = Nothing | |
40 | , Atom.feedIcon = Nothing | |
41 | , Atom.feedLinks = [] | |
42 | , Atom.feedLogo = Nothing | |
43 | , Atom.feedRights = Nothing | |
44 | , Atom.feedSubtitle = Nothing | |
45 | , Atom.feedEntries = map mkEntry postsRev | |
46 | , Atom.feedAttrs = [] | |
47 | , Atom.feedOther = [] | |
48 | } | |
49 | ||
50 | mkEntry :: Post -> Atom.Entry | |
51 | mkEntry p = Atom.Entry | |
52 | { Atom.entryId = postURL p | |
53 | , Atom.entryTitle = Atom.TextString (postTitle p) | |
54 | , Atom.entryUpdated = (postDateT p) | |
55 | , Atom.entryAuthors = [author] | |
56 | , Atom.entryCategories = [] | |
57 | , Atom.entryContent = Just (Atom.HTMLContent (postContents p)) | |
58 | , Atom.entryContributor = [author] | |
59 | , Atom.entryLinks = [ Atom.nullLink (postURL p) ] | |
60 | , Atom.entryPublished = Just (postDateT p) | |
61 | , Atom.entryRights = Nothing | |
62 | , Atom.entrySource = Nothing | |
63 | , Atom.entrySummary = Nothing | |
64 | , Atom.entryInReplyTo = Nothing | |
65 | , Atom.entryInReplyTotal = Nothing | |
66 | , Atom.entryAttrs = [] | |
67 | , Atom.entryOther = [] | |
68 | } |
1 | {-# LANGUAGE OverloadedStrings #-} | |
2 | ||
3 | module Inf.Templates | |
4 | ( main | |
5 | , page | |
6 | , list | |
7 | ||
8 | , dbException | |
9 | , someException | |
10 | ||
11 | , markdown | |
12 | ) where | |
13 | ||
14 | import qualified Control.Exception as Exn | |
15 | import Control.Monad ((=<<)) | |
16 | import Data.Aeson ((.=)) | |
17 | import qualified Data.Aeson as Aeson | |
18 | import qualified Data.Text as TS | |
19 | import qualified Data.Text.Lazy as T | |
20 | import qualified Text.Mustache as Stache | |
21 | import qualified Text.Pandoc.Class as Pandoc | |
22 | import qualified Text.Pandoc.Extensions as Pandoc | |
23 | import qualified Text.Pandoc.Highlighting as Pandoc | |
24 | import qualified Text.Pandoc.Options as Pandoc | |
25 | import qualified Text.Pandoc.Readers.Markdown as Pandoc | |
26 | import qualified Text.Pandoc.Writers.HTML as Pandoc | |
27 | ||
28 | import Inf.Types | |
29 | ||
30 | markdownOpts :: Pandoc.ReaderOptions | |
31 | markdownOpts = Pandoc.def | |
32 | { Pandoc.readerExtensions = Pandoc.pandocExtensions | |
33 | } | |
34 | ||
35 | htmlOpts :: Pandoc.WriterOptions | |
36 | htmlOpts = Pandoc.def | |
37 | { Pandoc.writerHighlightStyle = Just Pandoc.tango | |
38 | } | |
39 | ||
40 | markdown :: TS.Text -> IO TS.Text | |
41 | markdown raw = Pandoc.runIOorExplode $ do | |
42 | pandoc <- Pandoc.readMarkdown markdownOpts raw | |
43 | Pandoc.writeHtml5String htmlOpts pandoc | |
44 | ||
45 | main :: T.Text -> T.Text -> IO T.Text | |
46 | main title contents = do | |
47 | template <- Stache.compileMustacheFile "templates/main.mustache" | |
48 | let vals = Aeson.object | |
49 | [ "title" .= title | |
50 | , "contents" .= contents | |
51 | ] | |
52 | pure (Stache.renderMustache template vals) | |
53 | ||
54 | page :: TS.Text -> IO T.Text | |
55 | page contents = do | |
56 | template <- Stache.compileMustacheFile "templates/page.mustache" | |
57 | mk <- markdown contents | |
58 | let vals = Aeson.object | |
59 | [ "contents" .= mk ] | |
60 | pure (Stache.renderMustache template vals) | |
61 | ||
62 | list :: [PostRef] -> IO T.Text | |
63 | list posts = do | |
64 | template <- Stache.compileMustacheFile "templates/list.mustache" | |
65 | let vals = Aeson.object | |
66 | [ "posts" .= | |
67 | [ Aeson.object [ "url" .= postRefURL ref | |
68 | , "title" .= prName ref | |
69 | , "date" .= postRefDateT ref | |
70 | ] | |
71 | | ref <- posts | |
72 | ] | |
73 | ] | |
74 | pure (Stache.renderMustache template vals) | |
75 | ||
76 | ||
77 | dbException :: DBException -> IO T.Text | |
78 | dbException exn = main "Error" =<< page (TS.unlines | |
79 | ( "## Internal server error" | |
80 | : case exn of | |
81 | NoSuchPost -> [ "database misconfiguration" ] | |
82 | BadUserError -> [ "bad user session" ] | |
83 | NonUniqueResult -> [ "database misconfiguration" ] | |
84 | MissingPost pId -> [ "missing post: `" <> TS.pack (show pId) <> "`"] | |
85 | MissingPage name -> [ "missing page: `" <> name <> "`"] | |
86 | NoPostFound year month slug -> | |
87 | [ "no such post: `" <> year <> "/" <> month <> "/" <> slug <> "`" ] | |
88 | )) | |
89 | ||
90 | someException :: Exn.SomeException -> IO T.Text | |
91 | someException exn = main "Internal Sever Error" =<< page (TS.unlines | |
92 | [ "The following unexpected error occurred:" | |
93 | , "```" | |
94 | , TS.pack (show exn) | |
95 | , "```" | |
96 | ]) |
2 | 2 | |
3 | 3 | module Inf.Types where |
4 | 4 | |
5 | import qualified Control.Exception as Exn | |
5 | 6 | import qualified Data.Char as Char |
6 | 7 | import qualified Data.Text as T |
7 | 8 | import qualified Data.Time as Time |
67 | 68 | , rpContents = "" |
68 | 69 | } |
69 | 70 | |
71 | data Page = Page | |
72 | { pageName :: T.Text | |
73 | , pageText :: T.Text | |
74 | } deriving Show | |
75 | ||
76 | instance SQL.FromRow Page where | |
77 | fromRow = uncurry Page <$> SQL.fromRow | |
70 | 78 | |
71 | 79 | data URL = URL |
72 | 80 | { urlRelative :: T.Text |
100 | 108 | ] |
101 | 109 | |
102 | 110 | |
111 | postRefDateT :: PostRef -> T.Text | |
112 | postRefDateT PostRef { prDate = date } = | |
113 | T.pack (Time.formatTime | |
114 | Time.defaultTimeLocale | |
115 | Time.rfc822DateFormat | |
116 | date) | |
117 | ||
103 | 118 | postRefURL :: PostRef -> T.Text |
104 | 119 | postRefURL PostRef { prYear = year, prMonth = month, prSlug = slug } = |
105 | 120 | T.concat [ "/", T.pack (show year) |
123 | 138 | , rpContents = postContents p |
124 | 139 | , rpAuthor = postAuthor p |
125 | 140 | } |
141 | ||
142 | ||
143 | ||
144 | data DBException | |
145 | = MissingPost PostId | |
146 | | MissingPage T.Text | |
147 | | NoPostFound T.Text T.Text T.Text | |
148 | | NonUniqueResult | |
149 | | BadUserError | |
150 | | NoSuchPost | |
151 | deriving (Eq, Show) | |
152 | ||
153 | instance Exn.Exception DBException where |
2 | 2 | |
3 | 3 | module Main where |
4 | 4 | |
5 |
import qualified |
|
5 | import qualified Control.Exception as Exn | |
6 | import Control.Monad ((>=>)) | |
7 | import qualified Data.Text as TS | |
8 | import qualified Data.Text.Encoding as EncodingS | |
9 | import qualified Data.Text.Lazy as T | |
10 | import qualified Data.Text.Lazy.Encoding as Encoding | |
11 | import qualified Network.HTTP.Types as HTTP | |
6 | 12 | import qualified Network.Wai as W |
7 | 13 | import qualified Network.Wai.Handler.Warp as W |
14 | import System.FilePath ((</>)) | |
8 | 15 | import qualified System.Environment as Env |
9 | 16 | import qualified Text.Read as Read |
17 | ||
18 | import qualified Inf.DB.Monad as DB | |
19 | import qualified Inf.DB as DB | |
20 | import qualified Inf.Feed as Feed | |
21 | import qualified Inf.Templates as Template | |
22 | import Inf.Types | |
10 | 23 | |
11 | 24 | main :: IO () |
12 | 25 | main = do |
15 | 28 | Just x |
16 | 29 | | Just p <- Read.readMaybe x -> p |
17 | 30 | _ -> 8080 |
18 |
|
|
31 | conn <- DB.open "test.db" | |
32 | W.run port $ \ r k -> do | |
33 | resp <- route conn r | |
34 | `Exn.catch` (Template.dbException >=> raise500) | |
35 | `Exn.catch` (Template.someException >=> raise500) | |
36 | k resp | |
19 | 37 | |
38 | page :: T.Text -> T.Text -> IO W.Response | |
39 | page title stuff = do | |
40 | pg <- Template.main title stuff | |
41 | let headers = [(HTTP.hContentType, "text/html")] | |
42 | pure (W.responseLBS HTTP.status200 [] (Encoding.encodeUtf8 pg)) | |
20 | 43 | |
21 | raise404 :: [T.Text] -> IO W.Response | |
22 | raise404 = undefined | |
44 | raise404 :: [TS.Text] -> IO W.Response | |
45 | raise404 msgs = do | |
46 | contents <- Template.page $ TS.unwords | |
47 | [ "No handler found for page " | |
48 | , "`/" <> TS.intercalate "/" msgs <> "`" | |
49 | ] | |
50 | pg <- Template.main "404" contents | |
51 | pure (W.responseLBS HTTP.status404 [] (Encoding.encodeUtf8 pg)) | |
23 | 52 | |
53 | raise500 :: T.Text -> IO W.Response | |
54 | raise500 msgs = | |
55 | let body = Encoding.encodeUtf8 msgs | |
56 | in pure (W.responseLBS HTTP.status500 [] body) | |
24 | 57 | |
25 | route :: W.Request -> IO W.Response | |
26 | route req = case (W.requestMethod req, W.pathInfo req) of | |
27 | ("GET", []) -> undefined | |
58 | route :: DB.Connection -> W.Request -> IO W.Response | |
59 | route c req = case (W.requestMethod req, W.pathInfo req) of | |
60 | ("GET", []) -> do | |
61 | post <- DB.runDB DB.newestPostRef c | |
62 | let headers = [(HTTP.hLocation, EncodingS.encodeUtf8 (postRefURL post))] | |
63 | pure (W.responseLBS HTTP.status307 headers "redirecting...") | |
64 | ||
28 | 65 | ("POST", []) -> undefined |
66 | ||
29 | 67 | ("GET", ["auth"]) -> undefined |
30 | ("GET", ["archive"]) -> undefined | |
68 | ||
69 | ("GET", ["archive"]) -> do | |
70 | posts <- DB.runDB DB.allPostRefs c | |
71 | contents <- Template.list posts | |
72 | page "Past Entries" contents | |
73 | ||
31 | 74 | ("GET", ["create"]) -> undefined |
32 | ("GET", [y, m, s]) -> undefined | |
75 | ||
76 | ("GET", [y, m, s]) -> do | |
77 | post <- DB.runDB (DB.postByDateAndSlug y m s) c | |
78 | contents <- Template.page (postContents post) | |
79 | page (T.fromStrict (postTitle post)) contents | |
80 | ||
33 | 81 | ("POST", [y, m, s]) -> undefined |
82 | ||
34 | 83 | ("GET", [y, m, s, "edit"]) -> undefined |
84 | ||
35 | 85 | ("GET", [y, m, s, "delete"]) -> undefined |
86 | ||
36 | 87 | ("GET", ["login"]) -> undefined |
88 | ||
37 | 89 | ("GET", ["logout"]) -> undefined |
38 | ("GET", ["oldest"]) -> undefined | |
39 | ("GET", ["static", fp]) -> undefined | |
40 | ("GET", ["rss"]) -> undefined | |
41 | ("GET", [page]) -> undefined | |
90 | ||
91 | ("GET", ["newest"]) -> do | |
92 | post <- DB.runDB DB.newestPostRef c | |
93 | let headers = [(HTTP.hLocation, EncodingS.encodeUtf8 (postRefURL post))] | |
94 | pure (W.responseLBS HTTP.status307 headers "redirecting...") | |
95 | ||
96 | ("GET", ["oldest"]) -> do | |
97 | post <- DB.runDB DB.oldestPostRef c | |
98 | let headers = [(HTTP.hLocation, EncodingS.encodeUtf8 (postRefURL post))] | |
99 | pure (W.responseLBS HTTP.status307 headers "redirecting...") | |
100 | ||
101 | ("GET", ["static", fp]) -> | |
102 | let path = "static" </> TS.unpack fp | |
103 | in pure (W.responseFile HTTP.status200 [] path Nothing) | |
104 | ||
105 | ("GET", ["rss"]) -> do | |
106 | posts <- DB.runDB DB.allPosts c | |
107 | feed <- Feed.renderFeed posts | |
108 | let headers = | |
109 | [(HTTP.hContentType, "application/atom+xml")] | |
110 | pure (W.responseLBS HTTP.status200 headers feed) | |
111 | ||
112 | ("GET", [pagename]) -> do | |
113 | pg <- DB.runDB (DB.staticPage pagename) c | |
114 | contents <- Template.page (pageText pg) | |
115 | page (T.fromStrict pagename) contents | |
116 | ||
42 | 117 | ("GET", [page, "edit"]) -> undefined |
118 | ||
43 | 119 | (_, path) -> raise404 path |
44 |
1 | a.sourceLine { display: inline-block; line-height: 1.25; } | |
2 | a.sourceLine { pointer-events: none; color: inherit; text-decoration: inherit; } | |
3 | a.sourceLine:empty { height: 1.2em; } | |
4 | .sourceCode { overflow: visible; } | |
5 | code.sourceCode { white-space: pre; position: relative; } | |
6 | div.sourceCode { margin: 1em 0; } | |
7 | pre.sourceCode { margin: 0; } | |
8 | @media screen { | |
9 | div.sourceCode { overflow: auto; } | |
10 | } | |
11 | @media print { | |
12 | code.sourceCode { white-space: pre-wrap; } | |
13 | a.sourceLine { text-indent: -1em; padding-left: 1em; } | |
14 | } | |
15 | pre.numberSource a.sourceLine | |
16 | { position: relative; left: -4em; } | |
17 | pre.numberSource a.sourceLine::before | |
18 | { content: attr(title); | |
19 | position: relative; left: -1em; text-align: right; vertical-align: baseline; | |
20 | border: none; pointer-events: all; display: inline-block; | |
21 | -webkit-touch-callout: none; -webkit-user-select: none; | |
22 | -khtml-user-select: none; -moz-user-select: none; | |
23 | -ms-user-select: none; user-select: none; | |
24 | padding: 0 4px; width: 4em; | |
25 | color: #aaaaaa; | |
26 | } | |
27 | pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; } | |
28 | div.sourceCode | |
29 | { background-color: #f8f8f8; } | |
30 | @media screen { | |
31 | a.sourceLine::before { text-decoration: underline; } | |
32 | } | |
33 | code span.al { color: #ef2929; } /* Alert */ | |
34 | code span.an { color: #8f5902; font-weight: bold; font-style: italic; } /* Annotation */ | |
35 | code span.at { color: #c4a000; } /* Attribute */ | |
36 | code span.bn { color: #0000cf; } /* BaseN */ | |
37 | code span.cf { color: #204a87; font-weight: bold; } /* ControlFlow */ | |
38 | code span.ch { color: #4e9a06; } /* Char */ | |
39 | code span.cn { color: #000000; } /* Constant */ | |
40 | code span.co { color: #8f5902; font-style: italic; } /* Comment */ | |
41 | code span.cv { color: #8f5902; font-weight: bold; font-style: italic; } /* CommentVar */ | |
42 | code span.do { color: #8f5902; font-weight: bold; font-style: italic; } /* Documentation */ | |
43 | code span.dt { color: #204a87; } /* DataType */ | |
44 | code span.dv { color: #0000cf; } /* DecVal */ | |
45 | code span.er { color: #a40000; font-weight: bold; } /* Error */ | |
46 | code span.ex { } /* Extension */ | |
47 | code span.fl { color: #0000cf; } /* Float */ | |
48 | code span.fu { color: #000000; } /* Function */ | |
49 | code span.im { } /* Import */ | |
50 | code span.in { color: #8f5902; font-weight: bold; font-style: italic; } /* Information */ | |
51 | code span.kw { color: #204a87; font-weight: bold; } /* Keyword */ | |
52 | code span.op { color: #ce5c00; font-weight: bold; } /* Operator */ | |
53 | code span.ot { color: #8f5902; } /* Other */ | |
54 | code span.pp { color: #8f5902; font-style: italic; } /* Preprocessor */ | |
55 | code span.sc { color: #000000; } /* SpecialChar */ | |
56 | code span.ss { color: #4e9a06; } /* SpecialString */ | |
57 | code span.st { color: #4e9a06; } /* String */ | |
58 | code span.va { color: #000000; } /* Variable */ | |
59 | code span.vs { color: #4e9a06; } /* VerbatimString */ | |
60 | code span.wa { color: #8f5902; font-weight: bold; font-style: italic; } /* Warning */ |
1 | body { | |
2 | font-family: "Arial", "Helvetica", sans-serif; | |
3 | margin-left: 0px; | |
4 | margin-right: 0px; | |
5 | background-color: #FFFFFF; | |
6 | color: #222222; | |
7 | font-size: large; | |
8 | } | |
9 | ||
10 | a:link { color: #bbbbbb; } | |
11 | a:hover { color: #bbbbbb; } | |
12 | a:active { color: #999999; } | |
13 | a:visited { color: #999999; } | |
14 | ||
15 | .username { | |
16 | width: 100%; | |
17 | padding-top: 5px; | |
18 | padding-bottom: 5px; | |
19 | background-color: rgba(178,32,27,0.95); | |
20 | color: #FFFFFF; | |
21 | text-align: center; | |
22 | margin-bottom: 10px; | |
23 | } | |
24 | ||
25 | .title { | |
26 | width: 100%; | |
27 | text-align: center; | |
28 | padding: 1px; | |
29 | background-color: #222222; | |
30 | color: #FFFFFF; | |
31 | margin-bottom: 20px; | |
32 | } | |
33 | ||
34 | .nav { | |
35 | width: 100%; | |
36 | background-color: #444444; | |
37 | padding-top: 12px; | |
38 | padding-bottom: 12px; | |
39 | margin: 0 auto; | |
40 | margin-bottom: 10px; | |
41 | text-align: center; | |
42 | color: #FFFFFF; | |
43 | } | |
44 | ||
45 | .navitem { | |
46 | padding-left: 100px; | |
47 | padding-top: 10px; | |
48 | padding-right: 100px; | |
49 | padding-bottom: 10px | |
50 | } | |
51 | ||
52 | .navitem a { | |
53 | color: #FFFFFF; | |
54 | } | |
55 | ||
56 | .navitem a visited { | |
57 | color: #FFFFFF; | |
58 | } | |
59 | ||
60 | .postbody { | |
61 | width: 60%; | |
62 | margin-left: auto; | |
63 | margin-right: auto; | |
64 | background-color: #FFFFFF; | |
65 | padding-left: 20px; | |
66 | padding-right: 20px; | |
67 | padding-top: 10px; | |
68 | padding-bottom: 30px; | |
69 | } | |
70 | ||
71 | .list { | |
72 | width: 60%; | |
73 | margin-left: auto; | |
74 | margin-right: auto; | |
75 | background-color: #FFFFFF; | |
76 | padding-left: 20px; | |
77 | padding-right: 20px; | |
78 | padding-top: 10px; | |
79 | padding-bottom: 30px; | |
80 | } | |
81 | ||
82 | .main h1 { | |
83 | color: #FFFFFF; | |
84 | background-color: #888888; | |
85 | text-align: center; | |
86 | padding: 10px; | |
87 | -moz-border-radius: 15px; | |
88 | border-radius: 15px; | |
89 | } | |
90 | ||
91 | .main h2 { | |
92 | color: #FFFFFF; | |
93 | background-color: #888888; | |
94 | text-align: center; | |
95 | padding: 10px; | |
96 | -moz-border-radius: 15px; | |
97 | border-radius: 15px; | |
98 | } | |
99 | ||
100 | .main h3 { | |
101 | color: #FFFFFF; | |
102 | background-color: #888888; | |
103 | text-align: center; | |
104 | padding: 10px; | |
105 | -moz-border-radius: 15px; | |
106 | border-radius: 15px; | |
107 | } | |
108 | ||
109 | .edit { | |
110 | text-align: center; | |
111 | } | |
112 | ||
113 | .editlink { | |
114 | text-align: center; | |
115 | } | |
116 | ||
117 | .author { | |
118 | font-style: italic; | |
119 | text-align: center; | |
120 | background-color: rgba(176,189,140,0.2); | |
121 | padding: 10px; | |
122 | width: 60%; | |
123 | margin-left: auto; | |
124 | margin-right: auto; | |
125 | } | |
126 | ||
127 | .new { | |
128 | display: inline-block; | |
129 | width: 50%; | |
130 | text-align: left; | |
131 | } | |
132 | ||
133 | .old { | |
134 | display: inline-block; | |
135 | width: 50%; | |
136 | text-align: right; | |
137 | } | |
138 | ||
139 | pre { | |
140 | padding: 10px; | |
141 | -moz-border-radius: 15px; | |
142 | border-radius: 15px; | |
143 | padding-left: 30px; | |
144 | padding-right: 30px; | |
145 | } | |
146 | ||
147 | code { | |
148 | padding: 2px; | |
149 | }⏎ |
1 | <div class="edit"> | |
2 | <p>Are you sure you want to delete this post?</p> | |
3 | <p> | |
4 | <a href="{{post_url}}">No</a> | |
5 | <form name="delpost" action="{{post_url}}" method="DELETE"> | |
6 | <input type="submit" value="yes" /> | |
7 | </form> | |
8 | </p> | |
9 | </div> |
1 | <div class="edit"> | |
2 | <form name="newpost" action="/" method="POST" | |
3 | enctype="application/x-www-form-urlencoded;charset=UTF-8"> | |
4 | <input type="hidden" name="id" value="{{id}}"/> | |
5 | <input type="text" name="title" value="{{title}}"/> | |
6 | <input type="hidden" name="author" value="{{author}}"/><br/> | |
7 | <textarea cols="80" rows="40" name="contents">{{contents}}</textarea><br/> | |
8 | <input type="submit"/> | |
9 | </form> | |
10 | </div> |
1 | <div class="list"> | |
2 | {{#posts}} | |
3 | <p><a href={{url}}>{{title}}</a> <span class="date">on {{date}}</span></p> | |
4 | {{/posts}} | |
5 | </div> |
1 | <form name="login" action="/login" method="POST" | |
2 | enctype="application/x-www-form-urlencoded;charset=UTF-8"> | |
3 | <input type="text" name="user"/><br/> | |
4 | <input type="password" name="passwd"/><br/> | |
5 | <input type="submit"/> | |
6 | </form> |
1 | <!DOCTYPE html> | |
2 | <html> | |
3 | <head> | |
4 | <meta charset="utf-8" /> | |
5 | <link rel="stylesheet" type="text/css" href="/static/main.css" /> | |
6 | <link rel="stylesheet" type="text/css" href="/static/highlighting.css" /> | |
7 | <link rel="alternate" type="application/rss+xml" | |
8 | title="Infinite Negative Utility" | |
9 | href="/rss" /> | |
10 | <title>{{title}} — Infinite Negative Utility</title> | |
11 | </head> | |
12 | <body> | |
13 | {{#user}} | |
14 | <div class="username"> | |
15 | Logged in as {{name}} | |
16 | — | |
17 | <a href="/create">Create a Post</a> | |
18 | — | |
19 | <a href="/change">Change My Password</a> | |
20 | — | |
21 | <a href="/logout">Log Out</a> | |
22 | </div> | |
23 | {{/user}} | |
24 | <div class="title"> | |
25 | <h1>{{title}}</h1> | |
26 | </div> | |
27 | <div class="nav"> | |
28 | <a class="navitem" href="/newest">Newest</a> | |
29 | <a class="navitem" href="/archive">Archive</a> | |
30 | <a class="navitem" href="/oldest">Oldest</a> | |
31 | <a class="navitem" href="/about">About</a> | |
32 | </div> | |
33 | <div class="main"> | |
34 | {{{ contents }}} | |
35 | </div> | |
36 | <div class="nav">©2018 Getty Ritter</div> | |
37 | </body> | |
38 | </html> |
1 | {{#user}} | |
2 | <form name="passwd" action="/change" method="POST" | |
3 | enctype="application/x-www-form-urlencoded;charset=UTF-8"> | |
4 | Old password for {{name}}:<br/> | |
5 | <input type="password" name="oldpasswd"/><br/> | |
6 | New password:<br/> | |
7 | <input type="password" name="p1"/><br/> | |
8 | <input type="password" name="p2"/><br/> | |
9 | <input type="submit"/> | |
10 | </form> | |
11 | {{/user}} |