toodles 1.0.3 → 1.1.0
raw patch · 12 files changed
+274/−69 lines, 12 filesdep +RSAdep +base64-bytestringdep +bytestringdep ~base
Dependencies added: RSA, base64-bytestring, bytestring, process, time
Dependency ranges changed: base
Files
- README.md +32/−37
- app/Main.hs +6/−2
- src/License.hs +79/−0
- src/Server.hs +32/−13
- src/ToodlesApi.hs +6/−4
- src/Types.hs +10/−2
- toodles-license-public-key.pem +9/−0
- toodles.cabal +23/−8
- verify.py +22/−0
- web/css/toodles.css +16/−0
- web/html/index.html +14/−1
- web/js/app.js +25/−2
README.md view
@@ -95,29 +95,7 @@ Submit a PR if you'd like a language to be added. There will eventually be support for this to be user configurable -### Installing--The easiest way to get toodles is via [stack](https://docs.haskellstack.org).-Just a `stack install --resolver=lts-12.14 toodles` and you're done! Alternatively, with GHC 8.4.3-you can use [cabal](https://www.haskell.org/cabal/download.html). If there is-desire for it I can look into precompiled distribution.--### Running--Invoking `toodles` with no arguments will treat the current directory as the-project root and will start a server on port 9001. You can set these with the-`-d` and `-p` flags, respectively.---```bash-# $ toodles -d <root directory of your project> -p <port to run server>-# for more info run:-# $ toodles --help-$ toodles -d /path/to/your/project -p 9001-# or simply-$ toodles-```-#### Running with Docker+### Running with Docker You can run a pre-built toodles for your current directory via docker: @@ -141,23 +119,40 @@ ``` -### Background+### Installing manually -I work at a small startup called DotDashPay and over time the TODOs in our code-base continued building up to the point where it was difficult to use them-holistically. While the information in the TODOs was actually very useful and-methodically written, the fact that were couldn't easily organize them started-to weigh on us as mounting tech debt.+The best way to install manually is with [stack](https://docs.haskellstack.org).+Just a `stack install --resolver=lts-12.14 toodles` and you're done! Alternatively, with GHC 8.4.3+you can use [cabal](https://www.haskell.org/cabal/download.html). -While not our main product focus, we try hard to find opportunities to build-tools that make use of the organization schemes we already have in place, since-doing so is a big win for us. Toodles became a nights and weekends side-project to use the pre-existing TODO scheme we had spent years using, but had-never effectively capitalized on.+You'll also need to PyCrypto. If you have pip, you can run: -A quick plug if you also like building great tools, like working in a fast paced-startup environment, and are located in the SF Bay Area: Reach out at-careers@dotdashpay.com and come work with us!+```bash+$ pip install pycrypto+```++If you've closed the toodles repo directly, you can also run++```bash+$ cd path/to/toodles+$ pip install -r requirements.txt+```++### Running++Invoking `toodles` with no arguments will treat the current directory as the+project root and will start a server on port 9001. You can set these with the+`-d` and `-p` flags, respectively.+++```bash+# $ toodles -d <root directory of your project> -p <port to run server>+# for more info run:+# $ toodles --help+$ toodles -d /path/to/your/project -p 9001+# or simply+$ toodles+``` ### Contributing
app/Main.hs view
@@ -3,6 +3,7 @@ module Main where import Config+import License import Paths_toodles import Server import Types@@ -15,6 +16,9 @@ main :: IO () main = do+ dataDir <- getDataDir+ licenseRead <- readLicense (dataDir ++ "/toodles-license-public-key.pem") "/etc/toodles/license.json"+ let license = (either (BadLicense) (id) licenseRead) userArgs <- toodlesArgs >>= setAbsolutePath case userArgs of (ToodlesArgs _ _ _ _ True _) -> do@@ -23,9 +27,9 @@ _ -> do let webPort = fromMaybe 9001 $ port userArgs ref <- newIORef Nothing- dataDir <- (++ "/web") <$> getDataDir putStrLn $ "serving on " ++ show webPort- run webPort $ app $ ToodlesState ref dataDir+ tierRef <- newIORef license+ run webPort $ app $ ToodlesState ref (dataDir ++ "/web") tierRef prettyFormat :: TodoEntry -> String prettyFormat (TodoEntryHead _ l a p n entryPriority f _ _ _ _ _ _) =
+ src/License.hs view
@@ -0,0 +1,79 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+++module License+ (+ UserTier(..),+ ToodlesLicense(..),+ readLicense+ ) where++import Paths_toodles++import Data.Aeson+import qualified Data.ByteString.Base64.Lazy as B64+import qualified Data.ByteString.Lazy.Char8 as LB+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time.Clock.POSIX+import GHC.Generics+import System.Directory+import System.Exit+import System.Process++data UserTier+ = BadLicense String+ | NoLiscense+ | Individual+ | Commercial+ deriving (Show, Eq, Ord, Generic, ToJSON, FromJSON)++data License = License {+ payload :: ToodlesLicense,+ encoded :: Text,+ payloadSignature :: Text+ } deriving (Generic, FromJSON, ToJSON, Show)++data ToodlesLicense = ToodlesLicense+ { validStart :: Integer+ , validEnd :: Integer+ , email :: Text+ , reference :: Text+ , product :: Text+ } deriving (Generic, FromJSON, ToJSON, Show)++readLicense :: FilePath -> FilePath -> IO (Either String UserTier)+readLicense publicKeyPath licensePath = do+ licenseExists <- doesFileExist licensePath+ if not licenseExists then+ return $ Right NoLiscense+ else do+ parsedContents <- eitherDecodeFileStrict licensePath+ either (return . Left) (isLicenseValid publicKeyPath) parsedContents++isLicenseValid :: FilePath -> License -> IO (Either String UserTier)+isLicenseValid publicKeyPath (License _ encodedPayload sig) = do+ dataDir <- getDataDir+ now <- ((* 1000) . round) `fmap` getPOSIXTime+ -- dependencies for license verification. for now, python+ -- TODO(#techdebt) - license verification should be done in haskell, this removed+ let args =+ [ dataDir ++ "/verify.py"+ , publicKeyPath+ , T.unpack sig+ , T.unpack encodedPayload+ ]+ decodedPayload =+ decode (B64.decodeLenient . LB.pack $ T.unpack encodedPayload)+ (exitcode, stdout, stderr) <- readProcessWithExitCode "python" args ""+ putStrLn stderr+ return $+ let validated = ("True" == T.strip (T.pack stdout))+ in if (exitcode == ExitSuccess) &&+ validated && (maybe 0 validEnd decodedPayload >= now)+ then Right Commercial+ else Left "Invalid license file"
src/Server.hs view
@@ -7,7 +7,9 @@ module Server where import Config+import License import Parse+import Paths_toodles import ToodlesApi import Types @@ -34,6 +36,9 @@ import Text.Printf import Text.Regex.Posix +freeResultsLimit :: Int+freeResultsLimit = 100+ data ToodlesConfig = ToodlesConfig { ignore :: [FilePath] , flags :: [UserFlag]@@ -54,18 +59,31 @@ server = liftIO . getFullSearchResults s :<|> deleteTodos s :<|> editTodos s+ :<|> getLicense s :<|> serveDirectoryFileServer (dataPath s) :<|> showRawFile s :<|> root s root :: ToodlesState -> [Text] -> Handler Html-root (ToodlesState _ dPath) path =+root (ToodlesState _ dPath _) path = if null path then liftIO $ BZ.preEscapedToHtml <$> readFile (dPath ++ "/html/index.html") else throwError $ err404 { errBody = "Not found" } +getLicense :: ToodlesState -> Handler GetLicenseResponse+getLicense (ToodlesState _ _ tierRef) = do+ license <- liftIO readUserTier+ _ <- liftIO $ atomicModifyIORef' tierRef (const (license, license))+ return $ GetLicenseResponse license++readUserTier :: IO UserTier+readUserTier = do+ dataDir <- getDataDir+ licenseRead <- readLicense (dataDir ++ "/toodles-license-public-key.pem") "/etc/toodles/license.json"+ return $ either BadLicense id licenseRead+ showRawFile :: ToodlesState -> Integer -> Handler Html-showRawFile (ToodlesState ref _) eId = do+showRawFile (ToodlesState ref _ _) eId = do storedResults <- liftIO $ readIORef ref case storedResults of (Just (TodoListResult r _)) -> do@@ -88,7 +106,7 @@ codeLines) editTodos :: ToodlesState -> EditTodoRequest -> Handler Text-editTodos s@(ToodlesState ref _) req = do+editTodos s@(ToodlesState ref _ _) req = do storedResults <- liftIO $ readIORef ref case storedResults of (Just (TodoListResult r _)) -> do@@ -133,10 +151,10 @@ data UpdateType = UpdateTypeEdit | UpdateTypeDelete deriving (Eq) updateCache :: MonadIO m => ToodlesState -> [TodoEntry] -> m ()-updateCache (ToodlesState ref _) entries = do+updateCache (ToodlesState ref _ _) entries = do storedResults <- liftIO $ readIORef ref case storedResults of- (Just (TodoListResult currentCache _)) -> do+ (Just (TodoListResult currentCache resultLimit)) -> do let idsToUpdate = map entryId entries newCache = TodoListResult@@ -144,7 +162,7 @@ (filter (\item -> entryId item `notElem` idsToUpdate) currentCache))- "edits applied"+ resultLimit _ <- liftIO $ atomicModifyIORef' ref (const (Just newCache, Just newCache)) return ()@@ -214,7 +232,7 @@ slice a b xs = take (b - a + 1) (drop a xs) deleteTodos :: ToodlesState -> DeleteTodoRequest -> Handler Text-deleteTodos (ToodlesState ref _) req = do+deleteTodos (ToodlesState ref _ _) req = do storedResults <- liftIO $ readIORef ref case storedResults of (Just refVal@(TodoListResult r _)) -> do@@ -268,16 +286,16 @@ return $ args {directory = absolute} getFullSearchResults :: ToodlesState -> Bool -> IO TodoListResult-getFullSearchResults (ToodlesState ref _) recompute = do+getFullSearchResults (ToodlesState ref _ tierRef) recompute = do result <- readIORef ref+ userLicense <- readIORef tierRef if recompute || isNothing result then do putStrLn "refreshing todo's" userArgs <- toodlesArgs >>= setAbsolutePath- sResults <- runFullSearch userArgs+ sResults <- runFullSearch (userArgs { limit_results = if userLicense == Commercial then 0 else freeResultsLimit}) atomicModifyIORef' ref (const (Just sResults, sResults))- else do- putStrLn "cached read"+ else return $ fromMaybe (error "tried to read from the cache when there wasn't anything there") result runFullSearch :: ToodlesArgs -> IO TodoListResult@@ -295,7 +313,8 @@ let filteredTodos = filter (filterSearch (assignee_search userArgs)) parsedTodos resultList = limitSearch filteredTodos $ limit_results userArgs indexedResults = map (\(i, r) -> r {entryId = i}) $ zip [1 ..] resultList- return $ TodoListResult indexedResults ""+ limit = limit_results userArgs+ return $ TodoListResult indexedResults (limit /= 0 && (length indexedResults >= limit)) where filterSearch :: Maybe SearchFilter -> TodoEntry -> Bool@@ -343,7 +362,7 @@ fileHasValidExtension path = any (\ext -> ext `T.isSuffixOf` T.pack path) (map extension fileTypeToComment) isValidFile :: FilePath -> Bool- isValidFile path = (not $ ignorePath path) && fileHasValidExtension path+ isValidFile path = not (ignorePath path) && fileHasValidExtension path mapHead :: (a -> a) -> [a] -> [a]
src/ToodlesApi.hs view
@@ -1,10 +1,10 @@-{-# LANGUAGE DataKinds,- ScopedTypeVariables,- TypeOperators #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeOperators #-} module ToodlesApi where -import Types+import Types import Data.Proxy (Proxy) import Data.Text (Text)@@ -17,6 +17,8 @@ :<|> "todos" :> "delete" :> ReqBody '[JSON] DeleteTodoRequest :> Post '[JSON] Text :<|> "todos" :> "edit" :> ReqBody '[JSON] EditTodoRequest :> Post '[JSON] Text++ :<|> "license" :> Post '[JSON] GetLicenseResponse :<|> "static" :> Raw
src/Types.hs view
@@ -5,6 +5,8 @@ module Types where +import License+ import Data.Aeson (FromJSON, ToJSON, Value (String), parseJSON, toJSON) import Data.Aeson.Types (typeMismatch)@@ -24,7 +26,8 @@ data ToodlesState = ToodlesState { results :: IORef (Maybe TodoListResult),- dataPath :: FilePath+ dataPath :: FilePath,+ userTier :: IORef UserTier } data CommentType = SingleLine | MultiLine deriving (Show, Eq, Generic)@@ -58,7 +61,7 @@ data TodoListResult = TodoListResult { todos :: [TodoEntry]- , message :: Text+ , limited :: Bool } deriving (Show, Generic) instance ToJSON TodoListResult @@ -75,6 +78,11 @@ , setPriority :: Maybe Integer } deriving (Show, Generic) instance FromJSON EditTodoRequest++data GetLicenseResponse = GetLicenseResponse+ { toodlesTier:: UserTier+ } deriving (Show, Generic)+instance ToJSON GetLicenseResponse data Flag = TODO | FIXME | XXX | UF UserFlag deriving (Generic)
+ toodles-license-public-key.pem view
@@ -0,0 +1,9 @@+-----BEGIN PUBLIC KEY-----+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvrVba8B99vGOUvHUSNSv+T6idquIYp3CdhRww2VckyrVwqCeFNePm6EDhHAsG4SVd94HQDpZdJd/Oohrqlt1I+UccaPse4W3J9nMJx82H9BK91VuFWLKvPAEbeQn+uStRsSeAPeIBVo+y4i5cYPqdV+eN6me2r79/cNwD21auTGTsTLJzY+RaiRA+pnmJuXvxhMjyER/GUn6hikJWrSF+6e+9txokiJR9r2hCv2JQqYHWp/moF/nd/25aTrGEgiWz+RGY15P4UUk1Ju1DmNUQA4/+RmOe1XnXw0X8aoKhuhTjZw5COsq5uHTq7tqC/CemQpldsETqj19XYO3TNi7ojmfa+EQIDAQAB+-----END PUBLIC KEY-----
toodles.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.12 name: toodles-version: 1.0.3+version: 1.1.0 license: MIT license-file: LICENSE copyright: 2018 Avi Press@@ -24,6 +24,8 @@ web/fonts/fontawesome-webfont.woff web/fonts/fontawesome-webfont.woff2 web/img/favicon.png+ verify.py+ toodles-license-public-key.pem extra-source-files: README.md @@ -38,6 +40,7 @@ Config ToodlesApi Server+ License hs-source-dirs: src other-modules: Paths_toodles@@ -45,21 +48,24 @@ ghc-options: -Wall -Wcompat build-depends: MissingH >=1.4.0.1 && <1.5,+ RSA >=2.3.0 && <2.4, aeson ==1.3.1.1,- base >=4.0 && <5,+ base >=4.4.0 && <5,+ base64-bytestring ==1.0.0.1, blaze-html ==0.9.1.1,+ bytestring >=0.10.8.2 && <0.11, cmdargs ==0.10.20, directory ==1.3.1.5, extra ==1.6.13,- hspec >=2.4.4 && <2.6,- hspec-expectations >=0.8.2 && <0.9, megaparsec ==6.5.0,+ process >=1.6.3.0 && <1.7, regex-posix ==0.95.2, servant ==0.14.1, servant-blaze ==0.8, servant-server ==0.14.1, strict ==0.3.2, text ==1.2.3.1,+ time >=1.8.0.2 && <1.9, wai ==3.2.1.2, warp ==3.2.25, yaml ==0.8.32@@ -69,6 +75,7 @@ hs-source-dirs: app src other-modules: Config+ License Parse Server ToodlesApi@@ -79,21 +86,24 @@ -with-rtsopts=-N build-depends: MissingH >=1.4.0.1 && <1.5,+ RSA >=2.3.0 && <2.4, aeson ==1.3.1.1,- base >=4.0 && <5,+ base >=4.4.0 && <5,+ base64-bytestring ==1.0.0.1, blaze-html ==0.9.1.1,+ bytestring >=0.10.8.2 && <0.11, cmdargs ==0.10.20, directory ==1.3.1.5, extra ==1.6.13,- hspec >=2.4.4 && <2.6,- hspec-expectations >=0.8.2 && <0.9, megaparsec ==6.5.0,+ process >=1.6.3.0 && <1.7, regex-posix ==0.95.2, servant ==0.14.1, servant-blaze ==0.8, servant-server ==0.14.1, strict ==0.3.2, text ==1.2.3.1,+ time >=1.8.0.2 && <1.9, wai ==3.2.1.2, warp ==3.2.25, yaml ==0.8.32@@ -104,6 +114,7 @@ hs-source-dirs: test src other-modules: Config+ License Parse Server ToodlesApi@@ -114,20 +125,24 @@ build-depends: MissingH >=1.4.0.1 && <1.5, aeson ==1.3.1.1,- base >=4.0 && <5,+ base >=4.4.0 && <5,+ base64-bytestring ==1.0.0.1, blaze-html ==0.9.1.1,+ bytestring >=0.10.8.2 && <0.11, cmdargs ==0.10.20, directory ==1.3.1.5, extra ==1.6.13, hspec >=2.4.4 && <2.6, hspec-expectations >=0.8.2 && <0.9, megaparsec ==6.5.0,+ process >=1.6.3.0 && <1.7, regex-posix ==0.95.2, servant ==0.14.1, servant-blaze ==0.8, servant-server ==0.14.1, strict ==0.3.2, text ==1.2.3.1,+ time >=1.8.0.2 && <1.9, toodles -any, wai ==3.2.1.2, warp ==3.2.25,
+ verify.py view
@@ -0,0 +1,22 @@+#!/usr/bin/env python++import sys+from Crypto.PublicKey import RSA+from Crypto.Signature import PKCS1_v1_5+from Crypto.Hash import SHA512+from base64 import b64decode, b64encode++def verify_sign(public_key_loc, signature, data):+ with open(public_key_loc, "r") as pub_key_file:+ pub_key = pub_key_file.read()+ rsakey = RSA.importKey(pub_key)+ signer = PKCS1_v1_5.new(rsakey)+ digest = SHA512.new()+ digest.update(data)+ if signer.verify(digest, b64decode(signature)):+ return True+ return False++if __name__ == "__main__":+ print(verify_sign(sys.argv[1], sys.argv[2], sys.argv[3]))+
web/css/toodles.css view
@@ -146,3 +146,19 @@ .input-error { color: red; }++.bad-license-banner {+ background-color: #d1345b;+ color: white;+ text-align: center;+}++.bad-license-banner a {+ text-decoration: underline;+ color: white;+}++.warning-banner {+ background-color: #FFBE0B;+}+
web/html/index.html view
@@ -15,7 +15,7 @@ <div class="navbar-brand"> <div class="navbar-item" href="#"> <span><img src="/static/img/favicon.png" alt="favicon" height="25" width="25"></span>- <span class="toodles-nav-title-text">Toodles</span>+ <span class="toodles-nav-title-text">Toodles <span v-show="license == 'Commercial'">| Pro</span></span> </div> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" v-on:click="toggleMenuBurger"> <span aria-hidden="true"></span>@@ -58,6 +58,19 @@ </div> </div> </nav>++ <section v-show="license == 'BadLicense'" class="bad-license-banner is-fullwidth" >+ <div class="container">+ Your license file is invalid. <a v-on:click="getLicense">Try again</a>+ </div>+ </section>++ <section v-show="limited" class="warning-banner is-fullwidth" >+ <div class="container">+ The free edition of Toodles lets you manage up to 100 TODO's, so your results here are truncated. Please <a href="http://toodles.avi.press/#pricing">upgrade to Toodles Pro</a> in order to manage all of your code's TODO's.+ </div>+ </section>+ <div class="modal"> <div class="modal-background" @click="closeModal"></div> <div class="modal-content">
web/js/app.js view
@@ -17,12 +17,17 @@ addKeyVals: "", addKeyValParseError: false, nothingFilledError: false,+ license: null,+ loadingLicense: false,+ limited: false, } }, created: function() { this.refresh()()+ return this.getLicense() }, methods: {+ // higher order for ease of calling from vue templates refresh: function(recompute) { return function() { this.loading = true@@ -49,7 +54,7 @@ selected: false } })- console.log("todos", this.todos)+ this.limited = data.limited this.loading = false if (!recompute) {@@ -120,7 +125,7 @@ console.log(name) } },- + toggleTodo: function(todo) { todo.selected = !todo.selected },@@ -182,6 +187,24 @@ hideDropdown: function(ev) { $(".navbar-menu").removeClass("is-active") $(".navbar-burger").removeClass("is-active")+ },++ getLicense: function() {+ const self = this+ self.loadingLicense = true+ $.ajax({+ url: "/license",+ type: "POST",+ dataType: "json",+ contentType: 'application/json',+ success: function(data){+ self.license = data.toodlesTier.tag+ self.loadingLicense = false+ },+ error: function(err){+ console.error(err)+ }+ }) }, submitTodoEdits: function(){