* start on federating faves

* outbound federation of likes working
This commit is contained in:
Tobi Smethurst 2021-05-24 18:49:48 +02:00 committed by GitHub
parent 2dbd132e50
commit e670c32a91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 250 additions and 47 deletions

View file

@ -270,7 +270,7 @@ type DB interface {
// FaveStatus faves the given status, using accountID as the faver. // FaveStatus faves the given status, using accountID as the faver.
// The returned fave will be nil if the status was already faved. // The returned fave will be nil if the status was already faved.
FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) // FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error)
// UnfaveStatus unfaves the given status, using accountID as the unfaver (sure, that's a word). // UnfaveStatus unfaves the given status, using accountID as the unfaver (sure, that's a word).
// The returned fave will be nil if the status was already not faved. // The returned fave will be nil if the status was already not faved.

View file

@ -1037,32 +1037,32 @@ func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID
return ps.conn.Model(&gtsmodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() return ps.conn.Model(&gtsmodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
} }
func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { // func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
// first check if a fave already exists, we can just return if so // // first check if a fave already exists, we can just return if so
existingFave := &gtsmodel.StatusFave{} // existingFave := &gtsmodel.StatusFave{}
err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() // err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select()
if err == nil { // if err == nil {
// fave already exists so just return nothing at all // // fave already exists so just return nothing at all
return nil, nil // return nil, nil
} // }
// an error occurred so it might exist or not, we don't know // // an error occurred so it might exist or not, we don't know
if err != pg.ErrNoRows { // if err != pg.ErrNoRows {
return nil, err // return nil, err
} // }
// it doesn't exist so create it // // it doesn't exist so create it
newFave := &gtsmodel.StatusFave{ // newFave := &gtsmodel.StatusFave{
AccountID: accountID, // AccountID: accountID,
TargetAccountID: status.AccountID, // TargetAccountID: status.AccountID,
StatusID: status.ID, // StatusID: status.ID,
} // }
if _, err = ps.conn.Model(newFave).Insert(); err != nil { // if _, err = ps.conn.Model(newFave).Insert(); err != nil {
return nil, err // return nil, err
} // }
return newFave, nil // return newFave, nil
} // }
func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
// if a fave doesn't exist, we don't need to do anything // if a fave doesn't exist, we don't need to do anything

View file

@ -777,7 +777,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err
if iter.IsIRI() { if iter.IsIRI() {
actorAccount := &gtsmodel.Account{} actorAccount := &gtsmodel.Account{}
if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here
return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host)) return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, uuid.NewString()))
} }
} }
} }
@ -787,7 +787,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err
// ID might already be set on a note we've created, so check it here and return it if it is // ID might already be set on a note we've created, so check it here and return it if it is
note, ok := t.(vocab.ActivityStreamsNote) note, ok := t.(vocab.ActivityStreamsNote)
if !ok { if !ok {
return nil, errors.New("newid: follow couldn't be parsed into vocab.ActivityStreamsNote") return nil, errors.New("newid: note couldn't be parsed into vocab.ActivityStreamsNote")
} }
idProp := note.GetJSONLDId() idProp := note.GetJSONLDId()
if idProp != nil { if idProp != nil {
@ -795,6 +795,19 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err
return idProp.GetIRI(), nil return idProp.GetIRI(), nil
} }
} }
case gtsmodel.ActivityStreamsLike:
// LIKE aka FAVE
// ID might already be set on a fave we've created, so check it here and return it if it is
fave, ok := t.(vocab.ActivityStreamsLike)
if !ok {
return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsLike")
}
idProp := fave.GetJSONLDId()
if idProp != nil {
if idProp.IsIRI() {
return idProp.GetIRI(), nil
}
}
} }
// fallback default behavior: just return a random UUID after our protocol and host // fallback default behavior: just return a random UUID after our protocol and host

View file

@ -32,7 +32,13 @@ type StatusFave struct {
TargetAccountID string `pg:",notnull"` TargetAccountID string `pg:",notnull"`
// database id of the status that has been 'faved' // database id of the status that has been 'faved'
StatusID string `pg:",notnull"` StatusID string `pg:",notnull"`
// ActivityPub URI of this fave
URI string `pg:",notnull"`
// FavedStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. // GTSStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around.
FavedStatus *Status `pg:"-"` GTSStatus *Status `pg:"-"`
// GTSTargetAccount is the account being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around.
GTSTargetAccount *Account `pg:"-"`
// GTSFavingAccount is the account doing the faving. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around.
GTSFavingAccount *Account `pg:"-"`
} }

View file

@ -22,6 +22,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/google/uuid"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -417,11 +418,15 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou
} }
// make the follow request // make the follow request
newFollowID := uuid.NewString()
fr := &gtsmodel.FollowRequest{ fr := &gtsmodel.FollowRequest{
ID: newFollowID,
AccountID: authed.Account.ID, AccountID: authed.Account.ID,
TargetAccountID: form.TargetAccountID, TargetAccountID: form.TargetAccountID,
ShowReblogs: true, ShowReblogs: true,
URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host), URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host, newFollowID),
Notify: false, Notify: false,
} }
if form.Reblogs != nil { if form.Reblogs != nil {

View file

@ -44,7 +44,7 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error
return err return err
} }
if status.VisibilityAdvanced.Federated { if status.VisibilityAdvanced != nil && status.VisibilityAdvanced.Federated {
return p.federateStatus(status) return p.federateStatus(status)
} }
return nil return nil
@ -60,6 +60,18 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error
} }
return p.federateFollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) return p.federateFollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount)
case gtsmodel.ActivityStreamsLike:
// CREATE LIKE/FAVE
fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave)
if !ok {
return errors.New("fave was not parseable as *gtsmodel.StatusFave")
}
if err := p.notifyFave(fave); err != nil {
return err
}
return p.federateFave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount)
} }
case gtsmodel.ActivityStreamsUpdate: case gtsmodel.ActivityStreamsUpdate:
// UPDATE // UPDATE
@ -214,3 +226,23 @@ func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, originA
_, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, accept) _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, accept)
return err return err
} }
func (p *processor) federateFave(fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error {
// if both accounts are local there's nothing to do here
if originAccount.Domain == "" && targetAccount.Domain == "" {
return nil
}
// create the AS fave
asFave, err := p.tc.FaveToAS(fave)
if err != nil {
return fmt.Errorf("federateFave: error converting fave to as format: %s", err)
}
outboxIRI, err := url.Parse(originAccount.OutboxURI)
if err != nil {
return fmt.Errorf("federateFave: error parsing outboxURI %s: %s", originAccount.OutboxURI, err)
}
_, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asFave)
return err
}

View file

@ -27,3 +27,7 @@ func (p *processor) notifyStatus(status *gtsmodel.Status) error {
func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { func (p *processor) notifyFollow(follow *gtsmodel.Follow) error {
return nil return nil
} }
func (p *processor) notifyFave(fave *gtsmodel.StatusFave) error {
return nil
}

View file

@ -25,6 +25,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
@ -168,6 +169,14 @@ func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apim
return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
} }
var boostOfStatus *gtsmodel.Status
if targetStatus.BoostOfID != "" {
boostOfStatus = &gtsmodel.Status{}
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
}
}
l.Trace("going to see if status is visible") l.Trace("going to see if status is visible")
visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
if err != nil { if err != nil {
@ -185,20 +194,44 @@ func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apim
} }
} }
// it's visible! it's faveable! so let's fave the FUCK out of it // first check if the status is already faved, if so we don't need to do anything
_, err = p.db.FaveStatus(targetStatus, authed.Account.ID) newFave := true
if err != nil { gtsFave := &gtsmodel.Status{}
return nil, fmt.Errorf("error faveing status: %s", err) if err := p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: authed.Account.ID}}, gtsFave); err == nil {
// we already have a fave for this status
newFave = false
} }
var boostOfStatus *gtsmodel.Status if newFave {
if targetStatus.BoostOfID != "" { thisFaveID := uuid.NewString()
boostOfStatus = &gtsmodel.Status{}
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { // we need to create a new fave in the database
return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) gtsFave := &gtsmodel.StatusFave{
ID: thisFaveID,
AccountID: authed.Account.ID,
TargetAccountID: targetAccount.ID,
StatusID: targetStatus.ID,
URI: util.GenerateURIForLike(authed.Account.Username, p.config.Protocol, p.config.Host, thisFaveID),
GTSStatus: targetStatus,
GTSTargetAccount: targetAccount,
GTSFavingAccount: authed.Account,
}
if err := p.db.Put(gtsFave); err != nil {
return nil, err
}
// send the new fave through the processor channel for federation etc
p.fromClientAPI <- gtsmodel.FromClientAPI{
APObjectType: gtsmodel.ActivityStreamsLike,
APActivityType: gtsmodel.ActivityStreamsCreate,
GTSModel: gtsFave,
OriginAccount: authed.Account,
TargetAccount: targetAccount,
} }
} }
// return the mastodon representation of the target status
mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
if err != nil { if err != nil {
return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)

View file

@ -126,6 +126,9 @@ type TypeConverter interface {
// AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation // AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation
AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error)
// FaveToAS converts a gts model status fave into an activityStreams LIKE, suitable for federation.
FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error)
} }
type converter struct { type converter struct {

View file

@ -559,3 +559,84 @@ func (c *converter) AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityS
return doc, nil return doc, nil
} }
/*
We want to end up with something like this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://ondergrond.org/users/dumpsterqueer",
"id": "https://ondergrond.org/users/dumpsterqueer#likes/44584",
"object": "https://testingtesting123.xyz/users/gotosocial_test_account/statuses/771aea80-a33d-4d6d-8dfd-57d4d2bfcbd4",
"type": "Like"
}
*/
func (c *converter) FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) {
// check if targetStatus is already pinned to this fave, and fetch it if not
if f.GTSStatus == nil {
s := &gtsmodel.Status{}
if err := c.db.GetByID(f.StatusID, s); err != nil {
return nil, fmt.Errorf("FaveToAS: error fetching target status from database: %s", err)
}
f.GTSStatus = s
}
// check if the targetAccount is already pinned to this fave, and fetch it if not
if f.GTSTargetAccount == nil {
a := &gtsmodel.Account{}
if err := c.db.GetByID(f.TargetAccountID, a); err != nil {
return nil, fmt.Errorf("FaveToAS: error fetching target account from database: %s", err)
}
f.GTSTargetAccount = a
}
// check if the faving account is already pinned to this fave, and fetch it if not
if f.GTSFavingAccount == nil {
a := &gtsmodel.Account{}
if err := c.db.GetByID(f.AccountID, a); err != nil {
return nil, fmt.Errorf("FaveToAS: error fetching faving account from database: %s", err)
}
f.GTSFavingAccount = a
}
// create the like
like := streams.NewActivityStreamsLike()
// set the actor property to the fave-ing account's URI
actorProp := streams.NewActivityStreamsActorProperty()
actorIRI, err := url.Parse(f.GTSFavingAccount.URI)
if err != nil {
return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSFavingAccount.URI, err)
}
actorProp.AppendIRI(actorIRI)
like.SetActivityStreamsActor(actorProp)
// set the ID property to the fave's URI
idProp := streams.NewJSONLDIdProperty()
idIRI, err := url.Parse(f.URI)
if err != nil {
return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.URI, err)
}
idProp.Set(idIRI)
like.SetJSONLDId(idProp)
// set the object property to the target status's URI
objectProp := streams.NewActivityStreamsObjectProperty()
statusIRI, err := url.Parse(f.GTSStatus.URI)
if err != nil {
return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSStatus.URI, err)
}
objectProp.AppendIRI(statusIRI)
like.SetActivityStreamsObject(objectProp)
// set the TO property to the target account's IRI
toProp := streams.NewActivityStreamsToProperty()
toIRI, err := url.Parse(f.GTSTargetAccount.URI)
if err != nil {
return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSTargetAccount.URI, err)
}
toProp.AppendIRI(toIRI)
like.SetActivityStreamsTo(toProp)
return like, nil
}

View file

@ -85,13 +85,18 @@ var (
// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following
followingPathRegex = regexp.MustCompile(followingPathRegexString) followingPathRegex = regexp.MustCompile(followingPathRegexString)
likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath)
// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked
likedPathRegex = regexp.MustCompile(likedPathRegexString)
// see https://ihateregex.io/expr/uuid/ // see https://ihateregex.io/expr/uuid/
uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}`
likedPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, LikedPath)
// likedPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked
likedPathRegex = regexp.MustCompile(likedPathRegexString)
likePathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, LikedPath, uuidRegexString)
// likePathRegex parses a path that validates and captures the username part and the uuid part
// from eg /users/example_username/liked/123e4567-e89b-12d3-a456-426655440000.
likePathRegex = regexp.MustCompile(likePathRegexString)
statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString) statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString)
// statusesPathRegex parses a path that validates and captures the username part and the uuid part // statusesPathRegex parses a path that validates and captures the username part and the uuid part
// from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000. // from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000.

View file

@ -22,8 +22,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
"github.com/google/uuid"
) )
const ( const (
@ -109,8 +107,14 @@ type UserURIs struct {
// GenerateURIForFollow returns the AP URI for a new follow -- something like: // GenerateURIForFollow returns the AP URI for a new follow -- something like:
// https://example.org/users/whatever_user/follow/41c7f33f-1060-48d9-84df-38dcb13cf0d8 // https://example.org/users/whatever_user/follow/41c7f33f-1060-48d9-84df-38dcb13cf0d8
func GenerateURIForFollow(username string, protocol string, host string) string { func GenerateURIForFollow(username string, protocol string, host string, thisFollowID string) string {
return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, FollowPath, uuid.NewString()) return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, FollowPath, thisFollowID)
}
// GenerateURIForFollow returns the AP URI for a new like/fave -- something like:
// https://example.org/users/whatever_user/liked/41c7f33f-1060-48d9-84df-38dcb13cf0d8
func GenerateURIForLike(username string, protocol string, host string, thisFavedID string) string {
return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, LikedPath, thisFavedID)
} }
// GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host.
@ -183,6 +187,11 @@ func IsLikedPath(id *url.URL) bool {
return likedPathRegex.MatchString(strings.ToLower(id.Path)) return likedPathRegex.MatchString(strings.ToLower(id.Path))
} }
// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked/SOME_UUID_OF_A_STATUS
func IsLikePath(id *url.URL) bool {
return likePathRegex.MatchString(strings.ToLower(id.Path))
}
// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS // IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
func IsStatusesPath(id *url.URL) bool { func IsStatusesPath(id *url.URL) bool {
return statusesPathRegex.MatchString(strings.ToLower(id.Path)) return statusesPathRegex.MatchString(strings.ToLower(id.Path))
@ -254,3 +263,15 @@ func ParseFollowingPath(id *url.URL) (username string, err error) {
username = matches[1] username = matches[1]
return return
} }
// ParseLikedPath returns the username and uuid from a path such as /users/example_username/liked/SOME_UUID_OF_A_STATUS
func ParseLikedPath(id *url.URL) (username string, uuid string, err error) {
matches := likePathRegex.FindStringSubmatch(id.Path)
if len(matches) != 3 {
err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches))
return
}
username = matches[1]
uuid = matches[2]
return
}