packages feed

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 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(){