diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/account/account.go | 2 | ||||
-rw-r--r-- | internal/api/client/account/accountget.go | 6 | ||||
-rw-r--r-- | internal/api/client/account/statuses.go | 24 | ||||
-rw-r--r-- | internal/api/s2s/user/inboxpost_test.go | 2 | ||||
-rw-r--r-- | internal/db/account.go | 2 | ||||
-rw-r--r-- | internal/db/bundb/account.go | 8 | ||||
-rw-r--r-- | internal/processing/account.go | 10 | ||||
-rw-r--r-- | internal/processing/account/account.go | 6 | ||||
-rw-r--r-- | internal/processing/account/delete.go | 2 | ||||
-rw-r--r-- | internal/processing/account/get.go | 34 | ||||
-rw-r--r-- | internal/processing/account/getstatuses.go | 14 | ||||
-rw-r--r-- | internal/processing/federation/getoutbox.go | 2 | ||||
-rw-r--r-- | internal/processing/federation/getuser.go | 15 | ||||
-rw-r--r-- | internal/processing/fromfederator_test.go | 2 | ||||
-rw-r--r-- | internal/processing/processor.go | 6 | ||||
-rw-r--r-- | internal/router/template.go | 6 | ||||
-rw-r--r-- | internal/typeutils/internaltoas_test.go | 2 | ||||
-rw-r--r-- | internal/web/base.go | 78 | ||||
-rw-r--r-- | internal/web/profile.go | 139 | ||||
-rw-r--r-- | internal/web/thread.go | 23 |
20 files changed, 315 insertions, 68 deletions
diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go index c38a8cfb4..4205baa2c 100644 --- a/internal/api/client/account/account.go +++ b/internal/api/client/account/account.go @@ -34,6 +34,8 @@ const ( LimitKey = "limit" // ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account. ExcludeRepliesKey = "exclude_replies" + // ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account. + ExcludeReblogsKey = "exclude_reblogs" // PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account. PinnedKey = "pinned" // MaxIDKey is for specifying the maximum ID of the status to retrieve. diff --git a/internal/api/client/account/accountget.go b/internal/api/client/account/accountget.go index 6085d2d3e..1fa7014eb 100644 --- a/internal/api/client/account/accountget.go +++ b/internal/api/client/account/accountget.go @@ -22,6 +22,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -76,9 +77,10 @@ func (m *Module) AccountGETHandler(c *gin.Context) { return } - acctInfo, err := m.processor.AccountGet(c.Request.Context(), authed, targetAcctID) + acctInfo, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, targetAcctID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + logrus.Debug(errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go index 67a9f7cb8..b440e582a 100644 --- a/internal/api/client/account/statuses.go +++ b/internal/api/client/account/statuses.go @@ -60,6 +60,12 @@ import ( // default: false // in: query // required: false +// - name: exclude_reblogs +// type: boolean +// description: Exclude statuses that are a reblog/boost of another status. +// default: false +// in: query +// required: false // - name: max_id // type: string // description: |- @@ -75,7 +81,7 @@ import ( // required: false // - name: pinned_only // type: boolean -// description: Show only pinned statuses. In other words,e xclude statuses that are not pinned to the given account ID. +// description: Show only pinned statuses. In other words, exclude statuses that are not pinned to the given account ID. // default: false // in: query // required: false @@ -149,13 +155,25 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if excludeRepliesString != "" { i, err := strconv.ParseBool(excludeRepliesString) if err != nil { - l.Debugf("error parsing replies string: %s", err) + l.Debugf("error parsing exclude replies string: %s", err) c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse exclude replies query param"}) return } excludeReplies = i } + excludeReblogs := false + excludeReblogsString := c.Query(ExcludeReblogsKey) + if excludeReblogsString != "" { + i, err := strconv.ParseBool(excludeReblogsString) + if err != nil { + l.Debugf("error parsing exclude reblogs string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse exclude reblogs query param"}) + return + } + excludeReblogs = i + } + maxID := "" maxIDString := c.Query(MaxIDKey) if maxIDString != "" { @@ -204,7 +222,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { publicOnly = i } - statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly) + statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) if errWithCode != nil { l.Debugf("error from processor account statuses get: %s", errWithCode) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go index 2f43799c8..f9a3c5de1 100644 --- a/internal/api/s2s/user/inboxpost_test.go +++ b/internal/api/s2s/user/inboxpost_test.go @@ -440,7 +440,7 @@ func (suite *InboxPostTestSuite) TestPostDelete() { suite.ErrorIs(err, db.ErrNoEntries) // no statuses from foss satan should be left in the database - dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false) + dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, false, "", "", false, false, false) suite.ErrorIs(err, db.ErrNoEntries) suite.Empty(dbStatuses) diff --git a/internal/db/account.go b/internal/db/account.go index 0a6f1bb48..4b0b0062d 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -52,7 +52,7 @@ type Account interface { // then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can // be very memory intensive so you probably shouldn't do this! // In case of no entries, a 'no entries' error will be returned - GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error) + GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 50b0bfa38..876fb5186 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -199,7 +199,7 @@ func (a *accountDB) GetLocalAccountByUsername(ctx context.Context, username stri account := new(gtsmodel.Account) q := a.newAccountQ(account). - Where("username = ?", username). + Where("LOWER(?) = LOWER(?)", bun.Ident("username"), username). // ignore casing WhereGroup(" AND ", whereEmptyOrNull("domain")) if err := q.Scan(ctx); err != nil { @@ -230,7 +230,7 @@ func (a *accountDB) CountAccountStatuses(ctx context.Context, accountID string) Count(ctx) } -func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, db.Error) { +func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, db.Error) { statuses := []*gtsmodel.Status{} q := a.conn. @@ -250,6 +250,10 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li q = q.WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id")) } + if excludeReblogs { + q = q.WhereGroup(" AND ", whereEmptyOrNull("boost_of_id")) + } + if maxID != "" { q = q.Where("id < ?", maxID) } diff --git a/internal/processing/account.go b/internal/processing/account.go index 80f6604fe..25f024785 100644 --- a/internal/processing/account.go +++ b/internal/processing/account.go @@ -34,16 +34,20 @@ func (p *processor) AccountDeleteLocal(ctx context.Context, authed *oauth.Auth, return p.accountProcessor.DeleteLocal(ctx, authed.Account, form) } -func (p *processor) AccountGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) { +func (p *processor) AccountGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Account, gtserror.WithCode) { return p.accountProcessor.Get(ctx, authed.Account, targetAccountID) } +func (p *processor) AccountGetLocalByUsername(ctx context.Context, authed *oauth.Auth, username string) (*apimodel.Account, gtserror.WithCode) { + return p.accountProcessor.GetLocalByUsername(ctx, authed.Account, username) +} + func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { return p.accountProcessor.Update(ctx, authed.Account, form) } -func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { - return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly) +func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { + return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) } func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index 1ef92cf85..2a9e5f898 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -47,12 +47,14 @@ type Processor interface { // Unlike Delete, it will propagate the deletion out across the federating API to other instances. DeleteLocal(ctx context.Context, account *gtsmodel.Account, form *apimodel.AccountDeleteRequest) gtserror.WithCode // Get processes the given request for account information. - Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, error) + Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, gtserror.WithCode) + // GetLocalByUsername processes the given request for account information targeting a local account by username. + GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode) // Update processes the update of an account with the given form Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. - StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) + StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) // FollowersGet fetches a list of the target account's followers. FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // FollowingGet fetches a list of the accounts that target account is following. diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index d15c4858c..a114777cf 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -143,7 +143,7 @@ func (p *processor) Delete(ctx context.Context, account *gtsmodel.Account, origi var maxID string selectStatusesLoop: for { - statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, maxID, "", false, false, false) + statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, false, maxID, "", false, false, false) if err != nil { if err == db.ErrNoEntries { // no statuses left for this instance so we're done diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go index 2571d7af1..97f2f0b4a 100644 --- a/internal/processing/account/get.go +++ b/internal/processing/account/get.go @@ -26,23 +26,41 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, error) { +func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, gtserror.WithCode) { targetAccount, err := p.db.GetAccountByID(ctx, targetAccountID) if err != nil { if err == db.ErrNoEntries { - return nil, errors.New("account not found") + return nil, gtserror.NewErrorNotFound(errors.New("account not found")) } - return nil, fmt.Errorf("db error: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %s", err)) } + return p.getAccountFor(ctx, requestingAccount, targetAccount) +} + +func (p *processor) GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode) { + targetAccount, err := p.db.GetLocalAccountByUsername(ctx, username) + if err != nil { + if err == db.ErrNoEntries { + return nil, gtserror.NewErrorNotFound(errors.New("account not found")) + } + return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %s", err)) + } + + return p.getAccountFor(ctx, requestingAccount, targetAccount) +} + +func (p *processor) getAccountFor(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (*apimodel.Account, gtserror.WithCode) { var blocked bool + var err error if requestingAccount != nil { - blocked, err = p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true) + blocked, err = p.db.IsBlocked(ctx, requestingAccount.ID, targetAccount.ID, true) if err != nil { - return nil, fmt.Errorf("error checking account block: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking account block: %s", err)) } } @@ -50,7 +68,7 @@ func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account if blocked { apiAccount, err = p.tc.AccountToAPIAccountBlocked(ctx, targetAccount) if err != nil { - return nil, fmt.Errorf("error converting account: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting account: %s", err)) } return apiAccount, nil } @@ -59,7 +77,7 @@ func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account if targetAccount.Domain != "" { targetAccountURI, err := url.Parse(targetAccount.URI) if err != nil { - return nil, fmt.Errorf("error parsing url %s: %s", targetAccount.URI, err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", targetAccount.URI, err)) } a, err := p.federator.GetRemoteAccount(ctx, requestingAccount.Username, targetAccountURI, true, false) @@ -74,7 +92,7 @@ func (p *processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account apiAccount, err = p.tc.AccountToAPIAccountPublic(ctx, targetAccount) } if err != nil { - return nil, fmt.Errorf("error converting account: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting account: %s", err)) } return apiAccount, nil } diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go index 47576f46c..c185302c5 100644 --- a/internal/processing/account/getstatuses.go +++ b/internal/processing/account/getstatuses.go @@ -28,16 +28,18 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { - if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } else if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) +func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { + if requestingAccount != nil { + if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } else if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + } } apiStatuses := []apimodel.Status{} - statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly) + statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) if err != nil { if err == db.ErrNoEntries { return apiStatuses, nil diff --git a/internal/processing/federation/getoutbox.go b/internal/processing/federation/getoutbox.go index 944c0b571..2c7511e45 100644 --- a/internal/processing/federation/getoutbox.go +++ b/internal/processing/federation/getoutbox.go @@ -89,7 +89,7 @@ func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, pag // scenario 2 -- get the requested page // limit pages to 30 entries per page - publicStatuses, err := p.db.GetAccountStatuses(ctx, requestedAccount.ID, 30, true, maxID, minID, false, false, true) + publicStatuses, err := p.db.GetAccountStatuses(ctx, requestedAccount.ID, 30, true, true, maxID, minID, false, false, true) if err != nil && err != db.ErrNoEntries { return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/federation/getuser.go b/internal/processing/federation/getuser.go index 6d5b8463f..b201bea4b 100644 --- a/internal/processing/federation/getuser.go +++ b/internal/processing/federation/getuser.go @@ -38,17 +38,20 @@ func (p *processor) GetUser(ctx context.Context, requestedUsername string, reque } var requestedPerson vocab.ActivityStreamsPerson - switch { - case uris.IsPublicKeyPath(requestURL): + if uris.IsPublicKeyPath(requestURL) { // 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(ctx, requestedAccount) if err != nil { return nil, gtserror.NewErrorInternalError(err) } - case uris.IsUserPath(requestURL): - // 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 + } else { + // if it's any other path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) - if err != nil || !authenticated { + if err != nil { + return nil, gtserror.NewErrorNotAuthorized(err, "not authorized") + } + + if !authenticated { return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized") } @@ -73,8 +76,6 @@ func (p *processor) GetUser(ctx context.Context, requestedUsername string, reque if err != nil { return nil, gtserror.NewErrorInternalError(err) } - default: - return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path")) } data, err := streams.Serialize(requestedPerson) diff --git a/internal/processing/fromfederator_test.go b/internal/processing/fromfederator_test.go index 7d394456d..6028fd065 100644 --- a/internal/processing/fromfederator_test.go +++ b/internal/processing/fromfederator_test.go @@ -354,7 +354,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() { suite.False(zorkFollowsSatan) // no statuses from foss satan should be left in the database - dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false) + dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, false, "", "", false, false, false) suite.ErrorIs(err, db.ErrNoEntries) suite.Empty(dbStatuses) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index f5d9eab28..801d325a7 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -76,12 +76,14 @@ type Processor interface { // AccountDeleteLocal processes the delete of a LOCAL account using the given form. AccountDeleteLocal(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountDeleteRequest) gtserror.WithCode // AccountGet processes the given request for account information. - AccountGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) + AccountGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Account, gtserror.WithCode) + // AccountGet processes the given request for account information. + AccountGetLocalByUsername(ctx context.Context, authed *oauth.Auth, username string) (*apimodel.Account, gtserror.WithCode) // AccountUpdate processes the update of an account with the given form AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. - AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) + AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) // AccountFollowersGet fetches a list of the target account's followers. AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // AccountFollowingGet fetches a list of the accounts that target account is following. diff --git a/internal/router/template.go b/internal/router/template.go index 1a0186d6d..50d5f1a82 100644 --- a/internal/router/template.go +++ b/internal/router/template.go @@ -67,6 +67,11 @@ func timestamp(stamp string) string { return t.Format("January 2, 2006, 15:04:05") } +func timestampShort(stamp string) string { + t, _ := time.Parse(time.RFC3339, stamp) + return t.Format("January, 2006") +} + type iconWithLabel struct { faIcon string label string @@ -98,5 +103,6 @@ func LoadTemplateFunctions(engine *gin.Engine) { "oddOrEven": oddOrEven, "visibilityIcon": visibilityIcon, "timestamp": timestamp, + "timestampShort": timestampShort, }) } diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index d3e715a7e..72b928237 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -132,7 +132,7 @@ func (suite *InternalToASTestSuite) TestStatusesToASOutboxPage() { ctx := context.Background() // get public statuses from testaccount - statuses, err := suite.db.GetAccountStatuses(ctx, testAccount.ID, 30, true, "", "", false, false, true) + statuses, err := suite.db.GetAccountStatuses(ctx, testAccount.ID, 30, true, true, "", "", false, false, true) suite.NoError(err) page, err := suite.typeconverter.StatusesToASOutboxPage(ctx, testAccount.OutboxURI, "", "", statuses) diff --git a/internal/web/base.go b/internal/web/base.go index 58afd40a7..fff61043a 100644 --- a/internal/web/base.go +++ b/internal/web/base.go @@ -20,8 +20,10 @@ package web import ( "fmt" + "io/ioutil" "net/http" "path/filepath" + "strings" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" @@ -36,18 +38,68 @@ import ( const ( confirmEmailPath = "/" + uris.ConfirmEmailPath tokenParam = "token" + usernameKey = "username" + statusIDKey = "status" + profilePath = "/@:" + usernameKey + statusPath = profilePath + "/statuses/:" + statusIDKey ) // Module implements the api.ClientModule interface for web pages. type Module struct { - processor processing.Processor + processor processing.Processor + assetsPath string + adminPath string + defaultAvatars []string } // New returns a new api.ClientModule for web pages. -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, +func New(processor processing.Processor) (api.ClientModule, error) { + assetsBaseDir := viper.GetString(config.Keys.WebAssetBaseDir) + if assetsBaseDir == "" { + return nil, fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.Keys.WebAssetBaseDir) + } + + assetsPath, err := filepath.Abs(assetsBaseDir) + if err != nil { + return nil, fmt.Errorf("error getting absolute path of %s: %s", assetsBaseDir, err) } + + defaultAvatarsPath := filepath.Join(assetsPath, "default_avatars") + defaultAvatarFiles, err := ioutil.ReadDir(defaultAvatarsPath) + if err != nil { + return nil, fmt.Errorf("error reading default avatars at %s: %s", defaultAvatarsPath, err) + } + + defaultAvatars := []string{} + for _, f := range defaultAvatarFiles { + // ignore directories + if f.IsDir() { + continue + } + + // ignore files bigger than 50kb + if f.Size() > 50000 { + continue + } + + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(f.Name())), ".") + + // take only files with simple extensions + switch extension { + case "svg", "jpeg", "jpg", "gif", "png": + defaultAvatarPath := fmt.Sprintf("/assets/default_avatars/%s", f.Name()) + defaultAvatars = append(defaultAvatars, defaultAvatarPath) + default: + continue + } + } + + return &Module{ + processor: processor, + assetsPath: assetsPath, + adminPath: filepath.Join(assetsPath, "admin"), + defaultAvatars: defaultAvatars, + }, nil } func (m *Module) baseHandler(c *gin.Context) { @@ -88,20 +140,11 @@ func (m *Module) NotFoundHandler(c *gin.Context) { // Route satisfies the RESTAPIModule interface func (m *Module) Route(s router.Router) error { // serve static files from assets dir at /assets - assetBaseDir := viper.GetString(config.Keys.WebAssetBaseDir) - if assetBaseDir == "" { - return fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.Keys.WebAssetBaseDir) - } - assetPath, err := filepath.Abs(assetBaseDir) - if err != nil { - return fmt.Errorf("error getting absolute path of %s: %s", assetBaseDir, err) - } - s.AttachStaticFS("/assets", fileSystem{http.Dir(assetPath)}) + s.AttachStaticFS("/assets", fileSystem{http.Dir(m.assetsPath)}) // serve admin panel from within assets dir at /admin/ // and redirect /admin to /admin/ - adminPath := filepath.Join(assetPath, "admin") - s.AttachStaticFS("/admin/", fileSystem{http.Dir(adminPath)}) + s.AttachStaticFS("/admin/", fileSystem{http.Dir(m.adminPath)}) s.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/admin/") }) @@ -109,8 +152,11 @@ func (m *Module) Route(s router.Router) error { // serve front-page s.AttachHandler(http.MethodGet, "/", m.baseHandler) + // serve profile pages at /@username + s.AttachHandler(http.MethodGet, profilePath, m.profileTemplateHandler) + // serve statuses - s.AttachHandler(http.MethodGet, "/:user/statuses/:id", m.threadTemplateHandler) + s.AttachHandler(http.MethodGet, statusPath, m.threadTemplateHandler) // serve email confirmation page at /confirm_email?token=whatever s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) diff --git a/internal/web/profile.go b/internal/web/profile.go new file mode 100644 index 000000000..7fad7f4c6 --- /dev/null +++ b/internal/web/profile.go @@ -0,0 +1,139 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) profileTemplateHandler(c *gin.Context) { + l := logrus.WithField("func", "profileTemplateHandler") + l.Trace("rendering profile template") + ctx := c.Request.Context() + + username := c.Param(usernameKey) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no account username specified"}) + return + } + + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + l.Errorf("error authing profile GET request: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + instance, errWithCode := m.processor.InstanceGet(ctx, viper.GetString(config.Keys.Host)) + if errWithCode != nil { + l.Debugf("error getting instance from processor: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + account, errWithCode := m.processor.AccountGetLocalByUsername(ctx, authed, username) + if errWithCode != nil { + l.Debugf("error getting account from processor: %s", errWithCode.Error()) + if errWithCode.Code() == http.StatusNotFound { + m.NotFoundHandler(c) + return + } + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + // if we're getting an AP request on this endpoint we should render the account's AP representation instead + accept := c.NegotiateFormat(string(api.TextHTML), string(api.AppActivityJSON), string(api.AppActivityLDJSON)) + if accept == string(api.AppActivityJSON) || accept == string(api.AppActivityLDJSON) { + m.returnAPRepresentation(ctx, c, username, accept) + return + } + + // get latest 10 top-level public statuses; + // ie., exclude replies and boosts, public only, + // with or without media + statuses, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true) + if errWithCode != nil { + l.Debugf("error getting statuses from processor: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + // pick a random dummy avatar if this account avatar isn't set yet + if account.Avatar == "" && len(m.defaultAvatars) > 0 { + //nolint:gosec + randomIndex := rand.Intn(len(m.defaultAvatars)) + dummyAvatar := m.defaultAvatars[randomIndex] + account.Avatar = dummyAvatar + for _, s := range statuses { + s.Account.Avatar = dummyAvatar + } + } + + c.HTML(http.StatusOK, "profile.tmpl", gin.H{ + "instance": instance, + "account": account, + "statuses": statuses, + "stylesheets": []string{ + "/assets/Fork-Awesome/css/fork-awesome.min.css", + "/assets/status.css", + "/assets/profile.css", + }, + }) +} + +func (m *Module) returnAPRepresentation(ctx context.Context, c *gin.Context, username string, accept string) { + verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) + } + + signature, signed := c.Get(string(ap.ContextRequestingPublicKeySignature)) + if signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) + } + + user, errWithCode := m.processor.GetFediUser(ctx, username, c.Request.URL) // GetFediUser handles auth as well + if errWithCode != nil { + logrus.Infof(errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + b, mErr := json.Marshal(user) + if mErr != nil { + err := fmt.Errorf("could not marshal json: %s", mErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Data(http.StatusOK, accept, b) +} diff --git a/internal/web/thread.go b/internal/web/thread.go index 9c985d729..4a448690d 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -20,6 +20,7 @@ package web import ( "net/http" + "strings" "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -29,21 +30,21 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -type statusLink struct { - User string `uri:"user" binding:"required"` - ID string `uri:"id" binding:"required"` -} - func (m *Module) threadTemplateHandler(c *gin.Context) { l := logrus.WithField("func", "threadTemplateGET") l.Trace("rendering thread template") ctx := c.Request.Context() - var uriParts statusLink + username := c.Param(usernameKey) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no account username specified"}) + return + } - if err := c.ShouldBindUri(&uriParts); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) + statusID := c.Param(statusIDKey) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id specified"}) return } @@ -62,18 +63,18 @@ func (m *Module) threadTemplateHandler(c *gin.Context) { return } - status, err := m.processor.StatusGet(ctx, authed, uriParts.ID) + status, err := m.processor.StatusGet(ctx, authed, statusID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) return } - if uriParts.User[:1] != "@" || uriParts.User[1:] != status.Account.Username { + if !strings.EqualFold(username, status.Account.Username) { c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) return } - context, err := m.processor.StatusGetContext(ctx, authed, uriParts.ID) + context, err := m.processor.StatusGetContext(ctx, authed, statusID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) return |