diff --git a/internal/api/s2s/user/publickeyget.go b/internal/api/s2s/user/publickeyget.go new file mode 100644 index 0000000..b6aaded --- /dev/null +++ b/internal/api/s2s/user/publickeyget.go @@ -0,0 +1,45 @@ +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. +// +// The goal here is to return a MINIMAL activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. The account will only contain the id, +// public key, username, and type of the account. +func (m *Module) PublicKeyGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "PublicKeyGETHandler", + "url": c.Request.RequestURI, + }) + + requestedUsername := c.Param(UsernameKey) + if requestedUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + return + } + + // make sure this actually an AP request + format := c.NegotiateFormat(ActivityPubAcceptHeaders...) + if format == "" { + c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"}) + return + } + l.Tracef("negotiated format: %s", format) + + // make a copy of the context to pass along so we don't break anything + cp := c.Copy() + user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetFediUser handles auth as well + if err != nil { + l.Info(err.Error()) + c.JSON(err.Code(), gin.H{"error": err.Safe()}) + return + } + + c.JSON(http.StatusOK, user) +} diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go index ffb3080..0cb8e1e 100644 --- a/internal/api/s2s/user/user.go +++ b/internal/api/s2s/user/user.go @@ -40,6 +40,8 @@ const ( // Use this anywhere you need to know the username of the user being queried. // Eg https://example.org/users/:username UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey + // UsersPublicKeyPath is a path to a user's public key, for serving bare minimum AP representations. + UsersPublicKeyPath = UsersBasePathWithUsername + "/" + util.PublicKeyPath // UsersInboxPath is for serving POST requests to a user's inbox with the given username key. UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath // UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key. @@ -80,5 +82,6 @@ func (m *Module) Route(s router.Router) error { s.AttachHandler(http.MethodGet, UsersFollowersPath, m.FollowersGETHandler) s.AttachHandler(http.MethodGet, UsersFollowingPath, m.FollowingGETHandler) s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler) + s.AttachHandler(http.MethodGet, UsersPublicKeyPath, m.PublicKeyGETHandler) return nil } diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index ba9963a..cf1bcec 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -76,7 +76,7 @@ type Account struct { */ // Does this account need an approval for new followers? - Locked bool `pg:",default:false"` + Locked bool `pg:",default:true"` // Should this account be shown in the instance's profile directory? Discoverable bool `pg:",default:false"` // Default post privacy for this account diff --git a/internal/processing/federation.go b/internal/processing/federation.go index ab84421..a154034 100644 --- a/internal/processing/federation.go +++ b/internal/processing/federation.go @@ -25,6 +25,8 @@ import ( "net/url" "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -96,30 +98,49 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht } func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { + l := p.log.WithFields(logrus.Fields{ + "func": "GetFediUser", + "requestedUsername": requestedUsername, + "requestURL": request.URL.String(), + }) + // get the account the request is referring to requestedAccount := >smodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } - // authenticate the request - requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) - if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) - } + var requestedPerson vocab.ActivityStreamsPerson + var err error + if util.IsPublicKeyPath(request.URL) { + l.Debug("serving from public key path") + // if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key + requestedPerson, err = p.tc.AccountToASMinimal(requestedAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + } else if util.IsUserPath(request.URL) { + l.Debug("serving from user path") + // if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile + requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) + if err != nil { + return nil, gtserror.NewErrorNotAuthorized(err) + } - blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } + blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } - if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - - requestedPerson, err := p.tc.AccountToAS(requestedAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) + if blocked { + return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + } + requestedPerson, err = p.tc.AccountToAS(requestedAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + } else { + return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path")) } data, err := streams.Serialize(requestedPerson) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 806090f..80a9226 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -122,6 +122,7 @@ type TypeConverter interface { // AccountToAS converts a gts model account into an activity streams person, suitable for federation AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) + AccountToASMinimal(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) // StatusToAS converts a gts model status into an activity streams note, suitable for federation StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index bc7eee6..1760b87 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -258,6 +258,72 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso return person, nil } +// Converts a gts model account into a VERY MINIMAL Activity Streams person type, following +// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/ +// +// The returned account will just have the Type, Username, PublicKey, and ID properties set. +func (c *converter) AccountToASMinimal(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) { + person := streams.NewActivityStreamsPerson() + + // id should be the activitypub URI of this user + // something like https://example.org/users/example_user + profileIDURI, err := url.Parse(a.URI) + if err != nil { + return nil, err + } + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(profileIDURI) + person.SetJSONLDId(idProp) + + // preferredUsername + // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. + preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() + preferredUsernameProp.SetXMLSchemaString(a.Username) + person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + + // publicKey + // Required for signatures. + publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + + // create the public key + publicKey := streams.NewW3IDSecurityV1PublicKey() + + // set ID for the public key + publicKeyIDProp := streams.NewJSONLDIdProperty() + publicKeyURI, err := url.Parse(a.PublicKeyURI) + if err != nil { + return nil, err + } + publicKeyIDProp.SetIRI(publicKeyURI) + publicKey.SetJSONLDId(publicKeyIDProp) + + // set owner for the public key + publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() + publicKeyOwnerProp.SetIRI(profileIDURI) + publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + + // set the pem key itself + encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey) + if err != nil { + return nil, err + } + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() + publicKeyPEMProp.Set(string(publicKeyBytes)) + publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + + // append the public key to the public key property + publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + + // set the public key property on the Person + person.SetW3IDSecurityV1PublicKey(publicKeyProp) + + return person, nil +} + func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { // ensure prerequisites here before we get stuck in diff --git a/internal/util/regexes.go b/internal/util/regexes.go index 6ad7b74..13c3ce3 100644 --- a/internal/util/regexes.go +++ b/internal/util/regexes.go @@ -60,6 +60,9 @@ var ( // userPathRegex parses a path that validates and captures the username part from eg /users/example_username userPathRegex = regexp.MustCompile(userPathRegexString) + userPublicKeyPathRegexString = fmt.Sprintf(`^?/%s/(%s)/%s`, UsersPath, usernameRegexString, PublicKeyPath) + userPublicKeyPathRegex = regexp.MustCompile(userPublicKeyPathRegexString) + inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath) // inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox inboxPathRegex = regexp.MustCompile(inboxPathRegexString) diff --git a/internal/util/uri.go b/internal/util/uri.go index 7d48929..2bfdd6c 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -140,7 +140,7 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath) likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath) collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath) - publicKeyURI := fmt.Sprintf("%s#%s", userURI, PublicKeyPath) + publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath) return &UserURIs{ HostURL: hostURL, @@ -209,6 +209,11 @@ func IsStatusesPath(id *url.URL) bool { return statusesPathRegex.MatchString(id.Path) } +// IsPublicKeyPath returns true if the given URL path corresponds to eg /users/example_username/main-key +func IsPublicKeyPath(id *url.URL) bool { + return userPublicKeyPathRegex.MatchString(id.Path) +} + // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := statusesPathRegex.FindStringSubmatch(id.Path)