From bf8ee3abbd454a3b629a4cd80971b5a0832dd7f0 Mon Sep 17 00:00:00 2001 From: Janus Troelsen Date: Sat, 3 Sep 2022 09:43:51 -0500 Subject: [PATCH 1/6] Add vouching Vouching allows new users to get added to the uploaders group by way of other people "vouching" for then. This should alleviate privileged Hackage users, since they were previously the only people that could add people to the uploaders group. --- datafiles/templates/Html/vouch.html.st | 29 +++ hackage-server.cabal | 3 +- src/Distribution/Server/Features.hs | 8 + src/Distribution/Server/Features/Vouch.hs | 218 ++++++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 datafiles/templates/Html/vouch.html.st create mode 100644 src/Distribution/Server/Features/Vouch.hs diff --git a/datafiles/templates/Html/vouch.html.st b/datafiles/templates/Html/vouch.html.st new file mode 100644 index 000000000..89ae9c238 --- /dev/null +++ b/datafiles/templates/Html/vouch.html.st @@ -0,0 +1,29 @@ + + + +$hackageCssTheme()$ +Vouch for user | Hackage + + + +$hackagePageHeader()$ + +
+

Vouch for user

+ +

$msg$

+ +
+ +
+ +

Vouching cannot be undone! When the user has three vouches, the user +can upload packages. Note that users are, to a certain degree, held accountable +for the actions of the users they vouch for. Only vouch for people you know.

+ + + +
+ diff --git a/hackage-server.cabal b/hackage-server.cabal index fc0f51c88..e5749d161 100644 --- a/hackage-server.cabal +++ b/hackage-server.cabal @@ -373,8 +373,9 @@ library lib-server Distribution.Server.Features.Search.TermBag Distribution.Server.Features.Sitemap.Functions Distribution.Server.Features.Votes - Distribution.Server.Features.Votes.State Distribution.Server.Features.Votes.Render + Distribution.Server.Features.Votes.State + Distribution.Server.Features.Vouch Distribution.Server.Features.RecentPackages Distribution.Server.Features.PreferredVersions Distribution.Server.Features.PreferredVersions.State diff --git a/src/Distribution/Server/Features.hs b/src/Distribution/Server/Features.hs index be89e4175..0a9b52011 100644 --- a/src/Distribution/Server/Features.hs +++ b/src/Distribution/Server/Features.hs @@ -51,6 +51,7 @@ import Distribution.Server.Features.Votes (initVotesFeature) import Distribution.Server.Features.Sitemap (initSitemapFeature) import Distribution.Server.Features.UserNotify (initUserNotifyFeature) import Distribution.Server.Features.PackageFeed (initPackageFeedFeature) +import Distribution.Server.Features.Vouch (initVouchFeature) #endif import Distribution.Server.Features.ServerIntrospect (serverIntrospectFeature) @@ -159,6 +160,8 @@ initHackageFeatures env@ServerEnv{serverVerbosity = verbosity} = do initUserNotifyFeature env mkPackageFeedFeature <- logStartup "package feed" $ initPackageFeedFeature env + mkVouchFeature <- logStartup "vouch" $ + initVouchFeature env mkBrowseFeature <- logStartup "browse" $ initBrowseFeature env mkPackageJSONFeature <- logStartup "package info JSON" $ @@ -359,6 +362,10 @@ initHackageFeatures env@ServerEnv{serverVerbosity = verbosity} = do usersFeature tarIndexCacheFeature + vouchFeature <- mkVouchFeature + usersFeature + uploadFeature + browseFeature <- mkBrowseFeature coreFeature usersFeature @@ -415,6 +422,7 @@ initHackageFeatures env@ServerEnv{serverVerbosity = verbosity} = do , getFeatureInterface userNotifyFeature , getFeatureInterface packageFeedFeature , getFeatureInterface packageInfoJSONFeature + , getFeatureInterface vouchFeature #endif , staticFilesFeature , serverIntrospectFeature allFeatures diff --git a/src/Distribution/Server/Features/Vouch.hs b/src/Distribution/Server/Features/Vouch.hs new file mode 100644 index 000000000..df846d0b8 --- /dev/null +++ b/src/Distribution/Server/Features/Vouch.hs @@ -0,0 +1,218 @@ +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE DerivingStrategies #-} +module Distribution.Server.Features.Vouch where + +import Control.Monad (when, join) +import Control.Monad.Except (runExceptT, throwError) +import Control.Monad.Reader (ask) +import Control.Monad.State (get, put) +import qualified Data.ByteString.Lazy.Char8 as LBS +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe) +import Data.Time (UTCTime(..), addUTCTime, getCurrentTime, nominalDay, secondsToDiffTime) +import Data.Time.Format.ISO8601 (formatShow, iso8601Format) +import Text.XHtml.Strict (prettyHtmlFragment, stringToHtml, li) + +import Data.SafeCopy (base, deriveSafeCopy) +import Distribution.Server.Framework ((), AcidState, DynamicPath, HackageFeature, IsHackageFeature, IsHackageFeature(..), MemSize) +import Distribution.Server.Framework (MessageSpan(MText), Method(..), Query, Response, ServerEnv(..), ServerPartE, StateComponent(..), Update) +import Distribution.Server.Framework (abstractAcidStateComponent, emptyHackageFeature, errBadRequest) +import Distribution.Server.Framework (featureDesc, featureReloadFiles, featureResources, featureState) +import Distribution.Server.Framework (liftIO, makeAcidic, openLocalStateFrom, query, queryState, resourceAt, resourceDesc, resourceGet) +import Distribution.Server.Framework (resourcePost, toResponse, update, updateState) +import Distribution.Server.Framework.BackupRestore (RestoreBackup(..)) +import Distribution.Server.Framework.Templating (($=), TemplateAttr, getTemplate, loadTemplates, reloadTemplates, templateUnescaped) +import qualified Distribution.Server.Users.Group as Group +import Distribution.Server.Users.Types (UserId(..), UserInfo, UserName(..), userName) +import Distribution.Server.Features.Upload(UploadFeature(..)) +import Distribution.Server.Features.Users (UserFeature(..)) +import Distribution.Simple.Utils (toUTF8LBS) + +newtype VouchData = VouchData (Map.Map UserId [(UserId, UTCTime)]) + deriving (Show, Eq) + deriving newtype MemSize + +putVouch :: UserId -> (UserId, UTCTime) -> Update VouchData () +putVouch vouchee (voucher, now) = do + VouchData tbl <- get + let oldMap = fromMaybe [] (Map.lookup vouchee tbl) + newMap = (voucher, now) : oldMap + put $ VouchData (Map.insert vouchee newMap tbl) + +getVouchesFor :: UserId -> Query VouchData [(UserId, UTCTime)] +getVouchesFor needle = do + VouchData tbl <- ask + pure . fromMaybe [] $ Map.lookup needle tbl + +getVouchesData :: Query VouchData VouchData +getVouchesData = ask + +replaceVouchesData :: VouchData -> Update VouchData () +replaceVouchesData = put + +$(deriveSafeCopy 0 'base ''VouchData) + +makeAcidic ''VouchData + [ 'putVouch + , 'getVouchesFor + -- Stock + , 'getVouchesData + , 'replaceVouchesData + ] + +vouchStateComponent :: FilePath -> IO (StateComponent AcidState VouchData) +vouchStateComponent stateDir = do + st <- openLocalStateFrom (stateDir "db" "Vouch") (VouchData mempty) + let initialVouchData = VouchData mempty + restore = + RestoreBackup + { restoreEntry = error "Unexpected backup entry" + , restoreFinalize = return initialVouchData + } + pure StateComponent + { stateDesc = "Keeps track of vouches" + , stateHandle = st + , getState = query st GetVouchesData + , putState = update st . ReplaceVouchesData + , backupState = \_ _ -> [] + , restoreState = restore + , resetState = vouchStateComponent + } + +data VouchFeature = + VouchFeature + { vouchFeatureInterface :: HackageFeature + } + +instance IsHackageFeature VouchFeature where + getFeatureInterface = vouchFeatureInterface + +requiredCountOfVouches :: Int +requiredCountOfVouches = 3 + +isWithinLastMonth :: UTCTime -> (UserId, UTCTime) -> Bool +isWithinLastMonth now (_, vouchTime) = + addUTCTime (30 * nominalDay) vouchTime < now + +data Err + = NotAnUploader + | You'reTooNew + | VoucheeAlreadyUploader + | AlreadySufficientlyVouched + | YouAlreadyVouched + +data Success = AddVouchComplete | AddVouchIncomplete + +judge :: Group.UserIdSet -> UTCTime -> UserId -> [(UserId, UTCTime)] -> [(UserId, UTCTime)] -> UserId -> Either Err (Either Err Success) +judge ugroup now vouchee vouchersForVoucher existingVouchers voucher = runExceptT $ do + when (not (voucher `Group.member` ugroup)) $ + throwError NotAnUploader + -- You can only vouch for non-uploaders, so if this list has items, the user is uploader because of these vouches. + -- Make sure none of them are too recent. + when (length vouchersForVoucher >= requiredCountOfVouches && any (isWithinLastMonth now) vouchersForVoucher) $ + throwError You'reTooNew + when (vouchee `Group.member` ugroup) $ + throwError VoucheeAlreadyUploader + when (length existingVouchers >= 3) $ + throwError AlreadySufficientlyVouched + when (voucher `elem` map fst existingVouchers) $ + throwError YouAlreadyVouched + pure $ + if length existingVouchers == requiredCountOfVouches - 1 + then AddVouchComplete + else AddVouchIncomplete + +renderToLBS :: (UserId -> ServerPartE UserInfo) -> [(UserId, UTCTime)] -> ServerPartE TemplateAttr +renderToLBS lookupUserInfo vouches = do + rendered <- traverse renderVouchers vouches + pure $ + templateUnescaped "vouches" $ + if null rendered + then LBS.pack "Nobody has vouched yet." + else LBS.intercalate mempty rendered + where + renderVouchers :: (UserId, UTCTime) -> ServerPartE LBS.ByteString + renderVouchers (uid, timestamp) = do + info <- lookupUserInfo uid + let UserName name = userName info + -- We don't need to show millisecond precision + -- So we truncate it off here + truncated = truncate $ utctDayTime timestamp + newUTCTime = timestamp {utctDayTime = secondsToDiffTime truncated} + pure . toUTF8LBS . prettyHtmlFragment . li . stringToHtml $ name <> " vouched on " <> formatShow iso8601Format newUTCTime + +initVouchFeature :: ServerEnv -> IO (UserFeature -> UploadFeature -> IO VouchFeature) +initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMode} = do + vouchState <- vouchStateComponent serverStateDir + templates <- loadTemplates serverTemplatesMode [ serverTemplatesDir, serverTemplatesDir "Html"] + ["vouch.html"] + vouchTemplate <- getTemplate templates "vouch.html" + return $ \UserFeature{userNameInPath, lookupUserName, lookupUserInfo, guardAuthenticated} + UploadFeature{uploadersGroup} -> do + let + handleGetVouches :: DynamicPath -> ServerPartE Response + handleGetVouches dpath = do + uid <- lookupUserName =<< userNameInPath dpath + userIds <- queryState vouchState $ GetVouchesFor uid + param <- renderToLBS lookupUserInfo userIds + pure . toResponse $ vouchTemplate + [ "msg" $= "" + , param + ] + handlePostVouch :: DynamicPath -> ServerPartE Response + handlePostVouch dpath = do + voucher <- guardAuthenticated + ugroup <- liftIO $ Group.queryUserGroup uploadersGroup + now <- liftIO getCurrentTime + vouchee <- lookupUserName =<< userNameInPath dpath + vouchersForVoucher <- queryState vouchState $ GetVouchesFor voucher + existingVouchers <- queryState vouchState $ GetVouchesFor vouchee + case join $ judge ugroup now vouchee vouchersForVoucher existingVouchers voucher of + Left NotAnUploader -> + errBadRequest "Not an uploader" [MText "You must be an uploader yourself to vouch for other users."] + Left You'reTooNew -> + errBadRequest "You're too new" [MText "The latest of the vouches for your user must be at least 30 days old."] + Left VoucheeAlreadyUploader -> + errBadRequest "Vouchee already uploader" [MText "You can't vouch for this user, since they are already an uploader."] + Left AlreadySufficientlyVouched -> + errBadRequest "Already sufficiently vouched" [MText "There are already a sufficient number of vouches for this user."] + Left YouAlreadyVouched -> + errBadRequest "Already vouched" [MText "You have already vouched for this user."] + Right result -> do + updateState vouchState $ PutVouch vouchee (voucher, now) + param <- renderToLBS lookupUserInfo $ existingVouchers ++ [(voucher, now)] + case result of + AddVouchComplete -> do + liftIO $ Group.addUserToGroup uploadersGroup vouchee + pure . toResponse $ vouchTemplate + [ "msg" $= "Added vouch. User is now an uploader!" + , param + ] + AddVouchIncomplete -> do + let stillRequired = requiredCountOfVouches - length existingVouchers - 1 + pure . toResponse $ vouchTemplate + [ "msg" $= + "Added vouch. User still needs " + <> show stillRequired + <> if stillRequired == 1 then " vouch" else " vouches" + <> " to become uploader." + , param + ] + return $ VouchFeature $ + (emptyHackageFeature "vouch") + { featureDesc = "Vouching for users getting upload permission." + , featureResources = + [(resourceAt "/user/:username/vouch") + { resourceDesc = [(GET, "list people vouching") + ,(POST, "vouch for user") + ] + , resourceGet = [("html", handleGetVouches)] + , resourcePost = [("html", handlePostVouch)] + } + ] + , featureState = [ abstractAcidStateComponent vouchState ] + , featureReloadFiles = reloadTemplates templates + } From 3c82d01dec38a974d8d998233804a42ad5dccd1a Mon Sep 17 00:00:00 2001 From: Janus Troelsen Date: Mon, 16 Oct 2023 16:53:21 -0600 Subject: [PATCH 2/6] Vouching: Address review comments, add tests --- hackage-server.cabal | 8 ++ src/Distribution/Server/Features/Vouch.hs | 60 ++++++++------ tests/VouchTest.hs | 96 +++++++++++++++++++++++ 3 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 tests/VouchTest.hs diff --git a/hackage-server.cabal b/hackage-server.cabal index e5749d161..a4bde6531 100644 --- a/hackage-server.cabal +++ b/hackage-server.cabal @@ -574,6 +574,14 @@ test-suite HighLevelTest , io-streams ^>= 1.5.0.1 , http-io-streams ^>= 0.1.6.1 +test-suite VouchTest + import: test-defaults + type: exitcode-stdio-1.0 + main-is: VouchTest.hs + build-depends: + , tasty ^>= 1.4 + , tasty-hunit ^>= 0.10 + test-suite ReverseDependenciesTest import: test-defaults type: exitcode-stdio-1.0 diff --git a/src/Distribution/Server/Features/Vouch.hs b/src/Distribution/Server/Features/Vouch.hs index df846d0b8..88a0f3494 100644 --- a/src/Distribution/Server/Features/Vouch.hs +++ b/src/Distribution/Server/Features/Vouch.hs @@ -3,7 +3,7 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE DerivingStrategies #-} -module Distribution.Server.Features.Vouch where +module Distribution.Server.Features.Vouch (VouchError(..), VouchSuccess(..), initVouchFeature, judgeVouch) where import Control.Monad (when, join) import Control.Monad.Except (runExceptT, throwError) @@ -91,23 +91,32 @@ instance IsHackageFeature VouchFeature where getFeatureInterface = vouchFeatureInterface requiredCountOfVouches :: Int -requiredCountOfVouches = 3 +requiredCountOfVouches = 2 isWithinLastMonth :: UTCTime -> (UserId, UTCTime) -> Bool isWithinLastMonth now (_, vouchTime) = - addUTCTime (30 * nominalDay) vouchTime < now + addUTCTime (30 * nominalDay) vouchTime >= now -data Err +data VouchError = NotAnUploader | You'reTooNew | VoucheeAlreadyUploader | AlreadySufficientlyVouched | YouAlreadyVouched - -data Success = AddVouchComplete | AddVouchIncomplete - -judge :: Group.UserIdSet -> UTCTime -> UserId -> [(UserId, UTCTime)] -> [(UserId, UTCTime)] -> UserId -> Either Err (Either Err Success) -judge ugroup now vouchee vouchersForVoucher existingVouchers voucher = runExceptT $ do + deriving stock (Show, Eq) + +data VouchSuccess = AddVouchComplete | AddVouchIncomplete Int + deriving stock (Show, Eq) + +judgeVouch + :: Group.UserIdSet + -> UTCTime + -> UserId + -> [(UserId, UTCTime)] + -> [(UserId, UTCTime)] + -> UserId + -> Either VouchError VouchSuccess +judgeVouch ugroup now vouchee vouchersForVoucher existingVouchers voucher = join . runExceptT $ do when (not (voucher `Group.member` ugroup)) $ throwError NotAnUploader -- You can only vouch for non-uploaders, so if this list has items, the user is uploader because of these vouches. @@ -116,33 +125,35 @@ judge ugroup now vouchee vouchersForVoucher existingVouchers voucher = runExcept throwError You'reTooNew when (vouchee `Group.member` ugroup) $ throwError VoucheeAlreadyUploader - when (length existingVouchers >= 3) $ + when (length existingVouchers >= requiredCountOfVouches) $ throwError AlreadySufficientlyVouched when (voucher `elem` map fst existingVouchers) $ throwError YouAlreadyVouched pure $ if length existingVouchers == requiredCountOfVouches - 1 then AddVouchComplete - else AddVouchIncomplete + else + let stillRequired = requiredCountOfVouches - length existingVouchers - 1 + in AddVouchIncomplete stillRequired renderToLBS :: (UserId -> ServerPartE UserInfo) -> [(UserId, UTCTime)] -> ServerPartE TemplateAttr renderToLBS lookupUserInfo vouches = do - rendered <- traverse renderVouchers vouches + rendered <- traverse (renderVouchers lookupUserInfo) vouches pure $ templateUnescaped "vouches" $ if null rendered then LBS.pack "Nobody has vouched yet." else LBS.intercalate mempty rendered - where - renderVouchers :: (UserId, UTCTime) -> ServerPartE LBS.ByteString - renderVouchers (uid, timestamp) = do - info <- lookupUserInfo uid - let UserName name = userName info - -- We don't need to show millisecond precision - -- So we truncate it off here - truncated = truncate $ utctDayTime timestamp - newUTCTime = timestamp {utctDayTime = secondsToDiffTime truncated} - pure . toUTF8LBS . prettyHtmlFragment . li . stringToHtml $ name <> " vouched on " <> formatShow iso8601Format newUTCTime + +renderVouchers :: (UserId -> ServerPartE UserInfo) -> (UserId, UTCTime) -> ServerPartE LBS.ByteString +renderVouchers lookupUserInfo (uid, timestamp) = do + info <- lookupUserInfo uid + let UserName name = userName info + -- We don't need to show millisecond precision + -- So we truncate it off here + truncated = truncate $ utctDayTime timestamp + newUTCTime = timestamp {utctDayTime = secondsToDiffTime truncated} + pure . toUTF8LBS . prettyHtmlFragment . li . stringToHtml $ name <> " vouched on " <> formatShow iso8601Format newUTCTime initVouchFeature :: ServerEnv -> IO (UserFeature -> UploadFeature -> IO VouchFeature) initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMode} = do @@ -170,7 +181,7 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo vouchee <- lookupUserName =<< userNameInPath dpath vouchersForVoucher <- queryState vouchState $ GetVouchesFor voucher existingVouchers <- queryState vouchState $ GetVouchesFor vouchee - case join $ judge ugroup now vouchee vouchersForVoucher existingVouchers voucher of + case judgeVouch ugroup now vouchee vouchersForVoucher existingVouchers voucher of Left NotAnUploader -> errBadRequest "Not an uploader" [MText "You must be an uploader yourself to vouch for other users."] Left You'reTooNew -> @@ -191,8 +202,7 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo [ "msg" $= "Added vouch. User is now an uploader!" , param ] - AddVouchIncomplete -> do - let stillRequired = requiredCountOfVouches - length existingVouchers - 1 + AddVouchIncomplete stillRequired -> pure . toResponse $ vouchTemplate [ "msg" $= "Added vouch. User still needs " diff --git a/tests/VouchTest.hs b/tests/VouchTest.hs new file mode 100644 index 000000000..ece72d578 --- /dev/null +++ b/tests/VouchTest.hs @@ -0,0 +1,96 @@ +module Main where + +import Data.Time (UTCTime(UTCTime), fromGregorian) + +import Distribution.Server.Features.Vouch (VouchError(..), VouchSuccess(..), judgeVouch) +import Distribution.Server.Users.UserIdSet (fromList) +import Distribution.Server.Users.Types (UserId(UserId)) + +import Test.Tasty (TestTree, defaultMain, testGroup) +import Test.Tasty.HUnit (assertEqual, testCase) + +allTests :: TestTree +allTests = testGroup "VouchTest" + [ testCase "happy path, vouch added, but more vouches needed" $ do + let ref = Right (AddVouchIncomplete 1) + voucher = UserId 1 + vouchee = UserId 2 + assertEqual "must match" ref $ + judgeVouch + (fromList [voucher]) -- uploaders. Can't vouch if user is not a voucher + (UTCTime (fromGregorian 2020 1 1) 0) + vouchee + [] -- vouchers for voucher. If this short enough, voucher is assumed to be old enough to vouch themselves. + [] -- no existing vouchers + voucher + , testCase "happy path, vouch added, no more vouches needed" $ do + let ref = Right AddVouchComplete + voucher = UserId 1 + vouchee = UserId 2 + otherVoucherForVouchee = UserId 4 + assertEqual "must match" ref $ + judgeVouch + (fromList [voucher]) + (UTCTime (fromGregorian 2020 1 1) 0) + vouchee + [] + [(otherVoucherForVouchee, UTCTime (fromGregorian 2020 1 1) 0)] + voucher + , testCase "non-uploader tried to vouch" $ do + let ref = Left NotAnUploader + voucher = UserId 1 + vouchee = UserId 2 + assertEqual "must match" ref $ + judgeVouch + (fromList []) -- empty. Should contain voucher for operation to proceed. + (UTCTime (fromGregorian 2020 1 1) 0) + vouchee + [] + [] + voucher + , testCase "voucher too new" $ do + let ref = Left You'reTooNew + voucher = UserId 1 + vouchee = UserId 2 + fstVoucherForVoucher = UserId 3 + sndVoucherForVoucher = UserId 4 + now = UTCTime (fromGregorian 2020 1 1) 0 + assertEqual "must match" ref $ + judgeVouch + (fromList [voucher]) + now + vouchee + [ (fstVoucherForVoucher, now) -- These two timestamps are too new + , (sndVoucherForVoucher, now) + ] + [] + voucher + , testCase "vouchee already uploader" $ do + let ref = Left VoucheeAlreadyUploader + voucher = UserId 1 + vouchee = UserId 2 + now = UTCTime (fromGregorian 2020 1 1) 0 + assertEqual "must match" ref $ + judgeVouch + (fromList [voucher, vouchee]) -- vouchee is here. So they're already an uploader. + now + vouchee + [] + [] + voucher + , testCase "already vouched" $ do + let ref = Left YouAlreadyVouched + voucher = UserId 1 + vouchee = UserId 2 + assertEqual "must match" ref $ + judgeVouch + (fromList [voucher]) + (UTCTime (fromGregorian 2020 1 1) 0) + vouchee + [] + [(voucher, UTCTime (fromGregorian 2020 1 1) 0)] -- voucher is here. So they already vouched + voucher + ] + +main :: IO () +main = defaultMain allTests From ada92d091073ed0aa97b3e65854f619f21cfdac7 Mon Sep 17 00:00:00 2001 From: Janus Troelsen Date: Fri, 1 Dec 2023 23:13:57 -0600 Subject: [PATCH 3/6] Add e-mail notification when all vouches received --- src/Distribution/Server/Features.hs | 9 ++- .../Server/Features/UserNotify.hs | 23 +++++- src/Distribution/Server/Features/Vouch.hs | 77 ++++++++++++------- tests/ReverseDependenciesTest.hs | 5 ++ ...ationEmails-NotifyVouchingCompleted.golden | 35 +++++++++ 5 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden diff --git a/src/Distribution/Server/Features.hs b/src/Distribution/Server/Features.hs index 0a9b52011..b4e2d96d5 100644 --- a/src/Distribution/Server/Features.hs +++ b/src/Distribution/Server/Features.hs @@ -347,6 +347,10 @@ initHackageFeatures env@ServerEnv{serverVerbosity = verbosity} = do tagsFeature tarIndexCacheFeature + vouchFeature <- mkVouchFeature + usersFeature + uploadFeature + userNotifyFeature <- mkUserNotifyFeature usersFeature coreFeature @@ -356,16 +360,13 @@ initHackageFeatures env@ServerEnv{serverVerbosity = verbosity} = do reportsCoreFeature tagsFeature reverseFeature + vouchFeature packageFeedFeature <- mkPackageFeedFeature coreFeature usersFeature tarIndexCacheFeature - vouchFeature <- mkVouchFeature - usersFeature - uploadFeature - browseFeature <- mkBrowseFeature coreFeature usersFeature diff --git a/src/Distribution/Server/Features/UserNotify.hs b/src/Distribution/Server/Features/UserNotify.hs index fb11fa5ef..ce6a9f9ea 100644 --- a/src/Distribution/Server/Features/UserNotify.hs +++ b/src/Distribution/Server/Features/UserNotify.hs @@ -52,6 +52,7 @@ import Distribution.Server.Features.Tags import Distribution.Server.Features.Upload import Distribution.Server.Features.UserDetails import Distribution.Server.Features.Users +import Distribution.Server.Features.Vouch import Distribution.Server.Util.Email @@ -437,6 +438,7 @@ initUserNotifyFeature :: ServerEnv -> ReportsFeature -> TagsFeature -> ReverseFeature + -> VouchFeature -> IO UserNotifyFeature) initUserNotifyFeature env@ServerEnv{ serverStateDir, serverTemplatesDir, serverTemplatesMode } = do @@ -448,10 +450,10 @@ initUserNotifyFeature env@ServerEnv{ serverStateDir, serverTemplatesDir, [serverTemplatesDir, serverTemplatesDir "UserNotify"] [ "user-notify-form.html" ] - return $ \users core uploadfeature adminlog userdetails reports tags revers -> do + return $ \users core uploadfeature adminlog userdetails reports tags revers vouch -> do let feature = userNotifyFeature env users core uploadfeature adminlog userdetails reports tags - revers notifyState templates + revers vouch notifyState templates return feature data InRange = InRange | OutOfRange @@ -582,6 +584,7 @@ userNotifyFeature :: ServerEnv -> ReportsFeature -> TagsFeature -> ReverseFeature + -> VouchFeature -> StateComponent AcidState NotifyData -> Templates -> UserNotifyFeature @@ -594,6 +597,7 @@ userNotifyFeature serverEnv@ServerEnv{serverCron} ReportsFeature{..} TagsFeature{..} ReverseFeature{queryReverseIndex} + VouchFeature{drainQueuedNotifications} notifyState templates = UserNotifyFeature {..} @@ -709,6 +713,8 @@ userNotifyFeature serverEnv@ServerEnv{serverCron} revIdx <- liftIO queryReverseIndex dependencyUpdateNotifications <- concatMapM (genDependencyUpdateList notifyPrefs idx revIdx . pkgInfoToPkgId) revisionsAndUploads + vouchNotifications <- fmap (, NotifyVouchingCompleted) <$> drainQueuedNotifications + emails <- getNotificationEmails serverEnv userDetailsFeature users $ concat @@ -717,6 +723,7 @@ userNotifyFeature serverEnv@ServerEnv{serverCron} , docReportNotifications , tagProposalNotifications , dependencyUpdateNotifications + , vouchNotifications ] mapM_ sendNotifyEmailAndDelay emails @@ -897,6 +904,7 @@ data Notification -- ^ Packages maintained by user that depend on updated dep , notifyTriggerBounds :: NotifyTriggerBounds } + | NotifyVouchingCompleted deriving (Show) data NotifyMaintainerUpdateType = MaintainerAdded | MaintainerRemoved @@ -1021,6 +1029,10 @@ getNotificationEmails notifyWatchedPackages , DependencyNotification notifyPackageId ) + NotifyVouchingCompleted -> + generalNotification + renderNotifyVouchingCompleted + where generalNotification = (, GeneralNotification) @@ -1086,6 +1098,13 @@ getNotificationEmails ] <> EmailContentList (map renderPkgLink revDeps) + renderNotifyVouchingCompleted = + EmailContentParagraph + "You have received all necessary vouches. \ + \You have been added the the 'uploaders' group. \ + \You can now upload packages to Hackage. \ + \Note that packages cannot be deleted, so be careful." + {----- Rendering helpers -----} renderPackageName = emailContentStr . unPackageName diff --git a/src/Distribution/Server/Features/Vouch.hs b/src/Distribution/Server/Features/Vouch.hs index 88a0f3494..d6444e966 100644 --- a/src/Distribution/Server/Features/Vouch.hs +++ b/src/Distribution/Server/Features/Vouch.hs @@ -3,21 +3,24 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE DerivingStrategies #-} -module Distribution.Server.Features.Vouch (VouchError(..), VouchSuccess(..), initVouchFeature, judgeVouch) where +{-# LANGUAGE RankNTypes #-} +module Distribution.Server.Features.Vouch (VouchFeature(..), VouchData(..), VouchError(..), VouchSuccess(..), initVouchFeature, judgeVouch) where import Control.Monad (when, join) import Control.Monad.Except (runExceptT, throwError) import Control.Monad.Reader (ask) import Control.Monad.State (get, put) +import Control.Monad.IO.Class (MonadIO) import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.Map.Strict as Map +import qualified Data.Set as Set import Data.Maybe (fromMaybe) import Data.Time (UTCTime(..), addUTCTime, getCurrentTime, nominalDay, secondsToDiffTime) import Data.Time.Format.ISO8601 (formatShow, iso8601Format) import Text.XHtml.Strict (prettyHtmlFragment, stringToHtml, li) import Data.SafeCopy (base, deriveSafeCopy) -import Distribution.Server.Framework ((), AcidState, DynamicPath, HackageFeature, IsHackageFeature, IsHackageFeature(..), MemSize) +import Distribution.Server.Framework ((), AcidState, DynamicPath, HackageFeature, IsHackageFeature, IsHackageFeature(..), MemSize(..), memSize2) import Distribution.Server.Framework (MessageSpan(MText), Method(..), Query, Response, ServerEnv(..), ServerPartE, StateComponent(..), Update) import Distribution.Server.Framework (abstractAcidStateComponent, emptyHackageFeature, errBadRequest) import Distribution.Server.Framework (featureDesc, featureReloadFiles, featureResources, featureState) @@ -31,20 +34,26 @@ import Distribution.Server.Features.Upload(UploadFeature(..)) import Distribution.Server.Features.Users (UserFeature(..)) import Distribution.Simple.Utils (toUTF8LBS) -newtype VouchData = VouchData (Map.Map UserId [(UserId, UTCTime)]) +data VouchData = + VouchData + { vouches :: Map.Map UserId [(UserId, UTCTime)] + , notNotified :: Set.Set UserId + } deriving (Show, Eq) - deriving newtype MemSize + +instance MemSize VouchData where + memSize (VouchData vouches notified) = memSize2 vouches notified putVouch :: UserId -> (UserId, UTCTime) -> Update VouchData () putVouch vouchee (voucher, now) = do - VouchData tbl <- get + VouchData tbl notNotified <- get let oldMap = fromMaybe [] (Map.lookup vouchee tbl) newMap = (voucher, now) : oldMap - put $ VouchData (Map.insert vouchee newMap tbl) + put $ VouchData (Map.insert vouchee newMap tbl) notNotified getVouchesFor :: UserId -> Query VouchData [(UserId, UTCTime)] getVouchesFor needle = do - VouchData tbl <- ask + VouchData tbl _notNotified <- ask pure . fromMaybe [] $ Map.lookup needle tbl getVouchesData :: Query VouchData VouchData @@ -65,8 +74,8 @@ makeAcidic ''VouchData vouchStateComponent :: FilePath -> IO (StateComponent AcidState VouchData) vouchStateComponent stateDir = do - st <- openLocalStateFrom (stateDir "db" "Vouch") (VouchData mempty) - let initialVouchData = VouchData mempty + st <- openLocalStateFrom (stateDir "db" "Vouch") (VouchData mempty mempty) + let initialVouchData = VouchData mempty mempty restore = RestoreBackup { restoreEntry = error "Unexpected backup entry" @@ -85,6 +94,7 @@ vouchStateComponent stateDir = do data VouchFeature = VouchFeature { vouchFeatureInterface :: HackageFeature + , drainQueuedNotifications :: forall m. MonadIO m => m [UserId] } instance IsHackageFeature VouchFeature where @@ -167,8 +177,8 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo handleGetVouches :: DynamicPath -> ServerPartE Response handleGetVouches dpath = do uid <- lookupUserName =<< userNameInPath dpath - userIds <- queryState vouchState $ GetVouchesFor uid - param <- renderToLBS lookupUserInfo userIds + vouches <- queryState vouchState $ GetVouchesFor uid + param <- renderToLBS lookupUserInfo vouches pure . toResponse $ vouchTemplate [ "msg" $= "" , param @@ -197,6 +207,13 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo param <- renderToLBS lookupUserInfo $ existingVouchers ++ [(voucher, now)] case result of AddVouchComplete -> do + -- enqueue vouching completed notification + -- which will be read using drainQueuedNotifications + VouchData vouches notNotified <- + queryState vouchState GetVouchesData + let newState = VouchData vouches (Set.insert vouchee notNotified) + updateState vouchState $ ReplaceVouchesData newState + liftIO $ Group.addUserToGroup uploadersGroup vouchee pure . toResponse $ vouchTemplate [ "msg" $= "Added vouch. User is now an uploader!" @@ -211,18 +228,26 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo <> " to become uploader." , param ] - return $ VouchFeature $ - (emptyHackageFeature "vouch") - { featureDesc = "Vouching for users getting upload permission." - , featureResources = - [(resourceAt "/user/:username/vouch") - { resourceDesc = [(GET, "list people vouching") - ,(POST, "vouch for user") - ] - , resourceGet = [("html", handleGetVouches)] - , resourcePost = [("html", handlePostVouch)] - } - ] - , featureState = [ abstractAcidStateComponent vouchState ] - , featureReloadFiles = reloadTemplates templates - } + return $ VouchFeature { + vouchFeatureInterface = + (emptyHackageFeature "vouch") + { featureDesc = "Vouching for users getting upload permission." + , featureResources = + [(resourceAt "/user/:username/vouch") + { resourceDesc = [(GET, "list people vouching") + ,(POST, "vouch for user") + ] + , resourceGet = [("html", handleGetVouches)] + , resourcePost = [("html", handlePostVouch)] + } + ] + , featureState = [ abstractAcidStateComponent vouchState ] + , featureReloadFiles = reloadTemplates templates + }, + drainQueuedNotifications = do + VouchData vouches notNotified <- + queryState vouchState GetVouchesData + let newState = VouchData vouches mempty + updateState vouchState $ ReplaceVouchesData newState + pure $ Set.toList notNotified + } diff --git a/tests/ReverseDependenciesTest.hs b/tests/ReverseDependenciesTest.hs index 5b0333a04..fa78807d9 100644 --- a/tests/ReverseDependenciesTest.hs +++ b/tests/ReverseDependenciesTest.hs @@ -422,6 +422,8 @@ getNotificationEmailsTests = , notifyWatchedPackages = [PackageIdentifier "mtl" (mkVersion [2, 3])] , notifyTriggerBounds = BoundsOutOfRange } + , testGolden "Render NotifyVouchingCompleted" "getNotificationEmails-NotifyVouchingCompleted.golden" $ + fmap renderMail $ getNotificationEmailMocked userWatcher NotifyVouchingCompleted , testGolden "Render general notifications in single batched email" "getNotificationEmails-batched.golden" $ do emails <- getNotificationEmailsMocked . map (userWatcher,) $ @@ -455,6 +457,7 @@ getNotificationEmailsTests = NotifyDocsBuild{} -> () NotifyUpdateTags{} -> () NotifyDependencyUpdate{} -> () + NotifyVouchingCompleted{} -> () isGeneral = \case NotifyNewVersion{} -> True @@ -463,6 +466,7 @@ getNotificationEmailsTests = NotifyDocsBuild{} -> True NotifyUpdateTags{} -> True NotifyDependencyUpdate{} -> False + NotifyVouchingCompleted{} -> True -- userWatcher = user getting the notification -- userActor = user that did the action @@ -539,6 +543,7 @@ getNotificationEmailsTests = <$> genPackageId <*> Gen.list (Range.linear 1 10) genPackageId <*> Gen.element [Always, NewIncompatibility, BoundsOutOfRange] + , pure NotifyVouchingCompleted ] genPackageName = mkPackageName <$> Gen.string (Range.linear 1 30) Gen.unicode diff --git a/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden b/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden new file mode 100644 index 000000000..d0d1d301f --- /dev/null +++ b/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden @@ -0,0 +1,35 @@ +From: =?utf-8?Q?Hackage_website?= +To: =?utf-8?Q?user-watcher?= +Subject: [Hackage] Maintainer Notifications +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="YIYrWcf3to" + +--YIYrWcf3to +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +You have received all necessary vouches=2E You have been added the the 'upl= +oaders' group=2E You can now upload packages to Hackage=2E Note that packag= +es cannot be deleted, so be careful=2E + +You can adjust your notification preferences at +https://hackage=2Ehaskell=2Eorg/user/user-watcher/notify (https://hackage= +=2Ehaskell=2Eorg/user/user-watcher/notify) + + +--YIYrWcf3to +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +

+You have received all necessary vouches=2E You have been added the the 'upl= +oaders' group=2E You can now upload packages to Hackage=2E Note that packag= +es cannot be deleted, so be careful=2E +

+

+You can adjust your notification preferences at +
= +https://hackage=2Ehaskell=2Eorg/user/user-watcher/notify +

+--YIYrWcf3to-- \ No newline at end of file From d9fd27a6b0e25d60f830c05db51704c9594f20d7 Mon Sep 17 00:00:00 2001 From: Janus Troelsen Date: Fri, 1 Dec 2023 23:24:43 -0600 Subject: [PATCH 4/6] Rename user-facing strings to 'endorsements', add threshold template param --- datafiles/templates/Html/vouch.html.st | 10 +++---- .../Server/Features/UserNotify.hs | 2 +- src/Distribution/Server/Features/Vouch.hs | 29 ++++++++++--------- ...ationEmails-NotifyVouchingCompleted.golden | 12 ++++---- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/datafiles/templates/Html/vouch.html.st b/datafiles/templates/Html/vouch.html.st index 89ae9c238..36d0e7945 100644 --- a/datafiles/templates/Html/vouch.html.st +++ b/datafiles/templates/Html/vouch.html.st @@ -2,24 +2,24 @@ $hackageCssTheme()$ -Vouch for user | Hackage +Endorse user | Hackage $hackagePageHeader()$
-

Vouch for user

+

Endorse user

$msg$

- +
-

Vouching cannot be undone! When the user has three vouches, the user +

Endorsing cannot be undone! When the user has $requiredNumber$ endorsements, the user can upload packages. Note that users are, to a certain degree, held accountable -for the actions of the users they vouch for. Only vouch for people you know.

+for the actions of the users they endorse. Only endorse people you know.

    $vouches$ diff --git a/src/Distribution/Server/Features/UserNotify.hs b/src/Distribution/Server/Features/UserNotify.hs index ce6a9f9ea..2d0289a20 100644 --- a/src/Distribution/Server/Features/UserNotify.hs +++ b/src/Distribution/Server/Features/UserNotify.hs @@ -1100,7 +1100,7 @@ getNotificationEmails renderNotifyVouchingCompleted = EmailContentParagraph - "You have received all necessary vouches. \ + "You have received all necessary endorsements. \ \You have been added the the 'uploaders' group. \ \You can now upload packages to Hackage. \ \Note that packages cannot be deleted, so be careful." diff --git a/src/Distribution/Server/Features/Vouch.hs b/src/Distribution/Server/Features/Vouch.hs index d6444e966..39795f0b5 100644 --- a/src/Distribution/Server/Features/Vouch.hs +++ b/src/Distribution/Server/Features/Vouch.hs @@ -152,7 +152,7 @@ renderToLBS lookupUserInfo vouches = do pure $ templateUnescaped "vouches" $ if null rendered - then LBS.pack "Nobody has vouched yet." + then LBS.pack "Nobody has endorsed yet." else LBS.intercalate mempty rendered renderVouchers :: (UserId -> ServerPartE UserInfo) -> (UserId, UTCTime) -> ServerPartE LBS.ByteString @@ -181,6 +181,7 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo param <- renderToLBS lookupUserInfo vouches pure . toResponse $ vouchTemplate [ "msg" $= "" + , "requiredNumber" $= show requiredCountOfVouches , param ] handlePostVouch :: DynamicPath -> ServerPartE Response @@ -193,15 +194,15 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo existingVouchers <- queryState vouchState $ GetVouchesFor vouchee case judgeVouch ugroup now vouchee vouchersForVoucher existingVouchers voucher of Left NotAnUploader -> - errBadRequest "Not an uploader" [MText "You must be an uploader yourself to vouch for other users."] + errBadRequest "Not an uploader" [MText "You must be an uploader yourself to endorse other users."] Left You'reTooNew -> - errBadRequest "You're too new" [MText "The latest of the vouches for your user must be at least 30 days old."] + errBadRequest "You're too new" [MText "The latest of the endorsements for your user must be at least 30 days old."] Left VoucheeAlreadyUploader -> - errBadRequest "Vouchee already uploader" [MText "You can't vouch for this user, since they are already an uploader."] + errBadRequest "Endorsee already uploader" [MText "You can't endorse this user, since they are already an uploader."] Left AlreadySufficientlyVouched -> - errBadRequest "Already sufficiently vouched" [MText "There are already a sufficient number of vouches for this user."] + errBadRequest "Already sufficiently endorsed" [MText "There are already a sufficient number of endorsements for this user."] Left YouAlreadyVouched -> - errBadRequest "Already vouched" [MText "You have already vouched for this user."] + errBadRequest "Already endorsed" [MText "You have already endorsed this user."] Right result -> do updateState vouchState $ PutVouch vouchee (voucher, now) param <- renderToLBS lookupUserInfo $ existingVouchers ++ [(voucher, now)] @@ -216,26 +217,26 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo liftIO $ Group.addUserToGroup uploadersGroup vouchee pure . toResponse $ vouchTemplate - [ "msg" $= "Added vouch. User is now an uploader!" + [ "msg" $= "Added endorsement. User is now an uploader!" , param ] AddVouchIncomplete stillRequired -> pure . toResponse $ vouchTemplate [ "msg" $= - "Added vouch. User still needs " + "Added endorsement. User still needs " <> show stillRequired - <> if stillRequired == 1 then " vouch" else " vouches" + <> if stillRequired == 1 then " endorsement" else " endorsements" <> " to become uploader." , param ] return $ VouchFeature { vouchFeatureInterface = - (emptyHackageFeature "vouch") - { featureDesc = "Vouching for users getting upload permission." + (emptyHackageFeature "endorse") + { featureDesc = "Endorsing users such that they get upload permission." , featureResources = - [(resourceAt "/user/:username/vouch") - { resourceDesc = [(GET, "list people vouching") - ,(POST, "vouch for user") + [(resourceAt "/user/:username/endorse") + { resourceDesc = [(GET, "list people endorsing") + ,(POST, "endorse for user") ] , resourceGet = [("html", handleGetVouches)] , resourcePost = [("html", handlePostVouch)] diff --git a/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden b/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden index d0d1d301f..b45e64f28 100644 --- a/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden +++ b/tests/golden/ReverseDependenciesTest/getNotificationEmails-NotifyVouchingCompleted.golden @@ -8,9 +8,9 @@ Content-Type: multipart/alternative; boundary="YIYrWcf3to" Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: quoted-printable -You have received all necessary vouches=2E You have been added the the 'upl= -oaders' group=2E You can now upload packages to Hackage=2E Note that packag= -es cannot be deleted, so be careful=2E +You have received all necessary endorsements=2E You have been added the the= + 'uploaders' group=2E You can now upload packages to Hackage=2E Note that p= +ackages cannot be deleted, so be careful=2E You can adjust your notification preferences at https://hackage=2Ehaskell=2Eorg/user/user-watcher/notify (https://hackage= @@ -23,9 +23,9 @@ Content-Transfer-Encoding: quoted-printable

    -You have received all necessary vouches=2E You have been added the the 'upl= -oaders' group=2E You can now upload packages to Hackage=2E Note that packag= -es cannot be deleted, so be careful=2E +You have received all necessary endorsements=2E You have been added the the= + 'uploaders' group=2E You can now upload packages to Hackage=2E Note that p= +ackages cannot be deleted, so be careful=2E

    You can adjust your notification preferences at From f763229b614e479fba0b3678ca1b8a95f9334a1d Mon Sep 17 00:00:00 2001 From: gbaz Date: Mon, 4 Dec 2023 18:10:43 -0500 Subject: [PATCH 5/6] Update Vouch.hs --- src/Distribution/Server/Features/Vouch.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Distribution/Server/Features/Vouch.hs b/src/Distribution/Server/Features/Vouch.hs index 39795f0b5..ba08ecc4a 100644 --- a/src/Distribution/Server/Features/Vouch.hs +++ b/src/Distribution/Server/Features/Vouch.hs @@ -218,6 +218,7 @@ initVouchFeature ServerEnv{serverStateDir, serverTemplatesDir, serverTemplatesMo liftIO $ Group.addUserToGroup uploadersGroup vouchee pure . toResponse $ vouchTemplate [ "msg" $= "Added endorsement. User is now an uploader!" + , "requiredNumber" $= show requiredCountOfVouches , param ] AddVouchIncomplete stillRequired -> From 36ab220e84bb86ff97f5d12e93a67713f5ddc5fd Mon Sep 17 00:00:00 2001 From: gbaz Date: Mon, 4 Dec 2023 18:12:17 -0500 Subject: [PATCH 6/6] Update vouch.html.st --- datafiles/templates/Html/vouch.html.st | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/datafiles/templates/Html/vouch.html.st b/datafiles/templates/Html/vouch.html.st index 36d0e7945..cb34cd855 100644 --- a/datafiles/templates/Html/vouch.html.st +++ b/datafiles/templates/Html/vouch.html.st @@ -18,9 +18,7 @@ $hackagePageHeader()$

    Endorsing cannot be undone! When the user has $requiredNumber$ endorsements, the user -can upload packages. Note that users are, to a certain degree, held accountable -for the actions of the users they endorse. Only endorse people you know.

    - +will be added to the uploaders group, and allowed to upload packages. Only endorse people who you trust to upload packages responsibly.

      $vouches$