just scattered first efforts
Getty Ritter
6 years ago
1 | dist | |
2 | dist-* | |
3 | *~ | |
4 | cabal-dev | |
5 | *.o | |
6 | *.hi | |
7 | *.chi | |
8 | *.chs.h | |
9 | *.dyn_o | |
10 | *.dyn_hi | |
11 | .hpc | |
12 | .hsenv | |
13 | .cabal-sandbox/ | |
14 | cabal.sandbox.config | |
15 | *.prof | |
16 | *.aux | |
17 | *.hp | |
18 | *.eventlog | |
19 | cabal.project.local | |
20 | .ghc.environment.* |
1 | name: new-inf-blog | |
2 | version: 0.1.0.0 | |
3 | -- synopsis: | |
4 | -- description: | |
5 | license: BSD3 | |
6 | author: Getty Ritter <gettylefou@gmail.com> | |
7 | maintainer: Getty Ritter <gettylefou@gmail.com> | |
8 | copyright: @2018 Getty Ritter | |
9 | -- category: | |
10 | build-type: Simple | |
11 | cabal-version: >=1.14 | |
12 | ||
13 | executable new-inf-blog | |
14 | hs-source-dirs: src | |
15 | main-is: Main.hs | |
16 | other-modules: Inf.Types | |
17 | Inf.DB | |
18 | default-language: Haskell2010 | |
19 | default-extensions: ScopedTypeVariables | |
20 | ghc-options: -Wall | |
21 | build-depends: base >=4.7 && <5 | |
22 | , wai | |
23 | , warp | |
24 | , stache | |
25 | , text | |
26 | , time | |
27 | , sqlite-simple⏎ |
1 | {-# LANGUAGE OverloadedStrings #-} | |
2 | ||
3 | module Inf.DB where | |
4 | ||
5 | import qualified Control.Exception as Exn | |
6 | import qualified Database.SQLite.Simple as SQL | |
7 | import qualified Data.Text as T | |
8 | import qualified Data.Time as Time | |
9 | import Data.Typeable (Typeable) | |
10 | ||
11 | import Inf.Types | |
12 | ||
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 uname rp c = case rpId rp of | |
30 | Just (PostId n) | uname == rpAuthor rp -> do | |
31 | pure () | |
32 | SQL.execute c | |
33 | "UPDATE posts \ | |
34 | \ SET title = ?, author = ?, contents = ? \ | |
35 | \ WHERE id = ?" | |
36 | (rpTitle rp, userName (rpAuthor rp), rpContents rp, n) | |
37 | SQL.execute c | |
38 | "UPDATE lookup SET slug = ? WHERE post_id = ?" | |
39 | (slugify (rpTitle rp), n) | |
40 | pure True | |
41 | ||
42 | 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 | |
47 | "INSERT INTO posts \ | |
48 | \ (title, contents, author, time, next, prev) \ | |
49 | \ VALUES (?, ?, ?, ?, ?, ?)" | |
50 | (rpTitle rp, rpContents rp, rpAuthor rp, now, Nothing :: Maybe Int, prev :: Maybe Int) | |
51 | ||
52 | new <- SQL.lastInsertRowId c | |
53 | case prev of | |
54 | Just p -> SQL.execute c | |
55 | "UPDATE posts SET next = ? WHERE id = ?" (new, p) | |
56 | Nothing -> pure () | |
57 | ||
58 | let (year, month, _) = Time.toGregorian (Time.utctDay now) | |
59 | SQL.execute c | |
60 | "INSERT INTO lookup (year, month, time, slug, post_id) \ | |
61 | \ VALUES (?, ?, ?, ?, ?)" | |
62 | (year, month, now, slugify (rpTitle rp), new) | |
63 | pure True | |
64 | ||
65 | _ -> pure False | |
66 | ||
67 | deletePost :: RawPost -> DB Bool | |
68 | deletePost rp c | |
69 | | Just post <- rpId rp = do | |
70 | vals <- SQL.query c | |
71 | "SELECT next, prev FROM posts WHERE id = ?" | |
72 | (SQL.Only post) | |
73 | ||
74 | (oldNext :: Maybe Int, oldPrev :: Maybe Int) <- case vals of | |
75 | [] -> Exn.throwIO (MissingPost post) | |
76 | (x, y):_ -> pure (x, y) | |
77 | ||
78 | case oldPrev of | |
79 | Just n -> SQL.execute c | |
80 | "UPDATE posts SET next = ? WHERE id = ?" (oldNext, n) | |
81 | Nothing -> pure () | |
82 | ||
83 | case oldNext of | |
84 | Just n -> SQL.execute c | |
85 | "UPDATE posts SET prev = ? WHERE id = ?" (oldPrev, n) | |
86 | Nothing -> pure () | |
87 | ||
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 |
1 | {-# LANGUAGE OverloadedStrings #-} | |
2 | ||
3 | module Inf.Types where | |
4 | ||
5 | import qualified Data.Char as Char | |
6 | import qualified Data.Text as T | |
7 | import qualified Data.Time as Time | |
8 | import qualified Database.SQLite.Simple as SQL | |
9 | import qualified Database.SQLite.Simple.FromField as SQL | |
10 | import qualified Database.SQLite.Simple.ToField as SQL | |
11 | ||
12 | newtype PostId = PostId { fromPostId :: Int } deriving (Eq, Show) | |
13 | ||
14 | instance SQL.FromField PostId where | |
15 | fromField f = PostId `fmap` SQL.fromField f | |
16 | ||
17 | instance SQL.ToField PostId where | |
18 | toField = SQL.toField . fromPostId | |
19 | ||
20 | newtype User = User { userName :: T.Text } deriving (Eq, Show) | |
21 | ||
22 | instance SQL.FromField User where | |
23 | fromField f = User `fmap` SQL.fromField f | |
24 | ||
25 | instance SQL.ToField User where | |
26 | toField = SQL.toField . userName | |
27 | ||
28 | ||
29 | -- | A 'PostRef' is just the information needed to identify a post | |
30 | -- internally, including when it was posted and its title and slug | |
31 | data PostRef = PostRef | |
32 | { prYear :: Int | |
33 | , prMonth :: Int | |
34 | , prSlug :: T.Text | |
35 | , prName :: T.Text | |
36 | , prDate :: Time.UTCTime | |
37 | } deriving Show | |
38 | ||
39 | -- | A 'Post' is all the information needed to display a post in full, | |
40 | -- including the raw Markdown of the post itself | |
41 | data Post = Post | |
42 | { postId :: PostId | |
43 | , postDate :: Time.UTCTime | |
44 | , postTitle :: T.Text | |
45 | , postContents :: T.Text | |
46 | , postAuthor :: User | |
47 | , postNext :: Maybe PostRef | |
48 | , postPrev :: Maybe PostRef | |
49 | } deriving Show | |
50 | ||
51 | -- | A 'RawPost' is the information needed to create a new post, and | |
52 | -- thus may not have a post ID (as it might not have been submitted | |
53 | -- yet!) | |
54 | data RawPost = RawPost | |
55 | { rpId :: Maybe PostId | |
56 | , rpTitle :: T.Text | |
57 | , rpAuthor :: User | |
58 | , rpContents :: T.Text | |
59 | } deriving Show | |
60 | ||
61 | -- | A blank post for populating an editing field | |
62 | emptyRawPost :: RawPost | |
63 | emptyRawPost = RawPost | |
64 | { rpId = Nothing | |
65 | , rpTitle = "" | |
66 | , rpAuthor = User { userName = "" } | |
67 | , rpContents = "" | |
68 | } | |
69 | ||
70 | ||
71 | data URL = URL | |
72 | { urlRelative :: T.Text | |
73 | , urlAbsolute :: T.Text | |
74 | } deriving Show | |
75 | ||
76 | ||
77 | mkURL :: [T.Text] -> URL | |
78 | mkURL path = | |
79 | let pathJoined = T.intercalate "/" path | |
80 | root = "https://blog.infinitenegativeutility.com" | |
81 | in URL { urlRelative = T.cons '/' pathJoined | |
82 | , urlAbsolute = root `T.append` T.cons '/' pathJoined | |
83 | } | |
84 | ||
85 | ||
86 | postDateT :: Post -> T.Text | |
87 | postDateT Post { postDate = date } = | |
88 | T.pack (Time.formatTime | |
89 | Time.defaultTimeLocale | |
90 | Time.rfc822DateFormat | |
91 | date) | |
92 | ||
93 | ||
94 | postURL :: Post -> T.Text | |
95 | postURL Post { postDate = date, postTitle = title } = do | |
96 | let (year, month, _) = Time.toGregorian (Time.utctDay date) in | |
97 | T.concat [ "/", T.pack (show year) | |
98 | , "/", T.pack (show month) | |
99 | , "/", slugify title | |
100 | ] | |
101 | ||
102 | ||
103 | postRefURL :: PostRef -> T.Text | |
104 | postRefURL PostRef { prYear = year, prMonth = month, prSlug = slug } = | |
105 | T.concat [ "/", T.pack (show year) | |
106 | , "/", T.pack (show month) | |
107 | , "/", slug | |
108 | ] | |
109 | ||
110 | ||
111 | slugify :: T.Text -> T.Text | |
112 | slugify = T.map conv | |
113 | where | |
114 | conv c | |
115 | | Char.isAlphaNum c = Char.toLower c | |
116 | | otherwise = '-' | |
117 | ||
118 | ||
119 | postToRawPost :: Post -> RawPost | |
120 | postToRawPost p = RawPost | |
121 | { rpId = Just (postId p) | |
122 | , rpTitle = postTitle p | |
123 | , rpContents = postContents p | |
124 | , rpAuthor = postAuthor p | |
125 | } |
1 | {-# LANGUAGE OverloadedStrings #-} | |
2 | ||
3 | module Main where | |
4 | ||
5 | import qualified Data.Text as T | |
6 | import qualified Network.Wai as W | |
7 | import qualified Network.Wai.Handler.Warp as W | |
8 | import qualified System.Environment as Env | |
9 | import qualified Text.Read as Read | |
10 | ||
11 | main :: IO () | |
12 | main = do | |
13 | portVar <- Env.lookupEnv "port" | |
14 | let port = case portVar of | |
15 | Just x | |
16 | | Just p <- Read.readMaybe x -> p | |
17 | _ -> 8080 | |
18 | W.run port (\ r k -> route r >>= k) | |
19 | ||
20 | ||
21 | raise404 :: [T.Text] -> IO W.Response | |
22 | raise404 = undefined | |
23 | ||
24 | ||
25 | route :: W.Request -> IO W.Response | |
26 | route req = case (W.requestMethod req, W.pathInfo req) of | |
27 | ("GET", []) -> undefined | |
28 | ("POST", []) -> undefined | |
29 | ("GET", ["auth"]) -> undefined | |
30 | ("GET", ["archive"]) -> undefined | |
31 | ("GET", ["create"]) -> undefined | |
32 | ("GET", [y, m, s]) -> undefined | |
33 | ("POST", [y, m, s]) -> undefined | |
34 | ("GET", [y, m, s, "edit"]) -> undefined | |
35 | ("GET", [y, m, s, "delete"]) -> undefined | |
36 | ("GET", ["login"]) -> undefined | |
37 | ("GET", ["logout"]) -> undefined | |
38 | ("GET", ["oldest"]) -> undefined | |
39 | ("GET", ["static", fp]) -> undefined | |
40 | ("GET", ["rss"]) -> undefined | |
41 | ("GET", [page]) -> undefined | |
42 | ("GET", [page, "edit"]) -> undefined | |
43 | (_, path) -> raise404 path | |
44 |