From 26683b3d49beea9b1f0e8f78df4720285d4c0825 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 15 Apr 2022 14:33:01 +0200 Subject: [PATCH] [feature] Web profile pages for accounts (#449) * add default avatars * allow webModule to error * return errWithCode from account get * add AccountGetLocalByUsername * check nil requesting account * add timestampShort function for just month/year * move loading logic to New + add default avatars * add profile page view * update swagger docs * add excludeReblogs to GetAccountStatuses * ignore casing when selecting local account by username * appropriate redirects * css fiddling * add 'about' heading * adjust thread page to work with routing * return AP representation if requested + authorized * simplify auth check * go fmt * golangci-lint ignore math/rand --- cmd/gotosocial/action/server/server.go | 11 +- cmd/gotosocial/action/testrig/testrig.go | 11 +- docs/api/swagger.yaml | 7 +- internal/api/client/account/account.go | 2 + internal/api/client/account/accountget.go | 6 +- internal/api/client/account/statuses.go | 24 ++- internal/api/s2s/user/inboxpost_test.go | 2 +- internal/db/account.go | 2 +- internal/db/bundb/account.go | 8 +- internal/processing/account.go | 10 +- internal/processing/account/account.go | 6 +- internal/processing/account/delete.go | 2 +- internal/processing/account/get.go | 34 +++- internal/processing/account/getstatuses.go | 14 +- internal/processing/federation/getoutbox.go | 2 +- internal/processing/federation/getuser.go | 15 +- internal/processing/fromfederator_test.go | 2 +- internal/processing/processor.go | 6 +- internal/router/template.go | 6 + internal/typeutils/internaltoas_test.go | 2 +- internal/web/base.go | 78 +++++++-- internal/web/profile.go | 139 +++++++++++++++ internal/web/thread.go | 23 +-- web/assets/base.css | 31 ++-- .../default_avatars/GoToSocial_icon1.svg | 160 ++++++++++++++++++ .../default_avatars/GoToSocial_icon2.svg | 137 +++++++++++++++ .../default_avatars/GoToSocial_icon3.svg | 137 +++++++++++++++ .../default_avatars/GoToSocial_icon4.svg | 152 +++++++++++++++++ .../default_avatars/GoToSocial_icon5.svg | 152 +++++++++++++++++ .../default_avatars/GoToSocial_icon6.svg | 160 ++++++++++++++++++ web/assets/profile.css | 90 ++++++++++ web/gotosocial-styling/templates/profile.css | 94 ++++++++++ web/template/profile.tmpl | 47 +++++ 33 files changed, 1484 insertions(+), 88 deletions(-) create mode 100644 internal/web/profile.go create mode 100644 web/assets/default_avatars/GoToSocial_icon1.svg create mode 100644 web/assets/default_avatars/GoToSocial_icon2.svg create mode 100644 web/assets/default_avatars/GoToSocial_icon3.svg create mode 100644 web/assets/default_avatars/GoToSocial_icon4.svg create mode 100644 web/assets/default_avatars/GoToSocial_icon5.svg create mode 100644 web/assets/default_avatars/GoToSocial_icon6.svg create mode 100644 web/assets/profile.css create mode 100644 web/gotosocial-styling/templates/profile.css create mode 100644 web/template/profile.tmpl diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index edd6fc1a7..8c6cd7afe 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -148,6 +148,12 @@ var Start action.GTSAction = func(ctx context.Context) error { return fmt.Errorf("error creating oidc idp: %s", err) } + // build web module + webModule, err := web.New(processor) + if err != nil { + return fmt.Errorf("error creating web module: %s", err) + } + // build client api modules authModule := auth.New(dbService, oauthServer, idp) accountModule := account.New(processor) @@ -156,7 +162,6 @@ var Start action.GTSAction = func(ctx context.Context) error { followRequestsModule := followrequest.New(processor) webfingerModule := webfinger.New(processor) nodeInfoModule := nodeinfo.New(processor) - webBaseModule := web.New(processor) usersModule := user.New(processor) timelineModule := timeline.New(processor) notificationModule := notification.New(processor) @@ -179,8 +184,10 @@ var Start action.GTSAction = func(ctx context.Context) error { securityModule, authModule, + // now the web module + webModule, + // now everything else - webBaseModule, accountModule, instanceModule, appsModule, diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go index d79ba3ea4..1e38b5554 100644 --- a/cmd/gotosocial/action/testrig/testrig.go +++ b/cmd/gotosocial/action/testrig/testrig.go @@ -95,6 +95,12 @@ var Start action.GTSAction = func(ctx context.Context) error { return fmt.Errorf("error creating oidc idp: %s", err) } + // build web module + webModule, err := web.New(processor) + if err != nil { + return fmt.Errorf("error creating web module: %s", err) + } + // build client api modules authModule := auth.New(dbService, oauthServer, idp) accountModule := account.New(processor) @@ -103,7 +109,6 @@ var Start action.GTSAction = func(ctx context.Context) error { followRequestsModule := followrequest.New(processor) webfingerModule := webfinger.New(processor) nodeInfoModule := nodeinfo.New(processor) - webBaseModule := web.New(processor) usersModule := user.New(processor) timelineModule := timeline.New(processor) notificationModule := notification.New(processor) @@ -126,8 +131,10 @@ var Start action.GTSAction = func(ctx context.Context) error { securityModule, authModule, + // now the web module + webModule, + // now everything else - webBaseModule, accountModule, instanceModule, appsModule, diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 19b49489e..4c78baff9 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2086,6 +2086,11 @@ paths: in: query name: exclude_replies type: boolean + - default: false + description: Exclude statuses that are a reblog/boost of another status. + in: query + name: exclude_reblogs + type: boolean - description: |- Return only statuses *OLDER* than the given max status ID. The status with the specified ID will not be included in the response. @@ -2099,7 +2104,7 @@ paths: name: min_id type: string - default: false - description: Show only pinned statuses. In other words,e xclude statuses that + description: Show only pinned statuses. In other words, exclude statuses that are not pinned to the given account ID. in: query name: pinned_only 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 . +*/ + +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 diff --git a/web/assets/base.css b/web/assets/base.css index e105707c2..0ba9252c6 100644 --- a/web/assets/base.css +++ b/web/assets/base.css @@ -166,23 +166,24 @@ section.login form button { } section.error { - display: flex; - flex-direction: row; - align-items: center; -} -section.error span { - font-size: 2em; -} -section.error pre { - border: 1px solid #ff000080; - margin-left: 1em; - padding: 0 0.7em; - border-radius: 0.5em; - background-color: #ff000010; - font-size: 1.3em; - white-space: pre-wrap; + display: flex; + flex-direction: row; + align-items: center; } +section.error span { + font-size: 2em; + } + +section.error pre { + border: 1px solid #ff000080; + margin-left: 1em; + padding: 0 0.7em; + border-radius: 0.5em; + background-color: #ff000010; + font-size: 1.3em; + white-space: pre-wrap; + } input, select, textarea { border: 1px solid #fafaff; diff --git a/web/assets/default_avatars/GoToSocial_icon1.svg b/web/assets/default_avatars/GoToSocial_icon1.svg new file mode 100644 index 000000000..9b4e7665e --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon1.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/default_avatars/GoToSocial_icon2.svg b/web/assets/default_avatars/GoToSocial_icon2.svg new file mode 100644 index 000000000..cdd49ddaa --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon2.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/default_avatars/GoToSocial_icon3.svg b/web/assets/default_avatars/GoToSocial_icon3.svg new file mode 100644 index 000000000..205e229c9 --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon3.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/default_avatars/GoToSocial_icon4.svg b/web/assets/default_avatars/GoToSocial_icon4.svg new file mode 100644 index 000000000..ec6a46212 --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon4.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/default_avatars/GoToSocial_icon5.svg b/web/assets/default_avatars/GoToSocial_icon5.svg new file mode 100644 index 000000000..c505b67d7 --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon5.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/default_avatars/GoToSocial_icon6.svg b/web/assets/default_avatars/GoToSocial_icon6.svg new file mode 100644 index 000000000..43db9cbf0 --- /dev/null +++ b/web/assets/default_avatars/GoToSocial_icon6.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/profile.css b/web/assets/profile.css new file mode 100644 index 000000000..7631b08ab --- /dev/null +++ b/web/assets/profile.css @@ -0,0 +1,90 @@ +main { + background: transparent; +} + +.headerimage img { + width: 100%; + height: 15em; + object-fit: cover; + border-radius: 10px; + } + +.profile { + position: relative; + background: rgb(75, 84, 93); + padding: 2rem; + display: flex; + flex-wrap: wrap; + justify-content: space-around; + gap: 0.5rem; + margin-bottom: 0.2rem; +} + +.profile .basic { + display: flex; + flex-direction: column; + flex: 1 1 25em; + gap: 0.5rem; + } + +.profile .basic a { + position: relative; + z-index: 1; + color: inherit; + text-decoration: none; + } + +.profile .basic .avatar img { + height: 25em; + width: 25em; + object-fit: cover; + border-radius: 10px; + } + +.profile .basic .displayname { + font-weight: bold; + font-size: 1.6rem; + align-self: start; + } + +.profile .detailed { + display: flex; + flex-direction: column; + flex: 1 1 25em; + } + +.profile .detailed h2 { + margin-top: 0; + } + +.profile .detailed .bio { + margin: 0; + } + +.profile .detailed .bio a { + color: #de8957; + text-decoration: underline; + } + +.accountstats { + position: relative; + background: rgb(75, 84, 93); + padding: 0.5rem; + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + gap: 0.5rem; + margin-bottom: 0.2rem; +} + +.accountstats .entry { + background: rgb(89, 99, 110); + padding: 0.5rem; + flex-grow: 1; + text-align: center; + } + +footer + div { + /* something weird from the devstack.. */ + display: none; +} diff --git a/web/gotosocial-styling/templates/profile.css b/web/gotosocial-styling/templates/profile.css new file mode 100644 index 000000000..167ed7b9e --- /dev/null +++ b/web/gotosocial-styling/templates/profile.css @@ -0,0 +1,94 @@ +main { + background: transparent; +} + +.headerimage { + img { + width: 100%; + height: 15em; + object-fit: cover; + border-radius: 10px; + } +} + +.profile { + position: relative; + background: color($bg lightness(-3%)); + padding: 2rem; + display: flex; + flex-wrap: wrap; + justify-content: space-around; + gap: 0.5rem; + margin-bottom: 0.2rem; + + .basic { + display: flex; + flex-direction: column; + flex: 1 1 25em; + gap: 0.5rem; + + a { + position: relative; + z-index: 1; + color: inherit; + text-decoration: none; + } + + .avatar { + img { + height: 25em; + width: 25em; + object-fit: cover; + border-radius: 10px; + } + } + + .displayname { + font-weight: bold; + font-size: 1.6rem; + align-self: start; + } + } + + .detailed { + display: flex; + flex-direction: column; + flex: 1 1 25em; + + h2 { + margin-top: 0; + } + + .bio { + margin: 0; + + a { + color: $acc1; + text-decoration: underline; + } + } + } +} + +.accountstats { + position: relative; + background: color($bg lightness(-3%)); + padding: 0.5rem; + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + gap: 0.5rem; + margin-bottom: 0.2rem; + + .entry { + background: color($bg lightness(+3%)); + padding: 0.5rem; + flex-grow: 1; + text-align: center; + } +} + +footer + div { + /* something weird from the devstack.. */ + display: none; +} diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl new file mode 100644 index 000000000..cfabeee6f --- /dev/null +++ b/web/template/profile.tmpl @@ -0,0 +1,47 @@ +{{ template "header.tmpl" .}} +
+ {{ if .account.Header }}{{ end }} +
+ +
+

About @{{.account.Username}}

+
+ {{ if .account.Note }}{{ .account.Note | noescape }}{{else}}This GoToSocial user hasn't written a bio yet!{{end}} +
+
+
+
+
Joined {{.account.CreatedAt | timestampShort}}
+
Followed by {{.account.FollowersCount}}
+
Following {{.account.FollowingCount}}
+
Posted {{.account.StatusesCount}}
+
+

Recent public posts by @{{.account.Username}}

+
+ {{range .statuses}} +
+ {{ template "status.tmpl" .}} +
+ {{end}} +
+
+ +{{ template "footer.tmpl" .}} \ No newline at end of file