From 710b591b4ac1ba3be6685415c7de65ef8df7eba2 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Wed, 3 Apr 2019 22:07:30 +0200 Subject: [PATCH 001/238] Improve AuthPred memoisation --- src/Foundation.hs | 126 +++++++++++++++++------------- src/Import/NoFoundation.hs | 2 +- src/Model/Types.hs | 2 + src/Yesod/Core/Instances.hs | 65 ++++++++++----- src/Yesod/Core/Types/Instances.hs | 36 ++++++++- start.sh | 11 ++- 6 files changed, 156 insertions(+), 86 deletions(-) diff --git a/src/Foundation.hs b/src/Foundation.hs index 0d8e5d909..0aca60110 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -60,7 +60,7 @@ import Control.Monad.Trans.Maybe (MaybeT(..)) import Control.Monad.Trans.Reader (runReader, mapReaderT) import Control.Monad.Trans.Writer (WriterT(..), runWriterT) import Control.Monad.Writer.Class (MonadWriter(..)) -import Control.Monad.Memo (MemoT, startEvalMemoT, MonadMemo(..)) +import Control.Monad.Memo.Class (MonadMemo(..), for4) import qualified Control.Monad.Catch as C import Handler.Utils.StudyFeatures @@ -137,6 +137,8 @@ type SMTPPool = Pool SMTPConnection -- type Widget = WidgetT UniWorX IO () mkYesodData "UniWorX" $(parseRoutesFile "routes") +deriving instance Generic (Route UniWorX) + -- | Convenient Type Synonyms: type DB a = YesodDB UniWorX a type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget) @@ -385,24 +387,24 @@ appLanguagesOpts = do -- Access Control data AccessPredicate - = APPure (Route UniWorX -> Bool -> Reader MsgRenderer AuthResult) - | APHandler (Route UniWorX -> Bool -> Handler AuthResult) - | APDB (Route UniWorX -> Bool -> DB AuthResult) + = APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult) + | APHandler (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Handler AuthResult) + | APDB (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> DB AuthResult) class (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where - evalAccessPred :: AccessPredicate -> Route UniWorX -> Bool -> m AuthResult + evalAccessPred :: AccessPredicate -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> m AuthResult instance {-# INCOHERENT #-} (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where - evalAccessPred aPred r w = liftHandlerT $ case aPred of - (APPure p) -> runReader (p r w) <$> getMsgRenderer - (APHandler p) -> p r w - (APDB p) -> runDB $ p r w + evalAccessPred aPred aid r w = liftHandlerT $ case aPred of + (APPure p) -> runReader (p aid r w) <$> getMsgRenderer + (APHandler p) -> p aid r w + (APDB p) -> runDB $ p aid r w instance (MonadHandler m, HandlerSite m ~ UniWorX, backend ~ YesodPersistBackend UniWorX) => MonadAP (ReaderT backend m) where - evalAccessPred aPred r w = mapReaderT liftHandlerT $ case aPred of - (APPure p) -> lift $ runReader (p r w) <$> getMsgRenderer - (APHandler p) -> lift $ p r w - (APDB p) -> p r w + evalAccessPred aPred aid r w = mapReaderT liftHandlerT $ case aPred of + (APPure p) -> lift $ runReader (p aid r w) <$> getMsgRenderer + (APHandler p) -> lift $ p aid r w + (APDB p) -> p aid r w orAR, andAR :: MsgRenderer -> AuthResult -> AuthResult -> AuthResult @@ -423,16 +425,16 @@ trueAR = const Authorized falseAR = Unauthorized . ($ MsgUnauthorized) . render trueAP, falseAP :: AccessPredicate -trueAP = APPure . const . const $ trueAR <$> ask -falseAP = APPure . const . const $ falseAR <$> ask -- included for completeness +trueAP = APPure . const . const . const $ trueAR <$> ask +falseAP = APPure . const . const . const $ falseAR <$> ask -- included for completeness tagAccessPredicate :: AuthTag -> AccessPredicate tagAccessPredicate AuthFree = trueAP -tagAccessPredicate AuthAdmin = APDB $ \route _ -> case route of +tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of -- Courses: access only to school admins CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` userAdmin) -> do E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId @@ -444,13 +446,13 @@ tagAccessPredicate AuthAdmin = APDB $ \route _ -> case route of return Authorized -- other routes: access to any admin is granted here _other -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId adrights <- lift $ selectFirst [UserAdminUser ==. authId] [] guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) return Authorized -tagAccessPredicate AuthNoEscalation = APDB $ \route _ -> case route of +tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of AdminHijackUserR cID -> exceptT return return $ do - myUid <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + myUid <- maybeExceptT AuthenticationRequired $ return mAuthId uid <- decrypt cID otherSchoolsAdmin <- lift $ Set.fromList . map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. uid] [] otherSchoolsLecturer <- lift $ Set.fromList . map (userLecturerSchool . entityVal) <$> selectList [UserLecturerUser ==. uid] [] @@ -458,21 +460,21 @@ tagAccessPredicate AuthNoEscalation = APDB $ \route _ -> case route of guardMExceptT ((otherSchoolsAdmin `Set.union` otherSchoolsLecturer) `Set.isSubsetOf` mySchools) (unauthorizedI MsgUnauthorizedAdminEscalation) return Authorized r -> $unsupportedAuthPredicate AuthNoEscalation r -tagAccessPredicate AuthDeprecated = APHandler $ \r _ -> do +tagAccessPredicate AuthDeprecated = APHandler $ \_ r _ -> do $logWarnS "AccessControl" ("deprecated route: " <> tshow r) addMessageI Error MsgDeprecatedRoute allow <- appAllowDeprecated . appSettings <$> getYesod return $ bool (Unauthorized "Deprecated Route") Authorized allow -tagAccessPredicate AuthDevelopment = APHandler $ \r _ -> do +tagAccessPredicate AuthDevelopment = APHandler $ \_ r _ -> do $logWarnS "AccessControl" ("route in development: " <> tshow r) #ifdef DEVELOPMENT return Authorized #else return $ Unauthorized "Route under development" #endif -tagAccessPredicate AuthLecturer = APDB $ \route _ -> case route of +tagAccessPredicate AuthLecturer = APDB $ \mAuthId route _ -> case route of CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` lecturer) -> do E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse E.where_ $ lecturer E.^. LecturerUser E.==. E.val authId @@ -484,11 +486,11 @@ tagAccessPredicate AuthLecturer = APDB $ \route _ -> case route of return Authorized -- lecturer for any school will do _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] [] return Authorized -tagAccessPredicate AuthCorrector = APDB $ \route _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId +tagAccessPredicate AuthCorrector = APDB $ \mAuthId route _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ return mAuthId resList <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId @@ -515,7 +517,7 @@ tagAccessPredicate AuthCorrector = APDB $ \route _ -> exceptT return return $ do _ -> do guardMExceptT (not $ Map.null resMap) (unauthorizedI MsgUnauthorizedCorrectorAny) return Authorized -tagAccessPredicate AuthTime = APDB $ \route _ -> case route of +tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of CSheetR tid ssh csh shn subRoute -> maybeT (unauthorizedI MsgUnauthorizedSheetTime) $ do Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh Entity _sid Sheet{..} <- MaybeT . getBy $ CourseSheet cid shn @@ -541,8 +543,7 @@ tagAccessPredicate AuthTime = APDB $ \route _ -> case route of CourseR tid ssh csh CRegisterR -> do now <- liftIO getCurrentTime mbc <- getBy $ TermSchoolCourseShort tid ssh csh - mAid <- lift maybeAuthId - registered <- case (mbc,mAid) of + registered <- case (mbc,mAuthId) of (Just (Entity cid _), Just uid) -> isJust <$> (getBy $ UniqueParticipant uid cid) _ -> return False case mbc of @@ -564,9 +565,9 @@ tagAccessPredicate AuthTime = APDB $ \route _ -> case route of return Authorized r -> $unsupportedAuthPredicate AuthTime r -tagAccessPredicate AuthRegistered = APDB $ \route _ -> case route of +tagAccessPredicate AuthRegistered = APDB $ \mAuthId route _ -> case route of CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val authId @@ -577,7 +578,7 @@ tagAccessPredicate AuthRegistered = APDB $ \route _ -> case route of guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedRegistered) return Authorized r -> $unsupportedAuthPredicate AuthRegistered r -tagAccessPredicate AuthParticipant = APDB $ \route _ -> case route of +tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of CourseR tid ssh csh (CUserR cID) -> exceptT return return $ do let authorizedIfExists f = do [E.Value ok] <- lift . E.select . return . E.exists $ E.from f @@ -639,14 +640,14 @@ tagAccessPredicate AuthParticipant = APDB $ \route _ -> case route of E.&&. course E.^. CourseShorthand E.==. E.val csh unauthorizedI MsgUnauthorizedParticipant r -> $unsupportedAuthPredicate AuthParticipant r -tagAccessPredicate AuthCapacity = APDB $ \route _ -> case route of +tagAccessPredicate AuthCapacity = APDB $ \_ route _ -> case route of CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNoCapacity) $ do Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh registered <- lift $ fromIntegral <$> count [ CourseParticipantCourse ==. cid ] guard $ NTop courseCapacity > NTop (Just registered) return Authorized r -> $unsupportedAuthPredicate AuthCapacity r -tagAccessPredicate AuthEmpty = APDB $ \route _ -> case route of +tagAccessPredicate AuthEmpty = APDB $ \_ route _ -> case route of CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNotEmpty) $ do -- Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh @@ -657,50 +658,50 @@ tagAccessPredicate AuthEmpty = APDB $ \route _ -> case route of return E.countRows return Authorized r -> $unsupportedAuthPredicate AuthEmpty r -tagAccessPredicate AuthMaterials = APDB $ \route _ -> case route of +tagAccessPredicate AuthMaterials = APDB $ \_ route _ -> case route of CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnfreeMaterials) $ do Entity _ Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh guard courseMaterialFree return Authorized r -> $unsupportedAuthPredicate AuthMaterials r -tagAccessPredicate AuthOwner = APDB $ \route _ -> case route of +tagAccessPredicate AuthOwner = APDB $ \mAuthId route _ -> case route of CSubmissionR _ _ _ _ cID _ -> exceptT return return $ do sid <- catchIfMExceptT (const $ unauthorizedI MsgUnauthorizedSubmissionOwner) (const True :: CryptoIDError -> Bool) $ decrypt cID - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + authId <- maybeExceptT AuthenticationRequired $ return mAuthId void . maybeMExceptT (unauthorizedI MsgUnauthorizedSubmissionOwner) . getBy $ UniqueSubmissionUser authId sid return Authorized r -> $unsupportedAuthPredicate AuthOwner r -tagAccessPredicate AuthRated = APDB $ \route _ -> case route of +tagAccessPredicate AuthRated = APDB $ \_ route _ -> case route of CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionRated) $ do sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID sub <- MaybeT $ get sid guard $ submissionRatingDone sub return Authorized r -> $unsupportedAuthPredicate AuthRated r -tagAccessPredicate AuthUserSubmissions = APDB $ \route _ -> case route of +tagAccessPredicate AuthUserSubmissions = APDB $ \_ route _ -> case route of CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedUserSubmission) $ do Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh Entity _ Sheet{sheetSubmissionMode} <- MaybeT . getBy $ CourseSheet cid shn guard $ sheetSubmissionMode == UserSubmissions return Authorized r -> $unsupportedAuthPredicate AuthUserSubmissions r -tagAccessPredicate AuthCorrectorSubmissions = APDB $ \route _ -> case route of +tagAccessPredicate AuthCorrectorSubmissions = APDB $ \_ route _ -> case route of CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedCorrectorSubmission) $ do Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh Entity _ Sheet{sheetSubmissionMode} <- MaybeT . getBy $ CourseSheet cid shn guard $ sheetSubmissionMode == CorrectorSubmissions return Authorized r -> $unsupportedAuthPredicate AuthCorrectorSubmissions r -tagAccessPredicate AuthAuthentication = APDB $ \route _ -> case route of +tagAccessPredicate AuthAuthentication = APDB $ \mAuthId route _ -> case route of MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageAuth) $ do smId <- decrypt cID SystemMessage{..} <- MaybeT $ get smId - isAuthenticated <- isJust <$> liftHandlerT maybeAuthId + let isAuthenticated = isJust mAuthId guard $ not systemMessageAuthenticatedOnly || isAuthenticated return Authorized r -> $unsupportedAuthPredicate AuthAuthentication r -tagAccessPredicate AuthRead = APHandler . const $ bool (return Authorized) (unauthorizedI MsgUnauthorizedWrite) -tagAccessPredicate AuthWrite = APHandler . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized) +tagAccessPredicate AuthRead = APHandler . const . const $ bool (return Authorized) (unauthorizedI MsgUnauthorizedWrite) +tagAccessPredicate AuthWrite = APHandler . const . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized) newtype InvalidAuthTag = InvalidAuthTag Text @@ -734,25 +735,29 @@ routeAuthTags = fmap (impureNonNull . Set.mapMonotonic impureNonNull) . ofoldM p | otherwise = Left $ InvalidAuthTag t -evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> NonNull (DNF AuthTag) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult +evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> NonNull (DNF AuthTag) -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult -- ^ `tell`s disabled predicates, identified as pivots -evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toNullable -> authDNF) route isWrite - = startEvalMemoT $ do - mr <- lift getMsgRenderer +evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toNullable -> authDNF) mAuthId route isWrite + = do + mr <- getMsgRenderer let authTagIsInactive = not . authTagIsActive - evalAuthTag :: AuthTag -> MemoT AuthTag AuthResult (WriterT (Set AuthTag) m) AuthResult - evalAuthTag = memo $ \authTag -> lift . lift $ evalAccessPred (tagAccessPredicate authTag) route isWrite + evalAuthTag :: AuthTag -> WriterT (Set AuthTag) m AuthResult + evalAuthTag authTag = lift . (runCachedMemoT :: CachedMemoT (AuthTag, Maybe UserId, Route UniWorX, Bool) AuthResult m _ -> m _) $ for4 memo evalAccessPred' authTag mAuthId route isWrite + where + evalAccessPred' authTag' mAuthId' route' isWrite' = CachedMemoT $ do + $logDebugS "evalAccessPred" $ tshow (authTag', mAuthId', route', isWrite') + evalAccessPred (tagAccessPredicate authTag') mAuthId' route' isWrite' orAR', andAR' :: forall m'. Monad m' => m' AuthResult -> m' AuthResult -> m' AuthResult orAR' = shortCircuitM (is _Authorized) (orAR mr) andAR' = shortCircuitM (is _Unauthorized) (andAR mr) - evalDNF :: [[AuthTag]] -> MemoT AuthTag AuthResult (WriterT (Set AuthTag) m) AuthResult + evalDNF :: [[AuthTag]] -> WriterT (Set AuthTag) m AuthResult evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthTag aTag) (return $ trueAR mr) ats) (return $ falseAR mr) - lift . $logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive) authDNF + $logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive) authDNF result <- evalDNF $ filter (all authTagIsActive) authDNF @@ -760,16 +765,25 @@ evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toN whenM (allM conj (\aTag -> (return . not $ authTagIsActive aTag) `or2M` (not . is _Unauthorized <$> evalAuthTag aTag))) $ do let pivots = filter authTagIsInactive conj whenM (allM pivots $ fmap (is _Authorized) . evalAuthTag) $ do - lift $ $logDebugS "evalAuthTags" [st|Recording pivots: #{tshow pivots}|] - lift . tell $ Set.fromList pivots + $logDebugS "evalAuthTags" [st|Recording pivots: #{tshow pivots}|] + tell $ Set.fromList pivots return result +evalAccessFor :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> m AuthResult +evalAccessFor mAuthId route isWrite = do + dnf <- either throwM return $ routeAuthTags route + fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) dnf mAuthId route isWrite + +evalAccessForDB :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult +evalAccessForDB = evalAccessFor + evalAccess :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> m AuthResult evalAccess route isWrite = do + mAuthId <- liftHandlerT maybeAuthId tagActive <- fromMaybe def <$> lookupSessionJson SessionActiveAuthTags dnf <- either throwM return $ routeAuthTags route - (result, deactivated) <- runWriterT $ evalAuthTags tagActive dnf route isWrite + (result, deactivated) <- runWriterT $ evalAuthTags tagActive dnf mAuthId route isWrite result <$ tellSessionJson SessionInactiveAuthTags deactivated evalAccessDB :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs index 457682087..21e0b5de5 100644 --- a/src/Import/NoFoundation.hs +++ b/src/Import/NoFoundation.hs @@ -17,7 +17,7 @@ import Yesod.Default.Config2 as Import import Utils as Import import Utils.Modal as Import import Yesod.Core.Json as Import (provideJson) -import Yesod.Core.Types.Instances as Import () +import Yesod.Core.Types.Instances as Import (CachedMemoT(..)) import Data.Fixed as Import diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 775900850..7cf317d0c 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -749,6 +749,8 @@ instance ToJSONKey AuthTag where instance FromJSONKey AuthTag where fromJSONKey = FromJSONKeyTextParser $ parseJSON . Aeson.String +instance Binary AuthTag + newtype AuthTagActive = AuthTagActive { authTagIsActive :: AuthTag -> Bool } deriving (Read, Show, Generic) diff --git a/src/Yesod/Core/Instances.hs b/src/Yesod/Core/Instances.hs index 6512c936a..b8c6fed80 100644 --- a/src/Yesod/Core/Instances.hs +++ b/src/Yesod/Core/Instances.hs @@ -15,37 +15,58 @@ import Data.ByteString.Builder (toLazyByteString) import System.FilePath (()) import Data.Aeson +import Data.Aeson.Types import Control.Monad.Fix import Control.Monad.Fail (MonadFail) import qualified Control.Monad.Fail as MonadFail import Control.Monad.Except (MonadError(..)) import Data.Functor.Extend - + +import Data.Binary (Binary) +import qualified Data.Binary as Binary + + +routeFromPathPiece :: ParseRoute site => Text -> Maybe (Route site) +routeFromPathPiece + = parseRoute + . over (_2.traverse._2) (fromMaybe "") + . over _2 queryToQueryText + . decodePath + . encodeUtf8 + +routeToPathPiece :: RenderRoute site => Route site -> Text +routeToPathPiece + = pack + . ("/" ) + . unpack + . decodeUtf8 + . toLazyByteString + . uncurry encodePath + . over _2 queryTextToQuery + . over (_2.traverse._2) (assertM' $ not . null) + . renderRoute + instance (RenderRoute site, ParseRoute site) => PathPiece (Route site) where - fromPathPiece - = parseRoute - . over (_2.traverse._2) (fromMaybe "") - . over _2 queryToQueryText - . decodePath - . encodeUtf8 - toPathPiece - = pack - . ("/" ) - . unpack - . decodeUtf8 - . toLazyByteString - . uncurry encodePath - . over _2 queryTextToQuery - . over (_2.traverse._2) (assertM' $ not . null) - . renderRoute + fromPathPiece = routeFromPathPiece + toPathPiece = routeToPathPiece -instance (RenderRoute site, ParseRoute site) => FromJSON (Route site) where - parseJSON = withText "Route" $ maybe (fail "Could not parse route") return . fromPathPiece +instance ParseRoute site => FromJSON (Route site) where + parseJSON = withText "Route" $ maybe (fail "Could not parse route") return . routeFromPathPiece -instance (RenderRoute site, ParseRoute site) => ToJSON (Route site) where - toJSON = String . toPathPiece +instance RenderRoute site => ToJSON (Route site) where + toJSON = String . routeToPathPiece + +instance ParseRoute site => FromJSONKey (Route site) where + fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Coulde not parse route") return . routeFromPathPiece + +instance RenderRoute site => ToJSONKey (Route site) where + toJSONKey = toJSONKeyText routeToPathPiece + +instance (RenderRoute site, ParseRoute site) => Binary (Route site) where + put = Binary.put . toPathPiece + get = Binary.get >>= maybe (fail "Could not parse route") return . fromPathPiece instance Monad FormResult where @@ -77,3 +98,5 @@ instance Extend FormResult where duplicated (FormSuccess x) = FormSuccess $ FormSuccess x duplicated FormMissing = FormMissing duplicated (FormFailure errs) = FormFailure errs + + diff --git a/src/Yesod/Core/Types/Instances.hs b/src/Yesod/Core/Types/Instances.hs index e296d0c52..5402ce3ba 100644 --- a/src/Yesod/Core/Types/Instances.hs +++ b/src/Yesod/Core/Types/Instances.hs @@ -1,16 +1,48 @@ {-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE GeneralizedNewtypeDeriving, UndecidableInstances #-} module Yesod.Core.Types.Instances - ( + ( CachedMemoT(..) ) where -import ClassyPrelude +import ClassyPrelude.Yesod import Yesod.Core.Types import Control.Monad.Fix +import Control.Monad.Memo + +import Data.Binary (Binary) +import qualified Data.Binary as Binary + +import Control.Monad.Logger (MonadLoggerIO) + + instance MonadFix m => MonadFix (HandlerT site m) where mfix f = HandlerT $ \r -> mfix $ \a -> unHandlerT (f a) r instance MonadFix m => MonadFix (WidgetT site m) where mfix f = WidgetT $ \r -> mfix $ \ ~(a, _) -> unWidgetT (f a) r + + +-- | Type-level tags for compatability of Yesod `cached`-System with `MonadMemo` +newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT :: m a } + deriving newtype ( Functor, Applicative, Alternative, Monad, MonadPlus, MonadFix + , MonadIO + , MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO + , MonadResource, MonadHandler, MonadWidget + , IsString, Semigroup, Monoid + ) + +deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m) +deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m) + +deriving newtype instance MonadReader r m => MonadReader r (CachedMemoT k v m) + +instance MonadTrans (CachedMemoT k v) where + lift = CachedMemoT + + +-- | Uses `cachedBy` with a `Binary`-encoded @k@ +instance (Typeable v, Binary k, MonadHandler m) => MonadMemo k v (CachedMemoT k v m) where + memo act key = cachedBy (toStrict $ Binary.encode key) $ act key diff --git a/start.sh b/start.sh index b72d043c2..cdad4b731 100755 --- a/start.sh +++ b/start.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash unset HOST -export DETAILED_LOGGING=true -export LOG_ALL=false -export LOGLEVEL=info -export DUMMY_LOGIN=true -export ALLOW_DEPRECATED=true -export PWFILE=users.yml +export DETAILED_LOGGING=${DETAILED_LOGGIN:-true} +export LOG_ALL=${LOG_ALL:-false} +export LOGLEVEL=${LOGLEVEL:-info} +export DUMMY_LOGIN=${DUMMY_LOGIN:-true} +export ALLOW_DEPRECATED=${ALLOW_DEPRECATED:-true} move-back() { mv -v .stack-work .stack-work-run From af6a21438e640bbfe22247c07ecc9e1771f75a17 Mon Sep 17 00:00:00 2001 From: Felix Hamann Date: Wed, 3 Apr 2019 23:21:12 +0200 Subject: [PATCH 002/238] add new JS utility registry and proof-of-concept utility --- src/Foundation.hs | 27 ++++---- static/js/utils/poc.js | 33 +++++++++ static/js/utils/registry.js | 109 ++++++++++++++++++++++++++++++ static/js/utils/setup.js | 114 -------------------------------- templates/default-layout.hamlet | 2 +- 5 files changed, 157 insertions(+), 128 deletions(-) create mode 100644 static/js/utils/poc.js create mode 100644 static/js/utils/registry.js delete mode 100644 static/js/utils/setup.js diff --git a/src/Foundation.hs b/src/Foundation.hs index 0d8e5d909..a15dd5a9b 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -1065,19 +1065,20 @@ siteLayout' headingOverride widget = do addScript $ StaticR js_polyfills_fetchPolyfill_js addScript $ StaticR js_polyfills_urlPolyfill_js -- JavaScript utils - addScript $ StaticR js_utils_alerts_js - addScript $ StaticR js_utils_asidenav_js - addScript $ StaticR js_utils_asyncForm_js - addScript $ StaticR js_utils_asyncTable_js - addScript $ StaticR js_utils_asyncTableFilter_js - addScript $ StaticR js_utils_checkAll_js - addScript $ StaticR js_utils_httpClient_js - addScript $ StaticR js_utils_form_js - addScript $ StaticR js_utils_inputs_js - addScript $ StaticR js_utils_modal_js - addScript $ StaticR js_utils_setup_js - addScript $ StaticR js_utils_showHide_js - addScript $ StaticR js_utils_tabber_js + -- addScript $ StaticR js_utils_alerts_js + -- addScript $ StaticR js_utils_asidenav_js + -- addScript $ StaticR js_utils_asyncForm_js + -- addScript $ StaticR js_utils_asyncTable_js + -- addScript $ StaticR js_utils_asyncTableFilter_js + -- addScript $ StaticR js_utils_checkAll_js + -- addScript $ StaticR js_utils_httpClient_js + -- addScript $ StaticR js_utils_form_js + -- addScript $ StaticR js_utils_inputs_js + -- addScript $ StaticR js_utils_modal_js + addScript $ StaticR js_utils_registry_js + addScript $ StaticR js_utils_poc_js + -- addScript $ StaticR js_utils_showHide_js + -- addScript $ StaticR js_utils_tabber_js addStylesheet $ StaticR css_utils_alerts_scss addStylesheet $ StaticR css_utils_asidenav_scss addStylesheet $ StaticR css_utils_asyncForm_scss diff --git a/static/js/utils/poc.js b/static/js/utils/poc.js new file mode 100644 index 000000000..5d87a0f82 --- /dev/null +++ b/static/js/utils/poc.js @@ -0,0 +1,33 @@ +(function() { + + var UTIL_NAME = 'poc'; + var UTIL_SELECTOR = '[uw-poc]'; + + var util = function(element) { + + function _init() { + var color = 'red'; + if (element.dataset.color) { + color = element.dataset.color; + } + element.style.outline = '1px solid ' + color; + } + + _init(); + + return { + name: UTIL_NAME, + element: element, + destroy: function() {}, + }; + }; + + if (UtilRegistry) { + UtilRegistry.register({ + name: UTIL_NAME, + selector: UTIL_SELECTOR, + setup: util, + }); + } + +})(); diff --git a/static/js/utils/registry.js b/static/js/utils/registry.js new file mode 100644 index 000000000..775b58fe4 --- /dev/null +++ b/static/js/utils/registry.js @@ -0,0 +1,109 @@ +(function() { + 'use strict'; + + var registeredUtils = []; + var activeUtilInstances = []; + + // Registry + // (revealing module pattern) + window.UtilRegistry = (function() { + + /** + * function registerUtil + * + * utils need to have at least these properties: + * name: string | utils name, e.g. 'example' + * selector: string | utils selector, e.g. '[uw-example]' + * setup: Function | utils setup function, see below + * + * setup function must return instance object with at least these properties: + * name: string | utils name + * element: HTMLElement | element the util is applied to + * destroy: Function | function to destroy the util and remove any listeners + * + * @param util Object Utility that should be added to the registry + */ + function registerUtil(util) { + console.log('registering util', util); + registeredUtils.push(util); + } + + function deregisterUtil(name, destroy) { + var utilIndex = _findUtilIndex(name); + + if (utilIndex >= 0) { + if (destroy === true) { + _destroyUtilInstances(name); + } + + registeredUtils.splice(utilIndex, 1); + } + } + + function setupAllUtils() { + console.log('setting up all registered utils'); + registeredUtils.forEach(function(util) { + setupUtil(util); + }); + } + + function setupUtil(util, scope) { + console.log('setting up util', { util }); + scope = scope || document; + + if (util && typeof util.setup === 'function') { + const elements = _findUtilElements(util, scope); + + elements.forEach(function(element) { + var utilInstance = util.setup(element); + if (utilInstance) { + activeUtilInstances.push(utilInstance); + } + }); + } + } + + function findUtil(name) { + return registeredUtils.find(function(util) { + return util.name === name; + }); + } + + function _findUtilElements(util, scope) { + return Array.from(scope.querySelectorAll(util.selector)); + } + + function _findUtilIndex(name) { + return registeredUtils.findIndex(function(util) { + return util.name === name; + }); + } + + function _destroyUtilInstances(name) { + console.log('TODO: destroy util instances', { name }); + // call destroy on each activeUtilInstance that matches the name + } + + // public API + return { + register: registerUtil, + deregister: deregisterUtil, + setupAll: setupAllUtils, + setup: setupUtil, + find: findUtil, + } + })(); + + document.addEventListener('DOMContentLoaded', function() { + window.UtilRegistry.setupAll(); + }); + + + // REMOVE ME. JUST HERE TO AVOID JS ERRORS + window.utils = { + setup: function(name) { + console.log('not really setting up', name); + }, + }; + +})(); diff --git a/static/js/utils/setup.js b/static/js/utils/setup.js deleted file mode 100644 index bb3bb0e3d..000000000 --- a/static/js/utils/setup.js +++ /dev/null @@ -1,114 +0,0 @@ -(function() { - 'use strict'; - - window.utils = window.utils || {}; - - var registeredSetupListeners = {}; - var activeInstances = {}; - -/** - * setup function to initiate a util (utilName) on a scope (sope) with options (options). - * - * Utils need to be defined as property of `window.utils` and need to accept a scope and (optionally) options. - * Example: `window.utils.autoSubmit = function(scope, options) { ... };` - */ - - window.utils.setup = function(utilName, scope, options) { - if (!utilName || !scope) { - return; - } - - options = options || {}; - - var utilInstance; - - // i18n - if (window.I18N) { - options.i18n = window.I18N; - } - - if (activeInstances[utilName]) { - var instanceWithSameScope = activeInstances[utilName] - .filter(function(instance) { return !!instance; }) - .find(function(instance) { - return instance.scope === scope; - }); - var isAlreadySetup = !!instanceWithSameScope; - - if (isAlreadySetup) { - console.warn('Trying to setup a JS utility that\'s already been set up', { utility: utilName, scope, options }); - if (!options.force) { - return false; - } - } - } - - function setup() { - var listener = function(event) { - if (event.detail.targetUtil !== utilName) { - return false; - } - - if (options.setupFunction) { - utilInstance = options.setupFunction(scope, options); - } else { - var util = window.utils[utilName]; - if (!util) { - throw new Error('"' + utilName + '" is not a known js util'); - } - - utilInstance = util(scope, options); - } - - if (utilInstance) { - if (activeInstances[utilName] && Array.isArray(activeInstances[utilName])) { - activeInstances[utilName].push(utilInstance); - } else { - activeInstances[utilName] = [ utilInstance ]; - } - } - }; - - if (registeredSetupListeners[utilName] && Array.isArray(registeredSetupListeners[utilName])) { - window.utils.teardown(utilName); - } - - if (!registeredSetupListeners[utilName] || Array.isArray(registeredSetupListeners[utilName])) { - registeredSetupListeners[utilName] = []; - } - registeredSetupListeners[utilName].push(listener); - - document.addEventListener('setup', listener); - - document.dispatchEvent(new CustomEvent('setup', { - detail: { targetUtil: utilName, module: 'none' }, - bubbles: true, - cancelable: true, - })); - } - - setup(); - - return utilInstance; - }; - - window.utils.teardown = function(utilName, destroy) { - if (registeredSetupListeners[utilName]) { - registeredSetupListeners[utilName] - .filter(function(listener) { return !!listener }) - .forEach(function(listener) { - document.removeEventListener('setup', listener); - }); - delete registeredSetupListeners[utilName]; - } - - if (destroy === true && activeInstances[utilName]) { - activeInstances[utilName] - .filter(function(instance) { return !!instance }) - .forEach(function(instance) { - instance.destroy(); - }); - delete activeInstances[utilName]; - } - } -})(); diff --git a/templates/default-layout.hamlet b/templates/default-layout.hamlet index f43282fb0..7a679af27 100644 --- a/templates/default-layout.hamlet +++ b/templates/default-layout.hamlet @@ -6,7 +6,7 @@ $if not isModal
-
+
$if not isModal From 6d824d33928a39c66054cc814648787213ce2984 Mon Sep 17 00:00:00 2001 From: Felix Hamann Date: Wed, 3 Apr 2019 23:23:53 +0200 Subject: [PATCH 003/238] WIP: refactor show hide JS utility to work with new registry --- src/Foundation.hs | 2 +- static/js/utils/showHide.js | 106 ++++++++++++++++++++++-------------- 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/Foundation.hs b/src/Foundation.hs index a15dd5a9b..a0d739bc7 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -1077,7 +1077,7 @@ siteLayout' headingOverride widget = do -- addScript $ StaticR js_utils_modal_js addScript $ StaticR js_utils_registry_js addScript $ StaticR js_utils_poc_js - -- addScript $ StaticR js_utils_showHide_js + addScript $ StaticR js_utils_showHide_js -- addScript $ StaticR js_utils_tabber_js addStylesheet $ StaticR css_utils_alerts_scss addStylesheet $ StaticR css_utils_asidenav_scss diff --git a/static/js/utils/showHide.js b/static/js/utils/showHide.js index 0441cde4b..6689b83bd 100644 --- a/static/js/utils/showHide.js +++ b/static/js/utils/showHide.js @@ -1,77 +1,99 @@ (function() { 'use strict'; - window.utils = window.utils || {}; + var UTIL_NAME = 'showHide'; + var UTIL_SELECTOR = '[uw-show-hide]'; var JS_INITIALIZED_CLASS = 'js-show-hide-initialized'; var LOCAL_STORAGE_SHOW_HIDE = 'SHOW_HIDE'; - var SHOW_HIDE_TOGGLE_CLASS = 'js-show-hide__toggle'; var SHOW_HIDE_COLLAPSED_CLASS = 'js-show-hide--collapsed'; var SHOW_HIDE_TARGET_CLASS = 'js-show-hide__target'; /** - * div - * div.js-show-hide__toggle - * toggle here - * div - * content here + *
+ *
+ * [toggle here] + *
+ * [content here] */ - window.utils.showHide = function(wrapper, options) { + var util = function(element) { - options = options || {}; + function _init() { + if (!element) { + throw new Error('ShowHide utility cannot be setup without an element!'); + } - function addEventHandler(el) { - el.addEventListener('click', function elClickListener() { - var newState = el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS); - updateLSState(el.dataset.shIndex || null, newState); + if (element.classList.contains(JS_INITIALIZED_CLASS)) { + return false; + } + + _addEventHandler(); + } + + function _addEventHandler() { + element.addEventListener('click', function clickListener() { + console.log('showhide clicked'); + var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS); + _updateLSState(element.dataset.shIndex || null, newState); }); } - function updateLSState(index, state) { + function _updateLSState(index, state) { if (!index) { return false; } - var lsData = getLocalStorageData(); + var lsData = _getLocalStorageData(); lsData[index] = state; window.localStorage.setItem(LOCAL_STORAGE_SHOW_HIDE, JSON.stringify(lsData)); } - function collapsedStateInLocalStorage(index) { - var lsState = getLocalStorageData(); - return lsState[index]; - } + // function _getCollapsedLSState(index) { + // var lsState = _getLocalStorageData(); + // return lsState[index]; + // } - function getLocalStorageData() { + function _getLocalStorageData() { return JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_SHOW_HIDE)) || {}; } - Array - .from(wrapper.querySelectorAll('.' + SHOW_HIDE_TOGGLE_CLASS)) - .forEach(function(el) { - if (el.classList.contains(JS_INITIALIZED_CLASS)) { - return false; - } + // var showHides = Array.from(scope.querySelectorAll(UTIL_SELECTOR)); + // showHides.forEach(function(el) { + // if (el.classList.contains(JS_INITIALIZED_CLASS)) { + // return false; + // } - var index = el.dataset.shIndex || null; - var isCollapsed = el.dataset.collapsed === 'true'; - var lsCollapsedState = collapsedStateInLocalStorage(index); - if (typeof lsCollapsedState !== 'undefined') { - isCollapsed = lsCollapsedState; - } - el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, isCollapsed); + // var index = el.dataset.shIndex || null; + // var isCollapsed = el.dataset.collapsed === 'true'; + // var lsCollapsedState = _getCollapsedLSState(index); + // if (typeof lsCollapsedState !== 'undefined') { + // isCollapsed = lsCollapsedState; + // } + // el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, isCollapsed); - Array.from(el.parentElement.children).forEach(function(el) { - if (!el.classList.contains('' + SHOW_HIDE_TOGGLE_CLASS)) { - el.classList.add(SHOW_HIDE_TARGET_CLASS); - } - }); - el.classList.add(JS_INITIALIZED_CLASS); - addEventHandler(el); - }); + // Array.from(el.parentElement.children).forEach(function(el) { + // if (!el.matches(UTIL_SELECTOR)) { + // el.classList.add(SHOW_HIDE_TARGET_CLASS); + // } + // }); + // el.classList.add(JS_INITIALIZED_CLASS); + // _addEventHandler(el); + // }); + + _init(); return { - scope: wrapper, + name: UTIL_NAME, + element: element, destroy: function() {}, }; }; + + if (UtilRegistry) { + UtilRegistry.register({ + name: UTIL_NAME, + selector: UTIL_SELECTOR, + setup: util + }); + } + })(); From ff59d0a41269e105865cf589fac364de34935bf7 Mon Sep 17 00:00:00 2001 From: Felix Hamann Date: Wed, 3 Apr 2019 23:31:22 +0200 Subject: [PATCH 004/238] move JS UtilRegistry to top of imports to ensure its present in scope --- src/Foundation.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Foundation.hs b/src/Foundation.hs index a0d739bc7..365a64ccf 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -1065,6 +1065,7 @@ siteLayout' headingOverride widget = do addScript $ StaticR js_polyfills_fetchPolyfill_js addScript $ StaticR js_polyfills_urlPolyfill_js -- JavaScript utils + addScript $ StaticR js_utils_registry_js -- addScript $ StaticR js_utils_alerts_js -- addScript $ StaticR js_utils_asidenav_js -- addScript $ StaticR js_utils_asyncForm_js @@ -1075,7 +1076,6 @@ siteLayout' headingOverride widget = do -- addScript $ StaticR js_utils_form_js -- addScript $ StaticR js_utils_inputs_js -- addScript $ StaticR js_utils_modal_js - addScript $ StaticR js_utils_registry_js addScript $ StaticR js_utils_poc_js addScript $ StaticR js_utils_showHide_js -- addScript $ StaticR js_utils_tabber_js From 4520c1be49098c5eb768c6162ffe0e71bff28ad8 Mon Sep 17 00:00:00 2001 From: Steffen Jost Date: Thu, 4 Apr 2019 15:59:45 +0200 Subject: [PATCH 005/238] email page form incomplete --- messages/uniworx/de.msg | 8 ++- routes | 1 + src/Handler/Course.hs | 11 +++- src/Handler/Utils/Communication.hs | 88 +++++++++++++++++++++++++++++ src/Handler/Utils/Form/MassInput.hs | 33 ++++++++--- src/Utils/Form.hs | 1 + src/Utils/Parameters.hs | 2 +- 7 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 src/Handler/Utils/Communication.hs diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index e5eed4900..b36af2378 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -729,4 +729,10 @@ DBTIRowsMissing n@Int: #{pluralDE n "Eine Zeile ist" "Einige Zeile sind"} aus de MassInputAddDimension: Hinzufügen MassInputDeleteCell: Entfernen -NavigationFavourites: Favoriten \ No newline at end of file +NavigationFavourites: Favoriten + +CommSubject: Betreff +CommRecipients: Empfänger + +AddRecipientGroups: Empfängergruppen +AddRecipientCustom: Weitere Empfänger diff --git a/routes b/routes index d558de967..391821721 100644 --- a/routes +++ b/routes @@ -78,6 +78,7 @@ /users CUsersR GET POST /users/#CryptoUUIDUser CUserR GET POST !lecturerANDparticipant /correctors CHiWisR GET + /mail CCommR GET POST /notes CNotesR GET POST !corrector /subs CCorrectionsR GET POST /ex SheetListR GET !registered !materials !corrector diff --git a/src/Handler/Course.hs b/src/Handler/Course.hs index 5d4ec2bf9..3a2867f62 100644 --- a/src/Handler/Course.hs +++ b/src/Handler/Course.hs @@ -821,7 +821,7 @@ colUserDegreeShort = sortable (Just "degree-short") (i18nCell MsgStudyFeatureDeg foldMap (i18nCell . ShortStudyDegree) . preview (_userTableFeatures . _2 . _Just) -data CourseUserAction = CourseUserDeregister +data CourseUserAction = CourseUserSendMail | CourseUserDeregister deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) instance Universe CourseUserAction @@ -915,6 +915,9 @@ postCUsersR tid ssh csh = do table <- makeCourseUserTable cid colChoices psValidator return (ent, numParticipants, table) formResult participantRes $ \case + (CourseUserSendMail, selectedUsers) -> do + cids <- traverse encrypt $ Set.toList selectedUsers :: Handler [CryptoUUIDUser] + redirect (CourseR tid ssh csh CCommR, [(toPathPiece GetRecipient, toPathPiece cid) | cid <- cids]) (CourseUserDeregister,selectedUsers) -> do nrDel <- runDB $ deleteWhereCount [ CourseParticipantCourse ==. cid @@ -1026,3 +1029,9 @@ getCNotesR, postCNotesR :: TermId -> SchoolId -> CourseShorthand -> Handler Html -- If they are shared, adjust MsgCourseUserNoteTooltip getCNotesR = error "CNotesR: Not implemented" postCNotesR = error "CNotesR: Not implemented" + + + +getCCommR, postCCommR :: TermId -> SchoolId -> CourseShorthand -> Handler Html +getCCommR = postCCommR +postCCommR tid ssh csh = commR _hole diff --git a/src/Handler/Utils/Communication.hs b/src/Handler/Utils/Communication.hs new file mode 100644 index 000000000..7e7d24522 --- /dev/null +++ b/src/Handler/Utils/Communication.hs @@ -0,0 +1,88 @@ +module Handler.Utils.Communication where + +import Import + +import qualified Database.Esqueleto as E + +data RecipientGroup = RGCourseParticipants | RGCourseLecturers | RGCourseCorrectors + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) + +instance Universe RecipientGroup +instance Finite RecipientGroup +nullaryPathPiece ''RecipientGroup $ camelToPathPiece' 1 +embedRenderMessage ''UniWorX ''RecipientGroup id + + +data RecipientAddOptions + = AddRecipientGroups + | AddRecipientGroup RecipientGroup + | AddRecipientCustom + deriving (Eq, Ord, Read, Show, Generic, Typeable) + +instance Universe RecipientAddOptions where + universe = AddRecipientGroups: + [AddRecipientGroup g | g <- universe] + ++ [AddRecipientCustom] +instance Finite RecipientAddOptions + +instance PathPiece RecipientAddOption where + toPathPiece AddRecipientGroups = "recipient-groups" + toPathPiece AddRecipientCustom = "recipient-custom" + toPathPiece (AddRecipientGroup g) = toPathPiece g + + fromPathPiece = finiteFromPathPiece + +instance RenderMessage UniWorX RecipientAddOption where + renderMessage foundation ls = \case + AddRecipientGroups -> renderMessage' MsgAddRecipientGroups + AddRecipientCustom -> renderMessage' MsgAddRecipientCustom + AddRecipientGroup g -> renderMessage' g + where renderMessage' = renderMessage foundation ls + + +data CommunicationRoute = CommuncationRoute + { crRecipients :: Map RecipientGroup (E.SqlQuery (E.SqlExpr (Entity User))) + , crJob :: MailT Handler () -> Handler Job + } + +data Communication = Communication + { cRecipients :: Set (Either Email UserId) + , cSubject :: Text + } + + + +commR :: CommunicationRoute -> Handler Html +commR CommunicationRoute{..} = do + MsgRenderer mr <- getMsgRenderer + + suggestedRecipients' <- runDB $ traverse E.select crRecipients + suggestedRecipients <- forM suggestedRecipients' . mapM $ \ent@(Entity rid _) -> (,) <$> encrypt rid <*> pure ent + + let recipientAForm :: AForm (Set (Either Email UserId)) + recipientAForm = postProcess <$> massInputA MassInput{..} (fslI MsgCommRecipients) True Nothing + where miAdd _ _ nudge submitButton = Just $ \csrf -> do + let addOptions = Map.fromList . concat $ + [ pure (AddRecipientGroups, apreq (selectField . return $ mkOptionsList + [ Option (mr g) (Set.fromList $ map (Right . entityKey . snd) recs) (toPathPiece g) | (g,recs) <- Map.toList suggestedRecipients ] + ) ) + , do + (g,recs) <- Map.toList suggestedRecipients + return ( AddRecipientGroup g + , apreq (selectField . return $ mkOptionsList + [ Option userDisplayName (Right rid) (toPathPiece cid) | (cid, Entity rid User{..}) <- recs ] + ) + -- , pure (AddRecipientCustom, _ ) + ] + + + multiAction () + miCell + miDelete + miAllowAdd + miButtonAction + + runFormPost . identifyForm FIDCommunication $ renderAForm FormStandard $ Communication + <$> recipientAForm + <*> areq textField (fslI MsgCommSubject) Nothing + diff --git a/src/Handler/Utils/Form/MassInput.hs b/src/Handler/Utils/Form/MassInput.hs index d1c403ec7..6f5e9f6dc 100644 --- a/src/Handler/Utils/Form/MassInput.hs +++ b/src/Handler/Utils/Form/MassInput.hs @@ -3,10 +3,11 @@ module Handler.Utils.Form.MassInput ( MassInput(..) , massInput - , massInputList , BoxDimension(..) , IsBoxCoord(..), boxDimension , Liveliness(..) + , massInputA + , massInputList , ListLength(..), ListPosition(..), miDeleteList ) where @@ -33,7 +34,7 @@ import Control.Monad.Reader.Class (MonadReader(local)) data BoxDimension x = forall n. (Enum n, Num n) => BoxDimension (Lens' x n) - + class (PathPiece x, ToJSONKey x, FromJSONKey x, Eq x, Ord x) => IsBoxCoord x where boxDimensions :: [BoxDimension x] boxOrigin :: x @@ -47,7 +48,7 @@ boxDimension n -- zeroDimension :: IsBoxCoord x => Natural -> x -> x -- zeroDimension (boxDimension -> BoxDimension dim) = set dim $ boxOrigin ^. dim - + class (IsBoxCoord (BoxCoord a), Lattice a, BoundedJoinSemiLattice a) => Liveliness a where type BoxCoord a :: * liveCoords :: Prism' (Set (BoxCoord a)) a @@ -221,12 +222,12 @@ massInput :: forall handler cellData cellResult liveliness. -> (Markup -> MForm handler (FormResult (Map (BoxCoord liveliness) (cellData, cellResult)), FieldView UniWorX)) massInput MassInput{..} FieldSettings{..} fvRequired initialResult csrf = do let initialShape = fmap fst <$> initialResult - + miName <- maybe newFormIdent return fsName fvId <- maybe newIdent return fsId miAction <- traverse toTextUrl $ miButtonAction fvId let addFormAction = maybe id (addAttr "formaction") miAction - + let shapeName :: MassInputFieldName (BoxCoord liveliness) shapeName = MassInputShape{..} @@ -303,8 +304,8 @@ massInput MassInput{..} FieldSettings{..} fvRequired initialResult csrf = do let shapeChanged = Fold.any (isn't _FormMissing . view _1) addResults || Fold.any (is _FormSuccess . view _1) delResults shape <- if - | Just s <- addShape -> return s - | Just s <- delShape -> return s + | Just s <- addShape -> return s + | Just s <- delShape -> return s | otherwise -> return sentShape' liveliness <- maybe (throwM MassInputInvalidShape) return $ Map.keysSet shape ^? liveCoords :: MForm handler liveliness @@ -349,7 +350,7 @@ massInput MassInput{..} FieldSettings{..} fvRequired initialResult csrf = do cells | [] <- remDims = do coord <- coords - Just (_data, (_cellRes, cellWdgt)) <- return $ Map.lookup coord cellResults + Just (_data, (_cellRes, cellWdgt)) <- return $ Map.lookup coord cellResults let deleteButton = snd <$> Map.lookup coord delResults return (coord, $(widgetFile "widgets/massinput/cell")) | otherwise = @@ -360,7 +361,7 @@ massInput MassInput{..} FieldSettings{..} fvRequired initialResult csrf = do miWidget = miWidget' boxOrigin $ zip [0..] boxDimensions MsgRenderer mr <- getMsgRenderer - + let fvLabel = toHtml $ mr fsLabel fvTooltip = toHtml . mr <$> fsTooltip @@ -393,3 +394,17 @@ massInputList field fieldSettings miButtonAction miSettings miRequired miPrevRes miSettings miRequired (Map.fromList . zip [0..] . map ((), ) <$> miPrevResult) + +massInputA :: forall handler cellData cellResult liveliness. + ( MonadHandler handler, HandlerSite handler ~ UniWorX + , ToJSON cellData, FromJSON cellData + , Liveliness liveliness + , MonadLogger handler + ) + => MassInput handler liveliness cellData cellResult + -> FieldSettings UniWorX + -> Bool -- ^ Required? + -> Maybe (Map (BoxCoord liveliness) (cellData, cellResult)) + -> AForm handler (Map (BoxCoord liveliness) (cellData, cellResult)) +massInputA mi fs fvRequired initialResult = formToAForm $ + over _2 pure <$> massInput mi fs fvRequired initialResult mempty \ No newline at end of file diff --git a/src/Utils/Form.hs b/src/Utils/Form.hs index 96dec5423..1f207d484 100644 --- a/src/Utils/Form.hs +++ b/src/Utils/Form.hs @@ -189,6 +189,7 @@ data FormIdentifier | FIDcUserNote | FIDAdminDemo | FIDUserDelete + | FIDCommunication deriving (Eq, Ord, Read, Show) instance PathPiece FormIdentifier where diff --git a/src/Utils/Parameters.hs b/src/Utils/Parameters.hs index 81b0c210a..5d5335a98 100644 --- a/src/Utils/Parameters.hs +++ b/src/Utils/Parameters.hs @@ -20,7 +20,7 @@ import Data.Universe import Control.Monad.Trans.Maybe (MaybeT(..)) -data GlobalGetParam = GetReferer +data GlobalGetParam = GetReferer | GetRecipient deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe GlobalGetParam From 680b674b09c474db22dae9135ddaf60435a5a302 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Thu, 4 Apr 2019 19:33:39 +0200 Subject: [PATCH 006/238] Solidify design of BearerToken --- config/settings.yml | 1 + package.yaml | 2 + src/Application.hs | 18 ++-- src/Auth/LDAP.hs | 2 +- src/CryptoID.hs | 5 + src/Data/Aeson/Types/Instances.hs | 14 +++ src/Data/NonNull/Instances.hs | 20 ++++ src/Foundation.hs | 45 +++++---- src/Handler/Utils.hs | 4 +- src/Handler/Utils/DateTime.hs | 6 +- src/Import/NoFoundation.hs | 4 + src/Jobs.hs | 17 ++-- src/Jobs/Crontab.hs | 2 +- src/Jobs/Handler/HelpRequest.hs | 2 +- src/Jose/Jwt/Instances.hs | 18 ++++ src/Model/Migration/Types.hs | 1 - src/Model/Token.hs | 158 ++++++++++++++++++++++++++++++ src/Model/Types.hs | 43 +++++++- src/Model/Types/JSON.hs | 23 ++++- src/Settings.hs | 4 + src/Settings/Cluster.hs | 14 +++ src/Utils/Lens.hs | 8 ++ 22 files changed, 363 insertions(+), 48 deletions(-) create mode 100644 src/Data/Aeson/Types/Instances.hs create mode 100644 src/Data/NonNull/Instances.hs create mode 100644 src/Jose/Jwt/Instances.hs create mode 100644 src/Model/Token.hs diff --git a/config/settings.yml b/config/settings.yml index 3211d42db..9479d002a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -27,6 +27,7 @@ notification-rate-limit: 3600 notification-collate-delay: 300 notification-expiration: 259201 session-timeout: 7200 +jwt-expiration: 604800 maximum-content-length: 52428800 log-settings: diff --git a/package.yaml b/package.yaml index 339ecff3e..c856a6e95 100644 --- a/package.yaml +++ b/package.yaml @@ -117,6 +117,8 @@ dependencies: - lattices - hsass - semigroupoids + - jose-jwt + - mono-traversable other-extensions: - GeneralizedNewtypeDeriving diff --git a/src/Application.hs b/src/Application.hs index 20824d216..5b130dd50 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -101,7 +101,7 @@ mkYesodDispatch "UniWorX" resourcesUniWorX -- the place to put your migrate statements to have automatic database -- migrations handled by Yesod. makeFoundation :: (MonadResource m, MonadBaseControl IO m) => AppSettings -> m UniWorX -makeFoundation appSettings@AppSettings{..} = do +makeFoundation appSettings'@AppSettings{..} = do -- Some basic initializations: HTTP connection manager, logger, and static -- subsite. appHttpManager <- newManager @@ -141,7 +141,7 @@ makeFoundation appSettings@AppSettings{..} = do -- logging function. To get out of this loop, we initially create a -- temporary foundation without a real connection pool, get a log function -- from there, and then create the real foundation. - let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached = UniWorX {..} + let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet = UniWorX {..} -- The UniWorX {..} syntax is an example of record wild cards. For more -- information, see: -- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html @@ -153,13 +153,14 @@ makeFoundation appSettings@AppSettings{..} = do (error "sessionKey forced in tempFoundation") (error "secretBoxKey forced in tempFoundation") (error "widgetMemcached forced in tempFoundation") + (error "JSONWebKeySet forced in tempFoundation") logFunc loc src lvl str = do f <- messageLoggerSource tempFoundation <$> readTVarIO (snd appLogger) f loc src lvl str flip runLoggingT logFunc $ do $logDebugS "InstanceID" $ UUID.toText appInstanceID - -- logDebugS "Configuration" $ tshow appSettings + -- logDebugS "Configuration" $ tshow appSettings' smtpPool <- traverse createSmtpPool appSmtpConf @@ -177,8 +178,9 @@ makeFoundation appSettings@AppSettings{..} = do appCryptoIDKey <- clusterSetting (Proxy :: Proxy 'ClusterCryptoIDKey) `runSqlPool` sqlPool appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool + appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool - let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached + let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet handleJobs foundation @@ -265,7 +267,7 @@ makeLogWare app = do logger <- readTVarIO . snd $ appLogger app logWare <- mkRequestLogger def { outputFormat = bool - (Apache . bool FromSocket FromHeader . appIpFromHeader $ appSettings app) + (Apache . bool FromSocket FromHeader $ app ^. _appIpFromHeader) (Detailed True) logDetailed , destination = Logger $ loggerSet logger @@ -287,8 +289,8 @@ makeLogWare app = do -- | Warp settings for the given foundation value. warpSettings :: UniWorX -> Settings warpSettings foundation = defaultSettings - & setPort (appPort $ appSettings foundation) - & setHost (appHost $ appSettings foundation) + & setPort (foundation ^. _appPort) + & setHost (foundation ^. _appHost) & setOnException (\_req e -> when (defaultShouldDisplayException e) $ do logger <- readTVarIO . snd $ appLogger foundation @@ -384,6 +386,6 @@ addPWEntry :: User -> Text {-^ Password -} -> IO () addPWEntry User{ userAuthentication = _, ..} (Text.encodeUtf8 -> pw) = db $ do - PWHashConf{..} <- getsYesod $ appAuthPWHash . appSettings + PWHashConf{..} <- getsYesod $ view _appAuthPWHash (AuthPWHash . Text.decodeUtf8 -> userAuthentication) <- liftIO $ makePasswordWith pwHashAlgorithm pw pwHashStrength void $ insert User{..} diff --git a/src/Auth/LDAP.hs b/src/Auth/LDAP.hs index 5233faaf3..d65c1ee9b 100644 --- a/src/Auth/LDAP.hs +++ b/src/Auth/LDAP.hs @@ -159,7 +159,7 @@ campusUser conf@LdapConf{..} pool Creds{..} = liftIO . (`catches` errHandlers) $ ] -- ldapConfig :: UniWorX -> LDAPConfig --- ldapConfig _app@(appSettings -> settings) = LDAPConfig +-- ldapConfig _app@(appSettings' -> settings) = LDAPConfig -- { usernameFilter = \u -> principalName <> "=" <> u -- , identifierModifier -- , ldapUri = appLDAPURI settings diff --git a/src/CryptoID.hs b/src/CryptoID.hs index 899047c3b..22266fc3a 100644 --- a/src/CryptoID.hs +++ b/src/CryptoID.hs @@ -29,6 +29,11 @@ import Data.Aeson (ToJSON(..), ToJSONKey(..), ToJSONKeyFunction(..), FromJSON(.. import Data.Aeson.Encoding (text) +instance {-# OVERLAPPING #-} MonadThrow m => MonadCrypto (ReaderT CryptoIDKey m) where + type MonadCryptoKey (ReaderT CryptoIDKey m) = CryptoIDKey + cryptoIDKey f = ask >>= f + + -- Generates CryptoUUID... and CryptoFileName... Datatypes decCryptoIDs [ ''SubmissionId , ''FileId diff --git a/src/Data/Aeson/Types/Instances.hs b/src/Data/Aeson/Types/Instances.hs new file mode 100644 index 000000000..f785576f2 --- /dev/null +++ b/src/Data/Aeson/Types/Instances.hs @@ -0,0 +1,14 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Aeson.Types.Instances + ( + ) where + +import ClassyPrelude + +import Data.Aeson.Types (Parser) +import Control.Monad.Catch + + +instance MonadThrow Parser where + throwM = fail . show diff --git a/src/Data/NonNull/Instances.hs b/src/Data/NonNull/Instances.hs new file mode 100644 index 000000000..1a11a66d9 --- /dev/null +++ b/src/Data/NonNull/Instances.hs @@ -0,0 +1,20 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.NonNull.Instances + ( + ) where + +import ClassyPrelude + +import Data.Aeson + + +instance ToJSON a => ToJSON (NonNull a) where + toJSON = toJSON . toNullable + +instance (FromJSON a, MonoFoldable a) => FromJSON (NonNull a) where + parseJSON = parseJSON >=> maybe (fail "Expected non-empty structure") return . fromNullable + + +instance Hashable a => Hashable (NonNull a) where + hashWithSalt s = hashWithSalt s . toNullable diff --git a/src/Foundation.hs b/src/Foundation.hs index 0aca60110..09755de7d 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -77,6 +77,7 @@ import qualified Yesod.Auth.Message as Auth import qualified Data.Conduit.List as C import qualified Crypto.Saltine.Core.SecretBox as SecretBox +import qualified Jose.Jwk as Jose import qualified Database.Memcached.Binary.IO as Memcached import Data.Bits (Bits(zeroBits)) @@ -96,6 +97,8 @@ instance DisplayAble TermId where instance DisplayAble SchoolId where display = CI.original . unSchoolKey +type SMTPPool = Pool SMTPConnection + -- infixl 9 :$: -- pattern a :$: b = a b @@ -104,7 +107,7 @@ instance DisplayAble SchoolId where -- starts running, such as database connections. Every handler will have -- access to the data present here. data UniWorX = UniWorX - { appSettings :: AppSettings + { appSettings' :: AppSettings , appStatic :: EmbeddedStatic -- ^ Settings for static file serving. , appConnPool :: ConnectionPool -- ^ Database connection pool. , appSmtpPool :: Maybe SMTPPool @@ -119,9 +122,16 @@ data UniWorX = UniWorX , appCronThread :: TMVar (ReleaseKey, ThreadId) , appSessionKey :: ClientSession.Key , appSecretBoxKey :: SecretBox.Key + , appJSONWebKeySet :: Jose.JwkSet } -type SMTPPool = Pool SMTPConnection +makeLenses_ ''UniWorX +instance HasInstanceID UniWorX InstanceId where + instanceID = _appInstanceID +instance HasJSONWebKeySet UniWorX Jose.JwkSet where + jsonWebKeySet = _appJSONWebKeySet +instance HasAppSettings UniWorX where + appSettings = _appSettings' -- This is where we define all of the routes in our application. For a full -- explanation of the syntax, please see: @@ -463,7 +473,7 @@ tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of tagAccessPredicate AuthDeprecated = APHandler $ \_ r _ -> do $logWarnS "AccessControl" ("deprecated route: " <> tshow r) addMessageI Error MsgDeprecatedRoute - allow <- appAllowDeprecated . appSettings <$> getYesod + allow <- view _appAllowDeprecated return $ bool (Unauthorized "Deprecated Route") Authorized allow tagAccessPredicate AuthDevelopment = APHandler $ \_ r _ -> do $logWarnS "AccessControl" ("route in development: " <> tshow r) @@ -809,17 +819,17 @@ instance Yesod UniWorX where -- Controls the base of generated URLs. For more information on modifying, -- see: https://github.com/yesodweb/yesod/wiki/Overriding-approot approot = ApprootRequest $ \app req -> - case appRoot $ appSettings app of + case app ^. _appRoot of Nothing -> getApprootText guessApproot app req Just root -> root -- Store session data on the client in encrypted cookies, -- default session idle timeout is 120 minutes - makeSessionBackend UniWorX{appSessionKey,appSettings=AppSettings{appSessionTimeout}} = do - (getCachedDate, _) <- clientSessionDateCacher appSessionTimeout - return . Just $ clientSessionBackend appSessionKey getCachedDate + makeSessionBackend app = do + (getCachedDate, _) <- clientSessionDateCacher (app ^. _appSessionTimeout) + return . Just $ clientSessionBackend (app ^. _appSessionKey) getCachedDate - maximumContentLength UniWorX{appSettings=AppSettings{appMaximumContentLength}} _ = appMaximumContentLength + maximumContentLength app _ = app ^. _appMaximumContentLength -- Yesod Middleware allows you to run code before and after each handler function. -- The defaultYesodMiddleware adds the response header "Vary: Accept, Accept-Language" and performs authorization checks. @@ -878,7 +888,7 @@ instance Yesod UniWorX where encrypted :: ToJSON a => a -> Widget -> Widget encrypted plaintextJson plaintext = do canDecrypt <- (== Authorized) <$> evalAccess AdminErrMsgR True - shouldEncrypt <- getsYesod $ appEncryptErrors . appSettings + shouldEncrypt <- view _appEncryptErrors if | shouldEncrypt , not canDecrypt -> do @@ -919,8 +929,8 @@ instance Yesod UniWorX where isAuthorized = evalAccess addStaticContent ext _mime content = do - UniWorX{appWidgetMemcached, appSettings} <- getYesod - for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings) $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConnectInfo = _, .. }) -> do + UniWorX{appWidgetMemcached, appSettings'} <- getYesod + for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings') $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConnectInfo = _, .. }) -> do let expiry = (maybe 0 ceiling widgetMemcachedExpiry) touch = liftIO $ Memcached.touch expiry (encodeUtf8 $ pack fileName) mConn add = liftIO $ Memcached.add zeroBits expiry (encodeUtf8 $ pack fileName) content mConn @@ -971,8 +981,7 @@ siteLayout = siteLayout' . Just siteLayout' :: Maybe Widget -- ^ Optionally override `pageHeading` -> Widget -> Handler Html siteLayout' headingOverride widget = do - master <- getYesod - let AppSettings { appUserDefaults = UserDefaultConf{..}, .. } = appSettings master + AppSettings { appUserDefaults = UserDefaultConf{..}, .. } <- view appSettings isModal <- hasCustomHeader HeaderIsModal @@ -2081,7 +2090,7 @@ instance YesodAuth UniWorX where _other -> return res $logDebugS "auth" $ tshow Creds{..} - UniWorX{ appSettings = AppSettings{ appUserDefaults = UserDefaultConf{..}, ..}, .. } <- getYesod + UniWorX{ appSettings' = AppSettings{ appUserDefaults = UserDefaultConf{..}, ..}, .. } <- getYesod flip catches excHandlers $ case (,) <$> appLdapConf <*> appLdapPool of Just (ldapConf, ldapPool) -> fmap (either id id) . runExceptT $ do @@ -2200,7 +2209,7 @@ instance YesodAuth UniWorX where where insertMaybe key val = get key >>= maybe (insert_ val) (\_ -> return ()) - authPlugins (UniWorX{ appSettings = AppSettings{..}, appLdapPool }) = catMaybes + authPlugins (UniWorX{ appSettings' = AppSettings{..}, appLdapPool }) = catMaybes [ campusLogin <$> appLdapConf <*> appLdapPool , Just . hashLogin $ pwHashAlgorithm appAuthPWHash , dummyLogin <$ guard appAuthDummyLogin @@ -2225,9 +2234,9 @@ unsafeHandler f h = do instance YesodMail UniWorX where - defaultFromAddress = getsYesod $ appMailFrom . appSettings - mailObjectIdDomain = getsYesod $ appMailObjectDomain . appSettings - mailVerp = getsYesod $ appMailVerp . appSettings + defaultFromAddress = getsYesod $ view _appMailFrom + mailObjectIdDomain = getsYesod $ view _appMailObjectDomain + mailVerp = getsYesod $ view _appMailVerp mailDateTZ = return appTZ mailSmtp act = do pool <- maybe (throwM MailNotAvailable) return =<< getsYesod appSmtpPool diff --git a/src/Handler/Utils.hs b/src/Handler/Utils.hs index 46abeddd5..6e250cfd9 100644 --- a/src/Handler/Utils.hs +++ b/src/Handler/Utils.hs @@ -4,6 +4,8 @@ module Handler.Utils import Import +import Utils.Lens + import qualified Data.Text as T -- import qualified Data.Set (Set) import qualified Data.Set as Set @@ -40,7 +42,7 @@ downloadFiles = do case mauth of Just (Entity _ User{..}) -> return userDownloadFiles Nothing -> do - AppSettings{ appUserDefaults = UserDefaultConf{..} } <- getsYesod appSettings + UserDefaultConf{..} <- getsYesod $ view _appUserDefaults return userDefaultDownloadFiles tidFromText :: Text -> Maybe TermId diff --git a/src/Handler/Utils/DateTime.hs b/src/Handler/Utils/DateTime.hs index de2b0705a..15ecfc780 100644 --- a/src/Handler/Utils/DateTime.hs +++ b/src/Handler/Utils/DateTime.hs @@ -13,6 +13,8 @@ module Handler.Utils.DateTime import Import +import Utils.Lens + import Data.Time.Zones import qualified Data.Time.Zones as TZ @@ -83,7 +85,7 @@ getTimeLocale = getTimeLocale' <$> languages getDateTimeFormat :: (MonadHandler m, HandlerSite m ~ UniWorX) => SelDateTimeFormat -> m DateTimeFormat getDateTimeFormat sel = do mauth <- liftHandlerT maybeAuth - AppSettings{ appUserDefaults = UserDefaultConf{..} } <- getsYesod appSettings + UserDefaultConf{..} <- getsYesod $ view _appUserDefaults let fmt | Just (Entity _ User{..}) <- mauth @@ -182,4 +184,4 @@ weeksToAdd old new = loop 0 old where loop n t | t > new = n - | otherwise = loop (succ n) (addOneWeek t) \ No newline at end of file + | otherwise = loop (succ n) (addOneWeek t) diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs index 21e0b5de5..fd37d73bc 100644 --- a/src/Import/NoFoundation.hs +++ b/src/Import/NoFoundation.hs @@ -43,6 +43,7 @@ import GHC.Exts as Import (IsList) import Data.Hashable as Import import Data.List.NonEmpty as Import (NonEmpty(..)) import Data.List.NonEmpty.Instances as Import () +import Data.NonNull.Instances as Import () import Data.Text.Encoding.Error as Import(UnicodeException(..)) import Data.Semigroup as Import (Semigroup) import Data.Monoid as Import (Last(..), First(..)) @@ -56,6 +57,7 @@ import Control.Monad.Trans.Resource as Import (ReleaseKey) import Network.Mail.Mime.Instances as Import () import Yesod.Core.Instances as Import () +import Data.Aeson.Types.Instances as Import () import Ldap.Client.Pool as Import @@ -67,6 +69,8 @@ import Numeric.Natural.Instances as Import () import System.Random as Import (Random) import Control.Monad.Random.Class as Import (MonadRandom(..)) +import Jose.Jwt.Instances as Import () + import Control.Monad.Trans.RWS (RWST) diff --git a/src/Jobs.hs b/src/Jobs.hs index 2a9a42556..04df2686c 100644 --- a/src/Jobs.hs +++ b/src/Jobs.hs @@ -6,6 +6,7 @@ module Jobs ) where import Import +import Utils.Lens import Jobs.Types as Types hiding (JobCtl(JobCtlQueue)) import Jobs.Types (JobCtl(JobCtlQueue)) @@ -77,7 +78,7 @@ handleJobs :: ( MonadResource m -- Uses `unsafeHandler`, as per documentation all HTTP-related fields of state/environment are meaningless placeholders. -- Handling commands in `HandlerT` provides us with the facilities to render urls, unifies logging, provides a value of the foundation type, ... handleJobs foundation@UniWorX{..} = do - let num = appJobWorkers appSettings + let num = foundation ^. _appJobWorkers jobCrontab <- liftIO $ newTMVarIO HashMap.empty jobConfirm <- liftIO $ newTVarIO HashMap.empty @@ -135,7 +136,7 @@ execCrontab = evalStateT go HashMap.empty runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ merge refT <- liftIO getCurrentTime - settings <- getsYesod appSettings + settings <- getsYesod appSettings' currentState <- mapStateT (mapReaderT $ liftIO . atomically) $ do crontab' <- liftBase . tryReadTMVar =<< asks jobCrontab case crontab' of @@ -157,7 +158,7 @@ execCrontab = evalStateT go HashMap.empty | ((==) `on` HashMap.lookup jobCtl) newCrontab currentCrontab -> do now <- liftIO $ getCurrentTime - instanceID <- getsYesod appInstanceID + instanceID' <- getsYesod appInstanceID State.modify $ HashMap.alter (Just . ($ Max now) . maybe id (<>)) jobCtl case jobCtl of JobCtlQueue job -> do @@ -166,7 +167,7 @@ execCrontab = evalStateT go HashMap.empty CronLastExec { cronLastExecJob = toJSON job , cronLastExecTime = now - , cronLastExecInstance = instanceID + , cronLastExecInstance = instanceID' } [ CronLastExecTime =. now ] lift . lift $ queueDBJob job @@ -285,21 +286,21 @@ jLocked jId act = do let lock = runDB . setSerializable $ do qj@QueuedJob{..} <- maybe (throwM $ JNonexistant jId) return =<< get jId - instanceID <- getsYesod appInstanceID - threshold <- getsYesod $ appJobStaleThreshold . appSettings + instanceID' <- getsYesod $ view instanceID + threshold <- getsYesod $ view _appJobStaleThreshold now <- liftIO getCurrentTime hadStale <- maybeT (return False) $ do lockTime <- MaybeT $ return queuedJobLockTime lockInstance <- MaybeT $ return queuedJobLockInstance if - | lockInstance == instanceID + | lockInstance == instanceID' , diffUTCTime now lockTime >= threshold -> return True | otherwise -> throwM $ JLocked jId lockInstance lockTime when hadStale . $logWarnS "Jobs" $ "Ignored stale lock: " <> tshow qj - val <- updateGet jId [ QueuedJobLockInstance =. Just instanceID + val <- updateGet jId [ QueuedJobLockInstance =. Just instanceID' , QueuedJobLockTime =. Just now ] liftIO . atomically $ writeTVar hasLock True diff --git a/src/Jobs/Crontab.hs b/src/Jobs/Crontab.hs index 41b3441c6..af83ef1c5 100644 --- a/src/Jobs/Crontab.hs +++ b/src/Jobs/Crontab.hs @@ -23,7 +23,7 @@ import qualified Data.Conduit.List as C determineCrontab :: DB (Crontab JobCtl) -- ^ Extract all future jobs from the database (sheet deadlines, ...) determineCrontab = execWriterT $ do - AppSettings{..} <- getsYesod appSettings + AppSettings{..} <- getsYesod appSettings' case appJobFlushInterval of Just interval -> tell $ HashMap.singleton diff --git a/src/Jobs/Handler/HelpRequest.hs b/src/Jobs/Handler/HelpRequest.hs index 1ec904e2b..5623be772 100644 --- a/src/Jobs/Handler/HelpRequest.hs +++ b/src/Jobs/Handler/HelpRequest.hs @@ -20,7 +20,7 @@ dispatchJobHelpRequest :: Either (Maybe Address) UserId -> Maybe Text -- ^ Referer -> Handler () dispatchJobHelpRequest jSender jRequestTime jHelpRequest jReferer = do - supportAddress <- getsYesod $ appMailSupport . appSettings + supportAddress <- view _appMailSupport userInfo <- bitraverse return (runDB . getEntity) jSender let userAddress = either id diff --git a/src/Jose/Jwt/Instances.hs b/src/Jose/Jwt/Instances.hs new file mode 100644 index 000000000..f7607168c --- /dev/null +++ b/src/Jose/Jwt/Instances.hs @@ -0,0 +1,18 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Jose.Jwt.Instances + ( + ) where + +import ClassyPrelude.Yesod + +import Jose.Jwt + + +instance PathPiece Jwt where + toPathPiece (Jwt bytes) = decodeUtf8 bytes + fromPathPiece = Just . Jwt . encodeUtf8 + +deriving instance Typeable JwtError + +instance Exception JwtError diff --git a/src/Model/Migration/Types.hs b/src/Model/Migration/Types.hs index 0aed744b0..5ec81cd81 100644 --- a/src/Model/Migration/Types.hs +++ b/src/Model/Migration/Types.hs @@ -2,7 +2,6 @@ module Model.Migration.Types where import ClassyPrelude.Yesod import Data.Aeson.TH (deriveJSON, defaultOptions) -import Database.Persist.Sql import qualified Model as Current import qualified Model.Types.JSON as Current diff --git a/src/Model/Token.hs b/src/Model/Token.hs new file mode 100644 index 000000000..a57b5244b --- /dev/null +++ b/src/Model/Token.hs @@ -0,0 +1,158 @@ +{-# LANGUAGE UndecidableInstances #-} + +module Model.Token + ( BearerToken(..) + , bearerToken + , encodeToken, decodeToken + , tokenToJSON, tokenParseJSON + ) where + +import ClassyPrelude.Yesod +import Model +import Settings +import Utils.Lens hiding ((.=)) + +import Yesod.Auth (AuthId) + +-- import qualified Jose.Jwa as Jose +import Jose.Jwk (JwkSet) +-- import qualified Jose.Jwk as Jose +import Jose.Jwt (Jwt, JwtError, IntDate(..)) +import qualified Jose.Jwt as Jose + +import Jose.Jwt.Instances () +import Data.Aeson.Types.Instances () + +import qualified Crypto.Random as Crypto (MonadRandom) + +import Data.HashSet (HashSet) + +import qualified Data.HashMap.Strict as HashMap + +import Data.Aeson.Types (Parser, (.:?), (.:)) +import qualified Data.Aeson as JSON +import qualified Data.Aeson.Types as JSON + +import CryptoID + +import Data.Time.Clock +import Data.Time.Clock.POSIX + +import Control.Monad.Random (MonadRandom(..)) + + +data BearerToken site = BearerToken + { tokenIdentifier :: TokenId + , tokenAuthority :: AuthId site + , tokenRoutes :: Maybe (HashSet (Route site)) + , tokenAddAuth :: AuthCNF + , tokenIssuedAt :: UTCTime + , tokenIssuedBy :: InstanceId + , tokenExpiresAt + , tokenStartsAt :: Maybe UTCTime + } deriving (Generic, Typeable) + +deriving instance (Eq (AuthId site), Eq (Route site)) => Eq (BearerToken site) +deriving instance (Read (AuthId site), Eq (Route site), Hashable (Route site), Read (Route site)) => Read (BearerToken site) +deriving instance (Show (AuthId site), Show (Route site)) => Show (BearerToken site) + +tokenToJSON :: forall m. + ( MonadHandler m + , HasCryptoUUID (AuthId (HandlerSite m)) m + , RenderRoute (HandlerSite m) + ) => BearerToken (HandlerSite m) -> m Value +tokenToJSON BearerToken{..} = do + cID <- encrypt tokenAuthority :: m (CryptoUUID (AuthId (HandlerSite m))) + let stdPayload = Jose.JwtClaims + { jwtIss = Just $ toPathPiece tokenIssuedBy + , jwtSub = Nothing + , jwtAud = Nothing + , jwtExp = IntDate . utcTimeToPOSIXSeconds <$> tokenExpiresAt + , jwtNbf = IntDate . utcTimeToPOSIXSeconds <$> tokenStartsAt + , jwtIat = Just . IntDate $ utcTimeToPOSIXSeconds tokenIssuedAt + , jwtJti = Just $ toPathPiece tokenIdentifier + } + return . JSON.object $ + [ "authority" .= cID + , "routes" .= tokenRoutes + , "add-auth" .= tokenAddAuth + ] ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm + +tokenParseJSON :: forall site. + ( HasCryptoUUID (AuthId site) (ReaderT CryptoIDKey Parser) + , ParseRoute site + , Hashable (Route site) + ) + => Value + -> ReaderT CryptoIDKey Parser (BearerToken site) +tokenParseJSON v@(Object o) = do + tokenAuthority' <- lift (o .: "authority") :: ReaderT CryptoIDKey Parser (CryptoUUID (AuthId site)) + tokenAuthority <- decrypt tokenAuthority' + + tokenRoutes <- lift $ o .:? "routes" + tokenAddAuth <- lift $ o .: "add-auth" + Jose.JwtClaims{..} <- lift $ parseJSON v + + let unIntDate (IntDate posix) = posixSecondsToUTCTime posix + + Just tokenIssuedBy <- return $ jwtIss >>= fromPathPiece + Just tokenIdentifier <- return $ jwtJti >>= fromPathPiece + Just tokenIssuedAt <- return $ unIntDate <$> jwtIat + let tokenExpiresAt = unIntDate <$> jwtExp + tokenStartsAt = unIntDate <$> jwtNbf + + return BearerToken{..} +tokenParseJSON v = lift $ JSON.typeMismatch "BearerToken" v + + +bearerToken :: forall m. + ( MonadHandler m + , HasInstanceID (HandlerSite m) InstanceId + , HasCryptoUUID (AuthId (HandlerSite m)) m + , HasAppSettings (HandlerSite m) + ) + => AuthId (HandlerSite m) + -> Maybe (HashSet (Route (HandlerSite m))) + -> AuthCNF + -> Maybe (Maybe UTCTime) -- ^ @Nothing@ determines default expiry time automatically + -> Maybe UTCTime -- ^ @Nothing@ means token starts to be valid immediately + -> m (BearerToken (HandlerSite m)) +bearerToken tokenAuthority tokenRoutes tokenAddAuth mTokenExpiresAt tokenStartsAt = do + tokenIdentifier <- liftIO $ getRandom + tokenIssuedAt <- liftIO $ getCurrentTime + tokenIssuedBy <- getsYesod $ view instanceID + + defaultExpiration <- getsYesod $ view _appJWTExpiration + + let tokenExpiresAt + | Just t <- mTokenExpiresAt + = t + | Just tDiff <- defaultExpiration + = Just $ tDiff `addUTCTime` fromMaybe tokenIssuedAt tokenStartsAt + | otherwise + = Nothing + + return BearerToken{..} + + +encodeToken :: forall m. + ( Crypto.MonadRandom m + , MonadHandler m + , HasJSONWebKeySet (HandlerSite m) JwkSet + , HasInstanceID (HandlerSite m) InstanceId + , HasCryptoUUID (AuthId (HandlerSite m)) m + , RenderRoute (HandlerSite m) + ) + => BearerToken (HandlerSite m) -> m Jwt +encodeToken token = do + _payload <- tokenToJSON token + error "Not implemented" + +decodeToken :: forall m. + ( Crypto.MonadRandom m + , MonadHandler m + , HasJSONWebKeySet (HandlerSite m) JwkSet + , HasCryptoUUID (AuthId (HandlerSite m)) m + ) + => Jwt -> m (Either JwtError (BearerToken (HandlerSite m))) +decodeToken = error "Not implemented" diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 7cf317d0c..5c292d146 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -27,6 +27,8 @@ import Data.Universe.Helpers import Data.UUID.Types (UUID) import qualified Data.UUID.Types as UUID +import Data.NonNull.Instances () + import Data.Default import Text.Read (readMaybe) @@ -54,7 +56,7 @@ import qualified Data.Aeson as Aeson import qualified Data.Aeson.Types as Aeson import Data.Aeson (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), FromJSONKeyFunction(..), withText, withObject, Value()) import Data.Aeson.Types (toJSONKeyText) -import Data.Aeson.TH (deriveJSON, defaultOptions, Options(..), SumEncoding(..)) +import Data.Aeson.TH (deriveJSON, defaultOptions, Options(..), SumEncoding(..), mkToJSON, mkParseJSON) import GHC.Generics (Generic) import Generics.Deriving.Monoid (memptydefault, mappenddefault) @@ -79,7 +81,7 @@ import Model.Types.Wordlist import Data.Text.Metrics (damerauLevenshtein) import Data.Binary (Binary) - + instance PathPiece UUID where fromPathPiece = UUID.fromString . unpack @@ -731,7 +733,7 @@ data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prä | AuthDeprecated | AuthDevelopment | AuthFree - deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) instance Universe AuthTag instance Finite AuthTag @@ -774,6 +776,40 @@ instance FromJSON AuthTagActive where derivePersistFieldJSON ''AuthTagActive +data PredLiteral a = PLVariable { plVar :: a } | PLNegated { plVar :: a } + deriving (Eq, Ord, Read, Show, Generic, Typeable) + +instance Hashable a => Hashable (PredLiteral a) +deriveJSON defaultOptions + { constructorTagModifier = camelToPathPiece' 1 + , sumEncoding = ObjectWithSingleField + , unwrapUnaryRecords = True + } ''PredLiteral + + +newtype PredCNF a = PredCNF (Set (NonNull (Set (PredLiteral a)))) + deriving (Eq, Ord, Read, Show, Generic, Typeable) + deriving newtype (Semigroup, Monoid) + +newtype PredDNF a = PredDNF (Set (NonNull (Set (PredLiteral a)))) + deriving (Eq, Ord, Read, Show, Generic, Typeable) + deriving newtype (Semigroup, Monoid) + +$(return []) + +instance (Ord a, ToJSON a) => ToJSON (PredCNF a) where + toJSON = $(mkToJSON predNFAesonOptions ''PredCNF) +instance (Ord a, FromJSON a) => FromJSON (PredCNF a) where + parseJSON = $(mkParseJSON predNFAesonOptions ''PredCNF) + +instance (Ord a, ToJSON a) => ToJSON (PredDNF a) where + toJSON = $(mkToJSON predNFAesonOptions ''PredDNF) +instance (Ord a, FromJSON a) => FromJSON (PredDNF a) where + parseJSON = $(mkParseJSON predNFAesonOptions ''PredDNF) + +type AuthCNF = PredCNF AuthTag +type AuthDNF = PredDNF AuthTag + data LecturerType = CourseLecturer | CourseAssistant deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) @@ -801,4 +837,5 @@ type UserEmail = CI Email type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString type InstanceId = UUID +type TokenId = UUID type TermCandidateIncidence = UUID diff --git a/src/Model/Types/JSON.hs b/src/Model/Types/JSON.hs index e69f8f1b2..66ed78163 100644 --- a/src/Model/Types/JSON.hs +++ b/src/Model/Types/JSON.hs @@ -1,5 +1,6 @@ module Model.Types.JSON ( derivePersistFieldJSON + , predNFAesonOptions ) where import ClassyPrelude.Yesod hiding (derivePersistFieldJSON) @@ -9,11 +10,13 @@ import Database.Persist.Sql import qualified Data.ByteString.Lazy as LBS import qualified Data.Text.Encoding as Text -import qualified Data.Aeson as JSON +import Data.Aeson as JSON import Language.Haskell.TH import Language.Haskell.TH.Datatype +import Utils.PathPiece + derivePersistFieldJSON :: Name -> DecsQ derivePersistFieldJSON tName = do @@ -28,10 +31,10 @@ derivePersistFieldJSON tName = do | otherwise = cxt [[t|PersistField|] `appT` t] sequence [ instanceD iCxt ([t|PersistField|] `appT` t) - [ funD (mkName "toPersistValue") + [ funD 'toPersistValue [ clause [] (normalB [e|PersistDbSpecific . LBS.toStrict . JSON.encode|]) [] ] - , funD (mkName "fromPersistValue") + , funD 'fromPersistValue [ do bs <- newName "bs" clause [[p|PersistDbSpecific $(varP bs)|]] (normalB [e|first pack $ JSON.eitherDecodeStrict' $(varE bs)|]) [] @@ -45,8 +48,20 @@ derivePersistFieldJSON tName = do ] ] , instanceD sqlCxt ([t|PersistFieldSql|] `appT` t) - [ funD (mkName "sqlType") + [ funD 'sqlType [ clause [wildP] (normalB [e|SqlOther "jsonb"|]) [] ] ] ] + + +predNFAesonOptions :: Options +-- ^ Needed for JSON instances of `predCNF` and `predDNF` +-- +-- Moved to this module due to stage restriction +predNFAesonOptions = defaultOptions + { constructorTagModifier = camelToPathPiece' 1 + , sumEncoding = ObjectWithSingleField + , tagSingleConstructors = True + } + diff --git a/src/Settings.hs b/src/Settings.hs index f717ee378..ae2ce4b30 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -100,6 +100,7 @@ data AppSettings = AppSettings , appNotificationExpiration :: NominalDiffTime , appSessionTimeout :: NominalDiffTime , appMaximumContentLength :: Maybe Word64 + , appJWTExpiration :: Maybe NominalDiffTime , appInitialLogSettings :: LogSettings @@ -352,6 +353,7 @@ instance FromJSON AppSettings where appNotificationRateLimit <- o .: "notification-rate-limit" appNotificationCollateDelay <- o .: "notification-collate-delay" appNotificationExpiration <- o .: "notification-expiration" + appJWTExpiration <- o .:? "jwt-expiration" appSessionTimeout <- o .: "session-timeout" @@ -379,6 +381,8 @@ instance FromJSON AppSettings where return AppSettings {..} +makeClassy_ ''AppSettings + -- | Settings for 'widgetFile', such as which template languages to support and -- default Hamlet settings. -- diff --git a/src/Settings/Cluster.hs b/src/Settings/Cluster.hs index a6fb11799..872d901b7 100644 --- a/src/Settings/Cluster.hs +++ b/src/Settings/Cluster.hs @@ -32,11 +32,16 @@ import qualified Data.Binary as Binary import qualified Data.Serialize as Serialize import qualified Data.ByteString.Base64.URL as Base64 +import qualified Jose.Jwa as Jose +import qualified Jose.Jwk as Jose +import qualified Jose.Jwt as Jose + data ClusterSettingsKey = ClusterCryptoIDKey | ClusterClientSessionKey | ClusterSecretBoxKey + | ClusterJSONWebKeySet deriving (Eq, Ord, Enum, Bounded, Show, Read) instance Universe ClusterSettingsKey @@ -120,3 +125,12 @@ instance FromJSON SecretBox.Key where parseJSON = Aeson.withText "Key" $ \t -> do bytes <- either fail return . Base64.decode $ encodeUtf8 t maybe (fail "Could not parse key") return $ Saltine.decode bytes + + +instance ClusterSetting 'ClusterJSONWebKeySet where + type ClusterSettingValue 'ClusterJSONWebKeySet = Jose.JwkSet + initClusterSetting _ = liftIO $ do + now <- getCurrentTime + jwkSig <- Jose.generateSymmetricKey 32 (Jose.UTCKeyId now) Jose.Sig (Just $ Jose.Signed Jose.HS256) + return $ Jose.JwkSet [jwkSig] + knownClusterSetting _ = ClusterJSONWebKeySet diff --git a/src/Utils/Lens.hs b/src/Utils/Lens.hs index 0abc9a8ee..b6a09e3c3 100644 --- a/src/Utils/Lens.hs +++ b/src/Utils/Lens.hs @@ -93,4 +93,12 @@ makeLenses_ ''StudyTermCandidate -- makeClassy_ ''Load +-------------------------- +-- Fields for `UniWorX` -- +-------------------------- +class HasInstanceID s a | s -> a where + instanceID :: Lens' s a + +class HasJSONWebKeySet s a | s -> a where + jsonWebKeySet :: Lens' s a From cc8823c7cabc4cdee6c920f413ff08c995918063 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 5 Apr 2019 00:05:56 +0200 Subject: [PATCH 007/238] Prototype of !token --- haddock.sh | 13 ++++- messages/uniworx/de.msg | 5 ++ src/Foundation.hs | 87 +++++++++++++++++++++---------- src/Jose/Jwt/Instances.hs | 1 + src/Model/Token.hs | 101 ++++++++++++++++++++++++++---------- src/Model/Types.hs | 20 +++---- src/Utils.hs | 6 +++ src/Utils/Parameters.hs | 3 +- src/Yesod/Core/Instances.hs | 3 ++ test/Database.hs | 4 +- 10 files changed, 176 insertions(+), 67 deletions(-) diff --git a/haddock.sh b/haddock.sh index aaceeb329..7414e60e8 100755 --- a/haddock.sh +++ b/haddock.sh @@ -1,3 +1,14 @@ #!/usr/bin/env bash -exec -- stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal +move-back() { + mv -v .stack-work .stack-work-doc + [[ -d .stack-work-build ]] && mv -v .stack-work-build .stack-work +} + +if [[ -d .stack-work-doc ]]; then + [[ -d .stack-work ]] && mv -v .stack-work .stack-work-build + mv -v .stack-work-doc .stack-work + trap move-back EXIT +fi + +stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index da8e563fa..3172caf4e 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -203,6 +203,10 @@ CorrectorAssignTitle: Korrektor zuweisen Unauthorized: Sie haben hierfür keine explizite Berechtigung. UnauthorizedAnd l@Text r@Text: (#{l} UND #{r}) UnauthorizedOr l@Text r@Text: (#{l} ODER #{r}) +UnauthorizedNoToken: Ihrer Anfrage war kein Authorisierungs-Token beigefügt. +UnauthorizedTokenExpired: Ihr Authorisierungs-Token ist abgelaufen. +UnauthorizedTokenNotStarted: Ihr Authorisierungs-Token ist noch nicht gültig. +UnauthorizedTokenInvalidRoute: Ihr Authorisierungs-Token ist auf dieser Unterseite nicht gültig. UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator. UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen. UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist. @@ -698,6 +702,7 @@ AuthPredsActive: Aktive Authorisierungsprädikate AuthPredsActiveChanged: Authorisierungseinstellungen für aktuelle Sitzung gespeichert AuthTagFree: Seite ist universell zugänglich AuthTagAdmin: Nutzer ist Administrator +AuthTagToken: Nutzer präsentiert Authorisierungs-Token AuthTagNoEscalation: Nutzer-Rechte werden nicht auf fremde Institute ausgeweitet AuthTagDeprecated: Seite ist nicht überholt AuthTagDevelopment: Seite ist nicht in Entwicklung diff --git a/src/Foundation.hs b/src/Foundation.hs index 09755de7d..9634fda67 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -15,6 +15,7 @@ import Auth.LDAP import Auth.PWHash import Auth.Dummy import Jobs.Types +import Model.Token import qualified Network.Wai as W (pathInfo) @@ -43,6 +44,7 @@ import Data.Set (Set) import qualified Data.Set as Set import Data.Map (Map, (!?)) import qualified Data.Map as Map +import qualified Data.HashSet as HashSet import Data.List (nubBy) @@ -396,6 +398,17 @@ appLanguagesOpts = do -- Access Control +newtype InvalidAuthTag = InvalidAuthTag Text + deriving (Eq, Ord, Show, Read, Generic, Typeable) +instance Exception InvalidAuthTag + +data SessionAuthTags = SessionActiveAuthTags | SessionInactiveAuthTags + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) +instance Universe SessionAuthTags +instance Finite SessionAuthTags +nullaryPathPiece ''SessionAuthTags (camelToPathPiece' 1) + + data AccessPredicate = APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult) | APHandler (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Handler AuthResult) @@ -460,6 +473,26 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of adrights <- lift $ selectFirst [UserAdminUser ==. authId] [] guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) return Authorized +tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $ do + jwt <- maybeTMExceptT (unauthorizedI MsgUnauthorizedNoToken) $ asum + [ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece + , MaybeT $ lookupGlobalPostParam PostToken + , MaybeT $ lookupGlobalGetParam GetToken + ] + BearerToken{..} <- catch (decodeToken jwt) $ \case + BearerTokenExpired -> throwError =<< unauthorizedI MsgUnauthorizedTokenExpired + BearerTokenNotStarted -> throwError =<< unauthorizedI MsgUnauthorizedTokenNotStarted + other -> throwM other + unless (maybe True (HashSet.member route) tokenRoutes) $ + throwError =<< unauthorizedI MsgUnauthorizedTokenInvalidRoute + authorityVal <- evalAccessFor (Just tokenAuthority) route isWrite + unless (is _Authorized authorityVal) $ + throwError authorityVal + whenIsJust tokenAddAuth $ \addDNF -> do + additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) addDNF mAuthId route isWrite + unless (is _Authorized additionalVal) $ + throwError additionalVal + return Authorized tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of AdminHijackUserR cID -> exceptT return return $ do myUid <- maybeExceptT AuthenticationRequired $ return mAuthId @@ -714,27 +747,21 @@ tagAccessPredicate AuthRead = APHandler . const . const $ bool (return Authorize tagAccessPredicate AuthWrite = APHandler . const . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized) -newtype InvalidAuthTag = InvalidAuthTag Text - deriving (Eq, Ord, Show, Read, Generic, Typeable) -instance Exception InvalidAuthTag +defaultAuthDNF :: AuthDNF +defaultAuthDNF = PredDNF $ Set.fromList + [ impureNonNull . Set.singleton $ PLVariable AuthAdmin + , impureNonNull . Set.singleton $ PLVariable AuthToken + ] -type DNF a = Set (NonNull (Set a)) - -data SessionAuthTags = SessionActiveAuthTags | SessionInactiveAuthTags - deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) -instance Universe SessionAuthTags -instance Finite SessionAuthTags -nullaryPathPiece ''SessionAuthTags (camelToPathPiece' 1) - -routeAuthTags :: Route UniWorX -> Either InvalidAuthTag (NonNull (DNF AuthTag)) +routeAuthTags :: Route UniWorX -> Either InvalidAuthTag AuthDNF -- ^ DNF up to entailment: -- -- > (A_1 && A_2 && ...) OR' B OR' ... -- -- > A OR' B := ((A |- B) ==> A) && (A || B) -routeAuthTags = fmap (impureNonNull . Set.mapMonotonic impureNonNull) . ofoldM partition' (Set.singleton $ Set.singleton AuthAdmin) . routeAttrs +routeAuthTags = fmap (PredDNF . Set.mapMonotonic impureNonNull) . ofoldM partition' (Set.mapMonotonic toNullable $ dnfTerms defaultAuthDNF) . routeAttrs where - partition' :: Set (Set AuthTag) -> Text -> Either InvalidAuthTag (Set (Set AuthTag)) + partition' :: Set (Set AuthLiteral) -> Text -> Either InvalidAuthTag (Set (Set AuthLiteral)) partition' prev t | Just (Set.fromList . toNullable -> authTags) <- fromNullable =<< mapM fromPathPiece (Text.splitOn "AND" t) = if @@ -745,9 +772,9 @@ routeAuthTags = fmap (impureNonNull . Set.mapMonotonic impureNonNull) . ofoldM p | otherwise = Left $ InvalidAuthTag t -evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> NonNull (DNF AuthTag) -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult +evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> AuthDNF -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult -- ^ `tell`s disabled predicates, identified as pivots -evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toNullable -> authDNF) mAuthId route isWrite +evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . dnfTerms -> authDNF) mAuthId route isWrite = do mr <- getMsgRenderer let @@ -760,23 +787,31 @@ evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toN $logDebugS "evalAccessPred" $ tshow (authTag', mAuthId', route', isWrite') evalAccessPred (tagAccessPredicate authTag') mAuthId' route' isWrite' + evalAuthLiteral :: AuthLiteral -> WriterT (Set AuthTag) m AuthResult + evalAuthLiteral PLVariable{..} = evalAuthTag plVar + evalAuthLiteral PLNegated{..} = evalAuthTag plVar >>= \case + Unauthorized _ -> return Authorized + AuthenticationRequired -> return AuthenticationRequired + Authorized -> unauthorizedI plVar + orAR', andAR' :: forall m'. Monad m' => m' AuthResult -> m' AuthResult -> m' AuthResult orAR' = shortCircuitM (is _Authorized) (orAR mr) andAR' = shortCircuitM (is _Unauthorized) (andAR mr) - evalDNF :: [[AuthTag]] -> WriterT (Set AuthTag) m AuthResult - evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthTag aTag) (return $ trueAR mr) ats) (return $ falseAR mr) + evalDNF :: [[AuthLiteral]] -> WriterT (Set AuthTag) m AuthResult + evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthLiteral aTag) (return $ trueAR mr) ats) (return $ falseAR mr) - $logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive) authDNF + $logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive . plVar) authDNF - result <- evalDNF $ filter (all authTagIsActive) authDNF + result <- evalDNF $ filter (all $ authTagIsActive . plVar) authDNF - unless (is _Authorized result) . forM_ (filter (any authTagIsInactive) authDNF) $ \conj -> - whenM (allM conj (\aTag -> (return . not $ authTagIsActive aTag) `or2M` (not . is _Unauthorized <$> evalAuthTag aTag))) $ do - let pivots = filter authTagIsInactive conj - whenM (allM pivots $ fmap (is _Authorized) . evalAuthTag) $ do - $logDebugS "evalAuthTags" [st|Recording pivots: #{tshow pivots}|] - tell $ Set.fromList pivots + unless (is _Authorized result) . forM_ (filter (any $ authTagIsInactive . plVar) authDNF) $ \conj -> + whenM (allM conj (\aTag -> (return . not . authTagIsActive $ plVar aTag) `or2M` (not . is _Unauthorized <$> evalAuthLiteral aTag))) $ do + let pivots = filter (authTagIsInactive . plVar) conj + whenM (allM pivots $ fmap (is _Authorized) . evalAuthLiteral) $ do + let pivots' = plVar <$> pivots + $logDebugS "evalAuthTags" [st|Recording pivots: #{tshow pivots'}|] + tell $ Set.fromList pivots' return result diff --git a/src/Jose/Jwt/Instances.hs b/src/Jose/Jwt/Instances.hs index f7607168c..4bf4e3827 100644 --- a/src/Jose/Jwt/Instances.hs +++ b/src/Jose/Jwt/Instances.hs @@ -13,6 +13,7 @@ instance PathPiece Jwt where toPathPiece (Jwt bytes) = decodeUtf8 bytes fromPathPiece = Just . Jwt . encodeUtf8 +deriving instance Generic JwtError deriving instance Typeable JwtError instance Exception JwtError diff --git a/src/Model/Token.hs b/src/Model/Token.hs index a57b5244b..d9c3afe94 100644 --- a/src/Model/Token.hs +++ b/src/Model/Token.hs @@ -3,28 +3,26 @@ module Model.Token ( BearerToken(..) , bearerToken - , encodeToken, decodeToken - , tokenToJSON, tokenParseJSON + , encodeToken, BearerTokenException(..), decodeToken + , tokenToJSON, tokenParseJSON, tokenParseJSON' ) where import ClassyPrelude.Yesod import Model import Settings +import Utils (NTop(..)) import Utils.Lens hiding ((.=)) import Yesod.Auth (AuthId) --- import qualified Jose.Jwa as Jose -import Jose.Jwk (JwkSet) --- import qualified Jose.Jwk as Jose -import Jose.Jwt (Jwt, JwtError, IntDate(..)) +import qualified Jose.Jwa as Jose +import Jose.Jwk (JwkSet(..)) +import Jose.Jwt (Jwt(..), IntDate(..)) import qualified Jose.Jwt as Jose import Jose.Jwt.Instances () import Data.Aeson.Types.Instances () -import qualified Crypto.Random as Crypto (MonadRandom) - import Data.HashSet (HashSet) import qualified Data.HashMap.Strict as HashMap @@ -32,6 +30,9 @@ import qualified Data.HashMap.Strict as HashMap import Data.Aeson.Types (Parser, (.:?), (.:)) import qualified Data.Aeson as JSON import qualified Data.Aeson.Types as JSON +import qualified Data.Aeson.Parser as JSON +import qualified Data.Aeson.Parser.Internal as JSON (jsonEOF') +import qualified Data.Aeson.Internal as JSON (iparse, formatError) import CryptoID @@ -42,10 +43,10 @@ import Control.Monad.Random (MonadRandom(..)) data BearerToken site = BearerToken - { tokenIdentifier :: TokenId - , tokenAuthority :: AuthId site - , tokenRoutes :: Maybe (HashSet (Route site)) - , tokenAddAuth :: AuthCNF + { tokenIdentifier :: TokenId -- ^ Unique identifier for each token; maybe useful for tracing usage of tokens + , tokenAuthority :: AuthId site -- ^ Tokens only grant rights the `tokenAuthority` has (i.e. `AuthTag`s are evaluated with the user set to `tokenAuthority`) + , tokenRoutes :: Maybe (HashSet (Route site)) -- ^ Tokens can optionally be restricted to only be usable on a subset of routes + , tokenAddAuth :: Maybe AuthDNF -- ^ Tokens can specify an additional predicate logic formula of `AuthTag`s that needs to evaluate to `Authorized` in order for the token to be valid. , tokenIssuedAt :: UTCTime , tokenIssuedBy :: InstanceId , tokenExpiresAt @@ -73,10 +74,11 @@ tokenToJSON BearerToken{..} = do , jwtJti = Just $ toPathPiece tokenIdentifier } return . JSON.object $ - [ "authority" .= cID - , "routes" .= tokenRoutes - , "add-auth" .= tokenAddAuth - ] ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm + catMaybes [ Just $ "authority" .= cID + , ("routes" .=) <$> tokenRoutes + , ("add-auth" .=) <$> tokenAddAuth + ] + ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm tokenParseJSON :: forall site. ( HasCryptoUUID (AuthId site) (ReaderT CryptoIDKey Parser) @@ -90,7 +92,7 @@ tokenParseJSON v@(Object o) = do tokenAuthority <- decrypt tokenAuthority' tokenRoutes <- lift $ o .:? "routes" - tokenAddAuth <- lift $ o .: "add-auth" + tokenAddAuth <- lift $ o .:? "add-auth" Jose.JwtClaims{..} <- lift $ parseJSON v let unIntDate (IntDate posix) = posixSecondsToUTCTime posix @@ -104,6 +106,19 @@ tokenParseJSON v@(Object o) = do return BearerToken{..} tokenParseJSON v = lift $ JSON.typeMismatch "BearerToken" v +tokenParseJSON' :: forall m. + ( HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) + , ParseRoute (HandlerSite m) + , Hashable (Route (HandlerSite m)) + , MonadHandler m + , MonadCrypto m + , MonadCryptoKey m ~ CryptoIDKey + ) + => m (Value -> Parser (BearerToken (HandlerSite m))) +tokenParseJSON' = do + cidKey <- cryptoIDKey return + return $ flip runReaderT cidKey . tokenParseJSON + bearerToken :: forall m. ( MonadHandler m @@ -113,7 +128,7 @@ bearerToken :: forall m. ) => AuthId (HandlerSite m) -> Maybe (HashSet (Route (HandlerSite m))) - -> AuthCNF + -> Maybe AuthDNF -> Maybe (Maybe UTCTime) -- ^ @Nothing@ determines default expiry time automatically -> Maybe UTCTime -- ^ @Nothing@ means token starts to be valid immediately -> m (BearerToken (HandlerSite m)) @@ -135,9 +150,12 @@ bearerToken tokenAuthority tokenRoutes tokenAddAuth mTokenExpiresAt tokenStartsA return BearerToken{..} +jwtEncoding :: Jose.JwtEncoding +jwtEncoding = Jose.JwsEncoding Jose.HS256 + + encodeToken :: forall m. - ( Crypto.MonadRandom m - , MonadHandler m + ( MonadHandler m , HasJSONWebKeySet (HandlerSite m) JwkSet , HasInstanceID (HandlerSite m) InstanceId , HasCryptoUUID (AuthId (HandlerSite m)) m @@ -145,14 +163,43 @@ encodeToken :: forall m. ) => BearerToken (HandlerSite m) -> m Jwt encodeToken token = do - _payload <- tokenToJSON token - error "Not implemented" + payload <- Jose.Claims . toStrict . JSON.encode <$> tokenToJSON token + JwkSet jwks <- getsYesod $ view jsonWebKeySet + either throwM return =<< liftIO (Jose.encode jwks jwtEncoding payload) + + +data BearerTokenException + = BearerTokenJwtError Jose.JwtError + | BearerTokenUnsecured + | BearerTokenInvalidFormat String + | BearerTokenExpired | BearerTokenNotStarted + deriving (Eq, Show, Generic, Typeable) + +instance Exception BearerTokenException decodeToken :: forall m. - ( Crypto.MonadRandom m - , MonadHandler m + ( MonadHandler m , HasJSONWebKeySet (HandlerSite m) JwkSet - , HasCryptoUUID (AuthId (HandlerSite m)) m + , HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser) + , MonadCryptoKey m ~ CryptoIDKey + , MonadCrypto m + , MonadThrow m + , ParseRoute (HandlerSite m) + , Hashable (Route (HandlerSite m)) ) - => Jwt -> m (Either JwtError (BearerToken (HandlerSite m))) -decodeToken = error "Not implemented" + => Jwt -> m (BearerToken (HandlerSite m)) +decodeToken (Jwt bs) = do + JwkSet jwks <- getsYesod $ view jsonWebKeySet + content <- either (throwM . BearerTokenJwtError) return =<< liftIO (Jose.decode jwks Nothing bs) + content' <- case content of + Jose.Unsecured _ -> throwM BearerTokenUnsecured + Jose.Jws (_header, payload) -> return payload + Jose.Jwe (_header, payload) -> return payload + parser <- tokenParseJSON' + token@BearerToken{..} <- either (throwM . BearerTokenInvalidFormat . uncurry JSON.formatError) return $ JSON.eitherDecodeStrictWith JSON.jsonEOF' (JSON.iparse parser) content' + now <- liftIO getCurrentTime + unless (NTop tokenExpiresAt > NTop (Just now)) $ + throwM BearerTokenExpired + unless (tokenStartsAt <= Just now) $ + throwM BearerTokenNotStarted + return token diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 5c292d146..0c3fb1198 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -714,6 +714,7 @@ pseudonymFragments = folding data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prädikate sind sortier nach Relevanz für Benutzer = AuthAdmin + | AuthToken | AuthLecturer | AuthCorrector | AuthRegistered @@ -786,28 +787,27 @@ deriveJSON defaultOptions , unwrapUnaryRecords = True } ''PredLiteral +instance PathPiece a => PathPiece (PredLiteral a) where + toPathPiece PLVariable{..} = toPathPiece plVar + toPathPiece PLNegated{..} = "¬" <> toPathPiece plVar -newtype PredCNF a = PredCNF (Set (NonNull (Set (PredLiteral a)))) - deriving (Eq, Ord, Read, Show, Generic, Typeable) - deriving newtype (Semigroup, Monoid) + fromPathPiece t = PLVariable <$> fromPathPiece t + <|> PLNegated <$> (Text.stripPrefix "¬" t >>= fromPathPiece) -newtype PredDNF a = PredDNF (Set (NonNull (Set (PredLiteral a)))) + +newtype PredDNF a = PredDNF { dnfTerms :: Set (NonNull (Set (PredLiteral a))) } deriving (Eq, Ord, Read, Show, Generic, Typeable) deriving newtype (Semigroup, Monoid) $(return []) -instance (Ord a, ToJSON a) => ToJSON (PredCNF a) where - toJSON = $(mkToJSON predNFAesonOptions ''PredCNF) -instance (Ord a, FromJSON a) => FromJSON (PredCNF a) where - parseJSON = $(mkParseJSON predNFAesonOptions ''PredCNF) - instance (Ord a, ToJSON a) => ToJSON (PredDNF a) where toJSON = $(mkToJSON predNFAesonOptions ''PredDNF) instance (Ord a, FromJSON a) => FromJSON (PredDNF a) where parseJSON = $(mkParseJSON predNFAesonOptions ''PredDNF) -type AuthCNF = PredCNF AuthTag +type AuthLiteral = PredLiteral AuthTag + type AuthDNF = PredDNF AuthTag diff --git a/src/Utils.hs b/src/Utils.hs index 88adf17e4..a95cb7bfc 100644 --- a/src/Utils.hs +++ b/src/Utils.hs @@ -490,6 +490,12 @@ maybeExceptT err act = lift act >>= maybe (throwE err) return maybeMExceptT :: Monad m => m e -> m (Maybe b) -> ExceptT e m b maybeMExceptT err act = lift act >>= maybe (lift err >>= throwE) return +maybeTExceptT :: Monad m => e -> MaybeT m b -> ExceptT e m b +maybeTExceptT err act = maybeExceptT err $ runMaybeT act + +maybeTMExceptT :: Monad m => m e -> MaybeT m b -> ExceptT e m b +maybeTMExceptT err act = maybeMExceptT err $ runMaybeT act + whenExceptT :: Monad m => Bool -> e -> ExceptT e m () whenExceptT b err = when b $ throwE err diff --git a/src/Utils/Parameters.hs b/src/Utils/Parameters.hs index 81b0c210a..f7922adcb 100644 --- a/src/Utils/Parameters.hs +++ b/src/Utils/Parameters.hs @@ -20,7 +20,7 @@ import Data.Universe import Control.Monad.Trans.Maybe (MaybeT(..)) -data GlobalGetParam = GetReferer +data GlobalGetParam = GetReferer | GetToken deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe GlobalGetParam @@ -51,6 +51,7 @@ globalGetParamField ident Field{fieldParse} = runMaybeT $ do data GlobalPostParam = PostFormIdentifier | PostDeleteTarget | PostMassInputShape + | PostToken deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe GlobalPostParam diff --git a/src/Yesod/Core/Instances.hs b/src/Yesod/Core/Instances.hs index b8c6fed80..50b37679b 100644 --- a/src/Yesod/Core/Instances.hs +++ b/src/Yesod/Core/Instances.hs @@ -68,6 +68,9 @@ instance (RenderRoute site, ParseRoute site) => Binary (Route site) where put = Binary.put . toPathPiece get = Binary.get >>= maybe (fail "Could not parse route") return . fromPathPiece +instance RenderRoute site => Hashable (Route site) where + hashWithSalt s = hashWithSalt s . routeToPathPiece + instance Monad FormResult where (FormSuccess a) >>= f = f a diff --git a/test/Database.hs b/test/Database.hs index 2c1992fa1..d0404df66 100755 --- a/test/Database.hs +++ b/test/Database.hs @@ -24,7 +24,7 @@ import qualified Data.ByteString as BS import Data.Time -import Utils.Lens (review) +import Utils.Lens (review, view) import Control.Monad.Random.Class (MonadRandom(..)) @@ -82,7 +82,7 @@ insertFile fileTitle = do fillDb :: DB () fillDb = do - AppSettings{ appUserDefaults = UserDefaultConf{..}, .. } <- getsYesod appSettings + AppSettings{ appUserDefaults = UserDefaultConf{..}, .. } <- getsYesod $ view appSettings now <- liftIO getCurrentTime let insert' :: PersistRecordBackend r (YesodPersistBackend UniWorX) => r -> YesodDB UniWorX (Key r) From af6821c7c82966199c39b8a5f844331d809f4a80 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 5 Apr 2019 15:23:10 +0200 Subject: [PATCH 008/238] UserNotificationR --- messages/uniworx/de.msg | 8 ++++- routes | 2 ++ src/Foundation.hs | 28 ++++++++++++++++- src/Handler/Profile.hs | 31 +++++++++++++++++-- src/Import.hs | 1 + .../SendNotification/CorrectionsAssigned.hs | 4 ++- .../Handler/SendNotification/SheetActive.hs | 4 ++- .../Handler/SendNotification/SheetInactive.hs | 7 +++-- .../SendNotification/SubmissionRated.hs | 4 ++- .../SendNotification/UserRightsUpdate.hs | 3 +- src/Jobs/Handler/SendNotification/Utils.hs | 20 ++++++++++++ src/Model/Types.hs | 1 + templates/mail/editNotifications.hamlet | 5 ++- 13 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/Jobs/Handler/SendNotification/Utils.hs diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 3172caf4e..2e3b06abe 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -206,6 +206,7 @@ UnauthorizedOr l@Text r@Text: (#{l} ODER #{r}) UnauthorizedNoToken: Ihrer Anfrage war kein Authorisierungs-Token beigefügt. UnauthorizedTokenExpired: Ihr Authorisierungs-Token ist abgelaufen. UnauthorizedTokenNotStarted: Ihr Authorisierungs-Token ist noch nicht gültig. +UnauthorizedTokenInvalid: Ihr Authorisierungs-Token konnte nicht verarbeitet werden. UnauthorizedTokenInvalidRoute: Ihr Authorisierungs-Token ist auf dieser Unterseite nicht gültig. UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator. UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen. @@ -235,6 +236,7 @@ UnsupportedAuthPredicate authTagT@Text shownRoute@String: "#{authTagT}" wurde au UnauthorizedDisabledTag authTag@AuthTag: Authorisierungsprädikat "#{toPathPiece authTag}" ist für Ihre Sitzung nicht aktiv UnknownAuthPredicate tag@String: Authorisierungsprädikat "#{tag}" ist dem System nicht bekannt UnauthorizedRedirect: Die angeforderte Seite existiert nicht oder Sie haben keine Berechtigung, die angeforderte Seite zu sehen. +UnauthorizedSelf: Aktueller Nutzer ist nicht angegebener Benutzer. EMail: E-Mail EMailUnknown email@UserEmail: E-Mail #{email} gehört zu keinem bekannten Benutzer. @@ -276,6 +278,7 @@ ImpressumHeading: Impressum DataProtHeading: Datenschutzerklärung SystemMessageHeading: Uni2work Statusmeldung SystemMessageListHeading: Uni2work Statusmeldungen +NotificationSettingsHeading displayName@Text: Benachrichtigungs-Einstellungen für #{displayName} HomeOpenCourses: Kurse mit offener Registrierung HomeUpcomingSheets: Anstehende Übungsblätter @@ -292,7 +295,8 @@ Plugin: Plugin Ident: Identifikation LastLogin: Letzter Login Settings: Individuelle Benutzereinstellungen -SettingsUpdate: Einstellungen wurden gespeichert. +SettingsUpdate: Einstellungen erfolgreich gespeichert +NotificationSettingsUpdate: Benachrichtigungs-Einstellungen erfolgreich gespeichert Never: Nie MultiFileUploadInfo: (Mehrere Dateien mit Shift oder Strg auswählen) @@ -668,6 +672,7 @@ MenuCourseMembers: Kursteilnehmer MenuTermShow: Semester MenuSubmissionDelete: Abgabe löschen MenuUsers: Benutzer +MenuUserNotifications: Benachrichtigungs-Einstellungen MenuAdminTest: Admin-Demo MenuMessageList: Systemnachrichten MenuAdminErrMsg: Fehlermeldung entschlüsseln @@ -718,6 +723,7 @@ AuthTagOwner: Nutzer ist Besitzer AuthTagRated: Korrektur ist bewertet AuthTagUserSubmissions: Abgaben erfolgen durch Kursteilnehmer AuthTagCorrectorSubmissions: Abgaben erfolgen durch Korrektoren +AuthTagSelf: Nutzer greift nur auf eigene Daten zu AuthTagAuthentication: Nutzer ist angemeldet, falls erforderlich AuthTagRead: Zugriff ist nur lesend AuthTagWrite: Zugriff ist i.A. schreibend diff --git a/routes b/routes index f76fd47b7..24ce58838 100644 --- a/routes +++ b/routes @@ -16,6 +16,7 @@ -- !registered -- participant for this course (no effect outside of courses) -- !participant -- connected with a given course (not necessarily registered), i.e. has a submission, is a corrector, etc. (no effect outside of courses) -- !owner -- part of the group of owners of this submission +-- !self -- route refers to the currently logged in user themselves -- !capacity -- course this route is associated with has at least one unit of participant capacity -- !empty -- course this route is associated with has no participants whatsoever -- @@ -39,6 +40,7 @@ /users/#CryptoUUIDUser AdminUserR GET POST /users/#CryptoUUIDUser/delete AdminUserDeleteR POST /users/#CryptoUUIDUser/hijack AdminHijackUserR POST !adminANDno-escalation +/users/#CryptoUUIDUser/notifications UserNotificationR GET POST !self /admin AdminR GET /admin/features AdminFeaturesR GET POST /admin/test AdminTestR GET POST diff --git a/src/Foundation.hs b/src/Foundation.hs index 9634fda67..533804953 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -482,7 +482,9 @@ tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return r BearerToken{..} <- catch (decodeToken jwt) $ \case BearerTokenExpired -> throwError =<< unauthorizedI MsgUnauthorizedTokenExpired BearerTokenNotStarted -> throwError =<< unauthorizedI MsgUnauthorizedTokenNotStarted - other -> throwM other + other -> do + $logWarnS "AuthToken" $ tshow other + throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid unless (maybe True (HashSet.member route) tokenRoutes) $ throwError =<< unauthorizedI MsgUnauthorizedTokenInvalidRoute authorityVal <- evalAccessFor (Just tokenAuthority) route isWrite @@ -735,6 +737,20 @@ tagAccessPredicate AuthCorrectorSubmissions = APDB $ \_ route _ -> case route of guard $ sheetSubmissionMode == CorrectorSubmissions return Authorized r -> $unsupportedAuthPredicate AuthCorrectorSubmissions r +tagAccessPredicate AuthSelf = APHandler $ \mAuthId route _ -> exceptT return return $ do + referencedUser <- case route of + AdminUserR cID -> return cID + AdminUserDeleteR cID -> return cID + AdminHijackUserR cID -> return cID + UserNotificationR cID -> return cID + CourseR _ _ _ (CUserR cID) -> return cID + _other -> throwError =<< $unsupportedAuthPredicate AuthSelf route + referencedUser' <- decrypt referencedUser + case mAuthId of + Just uid + | uid == referencedUser' -> return Authorized + Nothing -> return AuthenticationRequired + _other -> unauthorizedI MsgUnauthorizedSelf tagAccessPredicate AuthAuthentication = APDB $ \mAuthId route _ -> case route of MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageAuth) $ do smId <- decrypt cID @@ -1465,6 +1481,16 @@ pageActions (AdminR) = , menuItemAccessCallback' = return True } ] +pageActions (AdminUserR cID) = [ + MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuUserNotifications + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ UserNotificationR cID + , menuItemModal = True + , menuItemAccessCallback' = return True + } + ] pageActions (InfoR) = [ MenuItem { menuItemType = PageActionPrime diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 5de418a34..326beb0d6 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -42,11 +42,10 @@ makeSettingForm template = identifyForm FIDsettings $ \html -> do & setTooltip MsgDownloadFilesTip ) (stgDownloadFiles <$> template) <* aformSection MsgFormNotifications - <*> (NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True) + <*> notificationForm (stgNotificationSettings <$> template) return (result, widget) -- no validation required here where themeList = [Option (display t) t (toPathPiece t) | t <- universeF] - nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt . stgNotificationSettings <$> template) -- -- Version with proper grouping: -- @@ -76,6 +75,12 @@ makeSettingForm template = identifyForm FIDsettings $ \html -> do -- <*> (NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True) -- nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt . stgNotificationSettings <$> template) +notificationForm :: Maybe NotificationSettings -> AForm Handler NotificationSettings +notificationForm template = NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True + where + nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt <$> template) + + getProfileR, postProfileR :: Handler Html getProfileR = postProfileR postProfileR = do @@ -532,3 +537,25 @@ postAuthPredsR = do siteLayoutMsg MsgAuthPredsActive $ do setTitleI MsgAuthPredsActive $(widgetFile "authpreds") + + +getUserNotificationR, postUserNotificationR :: CryptoUUIDUser -> Handler Html +getUserNotificationR = postUserNotificationR +postUserNotificationR cID = do + uid <- decrypt cID + User{userNotificationSettings, userDisplayName} <- runDB $ get404 uid + + ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings + isModal <- hasCustomHeader HeaderIsModal + let formWidget = wrapForm nsInnerWdgt def + { formAction = Just . SomeRoute $ UserNotificationR cID + , formEncoding = nsEnc + , formAttrs = [ ("data-ajax-submit", "") | isModal ] + } + + formResultModal nsRes (UserNotificationR cID) $ \ns -> do + lift . runDB $ update uid [ UserNotificationSettings =. ns ] + tell . pure =<< messageI Success MsgNotificationSettingsUpdate + + siteLayoutMsg (MsgNotificationSettingsHeading userDisplayName) $ + formWidget diff --git a/src/Import.hs b/src/Import.hs index 27dc6e5df..9743e86ac 100644 --- a/src/Import.hs +++ b/src/Import.hs @@ -6,3 +6,4 @@ import Foundation as Import import Import.NoFoundation as Import import Utils.SystemMessage as Import +import Model.Token as Import diff --git a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs index 51ec02f77..f7943cb6c 100644 --- a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs +++ b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs @@ -6,6 +6,7 @@ module Jobs.Handler.SendNotification.CorrectionsAssigned import Import +import Jobs.Handler.SendNotification.Utils import Handler.Utils.Mail import Text.Hamlet @@ -27,6 +28,7 @@ dispatchNotificationCorrectionsAssigned nUser nSheet jRecipient = do MsgRenderer mr <- getMailMsgRenderer let termDesc = mr . ShortTermIdentifier $ unTermKey courseTerm + editNotifications <- mkEditNotifications jRecipient + addAlternatives $ do - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/correctionsAssigned.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/SheetActive.hs b/src/Jobs/Handler/SendNotification/SheetActive.hs index 91a8fc716..6e3618de2 100644 --- a/src/Jobs/Handler/SendNotification/SheetActive.hs +++ b/src/Jobs/Handler/SendNotification/SheetActive.hs @@ -7,6 +7,7 @@ module Jobs.Handler.SendNotification.SheetActive import Import import Handler.Utils.Mail +import Jobs.Handler.SendNotification.Utils import Text.Hamlet import qualified Data.CaseInsensitive as CI @@ -26,6 +27,7 @@ dispatchNotificationSheetActive nSheet jRecipient = userMailT jRecipient $ do csh = courseShorthand shn = sheetName + editNotifications <- mkEditNotifications jRecipient + addAlternatives $ do - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/sheetActive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/SheetInactive.hs b/src/Jobs/Handler/SendNotification/SheetInactive.hs index 7112e5c39..bab937c89 100644 --- a/src/Jobs/Handler/SendNotification/SheetInactive.hs +++ b/src/Jobs/Handler/SendNotification/SheetInactive.hs @@ -8,6 +8,7 @@ module Jobs.Handler.SendNotification.SheetInactive import Import import Handler.Utils.Mail +import Jobs.Handler.SendNotification.Utils import Text.Hamlet import qualified Data.CaseInsensitive as CI @@ -29,8 +30,9 @@ dispatchNotificationSheetSoonInactive nSheet jRecipient = userMailT jRecipient $ csh = courseShorthand shn = sheetName + editNotifications <- mkEditNotifications jRecipient + addAlternatives $ do - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/sheetSoonInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) dispatchNotificationSheetInactive :: SheetId -> UserId -> Handler () @@ -54,7 +56,8 @@ dispatchNotificationSheetInactive nSheet jRecipient = userMailT jRecipient $ do csh = courseShorthand shn = sheetName + editNotifications <- mkEditNotifications jRecipient + addAlternatives $ do - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/sheetInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/SubmissionRated.hs b/src/Jobs/Handler/SendNotification/SubmissionRated.hs index 78083d83f..16423a924 100644 --- a/src/Jobs/Handler/SendNotification/SubmissionRated.hs +++ b/src/Jobs/Handler/SendNotification/SubmissionRated.hs @@ -9,6 +9,7 @@ import Import import Utils.Lens import Handler.Utils.DateTime import Handler.Utils.Mail +import Jobs.Handler.SendNotification.Utils import Text.Hamlet import qualified Data.Aeson as Aeson @@ -34,6 +35,8 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien csh = courseShorthand shn = sheetName + editNotifications <- mkEditNotifications jRecipient + -- TODO: provide convienience template-haskell for `addAlternatives` addAlternatives $ do provideAlternative $ Aeson.object @@ -51,5 +54,4 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien , "course-school" Aeson..= courseSchool ] -- provideAlternative $ \(MsgRenderer mr) -> ($(textFile "templates/mail/submissionRated.txt") :: TextUrl (Route UniWorX)) -- textFile does not support control statements - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/submissionRated.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs index aaf50ac72..90e645de7 100644 --- a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs +++ b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs @@ -8,6 +8,7 @@ import Import import Handler.Utils.Database import Handler.Utils.Mail +import Jobs.Handler.SendNotification.Utils import Text.Hamlet -- import qualified Data.CaseInsensitive as CI @@ -21,7 +22,7 @@ dispatchNotificationUserRightsUpdate nUser _originalRights jRecipient = userMai return (user,adminSchools,lecturerSchools) setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName -- MsgRenderer mr <- getMailMsgRenderer + editNotifications <- mkEditNotifications jRecipient addAlternatives $ do - let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet") providePreferredAlternative ($(ihamletFile "templates/mail/userRightsUpdate.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/Utils.hs b/src/Jobs/Handler/SendNotification/Utils.hs new file mode 100644 index 000000000..d7ca82a76 --- /dev/null +++ b/src/Jobs/Handler/SendNotification/Utils.hs @@ -0,0 +1,20 @@ +module Jobs.Handler.SendNotification.Utils + ( mkEditNotifications + ) where + +import Import + +import Text.Hamlet + +import qualified Data.HashSet as HashSet + + +mkEditNotifications :: (MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m (HtmlUrlI18n UniWorXMessage (Route UniWorX)) +mkEditNotifications uid = liftHandlerT $ do + cID <- encrypt uid + jwt <- encodeToken =<< bearerToken uid (Just . HashSet.singleton $ UserNotificationR cID) Nothing Nothing Nothing + let + editNotificationsUrl :: SomeRoute UniWorX + editNotificationsUrl = SomeRoute (UserNotificationR cID, [(toPathPiece GetToken, toPathPiece jwt)]) + editNotificationsUrl' <- toTextUrl editNotificationsUrl + return ($(ihamletFile "templates/mail/editNotifications.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 0c3fb1198..21672d9d2 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -727,6 +727,7 @@ data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prä | AuthCorrectorSubmissions | AuthCapacity | AuthEmpty + | AuthSelf | AuthAuthentication | AuthNoEscalation | AuthRead diff --git a/templates/mail/editNotifications.hamlet b/templates/mail/editNotifications.hamlet index 7ca5d9f8b..6e701e511 100644 --- a/templates/mail/editNotifications.hamlet +++ b/templates/mail/editNotifications.hamlet @@ -1,4 +1,3 @@

- - _{MsgProfileHeading} - \ _{MsgMailEditNotifications} \ No newline at end of file + + _{MsgMailEditNotifications} From 6e29d8ed89513200ef4b6ea9de9c8d80bbb7de6d Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 5 Apr 2019 16:37:39 +0200 Subject: [PATCH 009/238] Token revocation & Bugfixes --- messages/uniworx/de.msg | 3 + models/users | 1 + src/Foundation.hs | 23 ++-- src/Handler/Profile.hs | 115 +++++++++++++------ src/Model/Token.hs | 17 ++- templates/profile.hamlet | 2 - templates/profile/profile.hamlet | 13 +++ templates/profile/tokenExplanation/de.hamlet | 13 +++ 8 files changed, 141 insertions(+), 46 deletions(-) delete mode 100644 templates/profile.hamlet create mode 100644 templates/profile/profile.hamlet create mode 100644 templates/profile/tokenExplanation/de.hamlet diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 2e3b06abe..1c07086f8 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -10,6 +10,7 @@ BtnSave: Speichern BtnCandidatesInfer: Studienfachzuordnung automatisch lernen BtnCandidatesDeleteConflicts: Konflikte löschen BtnCandidatesDeleteAll: Alle Beobachtungen löschen +BtnResetTokens: Authorisierungs-Tokens invalidieren Aborted: Abgebrochen Remarks: Hinweise @@ -279,6 +280,8 @@ DataProtHeading: Datenschutzerklärung SystemMessageHeading: Uni2work Statusmeldung SystemMessageListHeading: Uni2work Statusmeldungen NotificationSettingsHeading displayName@Text: Benachrichtigungs-Einstellungen für #{displayName} +TokensLastReset: Tokens zuletzt invalidiert +TokensResetSuccess: Authorisierungs-Tokens invalidiert HomeOpenCourses: Kurse mit offener Registrierung HomeUpcomingSheets: Anstehende Übungsblätter diff --git a/models/users b/models/users index 80e5ff43c..cd08164d1 100644 --- a/models/users +++ b/models/users @@ -11,6 +11,7 @@ User json -- Each Uni2work user has a corresponding row in this table; create ident (CI Text) -- Case-insensitive user-identifier authentication AuthenticationMode -- 'AuthLDAP' or ('AuthPWHash'+password-hash) lastAuthentication UTCTime Maybe -- last login date + tokensIssuedAfter UTCTime Maybe -- do not accept bearer tokens issued before this time (accept all tokens if null) matrikelnummer Text Maybe -- optional immatriculation-string; usually a number, but not always (e.g. lecturers, pupils, guests,...) email (CI Text) -- Case-insensitive eMail address displayName Text -- we only show LDAP-DisplayName, and highlight LDAP-Surname within (appended if not contained) diff --git a/src/Foundation.hs b/src/Foundation.hs index 533804953..4e2ea8695 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -474,26 +474,26 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) return Authorized tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $ do - jwt <- maybeTMExceptT (unauthorizedI MsgUnauthorizedNoToken) $ asum - [ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece - , MaybeT $ lookupGlobalPostParam PostToken - , MaybeT $ lookupGlobalGetParam GetToken - ] + jwt <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askJwt BearerToken{..} <- catch (decodeToken jwt) $ \case BearerTokenExpired -> throwError =<< unauthorizedI MsgUnauthorizedTokenExpired BearerTokenNotStarted -> throwError =<< unauthorizedI MsgUnauthorizedTokenNotStarted other -> do $logWarnS "AuthToken" $ tshow other throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid - unless (maybe True (HashSet.member route) tokenRoutes) $ - throwError =<< unauthorizedI MsgUnauthorizedTokenInvalidRoute + + guardMExceptT (maybe True (HashSet.member route) tokenRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute) + + User{userTokensIssuedAfter} <- lift $ get404 tokenAuthority + guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired) + authorityVal <- evalAccessFor (Just tokenAuthority) route isWrite - unless (is _Authorized authorityVal) $ - throwError authorityVal + guardExceptT (is _Authorized authorityVal) authorityVal + whenIsJust tokenAddAuth $ \addDNF -> do additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) addDNF mAuthId route isWrite - unless (is _Authorized additionalVal) $ - throwError additionalVal + guardExceptT (is _Authorized additionalVal) additionalVal + return Authorized tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of AdminHijackUserR cID -> exceptT return return $ do @@ -2206,6 +2206,7 @@ instance YesodAuth UniWorX where , userDownloadFiles = userDefaultDownloadFiles , userNotificationSettings = def , userMailLanguages = def + , userTokensIssuedAfter = Nothing , .. } userUpdate = [ UserMatrikelnummer =. userMatrikelnummer diff --git a/src/Handler/Profile.hs b/src/Handler/Profile.hs index 326beb0d6..f01f34281 100644 --- a/src/Handler/Profile.hs +++ b/src/Handler/Profile.hs @@ -79,7 +79,26 @@ notificationForm :: Maybe NotificationSettings -> AForm Handler NotificationSett notificationForm template = NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True where nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt <$> template) - + + +data ButtonResetTokens = BtnResetTokens + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe ButtonResetTokens +instance Finite ButtonResetTokens + +nullaryPathPiece ''ButtonResetTokens $ camelToPathPiece' 1 + +embedRenderMessage ''UniWorX ''ButtonResetTokens id +instance Button UniWorX ButtonResetTokens where + btnClasses BtnResetTokens = [BCIsButton, BCDanger] + +data ProfileAnchor = ProfileSettings | ProfileResetTokens + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) +instance Universe ProfileAnchor +instance Finite ProfileAnchor + +nullaryPathPiece ''ProfileAnchor $ camelToPathPiece' 1 + getProfileR, postProfileR :: Handler Html getProfileR = postProfileR @@ -94,37 +113,60 @@ postProfileR = do , stgDownloadFiles = userDownloadFiles , stgNotificationSettings = userNotificationSettings } - ((res,formWidget), formEnctype) <- runFormPost $ makeSettingForm settingsTemplate - case res of - (FormSuccess SettingsForm{..}) -> do - runDB $ do - update uid [ UserMaxFavourites =. stgMaxFavourties - , UserTheme =. stgTheme - , UserDateTimeFormat =. stgDateTime - , UserDateFormat =. stgDate - , UserTimeFormat =. stgTime - , UserDownloadFiles =. stgDownloadFiles - , UserNotificationSettings =. stgNotificationSettings - ] - when (stgMaxFavourties < userMaxFavourites) $ do - -- prune Favourites to user-defined size - oldFavs <- selectKeysList [ CourseFavouriteUser ==. uid] - [ Desc CourseFavouriteTime - , OffsetBy stgMaxFavourties - ] - mapM_ delete oldFavs - addMessageI Info MsgSettingsUpdate - redirect ProfileR -- TODO: them change does not happen without redirect + ((res,formWidget), formEnctype) <- runFormPost . identifyForm ProfileSettings $ makeSettingForm settingsTemplate - (FormFailure msgs) -> forM_ msgs $ addMessage Warning . toHtml - _ -> return () + formResult res $ \SettingsForm{..} -> do + runDB $ do + update uid [ UserMaxFavourites =. stgMaxFavourties + , UserTheme =. stgTheme + , UserDateTimeFormat =. stgDateTime + , UserDateFormat =. stgDate + , UserTimeFormat =. stgTime + , UserDownloadFiles =. stgDownloadFiles + , UserNotificationSettings =. stgNotificationSettings + ] + when (stgMaxFavourties < userMaxFavourites) $ do + -- prune Favourites to user-defined size + oldFavs <- selectKeysList [ CourseFavouriteUser ==. uid] + [ Desc CourseFavouriteTime + , OffsetBy stgMaxFavourties + ] + mapM_ delete oldFavs + addMessageI Info MsgSettingsUpdate + redirect $ ProfileR :#: ProfileSettings + + ((tokenRes, tokenFormWidget), tokenEnctype) <- runFormPost . identifyForm ProfileResetTokens $ buttonForm + + formResult tokenRes $ \BtnResetTokens -> do + now <- liftIO getCurrentTime + runDB $ update uid [ UserTokensIssuedAfter =. Just now ] + addMessageI Info MsgTokensResetSuccess + redirect $ ProfileR :#: ProfileResetTokens + + tResetTime <- traverse (formatTime SelFormatDateTime) userTokensIssuedAfter siteLayout [whamlet|_{MsgProfileFor} ^{nameWidget userDisplayName userSurname}|] $ do setTitle . toHtml $ "Profil " <> userIdent - wrapForm formWidget def - { formAction = Just $ SomeRoute ProfileR - , formEncoding = formEnctype - } + let settingsForm = + wrapForm formWidget FormSettings + { formMethod = POST + , formAction = Just . SomeRoute $ ProfileR :#: ProfileSettings + , formEncoding = formEnctype + , formAttrs = [] + , formSubmit = FormSubmit + , formAnchor = Just ProfileSettings + } + tokenForm = + wrapForm tokenFormWidget FormSettings + { formMethod = POST + , formAction = Just . SomeRoute $ ProfileR :#: ProfileResetTokens + , formEncoding = tokenEnctype + , formAttrs = [] + , formSubmit = FormNoSubmit + , formAnchor = Just ProfileResetTokens + } + tokenExplanation = $(i18nWidgetFile "profile/tokenExplanation") + $(widgetFile "profile/profile") getProfileDataR :: Handler Html @@ -544,18 +586,27 @@ getUserNotificationR = postUserNotificationR postUserNotificationR cID = do uid <- decrypt cID User{userNotificationSettings, userDisplayName} <- runDB $ get404 uid - + ((nsRes, nsInnerWdgt), nsEnc) <- runFormPost . renderAForm FormStandard . notificationForm $ Just userNotificationSettings + mJwt <- askJwt isModal <- hasCustomHeader HeaderIsModal - let formWidget = wrapForm nsInnerWdgt def + let formWidget = wrapForm nsInnerWdgt' def { formAction = Just . SomeRoute $ UserNotificationR cID , formEncoding = nsEnc , formAttrs = [ ("data-ajax-submit", "") | isModal ] } + nsInnerWdgt' + = [whamlet| + $newline never + $maybe jwt <- mJwt + + ^{nsInnerWdgt} + |] - formResultModal nsRes (UserNotificationR cID) $ \ns -> do + formResultModal nsRes (UserNotificationR cID, [ (toPathPiece GetToken, toPathPiece jwt) | Just jwt <- pure mJwt ]) $ \ns -> do lift . runDB $ update uid [ UserNotificationSettings =. ns ] tell . pure =<< messageI Success MsgNotificationSettingsUpdate - siteLayoutMsg (MsgNotificationSettingsHeading userDisplayName) $ + siteLayoutMsg (MsgNotificationSettingsHeading userDisplayName) $ do + setTitleI $ MsgNotificationSettingsHeading userDisplayName formWidget diff --git a/src/Model/Token.hs b/src/Model/Token.hs index d9c3afe94..a4d108714 100644 --- a/src/Model/Token.hs +++ b/src/Model/Token.hs @@ -5,13 +5,15 @@ module Model.Token , bearerToken , encodeToken, BearerTokenException(..), decodeToken , tokenToJSON, tokenParseJSON, tokenParseJSON' + , askJwt ) where import ClassyPrelude.Yesod import Model import Settings -import Utils (NTop(..)) +import Utils (NTop(..), hoistMaybe) import Utils.Lens hiding ((.=)) +import Utils.Parameters import Yesod.Auth (AuthId) @@ -40,6 +42,7 @@ import Data.Time.Clock import Data.Time.Clock.POSIX import Control.Monad.Random (MonadRandom(..)) +import Control.Monad.Trans.Maybe (MaybeT(..)) data BearerToken site = BearerToken @@ -203,3 +206,15 @@ decodeToken (Jwt bs) = do unless (tokenStartsAt <= Just now) $ throwM BearerTokenNotStarted return token + + +askJwt :: forall m. + ( MonadHandler m + ) + => m (Maybe Jwt) +-- | Retrieve current `Jwt` from HTTP-Header, POST-Parameter, or GET-Parameter +askJwt = runMaybeT $ asum + [ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece + , MaybeT $ lookupGlobalPostParam PostToken + , MaybeT $ lookupGlobalGetParam GetToken + ] diff --git a/templates/profile.hamlet b/templates/profile.hamlet deleted file mode 100644 index fc6a9bef7..000000000 --- a/templates/profile.hamlet +++ /dev/null @@ -1,2 +0,0 @@ -

- ^{settingsForm} diff --git a/templates/profile/profile.hamlet b/templates/profile/profile.hamlet new file mode 100644 index 000000000..e2b1e4365 --- /dev/null +++ b/templates/profile/profile.hamlet @@ -0,0 +1,13 @@ +$newline never +
+ ^{settingsForm} +
+ ^{tokenExplanation} +

+ _{MsgTokensLastReset}: + $maybe tResetTime' <- tResetTime + \ #{tResetTime'} + $nothing + \ _{MsgNever} +
+ ^{tokenForm} diff --git a/templates/profile/tokenExplanation/de.hamlet b/templates/profile/tokenExplanation/de.hamlet new file mode 100644 index 000000000..2237bddee --- /dev/null +++ b/templates/profile/tokenExplanation/de.hamlet @@ -0,0 +1,13 @@ +

+ Das System stellt gelegentlich Benutzer-bezogene Authorisierungs-Tokens aus. + Diese Tokens erlauben es jedem, der in Besitz dieses Tokens ist, bestimmte Ihrer Benutzer-Rechte anzunehmen. + +

+ Dies ist insbesondere notwendig um verschickten Emails einen Link beifügen zu können, der das Deabonnieren von Benachrichtigungen erlaubt. + +

+ Mit dem untigen Knopf können Sie alle Authorisierungs-Tokens, die bisher für Sie ausgestellt wurden, als ungültig markieren. + Dies ist zum Beispiel dann notwendig, wenn Sie Grund haben zu vermuten, dass Dritte Zugriff auf eines Ihrer Tokens gehabt haben könnten. + +

+ Für die sichere Verwahrung Ihnen ausgehändigter Tokens sind immer Sie selbst verantwortlich. From e94792195595c07a08124e778cba6785c80f92e5 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 5 Apr 2019 17:15:26 +0200 Subject: [PATCH 010/238] Fix Test --- .../Handler/SendNotification/CorrectionsAssigned.hs | 2 +- src/Jobs/Handler/SendNotification/SheetActive.hs | 2 +- src/Jobs/Handler/SendNotification/SheetInactive.hs | 4 ++-- src/Jobs/Handler/SendNotification/UserRightsUpdate.hs | 2 +- src/Model/Token.hs | 4 ++-- test.sh | 11 +++++++++++ test/Database.hs | 5 +++++ test/ModelSpec.hs | 1 + test/TestImport.hs | 3 ++- 9 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs index f7943cb6c..2644a36ef 100644 --- a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs +++ b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs @@ -30,5 +30,5 @@ dispatchNotificationCorrectionsAssigned nUser nSheet jRecipient = do editNotifications <- mkEditNotifications jRecipient - addAlternatives $ do + addAlternatives $ providePreferredAlternative ($(ihamletFile "templates/mail/correctionsAssigned.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/SheetActive.hs b/src/Jobs/Handler/SendNotification/SheetActive.hs index 6e3618de2..1530d3e44 100644 --- a/src/Jobs/Handler/SendNotification/SheetActive.hs +++ b/src/Jobs/Handler/SendNotification/SheetActive.hs @@ -29,5 +29,5 @@ dispatchNotificationSheetActive nSheet jRecipient = userMailT jRecipient $ do editNotifications <- mkEditNotifications jRecipient - addAlternatives $ do + addAlternatives $ providePreferredAlternative ($(ihamletFile "templates/mail/sheetActive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/SheetInactive.hs b/src/Jobs/Handler/SendNotification/SheetInactive.hs index bab937c89..21cd7aced 100644 --- a/src/Jobs/Handler/SendNotification/SheetInactive.hs +++ b/src/Jobs/Handler/SendNotification/SheetInactive.hs @@ -32,7 +32,7 @@ dispatchNotificationSheetSoonInactive nSheet jRecipient = userMailT jRecipient $ editNotifications <- mkEditNotifications jRecipient - addAlternatives $ do + addAlternatives $ providePreferredAlternative ($(ihamletFile "templates/mail/sheetSoonInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) dispatchNotificationSheetInactive :: SheetId -> UserId -> Handler () @@ -58,6 +58,6 @@ dispatchNotificationSheetInactive nSheet jRecipient = userMailT jRecipient $ do editNotifications <- mkEditNotifications jRecipient - addAlternatives $ do + addAlternatives $ providePreferredAlternative ($(ihamletFile "templates/mail/sheetInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs index 90e645de7..a70d167e9 100644 --- a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs +++ b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs @@ -23,6 +23,6 @@ dispatchNotificationUserRightsUpdate nUser _originalRights jRecipient = userMai setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName -- MsgRenderer mr <- getMailMsgRenderer editNotifications <- mkEditNotifications jRecipient - addAlternatives $ do + addAlternatives $ providePreferredAlternative ($(ihamletFile "templates/mail/userRightsUpdate.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) diff --git a/src/Model/Token.hs b/src/Model/Token.hs index a4d108714..cfaaacb55 100644 --- a/src/Model/Token.hs +++ b/src/Model/Token.hs @@ -136,8 +136,8 @@ bearerToken :: forall m. -> Maybe UTCTime -- ^ @Nothing@ means token starts to be valid immediately -> m (BearerToken (HandlerSite m)) bearerToken tokenAuthority tokenRoutes tokenAddAuth mTokenExpiresAt tokenStartsAt = do - tokenIdentifier <- liftIO $ getRandom - tokenIssuedAt <- liftIO $ getCurrentTime + tokenIdentifier <- liftIO getRandom + tokenIssuedAt <- liftIO getCurrentTime tokenIssuedBy <- getsYesod $ view instanceID defaultExpiration <- getsYesod $ view _appJWTExpiration diff --git a/test.sh b/test.sh index 09d4b3a53..f4a4da1cf 100755 --- a/test.sh +++ b/test.sh @@ -1,3 +1,14 @@ #!/usr/bin/env bash +move-back() { + mv -v .stack-work .stack-work-test + [[ -d .stack-work-build ]] && mv -v .stack-work-build .stack-work +} + +if [[ -d .stack-work-test ]]; then + [[ -d .stack-work ]] && mv -v .stack-work .stack-work-build + mv -v .stack-work-test .stack-work + trap move-back EXIT +fi + exec -- stack build --test --fast --flag uniworx:dev --flag uniworx:library-only ${@} diff --git a/test/Database.hs b/test/Database.hs index d0404df66..3943263db 100755 --- a/test/Database.hs +++ b/test/Database.hs @@ -94,6 +94,7 @@ fillDb = do { userIdent = "G.Kleen@campus.lmu.de" , userAuthentication = AuthLDAP , userLastAuthentication = Just now + , userTokensIssuedAfter = Just now , userMatrikelnummer = Nothing , userEmail = "G.Kleen@campus.lmu.de" , userDisplayName = "Gregor Kleen" @@ -111,6 +112,7 @@ fillDb = do { userIdent = "felix.hamann@campus.lmu.de" , userAuthentication = AuthLDAP , userLastAuthentication = Nothing + , userTokensIssuedAfter = Nothing , userMatrikelnummer = Nothing , userEmail = "felix.hamann@campus.lmu.de" , userDisplayName = "Felix Hamann" @@ -128,6 +130,7 @@ fillDb = do { userIdent = "jost@tcs.ifi.lmu.de" , userAuthentication = AuthLDAP , userLastAuthentication = Nothing + , userTokensIssuedAfter = Nothing , userMatrikelnummer = Nothing , userEmail = "jost@tcs.ifi.lmu.de" , userDisplayName = "Steffen Jost" @@ -145,6 +148,7 @@ fillDb = do { userIdent = "max@campus.lmu.de" , userAuthentication = AuthLDAP , userLastAuthentication = Just now + , userTokensIssuedAfter = Nothing , userMatrikelnummer = Just "1299" , userEmail = "max@campus.lmu.de" , userDisplayName = "Max Musterstudent" @@ -162,6 +166,7 @@ fillDb = do { userIdent = "tester@campus.lmu.de" , userAuthentication = AuthLDAP , userLastAuthentication = Nothing + , userTokensIssuedAfter = Nothing , userMatrikelnummer = Just "999" , userEmail = "tester@campus.lmu.de" , userDisplayName = "Tina Tester" diff --git a/test/ModelSpec.hs b/test/ModelSpec.hs index 258211f94..3850363a6 100644 --- a/test/ModelSpec.hs +++ b/test/ModelSpec.hs @@ -41,6 +41,7 @@ instance Arbitrary User where ] userAuthentication <- arbitrary userLastAuthentication <- arbitrary + userTokensIssuedAfter <- arbitrary userMatrikelnummer <- fmap pack . assertM' (not . null) <$> listOf (elements ['0'..'9']) userEmail <- CI.mk . decodeUtf8 . Email.toByteString <$> arbitrary diff --git a/test/TestImport.hs b/test/TestImport.hs index f576ccf30..4ba8082bd 100644 --- a/test/TestImport.hs +++ b/test/TestImport.hs @@ -103,11 +103,12 @@ authenticateAs (Entity _ User{..}) = do -- checking is switched off in wipeDB for those database backends which need it. createUser :: (User -> User) -> YesodExample UniWorX (Entity User) createUser adjUser = do - UserDefaultConf{..} <- appUserDefaults . appSettings <$> getTestYesod + UserDefaultConf{..} <- appUserDefaults . view appSettings <$> getTestYesod let userMatrikelnummer = Nothing userAuthentication = AuthLDAP userLastAuthentication = Nothing + userTokensIssuedAfter = Nothing userIdent = "dummy@example.invalid" userEmail = "dummy@example.invalid" userDisplayName = "Dummy Example" From 5e71e8c9e6fa8deb00efcc4781f9df7c1687ee7d Mon Sep 17 00:00:00 2001 From: Felix Hamann Date: Thu, 4 Apr 2019 22:57:53 +0200 Subject: [PATCH 011/238] rework show-hide js utility --- static/css/utils/asidenav.scss | 19 +-- static/css/utils/showHide.scss | 24 +-- static/js/utils/modal.js | 16 +- static/js/utils/registry.js | 9 +- static/js/utils/showHide.js | 145 ++++++++++--------- templates/adminTest.hamlet | 2 +- templates/table/layout-filter-default.hamlet | 2 +- templates/table/layout-filter-default.lucius | 4 + templates/widgets/asidenav/asidenav.hamlet | 6 +- 9 files changed, 125 insertions(+), 102 deletions(-) diff --git a/static/css/utils/asidenav.scss b/static/css/utils/asidenav.scss index 1ac580d58..101bf5506 100644 --- a/static/css/utils/asidenav.scss +++ b/static/css/utils/asidenav.scss @@ -55,10 +55,6 @@ .asidenav__box-title { font-size: 18px; padding-left: 10px; - - &.js-show-hide__toggle::before { - z-index: 1; - } } } } @@ -94,18 +90,9 @@ margin-top: 30px; background-color: transparent; transition: all .2s ease; - padding: 30px 13px 10px; + padding: 10px 13px; margin: 0; border-bottom: 1px solid var(--color-grey); - - &.js-show-hide__toggle { - - &::before { - left: auto; - right: 20px; - color: var(--color-font); - } - } } /* LOGO */ @@ -361,9 +348,5 @@ background-color: var(--color-lightwhite); } } - - .js-show-hide__toggle::before { - content: none; - } } } diff --git a/static/css/utils/showHide.scss b/static/css/utils/showHide.scss index ab82286b8..1f85fbf36 100644 --- a/static/css/utils/showHide.scss +++ b/static/css/utils/showHide.scss @@ -1,10 +1,9 @@ $show-hide-toggle-size: 6px; -.js-show-hide__toggle { +.show-hide__toggle { position: relative; cursor: pointer; - padding: 3px 7px; &:hover { background-color: var(--color-grey-lighter); @@ -12,32 +11,33 @@ $show-hide-toggle-size: 6px; } } -.js-show-hide__toggle::before { +.show-hide__toggle::before { content: ''; position: absolute; width: $show-hide-toggle-size; height: $show-hide-toggle-size; left: -15px; - top: 12px - $show-hide-toggle-size / 2; + top: 50%; color: var(--color-primary); border-right: 2px solid currentColor; border-top: 2px solid currentColor; transition: transform .2s ease; - transform-origin: ($show-hide-toggle-size / 2); - transform: translateY($show-hide-toggle-size) rotate(-45deg); + transform: translateY(-50%) rotate(-45deg); } -.js-show-hide__target { - transition: all .2s ease; +.show-hide__toggle--right::before { + left: auto; + right: 20px; + color: var(--color-font); } -.js-show-hide--collapsed { +.show-hide--collapsed { - .js-show-hide__toggle::before { - transform: translateY($show-hide-toggle-size / 3) rotate(135deg); + .show-hide__toggle::before { + transform: translateY(-50%) rotate(135deg); } - .js-show-hide__target { + :not(.show-hide__toggle) { display: block; height: 0; margin: 0; diff --git a/static/js/utils/modal.js b/static/js/utils/modal.js index a5971edf7..50307f3db 100644 --- a/static/js/utils/modal.js +++ b/static/js/utils/modal.js @@ -2,6 +2,20 @@ 'use strict'; window.utils = window.utils || {}; + // ######################## + // TODO: make use of selector + // or think of a different way to dynamically initialize widgets + // with selectors with specific ids like #modal-hident69 + // + // Idee: + // Alles wegschmeißen zu dynamischen IDs. Util init rein über Selector '[uw-...]' + // bedarf Änderung in Templates. + // Ausserdem müssen sich Utils bei Event 'util-setup-ready' registrieren als Util + // utils.setup wird dann überflüssig, bzw. wird zu einer Registry / Controller + // der die utils bei DomCOntentLoaded intialisiert. + // + // ######################## + var SELECTOR = '[uw-modal]'; var JS_INITIALIZED_CLASS = 'js-modal-initialized'; var MODAL_OPEN_CLASS = 'modal--open'; @@ -17,7 +31,7 @@ var OVERLAY_OPEN_CLASS = 'modal__overlay--open'; var CLOSER_CLASS = 'modal__closer'; - window.utils.modal = function(modalElement, options) { + window.utils.modal = function(scope, options) { if (!modalElement || modalElement.classList.contains(JS_INITIALIZED_CLASS)) { return; diff --git a/static/js/utils/registry.js b/static/js/utils/registry.js index 775b58fe4..9acff588d 100644 --- a/static/js/utils/registry.js +++ b/static/js/utils/registry.js @@ -55,7 +55,14 @@ const elements = _findUtilElements(util, scope); elements.forEach(function(element) { - var utilInstance = util.setup(element); + var utilInstance = null; + + try { + utilInstance = util.setup(element); + } catch(err) { + console.warn('Error while trying to initialize a utility!', { util , element, err }); + } + if (utilInstance) { activeUtilInstances.push(utilInstance); } diff --git a/static/js/utils/showHide.js b/static/js/utils/showHide.js index 6689b83bd..cd3a6e4ac 100644 --- a/static/js/utils/showHide.js +++ b/static/js/utils/showHide.js @@ -1,98 +1,113 @@ (function() { 'use strict'; - var UTIL_NAME = 'showHide'; - var UTIL_SELECTOR = '[uw-show-hide]'; + var SHOW_HIDE_UTIL_NAME = 'showHide'; + var SHOW_HIDE_UTIL_SELECTOR = '[uw-show-hide]'; - var JS_INITIALIZED_CLASS = 'js-show-hide-initialized'; - var LOCAL_STORAGE_SHOW_HIDE = 'SHOW_HIDE'; - var SHOW_HIDE_COLLAPSED_CLASS = 'js-show-hide--collapsed'; - var SHOW_HIDE_TARGET_CLASS = 'js-show-hide__target'; + var SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE'; + var SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized'; + var SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed'; + var SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle'; + var SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right'; /** + * + * ShowHide Utility + * + * Attribute: uw-show-hide + * + * Params: (all optional) + * data-show-hide-id: string + * If this param is given the state of the utility will be persisted in the clients local storage. + * data-show-hide-collapsed: boolean property + * If this param is present the ShowHide utility will be collapsed. This value will be overruled by any value stored in the LocalStorage. + * data-show-hide-align: 'right' + * Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default. + * + * Example usage: *

- *
- * [toggle here] - *
- * [content here] + *
Click me + *
This will be toggled + *
This will be toggled as well */ - var util = function(element) { + var showHideUtil = function(element) { - function _init() { + var showHideId; + + function init() { if (!element) { throw new Error('ShowHide utility cannot be setup without an element!'); } - if (element.classList.contains(JS_INITIALIZED_CLASS)) { - return false; + if (element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) { + throw new Error('ShowHide utility already initialized!'); } - _addEventHandler(); + // register click listener + addClickListener(); + + // param showHideId + if (element.dataset.showHideId) { + showHideId = element.dataset.showHideId; + } + + // param showHideCollapsed + var collapsed = false; + if (element.dataset.showHideCollapsed !== undefined) { + collapsed = true; + } + if (showHideId) { + var localStorageCollapsed = getLocalStorage()[showHideId]; + if (typeof localStorageCollapsed !== 'undefined') { + collapsed = localStorageCollapsed; + } + } + element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed); + + // param showHideAlign + var alignment = element.dataset.showHideAlign; + if (alignment === 'right') { + element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS); + } + + // mark as initialized + element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS); + + return { + name: SHOW_HIDE_UTIL_NAME, + element: element, + destroy: function() {}, + }; } - function _addEventHandler() { + function addClickListener() { element.addEventListener('click', function clickListener() { - console.log('showhide clicked'); var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS); - _updateLSState(element.dataset.shIndex || null, newState); + + if (showHideId) { + setLocalStorage(showHideId, newState); + } }); } - function _updateLSState(index, state) { - if (!index) { - return false; - } - var lsData = _getLocalStorageData(); - lsData[index] = state; - window.localStorage.setItem(LOCAL_STORAGE_SHOW_HIDE, JSON.stringify(lsData)); + function setLocalStorage(id, state) { + var lsData = getLocalStorage(); + lsData[id] = state; + window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData)); } - // function _getCollapsedLSState(index) { - // var lsState = _getLocalStorageData(); - // return lsState[index]; - // } - - function _getLocalStorageData() { - return JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_SHOW_HIDE)) || {}; + function getLocalStorage() { + return JSON.parse(window.localStorage.getItem(SHOW_HIDE_LOCAL_STORAGE_KEY)) || {}; } - // var showHides = Array.from(scope.querySelectorAll(UTIL_SELECTOR)); - // showHides.forEach(function(el) { - // if (el.classList.contains(JS_INITIALIZED_CLASS)) { - // return false; - // } - - // var index = el.dataset.shIndex || null; - // var isCollapsed = el.dataset.collapsed === 'true'; - // var lsCollapsedState = _getCollapsedLSState(index); - // if (typeof lsCollapsedState !== 'undefined') { - // isCollapsed = lsCollapsedState; - // } - // el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, isCollapsed); - - // Array.from(el.parentElement.children).forEach(function(el) { - // if (!el.matches(UTIL_SELECTOR)) { - // el.classList.add(SHOW_HIDE_TARGET_CLASS); - // } - // }); - // el.classList.add(JS_INITIALIZED_CLASS); - // _addEventHandler(el); - // }); - - _init(); - - return { - name: UTIL_NAME, - element: element, - destroy: function() {}, - }; + return init(); }; if (UtilRegistry) { UtilRegistry.register({ - name: UTIL_NAME, - selector: UTIL_SELECTOR, - setup: util + name: SHOW_HIDE_UTIL_NAME, + selector: SHOW_HIDE_UTIL_SELECTOR, + setup: showHideUtil }); } diff --git a/templates/adminTest.hamlet b/templates/adminTest.hamlet index 7e59d9599..b595fe813 100644 --- a/templates/adminTest.hamlet +++ b/templates/adminTest.hamlet @@ -6,7 +6,7 @@ Der Handler sollte jeweils aktuelle Beispiele für alle möglichen Funktionalitäten enthalten, so dass man immer weiß, wo man nachschlagen kann.
-

Teilweise funktionierende Abschnitte +

Teilweise funktionierende Abschnitte