Skip to content

Commit 9e66993

Browse files
authored
Merge pull request #6036 from commercialhaskell/fix6034
Fix #6034 Allow authenticated requests to GitHub REST API
2 parents 859ad42 + 61eecd4 commit 9e66993

File tree

5 files changed

+120
-44
lines changed

5 files changed

+120
-44
lines changed

.github/workflows/integration-tests.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ on:
1111
- '**'
1212
workflow_dispatch:
1313

14+
# Stack will use the value of the GH_TOKEN environment variable to authenticate
15+
# its requests of the GitHub REST API, providing a higher request rate limit.
16+
env:
17+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18+
1419
# As of 26 December 2022, ubuntu-latest, windows-latest and macos-latest come
1520
# with Stack 2.9.1. ubuntu-latest and macos-latest come with GHC 9.4.3.
1621
# windows-latest comes with GHC 9.4.2. windows-latest comes with NSIS 3.08, for

ChangeLog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ Other enhancements:
2727

2828
* Better error message if the value of the `STACK_WORK` environment variable or
2929
`--work-dir` option is not a valid relative path.
30+
* Stack will use the value of the `GH_TOKEN`, or `GITHUB_TOKEN`, environment
31+
variable as credentials to authenticate its GitHub REST API requests.
32+
3033

3134
Bug fixes:
3235

doc/environment_variables.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
The environment variables listed in alphabetal order below can affect how Stack
66
behaves.
77

8+
## `GH_TOKEN` or `GITHUB_TOKEN`
9+
10+
:octicons-tag-24: UNRELEASED
11+
12+
Stack will use the value of the `GH_TOKEN` or, in the alternative,
13+
`GITHUB_TOKEN` environment variable (if not an empty string) as credentials to
14+
authenticate its requests of the GitHub REST API, using HTTP 'Basic'
15+
authentication.
16+
17+
GitHub limits the rate of unauthenticated requests to its API, although most
18+
users of Stack will not experience that limit from the use of Stack alone. The
19+
limit for authenticated requests is significantly higher.
20+
21+
For more information about authentication of requests of the GitHub REST API,
22+
see GitHub's REST API documentation.
23+
824
## `HACKAGE_KEY`
925

1026
[:octicons-tag-24: 2.7.5](https://github.com/commercialhaskell/stack/releases/tag/v2.7.5)

src/Stack/Constants.hs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ module Stack.Constants
124124
, relFileBuildLock
125125
, stackDeveloperModeDefault
126126
, globalFooter
127+
, gitHubBasicAuthType
128+
, gitHubTokenEnvVar
129+
, altGitHubTokenEnvVar
127130
) where
128131

129132
import Data.ByteString.Builder ( byteString )
@@ -605,3 +608,17 @@ stackDeveloperModeDefault = STACK_DEVELOPER_MODE_DEFAULT
605608
globalFooter :: String
606609
globalFooter =
607610
"Command 'stack --help' for global options that apply to all subcommands."
611+
612+
-- | The type for GitHub REST API HTTP \'Basic\' authentication.
613+
gitHubBasicAuthType :: ByteString
614+
gitHubBasicAuthType = "Bearer"
615+
616+
-- | Environment variable to hold credentials for GitHub REST API HTTP \'Basic\'
617+
-- authentication.
618+
gitHubTokenEnvVar :: String
619+
gitHubTokenEnvVar = "GH_TOKEN"
620+
621+
-- | Alternate environment variable to hold credentials for GitHub REST API HTTP
622+
-- \'Basic\' authentication.
623+
altGitHubTokenEnvVar :: String
624+
altGitHubTokenEnvVar = "GITHUB_TOKEN"

src/Stack/New.hs

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import qualified Data.Text.Lazy as TL
3333
import qualified Data.Text.Lazy.Encoding as TLE
3434
import Data.Time.Calendar ( toGregorian )
3535
import Data.Time.Clock ( getCurrentTime, utctDay )
36+
import Network.HTTP.Client ( applyBasicAuth )
3637
import Network.HTTP.StackClient
3738
( HttpException (..), HttpExceptionContent (..)
3839
, Response (..), VerifiedDownloadException (..)
@@ -45,7 +46,10 @@ import Path ( (</>), dirname, parent, parseRelDir, parseRelFile )
4546
import Path.IO
4647
( doesDirExist, doesFileExist, ensureDir, getCurrentDir )
4748
import RIO.Process ( proc, runProcess_, withWorkingDir )
48-
import Stack.Constants ( backupUrlRelPath, wiredInPackages )
49+
import Stack.Constants
50+
( altGitHubTokenEnvVar, backupUrlRelPath, gitHubBasicAuthType
51+
, gitHubTokenEnvVar, wiredInPackages
52+
)
4953
import Stack.Constants.Config ( templatesDir )
5054
import Stack.Prelude
5155
import Stack.Types.Config ( Config (..), HasConfig (..), SCM (..) )
@@ -54,6 +58,7 @@ import Stack.Types.TemplateName
5458
, TemplatePath (..), defaultTemplateName
5559
, parseRepoPathWithService, templateName, templatePath
5660
)
61+
import System.Environment ( lookupEnv )
5762
import qualified Text.Mustache as Mustache
5863
import qualified Text.Mustache.Render as Mustache
5964
import Text.ProjectTemplate
@@ -332,16 +337,15 @@ loadTemplate name logIt = do
332337
(do f <- loadLocalFile relFile eitherByteStringToText
333338
logIt LocalTemp
334339
pure f)
335-
(\(e :: PrettyException) -> do
336-
case relSettings rawParam of
337-
Just settings -> do
338-
let url = tplDownloadUrl settings
339-
extract = tplExtract settings
340-
downloadTemplate url extract (templateDir </> relFile)
341-
Nothing -> throwM e
340+
( \(e :: PrettyException) -> do
341+
settings <- fromMaybe (throwM e) (relSettings rawParam)
342+
let url = tplDownloadUrl settings
343+
mBasicAuth = tplBasicAuth settings
344+
extract = tplExtract settings
345+
downloadTemplate url mBasicAuth extract (templateDir </> relFile)
342346
)
343347
RepoPath rtp -> do
344-
let settings = settingsFromRepoTemplatePath rtp
348+
settings <- settingsFromRepoTemplatePath rtp
345349
downloadFromUrl settings templateDir
346350

347351
where
@@ -365,26 +369,30 @@ loadTemplate name logIt = do
365369
else throwM $ PrettyException $
366370
LoadTemplateFailed name path
367371

368-
relSettings :: String -> Maybe TemplateDownloadSettings
372+
relSettings :: String -> Maybe (RIO env TemplateDownloadSettings)
369373
relSettings req = do
370374
rtp <- parseRepoPathWithService defaultRepoService (T.pack req)
371375
pure (settingsFromRepoTemplatePath rtp)
372376

373377
downloadFromUrl :: TemplateDownloadSettings -> Path Abs Dir -> RIO env Text
374378
downloadFromUrl settings templateDir = do
375379
let url = tplDownloadUrl settings
380+
mBasicAuth = tplBasicAuth settings
376381
rel = fromMaybe backupUrlRelPath (parseRelFile url)
377-
downloadTemplate url (tplExtract settings) (templateDir </> rel)
378-
379-
downloadTemplate :: String
380-
-> (ByteString
381-
-> Either String Text)
382-
-> Path Abs File
383-
-> RIO env Text
384-
downloadTemplate url extract path = do
382+
downloadTemplate url mBasicAuth (tplExtract settings) (templateDir </> rel)
383+
384+
downloadTemplate ::
385+
String
386+
-> Maybe (ByteString, ByteString)
387+
-- ^ Optional HTTP \'Basic\' authentication (type, credentials)
388+
-> (ByteString -> Either String Text)
389+
-> Path Abs File
390+
-> RIO env Text
391+
downloadTemplate url mBasicAuth extract path = do
385392
req <- parseRequest url
386-
let dReq = setForceDownload True $
387-
mkDownloadRequest (setRequestCheckStatus req)
393+
let authReq = maybe id (uncurry applyBasicAuth) mBasicAuth req
394+
dReq = setForceDownload True $
395+
mkDownloadRequest (setRequestCheckStatus authReq)
388396
logIt RemoteTemp
389397
catch
390398
( do let label = T.pack $ toFilePath path
@@ -414,8 +422,11 @@ loadTemplate name logIt = do
414422
throwM $ PrettyException $
415423
DownloadTemplateFailed (templateName name) url exception
416424

425+
-- | Type representing settings for the download of Stack project templates.
417426
data TemplateDownloadSettings = TemplateDownloadSettings
418427
{ tplDownloadUrl :: String
428+
, tplBasicAuth :: Maybe (ByteString, ByteString)
429+
-- ^ Optional HTTP 'Basic' authentication (type, credentials)
419430
, tplExtract :: ByteString -> Either String Text
420431
}
421432

@@ -425,40 +436,64 @@ eitherByteStringToText = mapLeft show . decodeUtf8'
425436
asIsFromUrl :: String -> TemplateDownloadSettings
426437
asIsFromUrl url = TemplateDownloadSettings
427438
{ tplDownloadUrl = url
439+
, tplBasicAuth = Nothing
428440
, tplExtract = eitherByteStringToText
429441
}
430442

431-
-- | Construct a URL for downloading from a repo.
432-
settingsFromRepoTemplatePath :: RepoTemplatePath -> TemplateDownloadSettings
433-
settingsFromRepoTemplatePath (RepoTemplatePath GitHub user name) =
434-
-- T.concat ["https://raw.githubusercontent.com", "/", user, "/stack-templates/master/", name]
435-
TemplateDownloadSettings
436-
{ tplDownloadUrl = concat
437-
[ "https://api.github.com/repos/"
438-
, T.unpack user
439-
, "/stack-templates/contents/"
440-
, T.unpack name
441-
]
442-
, tplExtract = \bs -> do
443-
decodedJson <- eitherDecode (LB.fromStrict bs)
444-
case decodedJson of
445-
Object o | Just (String content) <- KeyMap.lookup "content" o -> do
446-
let noNewlines = T.filter (/= '\n')
447-
bsContent <- B64.decode $ T.encodeUtf8 (noNewlines content)
448-
mapLeft show $ decodeUtf8' bsContent
449-
_ ->
450-
Left "Couldn't parse GitHub response as a JSON object with a \"content\" field"
451-
}
452-
453-
settingsFromRepoTemplatePath (RepoTemplatePath GitLab user name) =
443+
-- | Construct settings for downloading a Stack project template from a
444+
-- repository.
445+
settingsFromRepoTemplatePath ::
446+
HasTerm env
447+
=> RepoTemplatePath
448+
-> RIO env TemplateDownloadSettings
449+
settingsFromRepoTemplatePath (RepoTemplatePath GitHub user name) = do
450+
let basicAuthMsg token = prettyInfoL
451+
[ flow "Using content of"
452+
, fromString token
453+
, flow " environment variable to authenticate GitHub REST API."
454+
]
455+
mBasicAuth <- do
456+
wantGitHubToken <- liftIO $ fromMaybe "" <$> lookupEnv gitHubTokenEnvVar
457+
if not (L.null wantGitHubToken)
458+
then do
459+
basicAuthMsg gitHubTokenEnvVar
460+
pure $ Just (gitHubBasicAuthType, fromString wantGitHubToken)
461+
else do
462+
wantAltGitHubToken <-
463+
liftIO $ fromMaybe "" <$> lookupEnv altGitHubTokenEnvVar
464+
if not (L.null wantAltGitHubToken)
465+
then do
466+
basicAuthMsg altGitHubTokenEnvVar
467+
pure $ Just (gitHubBasicAuthType, fromString wantAltGitHubToken)
468+
else pure Nothing
469+
pure $ TemplateDownloadSettings
470+
{ tplDownloadUrl = concat
471+
[ "https://api.github.com/repos/"
472+
, T.unpack user
473+
, "/stack-templates/contents/"
474+
, T.unpack name
475+
]
476+
, tplBasicAuth = mBasicAuth
477+
, tplExtract = \bs -> do
478+
decodedJson <- eitherDecode (LB.fromStrict bs)
479+
case decodedJson of
480+
Object o | Just (String content) <- KeyMap.lookup "content" o -> do
481+
let noNewlines = T.filter (/= '\n')
482+
bsContent <- B64.decode $ T.encodeUtf8 (noNewlines content)
483+
mapLeft show $ decodeUtf8' bsContent
484+
_ ->
485+
Left "Couldn't parse GitHub response as a JSON object with a \
486+
\\"content\" field"
487+
}
488+
settingsFromRepoTemplatePath (RepoTemplatePath GitLab user name) = pure $
454489
asIsFromUrl $ concat
455490
[ "https://gitlab.com"
456491
, "/"
457492
, T.unpack user
458493
, "/stack-templates/raw/master/"
459494
, T.unpack name
460495
]
461-
settingsFromRepoTemplatePath (RepoTemplatePath Bitbucket user name) =
496+
settingsFromRepoTemplatePath (RepoTemplatePath Bitbucket user name) = pure $
462497
asIsFromUrl $ concat
463498
[ "https://bitbucket.org"
464499
, "/"

0 commit comments

Comments
 (0)