-
-
Notifications
You must be signed in to change notification settings - Fork 5.8k
User keypairs and HTTP signatures for ActivityPub federation using go-ap #19133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 14 commits
Commits
Show all changes
116 commits
Select commit
Hold shift + click to select a range
f2db473
go.mod: add go-fed/{httpsig,activity/pub,activity/streams} dependency
4951af4
activitypub: implement /api/v1/activitypub/user/{username} (#14186)
678a56f
activitypub: add the public key to Person (#14186)
e8907c3
activitypub: go-fed conformant Clock instance
15c1f62
activitypub: signing http client
97fedf2
activitypub: implement the ReqSignature middleware
b342241
activitypub: hack_16834
2a8864f
Fix CI checks-backend errors with go mod tidy
b480c52
Change 2021 to 2022, properly format package imports
f9e33d9
Run make fmt and make generate-swagger
ea4129e
Use Gitea JSON library, add assert for pkp
456ed42
Run make fmt again, fix err var redeclaration
d75809a
Remove LogSQL from ActivityPub person test
ebef769
Assert if json.Unmarshal succeeds
46973f9
Cleanup, handle invalid usernames for ActivityPub person GET request
3ed4a71
Rename hack_16834 to user_settings
21c56f8
Use the httplib module instead of http for GET requests
373a84a
Clean up whitespace with make fmt
d1a53f7
Use time.RFC1123 and make the http.Client proxy-aware
65016b2
Check if digest algo is supported in setting module
1da0d49
Clean up some variable declarations
fdae736
Remove unneeded copy
7ea5e10
Use system timezone instead of setting.DefaultUILocation
5139b3d
Use named constant for httpsigExpirationTime
7931e21
Make pubKey IRI #main-key instead of /#main-key
702a963
Move /#main-key to #main-key in tests
523ca3d
Implemented Webfinger endpoint.
KN4CK3R 0d120f8
Add visible check.
KN4CK3R 5d61e59
Add user profile as alias.
KN4CK3R aa962c6
Add actor IRI and remote interaction URL to WebFinger response
07150b3
Merge branch 'master' into feature-activitypub
6543 55c5e93
fmt
6543 609fcc7
Merge branch 'master' into feature-activitypub
6543 364838c
Fix lint errors
501a39f
Merge remote-tracking branch 'github/feature-activitypub' into featur…
becdf5e
Use go-ap instead of go-fed
1e57f01
Merge remote-tracking branch 'github/main' into feature-activitypub
67e0fcd
Run go mod tidy to fix missing modules in go.mod and go.sum
57e6b67
make fmt
a8cb4a8
Convert remaining code to go-ap
94fbd80
Clean up go.sum
2f0a0b1
Merge branch 'main' into feature-activitypub
6543 46cab80
Fix JSON unmarshall error
86a3221
Fix CI errors by adding @context to Person() and making sure types match
d487a76
Correctly decode JSON in api_activitypub_person_test.go
fc58ab6
Force CI rerun
7428ff0
Fix TestActivityPubPersonInbox segfault
66b1761
Fix lint error
f7da251
Merge branch 'main' into feature-activitypub
6543 cf6aed3
Use @mariusor's suggestions for idiomatic go-ap usage
528c282
Correctly add inbox/outbox IRIs to person
7658649
Merge remote-tracking branch 'upstream/main' into feature-activitypub
6074222
Code cleanup
76f06ce
Remove another LogSQL from ActivityPub person test
d1f14ff
Move httpsig algos slice to an init() function
191919e
Merge remote-tracking branch 'upstream/main' into feature-activitypub
5823d81
Add actor IRI and remote interaction URL to WebFinger response
d91c61f
Update TestWebFinger to check for ActivityPub IRI in aliases
d7b81f5
make fmt
a5b00ec
Force CI rerun
b6b7fe2
WebFinger: Add CORS header and fix Href -> Template for remote intera…
718f35a
Merge remote-tracking branch 'upstream/main' into feature-activitypub
ed2a6f5
make lint-backend
f889793
Make sure Person endpoint has Content-Type application/activity+json …
3e9a69c
Use UTC instead of GMT
d749f8f
Rename pkey to pubKey
08eebff
Make sure HTTP request Date in GMT
2706e89
Merge branch 'main' into feature-activitypub
6543 900ceb2
make fmt
6543 3f2d8b0
dont drop err
6543 add8469
Merge branch 'feature-activitypub' of github.com:Ta180m/Gitea into fe…
e60158c
Make sure API responses always refer to username in original case
a4403e4
Move httpsig algs constant slice to modules/setting/federation.go
faf2855
Add new federation settings to app.example.ini and config-cheat-sheet
d06772b
Return if marshalling error
a312007
Make sure Person IRIs are generated correctly
f53e46c
If httpsig verification fails, fix Host header and try again
f8ad1a8
Apply suggestions from code review
6543 c05bad8
Merge branch 'main' into feature-activitypub
6543 14cfd8d
Revert "If httpsig verification fails, fix Host header and try again"
1da4849
Merge remote-tracking branch 'github/feature-activitypub' into featur…
f48115f
Go back to using ap.IRI to generate inbox and outbox IRIs
172c39f
use const for key values
6543 5840163
Update routers/web/webfinger.go
6543 46b344c
Merge branch 'main' into feature-activitypub
6543 e5ed91d
Merge remote-tracking branch 'github/feature-activitypub' into featur…
95aad98
Use ctx.JSON in Person response to make code cleaner
3fe4459
Revert "Use ctx.JSON in Person response to make code cleaner"
e9e8a03
Use activitypub.ActivityStreamsContentType for Person response Conten…
a2d5202
Limit maximum ActivityPub request and response sizes to a configurabl…
fb1f551
Move setting key constants to models/user/setting_keys.go
ad62049
Fix failing ActivityPubPerson integration test by checking the correc…
db13e1d
Add a warning about changing settings that can break federation
6336ba2
Add better comments
0c49fea
Don't multiply Federation.MaxSize by 1<<20 twice
6602fd1
Add more better comments
3a8b840
Fix failing ActivityPubMissingPerson test
2a013b8
make generate-swagger
c118dac
Move getting the RFC 2616 time to a separate function
b35490c
Merge remote-tracking branch 'upstream/main' into feature-activitypub
7a214dd
More code cleanup
7e1784f
Merge branch 'main' into feature-activitypub
6543 8e6f3fb
Update go-ap to fix empty liked collection and removed unneeded HTTP …
7446583
go mod tidy
47011db
Add ed25519 to httpsig algorithms
7bfadb4
Merge branch 'main' into feature-activitypub
6543 fe18cf7
Merge branch 'main' into feature-activitypub
6543 37d2d01
Use go-ap/jsonld to add @context and marshal JSON
7c10ab9
Change Gitea user agent from the default to Gitea/Version
0231dad
go mod tidy
adfb213
Merge branch 'main' into feature-activitypub
6543 1bd8eb6
Merge branch 'main' into feature-activitypub
6543 4ffb6b6
Use ctx.ServerError and remove all remote interaction code from webfi…
28fd3e7
Remove accidently added files
f602958
Use ctx.ServerError in reqsignature.go
a3ff170
Merge branch 'main' into feature-activitypub
6543 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// Use of this source code is governed by a MIT-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package integrations | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"net/http" | ||
"net/http/httptest" | ||
"net/url" | ||
"testing" | ||
|
||
user_model "code.gitea.io/gitea/models/user" | ||
"code.gitea.io/gitea/modules/activitypub" | ||
"code.gitea.io/gitea/modules/json" | ||
"code.gitea.io/gitea/modules/setting" | ||
|
||
"github.com/go-fed/activity/pub" | ||
"github.com/go-fed/activity/streams" | ||
"github.com/go-fed/activity/streams/vocab" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestActivityPubPerson(t *testing.T) { | ||
onGiteaRun(t, func(*testing.T, *url.URL) { | ||
setting.Federation.Enabled = true | ||
defer func() { | ||
setting.Federation.Enabled = false | ||
}() | ||
|
||
username := "user2" | ||
req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username)) | ||
resp := MakeRequest(t, req, http.StatusOK) | ||
assert.Contains(t, resp.Body.String(), "@context") | ||
var m map[string]interface{} | ||
err := json.Unmarshal(resp.Body.Bytes(), &m) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
assert.Equal(t, err, nil) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
|
||
var person vocab.ActivityStreamsPerson | ||
resolver, _ := streams.NewJSONResolver(func(c context.Context, p vocab.ActivityStreamsPerson) error { | ||
person = p | ||
return nil | ||
}) | ||
ctx := context.Background() | ||
err = resolver.Resolve(ctx, m) | ||
assert.Equal(t, err, nil) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
assert.Equal(t, "Person", person.GetTypeName()) | ||
assert.Equal(t, username, person.GetActivityStreamsName().Begin().GetXMLSchemaString()) | ||
keyID := person.GetJSONLDId().GetIRI().String() | ||
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.GetActivityStreamsOutbox().GetIRI().String()) | ||
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.GetActivityStreamsInbox().GetIRI().String()) | ||
|
||
pkp := person.GetW3IDSecurityV1PublicKey() | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
assert.NotNil(t, pkp) | ||
publicKeyID := keyID + "/#main-key" | ||
var pkpFound vocab.W3IDSecurityV1PublicKey | ||
for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { | ||
if !pkpIter.IsW3IDSecurityV1PublicKey() { | ||
continue | ||
} | ||
pkValue := pkpIter.Get() | ||
var pkID *url.URL | ||
pkID, err = pub.GetId(pkValue) | ||
if err != nil { | ||
return | ||
} | ||
assert.Equal(t, pkID.String(), publicKeyID) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
if pkID.String() != publicKeyID { | ||
continue | ||
} | ||
pkpFound = pkValue | ||
break | ||
} | ||
assert.NotNil(t, pkpFound) | ||
|
||
pkPemProp := pkpFound.GetW3IDSecurityV1PublicKeyPem() | ||
assert.NotNil(t, pkPemProp) | ||
assert.True(t, pkPemProp.IsXMLSchemaString()) | ||
|
||
pubKeyPem := pkPemProp.Get() | ||
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) | ||
}) | ||
} | ||
|
||
func TestActivityPubMissingPerson(t *testing.T) { | ||
onGiteaRun(t, func(*testing.T, *url.URL) { | ||
setting.Federation.Enabled = true | ||
defer func() { | ||
setting.Federation.Enabled = false | ||
}() | ||
|
||
req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser") | ||
resp := MakeRequest(t, req, http.StatusNotFound) | ||
assert.Contains(t, resp.Body.String(), "GetUserByName") | ||
}) | ||
} | ||
|
||
func TestActivityPubPersonInbox(t *testing.T) { | ||
srv := httptest.NewServer(c) | ||
defer srv.Close() | ||
|
||
onGiteaRun(t, func(*testing.T, *url.URL) { | ||
appURL := setting.AppURL | ||
setting.Federation.Enabled = true | ||
setting.Database.LogSQL = true | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
setting.AppURL = srv.URL | ||
defer func() { | ||
setting.Federation.Enabled = false | ||
setting.Database.LogSQL = false | ||
setting.AppURL = appURL | ||
}() | ||
username1 := "user1" | ||
user1, err := user_model.GetUserByName(username1) | ||
assert.NoError(t, err) | ||
user1url := fmt.Sprintf("%s/api/v1/activitypub/user/%s/#main-key", srv.URL, username1) | ||
c, err := activitypub.NewClient(user1, user1url) | ||
assert.NoError(t, err) | ||
username2 := "user2" | ||
user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user/%s/inbox", srv.URL, username2) | ||
|
||
// Signed request succeeds | ||
resp, err := c.Post([]byte{}, user2inboxurl) | ||
assert.NoError(t, err) | ||
assert.Equal(t, 204, resp.StatusCode) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
|
||
// Unsigned request fails | ||
req := NewRequest(t, "POST", user2inboxurl) | ||
MakeRequest(t, req, 500) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
}) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// Use of this source code is governed by a MIT-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package activitypub | ||
|
||
import ( | ||
"bytes" | ||
"crypto/rsa" | ||
"crypto/x509" | ||
"encoding/pem" | ||
"fmt" | ||
"net/http" | ||
|
||
user_model "code.gitea.io/gitea/models/user" | ||
"code.gitea.io/gitea/modules/setting" | ||
|
||
"github.com/go-fed/activity/pub" | ||
"github.com/go-fed/httpsig" | ||
) | ||
|
||
const ( | ||
// ActivityStreamsContentType const | ||
ActivityStreamsContentType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
) | ||
|
||
func containsRequiredHTTPHeaders(method string, headers []string) error { | ||
var hasRequestTarget, hasDate, hasDigest bool | ||
for _, header := range headers { | ||
hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget | ||
hasDate = hasDate || header == "Date" | ||
hasDigest = method == "GET" || hasDigest || header == "Digest" | ||
} | ||
if !hasRequestTarget { | ||
return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget) | ||
} else if !hasDate { | ||
return fmt.Errorf("missing http header for %s: Date", method) | ||
} else if !hasDigest { | ||
return fmt.Errorf("missing http header for %s: Digest", method) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
} | ||
return nil | ||
} | ||
|
||
// Client struct | ||
type Client struct { | ||
clock pub.Clock | ||
client *http.Client | ||
algs []httpsig.Algorithm | ||
digestAlg httpsig.DigestAlgorithm | ||
getHeaders []string | ||
postHeaders []string | ||
priv *rsa.PrivateKey | ||
pubID string | ||
} | ||
|
||
// NewClient function | ||
func NewClient(user *user_model.User, pubID string) (c *Client, err error) { | ||
if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { | ||
return | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that there is currently a PR that attempts to get rid of naked returns, I think you should convert them. |
||
} else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { | ||
return | ||
} else if !httpsig.IsSupportedDigestAlgorithm(setting.Federation.DigestAlgorithm) { | ||
err = fmt.Errorf("unsupported digest algorithm: %s", setting.Federation.DigestAlgorithm) | ||
return | ||
} | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
algos := make([]httpsig.Algorithm, len(setting.Federation.Algorithms)) | ||
for i, algo := range setting.Federation.Algorithms { | ||
algos[i] = httpsig.Algorithm(algo) | ||
} | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
clock, err := NewClock() | ||
if err != nil { | ||
return | ||
} | ||
|
||
priv, err := GetPrivateKey(user) | ||
if err != nil { | ||
return | ||
} | ||
privPem, _ := pem.Decode([]byte(priv)) | ||
privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) | ||
if err != nil { | ||
return | ||
} | ||
|
||
c = &Client{ | ||
clock: clock, | ||
client: &http.Client{}, | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
algs: algos, | ||
digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), | ||
getHeaders: setting.Federation.GetHeaders, | ||
postHeaders: setting.Federation.PostHeaders, | ||
priv: privParsed, | ||
pubID: pubID, | ||
} | ||
return | ||
} | ||
|
||
// NewRequest function | ||
func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { | ||
byteCopy := make([]byte, len(b)) | ||
copy(byteCopy, b) | ||
buf := bytes.NewBuffer(byteCopy) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
req, err = http.NewRequest(http.MethodPost, to, buf) | ||
if err != nil { | ||
return | ||
} | ||
req.Header.Add("Content-Type", ActivityStreamsContentType) | ||
req.Header.Add("Accept-Charset", "utf-8") | ||
req.Header.Add("Date", fmt.Sprintf("%s GMT", c.clock.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05"))) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
|
||
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, 60) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
if err != nil { | ||
return | ||
} | ||
err = signer.SignRequest(c.priv, c.pubID, req, b) | ||
return | ||
} | ||
|
||
// Post function | ||
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { | ||
var req *http.Request | ||
if req, err = c.NewRequest(b, to); err != nil { | ||
return | ||
} | ||
resp, err = c.client.Do(req) | ||
return | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// Use of this source code is governed by a MIT-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package activitypub | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"regexp" | ||
"testing" | ||
|
||
"code.gitea.io/gitea/models/unittest" | ||
user_model "code.gitea.io/gitea/models/user" | ||
"code.gitea.io/gitea/modules/setting" | ||
|
||
_ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestActivityPubSignedPost(t *testing.T) { | ||
assert.NoError(t, unittest.PrepareTestDatabase()) | ||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) | ||
pubID := "https://example.com/pubID" | ||
c, err := NewClient(user, pubID) | ||
assert.NoError(t, err) | ||
|
||
expected := "BODY" | ||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest")) | ||
assert.Contains(t, r.Header.Get("Signature"), pubID) | ||
assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType) | ||
body, err := io.ReadAll(r.Body) | ||
assert.NoError(t, err) | ||
assert.Equal(t, expected, string(body)) | ||
fmt.Fprintf(w, expected) | ||
})) | ||
defer srv.Close() | ||
|
||
r, err := c.Post([]byte(expected), srv.URL) | ||
assert.NoError(t, err) | ||
defer r.Body.Close() | ||
body, err := io.ReadAll(r.Body) | ||
assert.NoError(t, err) | ||
assert.Equal(t, expected, string(body)) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// Use of this source code is governed by a MIT-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package activitypub | ||
|
||
import ( | ||
"time" | ||
|
||
"code.gitea.io/gitea/modules/setting" | ||
|
||
"github.com/go-fed/activity/pub" | ||
) | ||
|
||
var _ pub.Clock = &Clock{} | ||
|
||
// Clock struct | ||
type Clock struct{} | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
|
||
// NewClock function | ||
func NewClock() (c *Clock, err error) { | ||
c = &Clock{} | ||
return | ||
} | ||
|
||
// Now function | ||
func (c *Clock) Now() time.Time { | ||
return time.Now().In(setting.DefaultUILocation) | ||
This conversation was marked as resolved.
Show resolved
Hide resolved
|
||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.