Skip to content

Commit 953a478

Browse files
authored
Add a route for uploading avatars (#4499)
2 parents d84d87c + e044126 commit 953a478

File tree

8 files changed

+219
-1
lines changed

8 files changed

+219
-1
lines changed

docs/settings.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,29 @@ Authorization: Bearer oauth2-access-token
10831083
HTTP/1.1 204 No Content
10841084
```
10851085

1086+
## Avatar
1087+
1088+
### PUT /settings/avatar
1089+
1090+
This route can be used to upload the avatar for an instance.
1091+
1092+
#### Request
1093+
1094+
```http
1095+
PUT /settings/avatar HTTP/1.1
1096+
Host: alice.cozy.example.net
1097+
Authorization: Bearer token
1098+
Content-Type: image/jpeg
1099+
1100+
...
1101+
```
1102+
1103+
#### Response
1104+
1105+
```http
1106+
HTTP/1.1 204 No Content
1107+
```
1108+
10861109
## Context
10871110

10881111
### GET /settings/onboarded

model/instance/instance.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,29 @@ func (i *Instance) MakeVFS() error {
251251
return err
252252
}
253253

254+
// AvatarFS returns the hidden filesystem for storing the avatar.
255+
func (i *Instance) AvatarFS() vfs.Avatarer {
256+
fsURL := config.FsURL()
257+
switch fsURL.Scheme {
258+
case config.SchemeFile:
259+
baseFS := afero.NewBasePathFs(afero.NewOsFs(),
260+
path.Join(fsURL.Path, i.DirName(), vfs.ThumbsDirName))
261+
return vfsafero.NewAvatarFs(baseFS)
262+
case config.SchemeMem:
263+
baseFS := vfsafero.GetMemFS(i.DomainName() + "-avatar")
264+
return vfsafero.NewAvatarFs(baseFS)
265+
case config.SchemeSwift, config.SchemeSwiftSecure:
266+
switch i.SwiftLayout {
267+
case 2:
268+
return vfsswift.NewAvatarFsV3(config.GetSwiftConnection(), i)
269+
default:
270+
panic(ErrInvalidSwiftLayout)
271+
}
272+
default:
273+
panic(fmt.Sprintf("instance: unknown storage provider %s", fsURL.Scheme))
274+
}
275+
}
276+
254277
// ThumbsFS returns the hidden filesystem for storing the thumbnails of the
255278
// photos/image
256279
func (i *Instance) ThumbsFS() vfs.Thumbser {

model/vfs/vfs.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,12 @@ type DiskThresholder interface {
262262
DiskQuota() int64
263263
}
264264

265+
// Avatarer defines an interface to define an avatar filesystem.
266+
type Avatarer interface {
267+
CreateAvatar(contentType string) (io.WriteCloser, error)
268+
ServeAvatarContent(w http.ResponseWriter, req *http.Request) error
269+
}
270+
265271
// Thumbser defines an interface to define a thumbnail filesystem.
266272
type Thumbser interface {
267273
ThumbExists(img *FileDoc, format string) (ok bool, err error)

model/vfs/vfsafero/avatar.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package vfsafero
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"os"
7+
8+
"github.com/cozy/cozy-stack/model/vfs"
9+
"github.com/spf13/afero"
10+
)
11+
12+
const AvatarFilename = "avatar"
13+
14+
// NewAvatarFs creates a new avatar filesystem base on a afero.Fs.
15+
func NewAvatarFs(fs afero.Fs) vfs.Avatarer {
16+
return &avatarFS{fs}
17+
}
18+
19+
type avatarFS struct {
20+
fs afero.Fs
21+
}
22+
23+
type avatarUpload struct {
24+
afero.File
25+
fs afero.Fs
26+
tmpname string
27+
}
28+
29+
func (u *avatarUpload) Close() error {
30+
if err := u.File.Close(); err != nil {
31+
_ = u.fs.Remove(u.tmpname)
32+
return err
33+
}
34+
return u.fs.Rename(u.tmpname, AvatarFilename)
35+
}
36+
37+
func (a *avatarFS) CreateAvatar(contentType string) (io.WriteCloser, error) {
38+
f, err := afero.TempFile(a.fs, "/", AvatarFilename)
39+
if err != nil {
40+
return nil, err
41+
}
42+
tmpname := f.Name()
43+
u := &avatarUpload{
44+
File: f,
45+
fs: a.fs,
46+
tmpname: tmpname,
47+
}
48+
return u, nil
49+
}
50+
51+
func (a *avatarFS) AvatarExists() (bool, error) {
52+
infos, err := a.fs.Stat(AvatarFilename)
53+
if os.IsNotExist(err) {
54+
return false, nil
55+
}
56+
if err != nil {
57+
return false, err
58+
}
59+
return infos.Size() > 0, nil
60+
}
61+
62+
func (a *avatarFS) ServeAvatarContent(w http.ResponseWriter, req *http.Request) error {
63+
s, err := a.fs.Stat(AvatarFilename)
64+
if err != nil {
65+
return err
66+
}
67+
if s.Size() == 0 {
68+
return os.ErrInvalid
69+
}
70+
f, err := a.fs.Open(AvatarFilename)
71+
if err != nil {
72+
return err
73+
}
74+
defer f.Close()
75+
http.ServeContent(w, req, AvatarFilename, s.ModTime(), f)
76+
return nil
77+
}

model/vfs/vfsswift/avatar_v3.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package vfsswift
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
9+
"github.com/cozy/cozy-stack/model/vfs"
10+
"github.com/cozy/cozy-stack/pkg/prefixer"
11+
"github.com/ncw/swift/v2"
12+
)
13+
14+
// NewAvatarFsV3 creates a new avatar filesystem base on swift.
15+
//
16+
// This version stores the avatar in the same container as the main data
17+
// container.
18+
func NewAvatarFsV3(c *swift.Connection, db prefixer.Prefixer) vfs.Avatarer {
19+
return &avatarV3{
20+
c: c,
21+
container: swiftV3ContainerPrefix + db.DBPrefix(),
22+
ctx: context.Background(),
23+
}
24+
}
25+
26+
type avatarV3 struct {
27+
c *swift.Connection
28+
container string
29+
ctx context.Context
30+
}
31+
32+
func (a *avatarV3) CreateAvatar(contentType string) (io.WriteCloser, error) {
33+
return a.c.ObjectCreate(a.ctx, a.container, "avatar", true, "", contentType, nil)
34+
}
35+
36+
func (a *avatarV3) ServeAvatarContent(w http.ResponseWriter, req *http.Request) error {
37+
f, o, err := a.c.ObjectOpen(a.ctx, a.container, "avatar", false, nil)
38+
if err != nil {
39+
return wrapSwiftErr(err)
40+
}
41+
defer f.Close()
42+
43+
w.Header().Set("Etag", fmt.Sprintf(`"%s"`, o["Etag"]))
44+
w.Header().Set("Content-Type", o["Content-Type"])
45+
http.ServeContent(w, req, "avatar", unixEpochZero, &backgroundSeeker{f})
46+
return nil
47+
}

web/public/public.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package public
55

66
import (
77
"net/http"
8+
"os"
89
"strings"
910
"time"
1011

@@ -20,6 +21,11 @@ import (
2021
// Avatar returns the default avatar currently.
2122
func Avatar(c echo.Context) error {
2223
inst := middlewares.GetInstance(c)
24+
err := inst.AvatarFS().ServeAvatarContent(c.Response(), c.Request())
25+
if err != os.ErrNotExist {
26+
return err
27+
}
28+
2329
switch c.QueryParam("fallback") {
2430
case "404":
2531
// Nothing

web/settings/settings.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"io"
910
"net/http"
1011
"net/url"
1112
"strings"
@@ -253,6 +254,31 @@ func isMovedError(err error) bool {
253254
return ok && j.Code == "moved"
254255
}
255256

257+
func (h *HTTPHandler) UploadAvatar(c echo.Context) error {
258+
inst := middlewares.GetInstance(c)
259+
if err := middlewares.AllowWholeType(c, http.MethodPut, consts.Settings); err != nil {
260+
return err
261+
}
262+
header := c.Request().Header
263+
size := c.Request().ContentLength
264+
if size > 20_000_000 {
265+
return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "Avatar is too big")
266+
}
267+
contentType := header.Get(echo.HeaderContentType)
268+
f, err := inst.AvatarFS().CreateAvatar(contentType)
269+
if err != nil {
270+
return jsonapi.InternalServerError(err)
271+
}
272+
_, err = io.Copy(f, c.Request().Body)
273+
if cerr := f.Close(); cerr != nil && err == nil {
274+
err = cerr
275+
}
276+
if err != nil {
277+
return jsonapi.InternalServerError(err)
278+
}
279+
return c.NoContent(http.StatusNoContent)
280+
}
281+
256282
// Register all the `/settings` routes to the given router.
257283
func (h *HTTPHandler) Register(router *echo.Group) {
258284
router.GET("/disk-usage", h.diskUsage)
@@ -281,6 +307,8 @@ func (h *HTTPHandler) Register(router *echo.Group) {
281307
router.PUT("/instance/sign_tos", h.updateInstanceTOS)
282308
router.DELETE("/instance/moved_from", h.clearMovedFrom)
283309

310+
router.PUT("/avatar", h.UploadAvatar)
311+
284312
router.GET("/flags", h.getFlags)
285313

286314
router.GET("/sessions", h.getSessions)

web/sharings/sharings.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"errors"
1111
"net/http"
1212
"net/url"
13+
"os"
1314
"strconv"
1415
"strings"
1516

@@ -844,9 +845,16 @@ func GetAvatar(c echo.Context) error {
844845
m := s.Members[index]
845846

846847
// Use the local avatar
847-
if m.Instance == "" || m.Instance == inst.PageURL("", nil) {
848+
if m.Instance == "" {
848849
return localAvatar(c, m)
849850
}
851+
if m.Instance == inst.PageURL("", nil) {
852+
err := inst.AvatarFS().ServeAvatarContent(c.Response(), c.Request())
853+
if err == os.ErrNotExist {
854+
return localAvatar(c, m)
855+
}
856+
return err
857+
}
850858

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

0 commit comments

Comments
 (0)