diff options
author | 2022-04-15 14:33:01 +0200 | |
---|---|---|
committer | 2022-04-15 14:33:01 +0200 | |
commit | 26683b3d49beea9b1f0e8f78df4720285d4c0825 (patch) | |
tree | 06bf09cdee7a60c41947a0bd03d6a42ad83ee784 /internal/web | |
parent | [bugfix] Fix broken only_media and only_public flags on /api/v1/accounts/:id/... (diff) | |
download | gotosocial-26683b3d49beea9b1f0e8f78df4720285d4c0825.tar.xz |
[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
Diffstat (limited to 'internal/web')
-rw-r--r-- | internal/web/base.go | 78 | ||||
-rw-r--r-- | internal/web/profile.go | 139 | ||||
-rw-r--r-- | internal/web/thread.go | 23 |
3 files changed, 213 insertions, 27 deletions
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 |