diff --git a/docs/settings.md b/docs/settings.md index fb59903da2a..cb5bacf2eb8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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 diff --git a/model/instance/instance.go b/model/instance/instance.go index 0e7f8405dbc..493ddde01f8 100644 --- a/model/instance/instance.go +++ b/model/instance/instance.go @@ -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 { diff --git a/model/vfs/vfs.go b/model/vfs/vfs.go index a127ddb1b08..7719b802a15 100644 --- a/model/vfs/vfs.go +++ b/model/vfs/vfs.go @@ -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) diff --git a/model/vfs/vfsafero/avatar.go b/model/vfs/vfsafero/avatar.go new file mode 100644 index 00000000000..1e413396ca9 --- /dev/null +++ b/model/vfs/vfsafero/avatar.go @@ -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 { + 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") +} + +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 +} diff --git a/model/vfs/vfsswift/avatar_v3.go b/model/vfs/vfsswift/avatar_v3.go new file mode 100644 index 00000000000..4499f0930eb --- /dev/null +++ b/model/vfs/vfsswift/avatar_v3.go @@ -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 +} diff --git a/web/public/public.go b/web/public/public.go index e44d5d2b8d7..7f6cab050b2 100644 --- a/web/public/public.go +++ b/web/public/public.go @@ -5,6 +5,7 @@ package public import ( "net/http" + "os" "strings" "time" @@ -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 diff --git a/web/settings/settings.go b/web/settings/settings.go index 686d69611fd..54bde562b9e 100644 --- a/web/settings/settings.go +++ b/web/settings/settings.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -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 { + return jsonapi.BadRequest(errors.New("Avatar is too big")) + } + 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) @@ -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) diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index 3ca37f3638e..d0deb93687d 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -10,6 +10,7 @@ import ( "errors" "net/http" "net/url" + "os" "strconv" "strings" @@ -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")