Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a route for uploading avatars #4499

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,29 @@ Authorization: Bearer oauth2-access-token
HTTP/1.1 204 No Content
```

## Avatar

### PUT /settings/avatar

This route can be used to upload the avatar for an instance.

#### Request

```http
PUT /settings/avatar HTTP/1.1
Host: alice.cozy.example.net
Authorization: Bearer token
Content-Type: image/jpeg

...
```

#### Response

```http
HTTP/1.1 204 No Content
```

## Context

### GET /settings/onboarded
Expand Down
23 changes: 23 additions & 0 deletions model/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,29 @@ func (i *Instance) MakeVFS() error {
return err
}

// AvatarFS returns the hidden filesystem for storing the avatar.
func (i *Instance) AvatarFS() vfs.Avatarer {
fsURL := config.FsURL()
switch fsURL.Scheme {
case config.SchemeFile:
baseFS := afero.NewBasePathFs(afero.NewOsFs(),
path.Join(fsURL.Path, i.DirName(), vfs.ThumbsDirName))
return vfsafero.NewAvatarFs(baseFS)
case config.SchemeMem:
baseFS := vfsafero.GetMemFS(i.DomainName() + "-avatar")
return vfsafero.NewAvatarFs(baseFS)
case config.SchemeSwift, config.SchemeSwiftSecure:
switch i.SwiftLayout {
case 2:
return vfsswift.NewAvatarFsV3(config.GetSwiftConnection(), i)
default:
panic(ErrInvalidSwiftLayout)
}
default:
panic(fmt.Sprintf("instance: unknown storage provider %s", fsURL.Scheme))
}
}

// ThumbsFS returns the hidden filesystem for storing the thumbnails of the
// photos/image
func (i *Instance) ThumbsFS() vfs.Thumbser {
Expand Down
6 changes: 6 additions & 0 deletions model/vfs/vfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ type DiskThresholder interface {
DiskQuota() int64
}

// Avatarer defines an interface to define an avatar filesystem.
type Avatarer interface {
CreateAvatar(contentType string) (io.WriteCloser, error)
ServeAvatarContent(w http.ResponseWriter, req *http.Request) error
}

// Thumbser defines an interface to define a thumbnail filesystem.
type Thumbser interface {
ThumbExists(img *FileDoc, format string) (ok bool, err error)
Expand Down
75 changes: 75 additions & 0 deletions model/vfs/vfsafero/avatar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package vfsafero

import (
"io"
"net/http"
"os"

"github.com/cozy/cozy-stack/model/vfs"
"github.com/spf13/afero"
)

// NewAvatarFs creates a new avatar filesystem base on a afero.Fs.
func NewAvatarFs(fs afero.Fs) vfs.Avatarer {
return &avatar{fs}
}

type avatar struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avatarFs seems more appropriate since this struct does not old data about avatars themselves but rather implements the interface allowing to manage them.

fs afero.Fs
}

type avatarUpload struct {
afero.File
fs afero.Fs
tmpname string
}

func (u *avatarUpload) Close() error {
if err := u.File.Close(); err != nil {
_ = u.fs.Remove(u.tmpname)
return err
}
return u.fs.Rename(u.tmpname, "avatar")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a constant storing the avatar filename would be nice

}

func (a *avatar) CreateAvatar(contentType string) (io.WriteCloser, error) {
f, err := afero.TempFile(a.fs, "/", "avatar")
if err != nil {
return nil, err
}
tmpname := f.Name()
u := &avatarUpload{
File: f,
fs: a.fs,
tmpname: tmpname,
}
return u, nil
}

func (a *avatar) AvatarExists() (bool, error) {
infos, err := a.fs.Stat("avatar")
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, err
}
return infos.Size() > 0, nil
}

func (a *avatar) ServeAvatarContent(w http.ResponseWriter, req *http.Request) error {
s, err := a.fs.Stat("avatar")
if err != nil {
return err
}
if s.Size() == 0 {
return os.ErrInvalid
}
f, err := a.fs.Open("avatar")
if err != nil {
return err
}
defer f.Close()
http.ServeContent(w, req, "avatar", s.ModTime(), f)
return nil
}
47 changes: 47 additions & 0 deletions model/vfs/vfsswift/avatar_v3.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package vfsswift

import (
"context"
"fmt"
"io"
"net/http"

"github.com/cozy/cozy-stack/model/vfs"
"github.com/cozy/cozy-stack/pkg/prefixer"
"github.com/ncw/swift/v2"
)

// NewAvatarFsV3 creates a new avatar filesystem base on swift.
//
// This version stores the avatar in the same container as the main data
// container.
func NewAvatarFsV3(c *swift.Connection, db prefixer.Prefixer) vfs.Avatarer {
return &avatarV3{
c: c,
container: swiftV3ContainerPrefix + db.DBPrefix(),
ctx: context.Background(),
}
}

type avatarV3 struct {
c *swift.Connection
container string
ctx context.Context
}

func (a *avatarV3) CreateAvatar(contentType string) (io.WriteCloser, error) {
return a.c.ObjectCreate(a.ctx, a.container, "avatar", true, "", contentType, nil)
}

func (a *avatarV3) ServeAvatarContent(w http.ResponseWriter, req *http.Request) error {
f, o, err := a.c.ObjectOpen(a.ctx, a.container, "avatar", false, nil)
if err != nil {
return wrapSwiftErr(err)
}
defer f.Close()

w.Header().Set("Etag", fmt.Sprintf(`"%s"`, o["Etag"]))
w.Header().Set("Content-Type", o["Content-Type"])
http.ServeContent(w, req, "avatar", unixEpochZero, &backgroundSeeker{f})
return nil
}
6 changes: 6 additions & 0 deletions web/public/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package public

import (
"net/http"
"os"
"strings"
"time"

Expand All @@ -20,6 +21,11 @@ import (
// Avatar returns the default avatar currently.
func Avatar(c echo.Context) error {
inst := middlewares.GetInstance(c)
err := inst.AvatarFS().ServeAvatarContent(c.Response(), c.Request())
if err != os.ErrNotExist {
return err
}

switch c.QueryParam("fallback") {
case "404":
// Nothing
Expand Down
28 changes: 28 additions & 0 deletions web/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -253,6 +254,31 @@ func isMovedError(err error) bool {
return ok && j.Code == "moved"
}

func (h *HTTPHandler) UploadAvatar(c echo.Context) error {
inst := middlewares.GetInstance(c)
if err := middlewares.AllowWholeType(c, http.MethodPut, consts.Settings); err != nil {
return err
}
header := c.Request().Header
size := c.Request().ContentLength
if size > 20_000_000 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have a MaxFileSize() method in the AvatarFS struct like the one in the Fs interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be an error returned by CreateAvatar()?!

return jsonapi.BadRequest(errors.New("Avatar is too big"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should return a 413 Entity Too Large error here.

}
contentType := header.Get(echo.HeaderContentType)
f, err := inst.AvatarFS().CreateAvatar(contentType)
if err != nil {
return jsonapi.InternalServerError(err)
}
_, err = io.Copy(f, c.Request().Body)
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
if err != nil {
return jsonapi.InternalServerError(err)
}
return c.NoContent(http.StatusNoContent)
}

// Register all the `/settings` routes to the given router.
func (h *HTTPHandler) Register(router *echo.Group) {
router.GET("/disk-usage", h.diskUsage)
Expand Down Expand Up @@ -281,6 +307,8 @@ func (h *HTTPHandler) Register(router *echo.Group) {
router.PUT("/instance/sign_tos", h.updateInstanceTOS)
router.DELETE("/instance/moved_from", h.clearMovedFrom)

router.PUT("/avatar", h.UploadAvatar)

router.GET("/flags", h.getFlags)

router.GET("/sessions", h.getSessions)
Expand Down
10 changes: 9 additions & 1 deletion web/sharings/sharings.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"net/http"
"net/url"
"os"
"strconv"
"strings"

Expand Down Expand Up @@ -841,9 +842,16 @@ func GetAvatar(c echo.Context) error {
m := s.Members[index]

// Use the local avatar
if m.Instance == "" || m.Instance == inst.PageURL("", nil) {
if m.Instance == "" {
return localAvatar(c, m)
}
if m.Instance == inst.PageURL("", nil) {
err := inst.AvatarFS().ServeAvatarContent(c.Response(), c.Request())
if err == os.ErrNotExist {
return localAvatar(c, m)
}
return err
}

// Use the public avatar from the member's instance
res, err := safehttp.DefaultClient.Get(m.Instance + "/public/avatar?fallback=404")
Expand Down
Loading