packages feed

quarantimer (empty) → 1.20200323

raw patch · 11 files changed

+635/−0 lines, 11 filesdep +aesondep +asyncdep +basesetup-changed

Dependencies added: aeson, async, base, bytestring, directory, filepath, lucid, process, servant, servant-client, servant-lucid, servant-multipart, servant-server, stm, text, time, uuid, wai, warp, warp-tls

Files

+ CHANGELOG view
@@ -0,0 +1,5 @@+quarantimer (1.20200323) unstable; urgency=medium++  * First release.++ -- Joey Hess <id@joeyh.name>  Mon, 23 Mar 2020 19:28:04 -0400
+ Config.hs view
@@ -0,0 +1,13 @@+module Config where++hostname :: String+hostname = "quarantimer.app"++staticDir :: FilePath+staticDir = "static"++maxImageSize :: Int+maxImageSize = 640++numImageCores :: Int+numImageCores = 1
+ Image.hs view
@@ -0,0 +1,35 @@+module Image where++import System.Process+import System.Exit+import Control.Concurrent.STM++newtype ImageQueue = ImageQueue (TQueue (IO Bool, TMVar Bool))++mkImageQueue :: IO ImageQueue+mkImageQueue = ImageQueue <$> newTQueueIO++queueAction :: ImageQueue -> IO Bool -> IO Bool+queueAction (ImageQueue q) a = do+	v <- newEmptyTMVarIO+	atomically $ writeTQueue q (a, v)+	atomically $ takeTMVar v++queueRunnerThread :: ImageQueue -> IO ()+queueRunnerThread q@(ImageQueue qv) = do+	(a, v) <- atomically $ readTQueue qv+	r <- a+	_ <- atomically $ tryPutTMVar v r+	queueRunnerThread q++scaleImage :: Int -> FilePath -> FilePath -> IO Bool+scaleImage maxsz src dst = do+	p <- spawnProcess "convert"+		[ src+		, "-resize"+		-- The ">" makes it only shrink large images, not scale up+		-- small.+		, show maxsz ++ "x" ++ show maxsz ++ ">"+		, dst+		]+	(== ExitSuccess) <$> waitForProcess p
+ LICENSE view
@@ -0,0 +1,25 @@+BSD 2-Clause License++Copyright (c) 2020, Joey Hess+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++* Redistributions of source code must retain the above copyright notice, this+  list of conditions and the following disclaimer.++* Redistributions in binary form must reproduce the above copyright notice,+  this list of conditions and the following disclaimer in the documentation+  and/or other materials provided with the distribution.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README view
@@ -0,0 +1,11 @@+The server is written in Haskell, build it with cabal or stack.+You should edit Config.hs first.++Imagemagick needs to be installed, it is used to process images.++The first run, the server will fail to listen on https due to a missing+certificate, but it will listen on http, so you can use certbot to get a+letsencrypt certificate, and then restart the server.++Contact Joey Hess <joeyh@joey.name> by email with any bug reports,+questions, or patches.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ TODO view
@@ -0,0 +1,10 @@+* Use webrtc to capture photo, less clunky and allows scaling client-side+  for faster upload.+* Once quarantine expires, show how long it's been since expiry in case the+  user is feeling extra careful.+* Expire old images after some reasonable amount of time. This can be done+  with just a find command.+* Add systemd service file and Makefile to install it better.+* Improve CSS. (Patches welcome on this one.)+* The javascript could let things be added in offline mode, and sync up+  with the server next time it comes online. (Patches welcome on this one.)
+ quarantimer.cabal view
@@ -0,0 +1,54 @@+Name: quarantimer+Version: 1.20200323+Cabal-Version: >= 1.8+Maintainer: Joey Hess <id@joeyh.name>+Author: Joey Hess+Stability: Experimental+Copyright: 2020 Joey Hess+License: BSD2+License-File: LICENSE+Homepage: https://quarantimer.app/+Category: Health+Build-Type: Simple+Synopsis: Coronavirus quarantine timer web app for your things+Description:+  Web app that tracks how long things potentially contaminated with+  Covid-19 have been in quarantine.+Extra-Source-Files:+  README+  TODO+  CHANGELOG+  static/js.js+  static/style.css++Executable quarantimer+  Main-Is: quarantimer.hs+  GHC-Options: -threaded -Wall -fno-warn-tabs -O2+  Build-Depends:+      base (>= 4.11 && < 5.0)+    , servant (>= 0.15 && < 0.18)+    , servant-server (>= 0.15 && < 0.18)+    , servant-client (>= 0.15 && < 0.18)+    , servant-lucid (== 0.9.0.1)+    , servant-multipart (== 0.11.*)+    , lucid (== 2.9.12)+    , aeson (>= 1.4.2 && < 1.5)+    , wai (== 3.2.*)+    , warp (== 3.2.*)+    , warp-tls (== 3.2.*)+    , uuid (== 1.3.*)+    , bytestring+    , text+    , filepath+    , time+    , async+    , directory+    , process+    , stm+  Other-Modules:+    Config+    Image++source-repository head+  type: git+  location: git://git.joeyh.name/quarantimer.git
+ quarantimer.hs view
@@ -0,0 +1,357 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DeriveAnyClass #-}++module Main where++import Config+import Image++import Servant+import Servant.HTML.Lucid+import Servant.Multipart+import Lucid+import Lucid.Base (makeAttribute)+import Network.Wai (Application)+import Network.Wai.Handler.Warp+import Network.Wai.Handler.WarpTLS+import Data.UUID+import Data.UUID.V4+import Data.Aeson+import GHC.Generics+import Control.Monad.IO.Class+import Data.Maybe+import Data.List+import Data.Time.Clock+import qualified Data.ByteString.Char8 as B8+import qualified Data.Text as T+import Text.Read+import System.FilePath+import Control.Monad+import Control.Concurrent.Async+import System.Directory+import System.IO++type HttpAPI = Get '[HTML] TopPage+	:<|> Capture "uuid" UserID :> Get '[HTML] UserPage+	:<|> Capture "uuid" UserID :> "things" :> Get '[JSON] [Thing]+	:<|> Capture "uuid" UserID :> "add"+		:> MultipartForm Tmp (MultipartData Tmp)+		:> Post '[JSON] Thing+	:<|> Capture "uuid" UserID :> "post"+		:> MultipartForm Tmp (MultipartData Tmp)+		:> Post '[HTML] UserPage+	:<|> "surfaces" :> Get '[JSON] [Surface]+	:<|> "static" :> Raw+	:<|> ".well-known" :> Raw++newtype UserID = UserID UUID+	deriving (Eq, Show)++newtype SurfaceID = SurfaceID Int+	deriving (Eq, Show, ToJSON, Generic)++instance FromHttpApiData UserID where+	parseUrlPiece t = UserID <$> parseUrlPiece t++data Thing = Thing+	{ thingSurface :: Surface+	, thingPhoto :: UrlString+	, thingQuarantineStarted :: UTCTime+	, thingSecondsUntilSafe :: Integer+	}+	deriving (ToJSON, Generic)++data Surface = Surface+	{ surfaceID :: SurfaceID+	, surfaceDesc :: T.Text+	, surfaceIcon :: UrlString+	, surfaceSecondsUntilSafe :: Integer+	}+	deriving (ToJSON, Generic)++type UrlString = String++data TopPage = TopPage++instance ToHtml TopPage where+	toHtml _ = html_ $ do+		head_ $ do+			title_ "hello"+		body_ $ do+			p_ "hello"+	toHtmlRaw = toHtml++data UserPage = UserPage UserID [Thing]++instance ToHtml UserPage where+	toHtml (UserPage _ things) = html_ $ do+		meta_+			[ name_ "viewport"+			, content_ "width=device-width, initial-scale=1"+			]+		head_ $ do+			title_ "Quarantimer"+			link_ +				[ href_ "/static/style.css"+				, rel_ "stylesheet"+				, type_ "text/css"+				]+			script_ [src_ "/static/js.js"] ("" :: T.Text)+		body_ $ do+			header_ $ do+				h1_ $ do+					a_ [href_ "/"] "Quarantimer"+					": Coronavirus quarantine timer"+				p_ $ do+					"When you have something that may "+					"be contaminated, take its picture, "+					"and this page will tell you when "+					"it's safe to touch it."++			form_ +				[ method_ "post"+				, action_ "post"+				, enctype_ "multipart/form-data"+				, onsubmit_ "validateform();"+				] $ do+				input_ +					[ type_ "file"+					, name_ "photo"+					, camera+					]+				forM_ surfaces $ \surface -> do+					br_ []+					let (SurfaceID sid) = surfaceID surface+					input_+						[ type_ "checkbox"+						, class_ "surface"+						, id_ (surfaceDesc surface)+						, name_ (surfaceDesc surface)+						, value_ (T.pack (show sid))+						]+					label_ [ for_ (surfaceDesc surface)]+						(toHtml ((surfaceDesc surface)))+				br_ []+				input_ [type_ "submit", value_ "Add"]++			forM_ things $ \thing -> div_ [class_ "thing"] $ do+				img_ +					[ class_ "photo"+					, src_ (T.pack (thingPhoto thing))+					]+				let timer = T.pack $ show $+					thingSecondsUntilSafe thing+				span_ +					[ class_ "timer"+					, title_ timer+					] (toHtml timer)+				br_ []+				br_ []++			footer_ $ do+				p_ $ do+					"Covid-19 data from "+					a_ [href_ "https://www.nejm.org/doi/10.1056/NEJMc2004973"]+						"Aerosol and Surface Stability of SARS-CoV-2 as Compared with SARS-CoV-1"+					" (March 17, 2020)"+				p_ $ do+					"Quarantimer "+					a_ [href_ "https://git.joeyh.name/index.cgi/quarantimer.git/"]+						"source code"+					" by "+					a_ [href_ "https://joeyh.name/"]+						"Joey Hess"+					". Have an idea to improve it? "+					a_ [href_ "mailto:joeyh@joeyh.name"]+						"Email me"+				p_ $ do+					"This is free software, and this "+					"service is provided for free. "+					"There is no warranty."+				p_ $ do+					"Your information will be shared "+					"only with people who you give "+					"a link to this page."+				p_ $ "Be safe. Wash your hands!"+				+	toHtmlRaw = toHtml++camera :: Attribute+camera = makeAttribute "capture" "environment"++main :: IO ()+main = do+	hSetBuffering stdout LineBuffering+	hSetBuffering stderr LineBuffering+	createDirectoryIfMissing False staticDir+	q <- mkImageQueue+	forM_ [1..numImageCores] $ \_ ->+		async $ queueRunnerThread q+	void $+		waitthread "https" (runTLS tlssettings (setPort 443 settings) (app q))+			`concurrently`+		waitthread "http" (runSettings (setPort 80 settings) (app q))+			`concurrently`+		waitthread "http 8080" (runSettings (setPort 7070 settings) (app q))+  where+	settings = defaultSettings+	tlssettings = tlsSettingsChain +		(le </> "cert.pem")+		[le </> "chain.pem"]+		(le </> "privkey.pem")+	le = "/etc/letsencrypt/live" </> hostname++	waitthread d a = do+		t <- async a+		res <- waitCatch t+		case res of+			Left e -> logMessage $ d ++ " server failed: " ++ show e+			Right () -> return ()++staticFileUrl :: FilePath -> UrlString+staticFileUrl f = "/static/" ++ f++staticUserFileUrl :: UserID -> FilePath -> UrlString+staticUserFileUrl (UserID u) f = staticFileUrl (toString u </> f)++app :: ImageQueue -> Application+app q = serve httpAPI (server q)++httpAPI :: Proxy HttpAPI+httpAPI = Proxy++server :: ImageQueue -> Server HttpAPI+server q = topPage+	:<|> userPage+	:<|> thingList+	:<|> addThing q+	:<|> addThing' q+	:<|> surfaceList+	:<|> serveDirectoryWebApp staticDir+	:<|> serveDirectoryWebApp (staticDir </> ".well-known")++topPage :: Handler TopPage+topPage = do+	u <- UserID <$> liftIO nextRandom+	redirUserPage u++redirUserPage :: UserID -> Handler a+redirUserPage (UserID u) = do+	let redirto = B8.pack ("/" ++ show u ++ "/")+	throwError $ err303 { errHeaders = [("Location", redirto)] }++userPage :: UserID -> Handler UserPage+userPage u = UserPage u <$> thingList u++surfaceList :: Handler [Surface]+surfaceList = return surfaces++-- Per https://www.nejm.org/doi/10.1056/NEJMc2004973+surfaces :: [Surface]+surfaces =+	[ Surface (SurfaceID 1) "cardboard" +		(staticFileUrl "cardboard.jpg")+		(hours 24)+	, Surface (SurfaceID 2) "plastic"+		(staticFileUrl "plastic.jpg")+		(hours 72)+	, Surface (SurfaceID 3) "metal"+		(staticFileUrl "metal.jpg")+		(hours 72)+	-- copper much less time, but don't expect people to+	-- know what is copper and what looks like copper but is+	-- not..+	, Surface (SurfaceID 0) "other"+		(staticFileUrl "other.jpg")+		(hours 72)+	]+  where+	hours n = 60*60*n++addThing' :: ImageQueue -> UserID -> MultipartData Tmp -> Handler UserPage+addThing' q u d = do+	_ <- addThing q u d+	redirUserPage u++addThing :: ImageQueue -> UserID -> MultipartData Tmp -> Handler Thing+addThing q user@(UserID u) d = case parseform of+	Nothing -> throwError err400+	Just (s, f) -> do+		fid <- liftIO nextRandom+		-- The filename contains the SurfaceID, and the UserID.+		-- The creation time of the file indicates when to start+		-- from, and that and the picture in the file is all the+		-- data we need to store.+		let newfile = toString u </> toString fid+			<.> (\(SurfaceID n) -> show n) (surfaceID s)+			<.> "jpg"+		let destfile = staticDir </> newfile++		liftIO $ createDirectoryIfMissing False (takeDirectory destfile)+		ok <- liftIO $ queueAction q $+			scaleImage maxImageSize f destfile+		liftIO $ logMessage $ "image converted and saved: "+			++ show ok ++ " " ++ T.unpack (surfaceDesc s)+		now <- liftIO getCurrentTime+		if ok+			then return $ Thing+				{ thingSurface = s+				, thingPhoto = staticUserFileUrl user newfile+				, thingQuarantineStarted = now+				, thingSecondsUntilSafe = surfaceSecondsUntilSafe s+				}+			else throwError err500+  where+	parseform = do+		let sids = map SurfaceID $+			mapMaybe (readMaybe . T.unpack . iValue) (inputs d)+		-- When multiple surfaces were selected, use the one+		-- with the longest time until safe.+		s <- listToMaybe $ reverse $ sortOn surfaceSecondsUntilSafe $+			filter (\x -> surfaceID x `elem` sids) surfaces+		f <- fdPayload <$> lookupFile "photo" d+		return (s, f)++-- Most recently added first.+thingList :: UserID -> Handler [Thing]+thingList user@(UserID u) = liftIO $ do+	logMessage "got a user's thing list"+	exists <- doesDirectoryExist userdir+	if exists+		then reverse . sortOn thingQuarantineStarted . catMaybes +			<$> (mapM go =<< getDirectoryContents userdir)+		else return []+  where+	go f = case parsefnsurface f of+		Nothing -> return Nothing+		Just s -> do+			mtime <- getModificationTime (userdir </> f)+			now <- getCurrentTime+			let secsold = max 0 (floor (diffUTCTime now mtime))+			let ts = max 0 (surfaceSecondsUntilSafe s - secsold)+			return $ Just $ Thing+				{ thingSurface = s+				, thingPhoto = staticUserFileUrl user f+				, thingQuarantineStarted = mtime+				, thingSecondsUntilSafe = ts+				}+				+	userdir = staticDir </> toString u++	parsefnsurface f+		| takeExtension f == ".jpg" = do+			let f' = dropExtension f+			sid <- SurfaceID +				<$> readMaybe (drop 1 (takeExtension f'))+			listToMaybe $+				filter (\x -> surfaceID x == sid) surfaces+		| otherwise = Nothing++logMessage :: String -> IO ()+logMessage s = do+	now <- getCurrentTime+	putStrLn ("[" ++ show now ++ "] " ++ s)
+ static/js.js view
@@ -0,0 +1,67 @@+var things;++window.onload=function(){+	var loaddate = Date.now();+	things = document.getElementsByClassName('thing');+	for (var i = 0; i < things.length; i++) {+		var elt = things[i];+		var img = elt.getElementsByTagName("img")[0];+		things[i].img = img;+		var timer = elt.getElementsByClassName("timer")[0];+		things[i].timer = timer;+		var n = parseInt(timer.attributes.title.value);+		var d = loaddate + (n * 1000);+		things[i].enddate = d;+	}++	animate();+}++function displayUnit(v,u) {+	var s;+	if (v == 1) { s = "" } else { s = "s" }+	return (v + " " + u + s);+}++function animate() {+	now = Date.now();+	for (var i = 0; i < things.length; i++) {+		var img = things[i].img;+		var timer = things[i].timer;+		var diff = (things[i].enddate - now) / 1e3;+		var h=(diff/60/60)>>0;+		var m=(diff/60 - h*60)>>0;+		if (diff > 0) {+			img.className = "infected";+			timer.innerHTML = "quarantine ends in " ++				displayUnit(h,"hour") + " " ++				displayUnit(m,"minute")+		} else {+			img.className = "safe";+			timer.innerHTML = "quarantine completed";+		}+	}++	setTimeout(animate,10000); // keep updating every 10s+}++function validateform() {+	var surfaces=document.getElementsByClassName("surface");+	var sok=false;+	for (var i=0,l=surfaces.length;i<l;i++) {+		if (surfaces[i].checked) {+			sok=true;+		}+	}+	photo=document.getElementsByName("photo")[0];+	if (photo.value.length == 0) {+		alert("Please take a photo of the thing you want to quarantine.");+		event.preventDefault();+	}+	else {+		if (! sok) {+			alert("Please indicate what the surface of the item is made of.");+			event.preventDefault();+		}+	}+}
+ static/style.css view
@@ -0,0 +1,56 @@+footer {+	padding: 24px;+	margin: 0 auto;+	background-color: #EFEFEF;+	width: 100%;+	box-sizing: border-box;+}++h1 {+	font-size: calc(16px + (26 - 16) * ((100vw - 300px) / (1600 - 300)));+}++img {+	max-width: 50%;+	height: auto;+}++.timer {+	margin: 10px;+}++.safe {+	margin: 10px;+	display: block;+	background: #cca92c;+	border: 3px solid lightgreen; +}++.infected {+	margin: 10px;+	display: block;+	background: #cca92c;+	box-shadow: 0 0 0 rgba(255,0,0, 0.4);+	animation: pulse 2s infinite;+	border: 3px solid red; +}++@-webkit-keyframes pulse {+	0% { -webkit-box-shadow: 0 0 0 0 rgba(255,0,0, 0.4); }+	70% { -webkit-box-shadow: 0 0 0 10px rgba(255,0,0, 0); }+	100% { -webkit-box-shadow: 0 0 0 0 rgba(255,0,0, 0); }+}+@keyframes pulse {+	0% {+		-moz-box-shadow: 0 0 0 0 rgba(255,0,0, 0.4);+		box-shadow: 0 0 0 0 rgba(255,0,0, 0.4);+	}+	70% {+		-moz-box-shadow: 0 0 0 10px rgba(255,0,0, 0);+		box-shadow: 0 0 0 10px rgba(255,0,0, 0);+	}+	100% {+		-moz-box-shadow: 0 0 0 0 rgba(255,0,0, 0);+		box-shadow: 0 0 0 0 rgba(255,0,0, 0);+	}+}