From dfb7730b633b54c8897ea474f6b45c68f2b3b1eb Mon Sep 17 00:00:00 2001 From: konrad Date: Sun, 2 Aug 2020 17:16:58 +0000 Subject: [PATCH] More avatar providers (#622) Don't fail if the last avatar file does not exist when deleting it Fix lint Remove old global avatar setting and update docs Generate docs Invalidate the avatar cache when uploading a new one Add debug logs Add caching for upload avatars Add cache locks Fix encoding Resize the uploaded image to a max of 1024 pixels Remove the old uploaded avatar if one already exists Add mimetype check for images Set avatar provider to upload when uploading an avatar Add upload avatar provider Make font size smaller to let the initials still look good in smaller sizes Add debug log Add cache and resizing of initials avatars Make font size depend on avatar size Add drawing initials avatar Add initials provider Make the initials avatar provider the default Add routes Add user avatar settings handler methods Add user avatar provider field Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/622 --- Makefile | 2 +- config.yml.sample | 4 - docs/content/doc/setup/config.md | 4 - docs/content/doc/usage/errors.md | 1 + go.mod | 4 + go.sum | 22 +- pkg/config/config.go | 2 - pkg/files/files.go | 10 +- pkg/metrics/active_users.go | 2 +- pkg/migration/20200801183357.go | 50 ++++ pkg/models/tasks.go | 3 + pkg/modules/avatar/initials/initials.go | 175 ++++++++++++ pkg/modules/avatar/upload/upload.go | 98 +++++++ pkg/modules/background/handler/background.go | 16 +- pkg/modules/background/upload/upload.go | 1 + pkg/routes/api/v1/avatar.go | 116 +++++++- .../{user_add_update.go => user_register.go} | 0 pkg/routes/api/v1/user_settings.go | 97 +++++++ pkg/routes/routes.go | 3 + pkg/swagger/docs.go | 251 ++++++++++++++++++ pkg/swagger/swagger.json | 251 ++++++++++++++++++ pkg/swagger/swagger.yaml | 166 ++++++++++++ pkg/user/error.go | 27 ++ pkg/user/user.go | 15 ++ 24 files changed, 1287 insertions(+), 33 deletions(-) create mode 100644 pkg/migration/20200801183357.go create mode 100644 pkg/modules/avatar/initials/initials.go create mode 100644 pkg/modules/avatar/upload/upload.go rename pkg/routes/api/v1/{user_add_update.go => user_register.go} (100%) create mode 100644 pkg/routes/api/v1/user_settings.go diff --git a/Makefile b/Makefile index 50765c35..c7ed8a5a 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ GOFLAGS := -v $(EXTRA_GOFLAGS) LDFLAGS := -X "code.vikunja.io/api/pkg/version.Version=$(shell git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')" -X "main.Tags=$(TAGS)" -PACKAGES ?= $(filter-out code.vikunja.io/api/pkg/integrations,$(shell go list)) +PACKAGES ?= $(filter-out code.vikunja.io/api/pkg/integrations,$(shell go list all | grep code\.vikunja\.io\/api)) SOURCES ?= $(shell find . -name "*.go" -type f) TAGS ?= diff --git a/config.yml.sample b/config.yml.sample index 3897f7db..e86b344b 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -178,10 +178,6 @@ migration: redirecturl: avatar: - # Switch between avatar providers. Possible values are gravatar and default. - # gravatar will fetch the avatar based on the user email. - # default will return a default avatar for every request. - provider: gravatar # When using gravatar, this is the duration in seconds until a cached gravatar user avatar expires gravatarexpiration: 3600 diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index 0888d93c..283e8c82 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -221,10 +221,6 @@ migration: redirecturl: avatar: - # Switch between avatar providers. Possible values are gravatar and default. - # gravatar will fetch the avatar based on the user email. - # default will return a default avatar for every request. - provider: gravatar # When using gravatar, this is the duration in seconds until a cached gravatar user avatar expires gravatarexpiration: 3600 diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 95fef49c..f64cb68c 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -37,6 +37,7 @@ This document describes the different errors Vikunja can return. | 1015 | 412 | Totp is already enabled for this user. | | 1016 | 412 | Totp is not enabled for this user. | | 1017 | 412 | The provided Totp passcode is invalid. | +| 1018 | 412 | The provided user avatar provider type setting is invalid. | ### Validation diff --git a/go.mod b/go.mod index 60884618..275e1294 100644 --- a/go.mod +++ b/go.mod @@ -28,12 +28,15 @@ require ( github.com/cweill/gotests v1.5.3 github.com/d4l3k/messagediff v1.2.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/disintegration/imaging v1.6.2 github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835 + github.com/gabriel-vasile/mimetype v1.1.1 github.com/getsentry/sentry-go v0.7.0 github.com/go-redis/redis/v7 v7.4.0 github.com/go-sql-driver/mysql v1.5.0 github.com/go-testfixtures/testfixtures/v3 v3.3.0 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 github.com/imdario/mergo v0.3.10 @@ -64,6 +67,7 @@ require ( github.com/swaggo/swag v1.6.7 github.com/ulule/limiter/v3 v3.5.0 golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de + golang.org/x/image v0.0.0-20200801110659-972c09e46d76 golang.org/x/lint v0.0.0-20200302205851-738671d3881b golang.org/x/net v0.0.0-20200602114024-627f9648deb9 // indirect golang.org/x/text v0.3.3 // indirect diff --git a/go.sum b/go.sum index eb821032..d4625694 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= @@ -134,11 +136,11 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835 h1:roDmqJ4Qes7hrDOsWsMCce0vQHz3xiMPjJ9m4c2eeNs= github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835/go.mod h1:BjL/N0+C+j9uNX+1xcNuM9vdSIcXCZrQZUYbXOFbgN8= +github.com/gabriel-vasile/mimetype v1.1.1 h1:qbN9MPuRf3bstHu9zkI9jDWNfH//9+9kHxr9oRBBBOA= +github.com/gabriel-vasile/mimetype v1.1.1/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To= github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= -github.com/getsentry/sentry-go v0.6.1 h1:K84dY1/57OtWhdyr5lbU78Q/+qgzkEyGc/ud+Sipi5k= -github.com/getsentry/sentry-go v0.6.1/go.mod h1:0yZBuzSvbZwBnvaF9VwZIMen3kXscY8/uasKtAX1qG8= github.com/getsentry/sentry-go v0.7.0 h1:MR2yfR4vFfv/2+iBuSnkdQwVg7N9cJzihZ6KJu7srwQ= github.com/getsentry/sentry-go v0.7.0/go.mod h1:pLFpD2Y5RHIKF9Bw3KH6/68DeN2K/XBJd8awjdPnUwg= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -205,6 +207,8 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -284,8 +288,6 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 h1:VHgatEHNcBFEB7inlalqfNqw65aNkM1lGX2yt3NmbS8= github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= -github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= -github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= @@ -417,10 +419,6 @@ github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.6.0 h1:I5DPxhYJChW9KYc66se+oKFFQX6VuQrKiprsX6ivRZc= github.com/lib/pq v1.6.0/go.mod h1:4vXEAYvW1fRQ2/FhZ78H73A60MHw1geSm145z2mdY1g= -github.com/lib/pq v1.7.0 h1:h93mCPfUSkaul3Ka/VG8uZdmW1uMHDGxzu0NWHuJmHY= -github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.7.1 h1:FvD5XTVTDt+KON6oIoOmHq6B6HzGuYEhuTMpEG0yuBQ= -github.com/lib/pq v1.7.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= @@ -610,8 +608,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= -github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -703,8 +699,6 @@ golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= -golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -713,7 +707,11 @@ golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxT golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200801110659-972c09e46d76 h1:U7GPaoQyQmX+CBRWXKrvRzWTbd+slqeSh8uARsIyhAw= +golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/pkg/config/config.go b/pkg/config/config.go index 229e01e6..aa861f07 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -117,7 +117,6 @@ const ( CorsOrigins Key = `cors.origins` CorsMaxAge Key = `cors.maxage` - AvatarProvider Key = `avatar.provider` AvatarGravaterExpiration Key = `avatar.gravatarexpiration` BackgroundsEnabled Key = `backgrounds.enabled` @@ -273,7 +272,6 @@ func InitDefaultConfig() { MigrationWunderlistEnable.setDefault(false) MigrationTodoistEnable.setDefault(false) // Avatar - AvatarProvider.setDefault("gravatar") AvatarGravaterExpiration.setDefault(3600) // List Backgrounds BackgroundsEnabled.setDefault(true) diff --git a/pkg/files/files.go b/pkg/files/files.go index d314cff3..90763508 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -67,7 +67,12 @@ func (f *File) LoadFileMetaByID() (err error) { } // Create creates a new file from an FileHeader -func Create(f io.ReadCloser, realname string, realsize uint64, a web.Auth) (file *File, err error) { +func Create(f io.Reader, realname string, realsize uint64, a web.Auth) (file *File, err error) { + return CreateWithMime(f, realname, realsize, a, "") +} + +// CreateWithMime creates a new file from an FileHeader and sets its mime type +func CreateWithMime(f io.Reader, realname string, realsize uint64, a web.Auth, mime string) (file *File, err error) { // Get and parse the configured file size var maxSize datasize.ByteSize @@ -84,6 +89,7 @@ func Create(f io.ReadCloser, realname string, realsize uint64, a web.Auth) (file Name: realname, Size: realsize, CreatedByID: a.GetID(), + Mime: mime, } _, err = x.Insert(file) @@ -111,6 +117,6 @@ func (f *File) Delete() (err error) { } // Save saves a file to storage -func (f *File) Save(fcontent io.ReadCloser) error { +func (f *File) Save(fcontent io.Reader) error { return afs.WriteReader(f.getFileName(), fcontent) } diff --git a/pkg/metrics/active_users.go b/pkg/metrics/active_users.go index 08434c3f..af3c7282 100644 --- a/pkg/metrics/active_users.go +++ b/pkg/metrics/active_users.go @@ -41,7 +41,7 @@ type ActiveUser struct { type activeUsersMap map[int64]*ActiveUser -// ActiveUsersMap is the type used to save active users +// ActiveUsers is the type used to save active users type ActiveUsers struct { users activeUsersMap mutex *sync.Mutex diff --git a/pkg/migration/20200801183357.go b/pkg/migration/20200801183357.go new file mode 100644 index 00000000..dc936300 --- /dev/null +++ b/pkg/migration/20200801183357.go @@ -0,0 +1,50 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type user20200801183357 struct { + AvatarProvider string `xorm:"varchar(255) null" json:"-"` + AvatarFileID int64 `xorn:"null" json:"-"` +} + +func (s user20200801183357) TableName() string { + return "users" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20200801183357", + Description: "Add avatar provider setting to user", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(user20200801183357{}) + if err != nil { + return err + } + + _, err = tx.Cols("avatar_provider").Update(&user20200801183357{AvatarProvider: "initials"}) + return err + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 5b59472f..1f082532 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -445,6 +445,9 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (err error) { // Get task attachments attachments, err := getTaskAttachmentsByTaskIDs(taskIDs) + if err != nil { + return + } // Get all users of a task // aka the ones who created a task diff --git a/pkg/modules/avatar/initials/initials.go b/pkg/modules/avatar/initials/initials.go new file mode 100644 index 00000000..727c8b0d --- /dev/null +++ b/pkg/modules/avatar/initials/initials.go @@ -0,0 +1,175 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package initials + +import ( + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/user" + "github.com/disintegration/imaging" + "strconv" + "strings" + "sync" + + "bytes" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/math/fixed" + "image" + "image/color" + "image/draw" + "image/png" +) + +// Provider represents the provider implementation of the initials provider +type Provider struct { +} + +var ( + avatarBgColors = []*color.RGBA{ + {69, 189, 243, 255}, + {224, 143, 112, 255}, + {77, 182, 172, 255}, + {149, 117, 205, 255}, + {176, 133, 94, 255}, + {240, 98, 146, 255}, + {163, 211, 108, 255}, + {121, 134, 203, 255}, + {241, 185, 29, 255}, + } + + // Contain the created avatars with a size of defaultSize + cache = map[int64]*image.RGBA64{} + cacheLock = sync.Mutex{} + cacheResized = map[string][]byte{} + cacheResizedLock = sync.Mutex{} +) + +func init() { + cache = make(map[int64]*image.RGBA64) + cacheResized = make(map[string][]byte) +} + +const ( + dpi = 72 + defaultSize = 1024 +) + +func drawImage(text rune, bg *color.RGBA) (img *image.RGBA64, err error) { + + size := defaultSize + fontSize := float64(size) * 0.8 + + // Inspired by https://github.com/holys/initials-avatar + + // Get the font + f, err := truetype.Parse(goregular.TTF) + if err != nil { + return img, err + } + + // Build the image background + img = image.NewRGBA64(image.Rect(0, 0, size, size)) + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + + // Add the text + drawer := &font.Drawer{ + Dst: img, + Src: image.White, + Face: truetype.NewFace(f, &truetype.Options{ + Size: fontSize, + DPI: dpi, + Hinting: font.HintingNone, + }), + } + + // Font Index + fi := f.Index(text) + + // Glyph example: http://www.freetype.org/freetype2/docs/tutorial/metrics.png + var gbuf truetype.GlyphBuf + fsize := fixed.Int26_6(fontSize * dpi * (64.0 / 72.0)) + err = gbuf.Load(f, fsize, fi, font.HintingFull) + if err != nil { + drawer.DrawString("") + return img, err + } + + // Center + dY := (size - int(gbuf.Bounds.Max.Y-gbuf.Bounds.Min.Y)>>6) / 2 + dX := (size - int(gbuf.Bounds.Max.X-gbuf.Bounds.Min.X)>>6) / 2 + y := int(gbuf.Bounds.Max.Y>>6) + dY + x := 0 - int(gbuf.Bounds.Min.X>>6) + dX + + drawer.Dot = fixed.Point26_6{ + X: fixed.I(x), + Y: fixed.I(y), + } + drawer.DrawString(string(text)) + + return img, err +} + +func getAvatarForUser(u *user.User) (fullSizeAvatar *image.RGBA64, err error) { + var cached bool + fullSizeAvatar, cached = cache[u.ID] + if !cached { + log.Debugf("Initials avatar for user %d not cached, creating...", u.ID) + firstRune := []rune(strings.ToUpper(u.Username))[0] + bg := avatarBgColors[int(u.ID)%len(avatarBgColors)] // Random color based on the user id + + fullSizeAvatar, err = drawImage(firstRune, bg) + if err != nil { + return nil, err + } + cacheLock.Lock() + cache[u.ID] = fullSizeAvatar + cacheLock.Unlock() + } + + return fullSizeAvatar, err +} + +// GetAvatar returns an initials avatar for a user +func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType string, err error) { + + var cached bool + cacheKey := strconv.Itoa(int(u.ID)) + "_" + strconv.Itoa(int(size)) + avatar, cached = cacheResized[cacheKey] + if !cached { + log.Debugf("Initials avatar for user %d and size %d not cached, creating...", u.ID, size) + fullAvatar, err := getAvatarForUser(u) + if err != nil { + return nil, "", err + } + + img := imaging.Resize(fullAvatar, int(size), int(size), imaging.Lanczos) + buf := &bytes.Buffer{} + err = png.Encode(buf, img) + if err != nil { + return nil, "", err + } + avatar = buf.Bytes() + cacheResizedLock.Lock() + cacheResized[cacheKey] = avatar + cacheResizedLock.Unlock() + } else { + log.Debugf("Serving initials avatar for user %d and size %d from cache", u.ID, size) + } + + return avatar, "image/png", err +} diff --git a/pkg/modules/avatar/upload/upload.go b/pkg/modules/avatar/upload/upload.go new file mode 100644 index 00000000..b2dd5dec --- /dev/null +++ b/pkg/modules/avatar/upload/upload.go @@ -0,0 +1,98 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package upload + +import ( + "bytes" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/user" + "github.com/disintegration/imaging" + "image" + "image/png" + "io/ioutil" + "sync" +) + +var ( + // This is a map with a map so we're able to clear all cached avatar (in all sizes) for one user at once + // The first map has as key the user id, the second one has the size as key + resizedCache = map[int64]map[int64][]byte{} + resizedCacheLock = sync.Mutex{} +) + +func init() { + resizedCache = make(map[int64]map[int64][]byte) +} + +// Provider represents the upload avatar provider +type Provider struct { +} + +// GetAvatar returns an uploaded user avatar +func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType string, err error) { + + a, cached := resizedCache[u.ID] + if cached { + if a != nil && a[size] != nil { + log.Debugf("Serving uploaded avatar for user %d and size %d from cache.", u.ID, size) + return a[size], "", nil + } + // This means we have a map for the user, but nothing in it. + if a == nil { + resizedCache[u.ID] = make(map[int64][]byte) + } + } else { + // Nothing ever cached for this user so we need to create the size map to avoid panics + resizedCache[u.ID] = make(map[int64][]byte) + } + + log.Debugf("Uploaded avatar for user %d and size %d not cached, resizing and caching.", u.ID, size) + + // If we get this far, the avatar is either not cached at all or not in this size + f := &files.File{ID: u.AvatarFileID} + if err := f.LoadFileByID(); err != nil { + return nil, "", err + } + + if err := f.LoadFileMetaByID(); err != nil { + return nil, "", err + } + + img, _, err := image.Decode(f.File) + if err != nil { + return nil, "", err + } + resizedImg := imaging.Resize(img, 0, int(size), imaging.Lanczos) + buf := &bytes.Buffer{} + if err := png.Encode(buf, resizedImg); err != nil { + return nil, "", err + } + + avatar, err = ioutil.ReadAll(buf) + resizedCacheLock.Lock() + resizedCache[u.ID][size] = avatar + resizedCacheLock.Unlock() + return avatar, f.Mime, err +} + +// InvalidateCache invalidates the avatar cache for a user +func InvalidateCache(u *user.User) { + resizedCacheLock.Lock() + delete(resizedCache, u.ID) + resizedCacheLock.Unlock() +} diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index 0b5430c9..c4a462e6 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -25,9 +25,12 @@ import ( v1 "code.vikunja.io/api/pkg/routes/api/v1" "code.vikunja.io/web" "code.vikunja.io/web/handler" + "github.com/gabriel-vasile/mimetype" "github.com/labstack/echo/v4" + "io" "net/http" "strconv" + "strings" ) // BackgroundProvider represents a thing which holds a background provider @@ -132,7 +135,18 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { } defer src.Close() - f, err := files.Create(src, file.Filename, uint64(file.Size), auth) + // Validate we're dealing with an image + mime, err := mimetype.DetectReader(src) + if err != nil { + return handler.HandleHTTPError(err, c) + } + if !strings.HasPrefix(mime.String(), "image") { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."}) + } + _, _ = src.Seek(0, io.SeekStart) + + // Save the file + f, err := files.CreateWithMime(src, file.Filename, uint64(file.Size), auth, mime.String()) if err != nil { if files.IsErrFileIsTooLarge(err) { return echo.ErrBadRequest diff --git a/pkg/modules/background/upload/upload.go b/pkg/modules/background/upload/upload.go index d9b85b09..c580e3b7 100644 --- a/pkg/modules/background/upload/upload.go +++ b/pkg/modules/background/upload/upload.go @@ -43,6 +43,7 @@ func (p *Provider) Search(search string, page int64) (result []*background.Image // @Param background formData string true "The file as single file." // @Security JWTKeyAuth // @Success 200 {object} models.Message "The background was set successfully." +// @Failure 400 {object} models.Message "File is no image." // @Failure 403 {object} models.Message "No access to the list." // @Failure 403 {object} models.Message "File too large." // @Failure 404 {object} models.Message "The list does not exist." diff --git a/pkg/routes/api/v1/avatar.go b/pkg/routes/api/v1/avatar.go index f0b02f44..b2789ef0 100644 --- a/pkg/routes/api/v1/avatar.go +++ b/pkg/routes/api/v1/avatar.go @@ -17,16 +17,27 @@ package v1 import ( - "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/avatar" "code.vikunja.io/api/pkg/modules/avatar/empty" "code.vikunja.io/api/pkg/modules/avatar/gravatar" - user2 "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/modules/avatar/initials" + "code.vikunja.io/api/pkg/modules/avatar/upload" + "code.vikunja.io/api/pkg/user" "code.vikunja.io/web/handler" + + "bytes" + "github.com/disintegration/imaging" + "github.com/gabriel-vasile/mimetype" "github.com/labstack/echo/v4" + "image" + "image/png" + "io" "net/http" "strconv" + "strings" ) // GetAvatar returns a user's avatar @@ -45,7 +56,7 @@ func GetAvatar(c echo.Context) error { username := c.Param("username") // Get the user - user, err := user2.GetUserWithEmail(&user2.User{Username: username}) + u, err := user.GetUserWithEmail(&user.User{Username: username}) if err != nil { log.Errorf("Error getting user for avatar: %v", err) return handler.HandleHTTPError(err, c) @@ -55,9 +66,13 @@ func GetAvatar(c echo.Context) error { // For now, we only have one avatar provider, in the future there could be multiple which // could be changed based on user settings etc. var avatarProvider avatar.Provider - switch config.AvatarProvider.GetString() { + switch u.AvatarProvider { case "gravatar": avatarProvider = &gravatar.Provider{} + case "initials": + avatarProvider = &initials.Provider{} + case "upload": + avatarProvider = &upload.Provider{} default: avatarProvider = &empty.Provider{} } @@ -73,11 +88,100 @@ func GetAvatar(c echo.Context) error { } // Get the avatar - a, mimeType, err := avatarProvider.GetAvatar(user, sizeInt) + a, mimeType, err := avatarProvider.GetAvatar(u, sizeInt) if err != nil { - log.Errorf("Error getting avatar for user %d: %v", user.ID, err) + log.Errorf("Error getting avatar for user %d: %v", u.ID, err) return handler.HandleHTTPError(err, c) } return c.Blob(http.StatusOK, mimeType, a) } + +// UploadAvatar uploads and sets a user avatar +// @Summary Upload a user avatar +// @Description Upload a user avatar. This will also set the user's avatar provider to "upload" +// @tags user +// @Accept mpfd +// @Produce json +// @Param avatar formData string true "The avatar as single file." +// @Security JWTKeyAuth +// @Success 200 {object} models.Message "The avatar was set successfully." +// @Failure 400 {object} models.Message "File is no image." +// @Failure 403 {object} models.Message "File too large." +// @Failure 500 {object} models.Message "Internal error" +// @Router /user/settings/avatar/upload [put] +func UploadAvatar(c echo.Context) (err error) { + + uc, err := user.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + u, err := user.GetUserByID(uc.ID) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + // Get + upload the image + file, err := c.FormFile("avatar") + if err != nil { + return err + } + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + // Validate we're dealing with an image + mime, err := mimetype.DetectReader(src) + if err != nil { + return handler.HandleHTTPError(err, c) + } + if !strings.HasPrefix(mime.String(), "image") { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."}) + } + _, _ = src.Seek(0, io.SeekStart) + + // Remove the old file if one exists + if u.AvatarFileID != 0 { + f := &files.File{ID: u.AvatarFileID} + if err := f.Delete(); err != nil { + if !files.IsErrFileDoesNotExist(err) { + return handler.HandleHTTPError(err, c) + } + } + u.AvatarFileID = 0 + } + + // Resize the new file to a max height of 1024 + img, _, err := image.Decode(src) + if err != nil { + return handler.HandleHTTPError(err, c) + } + resizedImg := imaging.Resize(img, 0, 1024, imaging.Lanczos) + buf := &bytes.Buffer{} + if err := png.Encode(buf, resizedImg); err != nil { + return handler.HandleHTTPError(err, c) + } + + upload.InvalidateCache(u) + + // Save the file + f, err := files.CreateWithMime(buf, file.Filename, uint64(file.Size), u, "image/png") + if err != nil { + if files.IsErrFileIsTooLarge(err) { + return echo.ErrBadRequest + } + + return handler.HandleHTTPError(err, c) + } + + u.AvatarFileID = f.ID + u.AvatarProvider = "upload" + + if _, err := user.UpdateUser(u); err != nil { + return handler.HandleHTTPError(err, c) + } + + return c.JSON(http.StatusOK, models.Message{Message: "Avatar was uploaded successfully."}) +} diff --git a/pkg/routes/api/v1/user_add_update.go b/pkg/routes/api/v1/user_register.go similarity index 100% rename from pkg/routes/api/v1/user_add_update.go rename to pkg/routes/api/v1/user_register.go diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go new file mode 100644 index 00000000..b7d29413 --- /dev/null +++ b/pkg/routes/api/v1/user_settings.go @@ -0,0 +1,97 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package v1 + +import ( + "code.vikunja.io/api/pkg/models" + user2 "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web/handler" + "github.com/labstack/echo/v4" + "net/http" +) + +// UserAvatarProvider holds the user avatar provider type +type UserAvatarProvider struct { + AvatarProvider string `json:"avatar_provider"` +} + +// GetUserAvatarProvider returns the currently set user avatar +// @Summary Return user avatar setting +// @Description Returns the current user's avatar setting. +// @tags user +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} UserAvatarProvider +// @Failure 400 {object} web.HTTPError "Something's invalid." +// @Failure 500 {object} models.Message "Internal server error." +// @Router /user/settings/avatar [get] +func GetUserAvatarProvider(c echo.Context) error { + + u, err := user2.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + user, err := user2.GetUserWithEmail(u) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + uap := &UserAvatarProvider{AvatarProvider: user.AvatarProvider} + return c.JSON(http.StatusOK, uap) +} + +// ChangeUserAvatarProvider changes the user's avatar provider +// @Summary Set the user's avatar +// @Description Changes the user avatar. Valid types are gravatar (uses the user email), upload, initials, default. +// @tags user +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param avatar body UserAvatarProvider true "The user's avatar setting" +// @Success 200 {object} UserAvatarProvider +// @Failure 400 {object} web.HTTPError "Something's invalid." +// @Failure 500 {object} models.Message "Internal server error." +// @Router /user/settings/avatar [post] +func ChangeUserAvatarProvider(c echo.Context) error { + + uap := &UserAvatarProvider{} + err := c.Bind(uap) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Bad avatar type provided.") + } + + u, err := user2.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + user, err := user2.GetUserWithEmail(u) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + user.AvatarProvider = uap.AvatarProvider + + _, err = user2.UpdateUser(user) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index f5d4205e..b9e1cde1 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -254,6 +254,9 @@ func registerAPIRoutes(a *echo.Group) { u.GET("s", apiv1.UserList) u.POST("/token", apiv1.RenewToken) u.POST("/settings/email", apiv1.UpdateUserEmail) + u.GET("/settings/avatar", apiv1.GetUserAvatarProvider) + u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider) + u.PUT("/settings/avatar/upload", apiv1.UploadAvatar) if config.ServiceEnableTotp.GetBool() { u.GET("/settings/totp", apiv1.UserTOTP) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 0ea99829..ec146431 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -923,6 +923,12 @@ var doc = `{ "$ref": "#/definitions/models.Message" } }, + "400": { + "description": "File is no image.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, "403": { "description": "File too large.", "schema": { @@ -1517,6 +1523,70 @@ var doc = `{ } } }, + "/lists/{listID}/duplicate": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Copies the list, tasks, files, kanban data, assignees, comments, attachments, lables, relations, backgrounds, user/team rights and link shares from one list to a new namespace. The user needs read access in the list and write access in the namespace of the new list.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "list" + ], + "summary": "Duplicate an existing list", + "parameters": [ + { + "type": "integer", + "description": "The list ID to duplicate", + "name": "listID", + "in": "path", + "required": true + }, + { + "description": "The target namespace which should hold the copied list.", + "name": "list", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ListDuplicate" + } + } + ], + "responses": { + "200": { + "description": "The created list.", + "schema": { + "$ref": "#/definitions/models.ListDuplicate" + } + }, + "400": { + "description": "Invalid list duplicate object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "403": { + "description": "The user does not have access to the list or namespace", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/lists/{listID}/tasks": { "get": { "security": [ @@ -5476,6 +5546,150 @@ var doc = `{ } } }, + "/user/settings/avatar": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns the current user's avatar setting.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Return user avatar setting", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Changes the user avatar. Valid types are gravatar (uses the user email), upload, initials, default.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Set the user's avatar", + "parameters": [ + { + "description": "The user's avatar setting", + "name": "avatar", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/user/settings/avatar/upload": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Upload a user avatar. This will also set the user's avatar provider to \"upload\"", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Upload a user avatar", + "parameters": [ + { + "type": "string", + "description": "The avatar as single file.", + "name": "avatar", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "The avatar was set successfully.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "File is no image.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "File too large.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/user/settings/email": { "post": { "security": [ @@ -6297,6 +6511,20 @@ var doc = `{ } } }, + "models.ListDuplicate": { + "type": "object", + "properties": { + "list": { + "description": "The copied list", + "type": "object", + "$ref": "#/definitions/models.List" + }, + "namespace_id": { + "description": "The target namespace ID", + "type": "integer" + } + } + }, "models.ListUser": { "type": "object", "properties": { @@ -7037,6 +7265,14 @@ var doc = `{ } } }, + "v1.UserAvatarProvider": { + "type": "object", + "properties": { + "avatar_provider": { + "type": "string" + } + } + }, "v1.UserPassword": { "type": "object", "properties": { @@ -7048,6 +7284,17 @@ var doc = `{ } } }, + "v1.legalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, "v1.vikunjaInfos": { "type": "object", "properties": { @@ -7066,6 +7313,10 @@ var doc = `{ "frontend_url": { "type": "string" }, + "legal": { + "type": "object", + "$ref": "#/definitions/v1.legalInfo" + }, "link_sharing_enabled": { "type": "boolean" }, diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 8a3dcc34..533f9cae 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -906,6 +906,12 @@ "$ref": "#/definitions/models.Message" } }, + "400": { + "description": "File is no image.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, "403": { "description": "File too large.", "schema": { @@ -1500,6 +1506,70 @@ } } }, + "/lists/{listID}/duplicate": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Copies the list, tasks, files, kanban data, assignees, comments, attachments, lables, relations, backgrounds, user/team rights and link shares from one list to a new namespace. The user needs read access in the list and write access in the namespace of the new list.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "list" + ], + "summary": "Duplicate an existing list", + "parameters": [ + { + "type": "integer", + "description": "The list ID to duplicate", + "name": "listID", + "in": "path", + "required": true + }, + { + "description": "The target namespace which should hold the copied list.", + "name": "list", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ListDuplicate" + } + } + ], + "responses": { + "200": { + "description": "The created list.", + "schema": { + "$ref": "#/definitions/models.ListDuplicate" + } + }, + "400": { + "description": "Invalid list duplicate object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "403": { + "description": "The user does not have access to the list or namespace", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/lists/{listID}/tasks": { "get": { "security": [ @@ -5459,6 +5529,150 @@ } } }, + "/user/settings/avatar": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns the current user's avatar setting.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Return user avatar setting", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Changes the user avatar. Valid types are gravatar (uses the user email), upload, initials, default.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Set the user's avatar", + "parameters": [ + { + "description": "The user's avatar setting", + "name": "avatar", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.UserAvatarProvider" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/user/settings/avatar/upload": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Upload a user avatar. This will also set the user's avatar provider to \"upload\"", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Upload a user avatar", + "parameters": [ + { + "type": "string", + "description": "The avatar as single file.", + "name": "avatar", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "The avatar was set successfully.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "File is no image.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "File too large.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/user/settings/email": { "post": { "security": [ @@ -6279,6 +6493,20 @@ } } }, + "models.ListDuplicate": { + "type": "object", + "properties": { + "list": { + "description": "The copied list", + "type": "object", + "$ref": "#/definitions/models.List" + }, + "namespace_id": { + "description": "The target namespace ID", + "type": "integer" + } + } + }, "models.ListUser": { "type": "object", "properties": { @@ -7019,6 +7247,14 @@ } } }, + "v1.UserAvatarProvider": { + "type": "object", + "properties": { + "avatar_provider": { + "type": "string" + } + } + }, "v1.UserPassword": { "type": "object", "properties": { @@ -7030,6 +7266,17 @@ } } }, + "v1.legalInfo": { + "type": "object", + "properties": { + "imprint_url": { + "type": "string" + }, + "privacy_policy_url": { + "type": "string" + } + } + }, "v1.vikunjaInfos": { "type": "object", "properties": { @@ -7048,6 +7295,10 @@ "frontend_url": { "type": "string" }, + "legal": { + "type": "object", + "$ref": "#/definitions/v1.legalInfo" + }, "link_sharing_enabled": { "type": "boolean" }, diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 5fa6ac2d..524c69b9 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -320,6 +320,16 @@ definitions: this value. type: string type: object + models.ListDuplicate: + properties: + list: + $ref: '#/definitions/models.List' + description: The copied list + type: object + namespace_id: + description: The target namespace ID + type: integer + type: object models.ListUser: properties: created: @@ -905,6 +915,11 @@ definitions: token: type: string type: object + v1.UserAvatarProvider: + properties: + avatar_provider: + type: string + type: object v1.UserPassword: properties: new_password: @@ -912,6 +927,13 @@ definitions: old_password: type: string type: object + v1.legalInfo: + properties: + imprint_url: + type: string + privacy_policy_url: + type: string + type: object v1.vikunjaInfos: properties: available_migrators: @@ -924,6 +946,9 @@ definitions: type: array frontend_url: type: string + legal: + $ref: '#/definitions/v1.legalInfo' + type: object link_sharing_enabled: type: boolean max_file_size: @@ -1578,6 +1603,10 @@ paths: description: The background was set successfully. schema: $ref: '#/definitions/models.Message' + "400": + description: File is no image. + schema: + $ref: '#/definitions/models.Message' "403": description: File too large. schema: @@ -2139,6 +2168,50 @@ paths: summary: Update an existing bucket tags: - task + /lists/{listID}/duplicate: + put: + consumes: + - application/json + description: Copies the list, tasks, files, kanban data, assignees, comments, + attachments, lables, relations, backgrounds, user/team rights and link shares + from one list to a new namespace. The user needs read access in the list and + write access in the namespace of the new list. + parameters: + - description: The list ID to duplicate + in: path + name: listID + required: true + type: integer + - description: The target namespace which should hold the copied list. + in: body + name: list + required: true + schema: + $ref: '#/definitions/models.ListDuplicate' + produces: + - application/json + responses: + "200": + description: The created list. + schema: + $ref: '#/definitions/models.ListDuplicate' + "400": + description: Invalid list duplicate object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "403": + description: The user does not have access to the list or namespace + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Duplicate an existing list + tags: + - list /lists/{listID}/tasks: get: consumes: @@ -4603,6 +4676,99 @@ paths: summary: Request password reset token tags: - user + /user/settings/avatar: + get: + consumes: + - application/json + description: Returns the current user's avatar setting. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.UserAvatarProvider' + "400": + description: Something's invalid. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal server error. + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Return user avatar setting + tags: + - user + post: + consumes: + - application/json + description: Changes the user avatar. Valid types are gravatar (uses the user + email), upload, initials, default. + parameters: + - description: The user's avatar setting + in: body + name: avatar + required: true + schema: + $ref: '#/definitions/v1.UserAvatarProvider' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.UserAvatarProvider' + "400": + description: Something's invalid. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal server error. + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Set the user's avatar + tags: + - user + /user/settings/avatar/upload: + put: + consumes: + - multipart/form-data + description: Upload a user avatar. This will also set the user's avatar provider + to "upload" + parameters: + - description: The avatar as single file. + in: formData + name: avatar + required: true + type: string + produces: + - application/json + responses: + "200": + description: The avatar was set successfully. + schema: + $ref: '#/definitions/models.Message' + "400": + description: File is no image. + schema: + $ref: '#/definitions/models.Message' + "403": + description: File too large. + schema: + $ref: '#/definitions/models.Message' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Upload a user avatar + tags: + - user /user/settings/email: post: consumes: diff --git a/pkg/user/error.go b/pkg/user/error.go index fab06d91..c40e6b06 100644 --- a/pkg/user/error.go +++ b/pkg/user/error.go @@ -366,3 +366,30 @@ func (err ErrInvalidTOTPPasscode) HTTPError() web.HTTPError { Message: "Invalid totp passcode.", } } + +// ErrInvalidAvatarProvider represents a "InvalidAvatarProvider" kind of error. +type ErrInvalidAvatarProvider struct { + AvatarProvider string +} + +// IsErrInvalidAvatarProvider checks if an error is a ErrInvalidAvatarProvider. +func IsErrInvalidAvatarProvider(err error) bool { + _, ok := err.(ErrInvalidAvatarProvider) + return ok +} + +func (err ErrInvalidAvatarProvider) Error() string { + return "Invalid avatar provider" +} + +// ErrCodeInvalidAvatarProvider holds the unique world-error code of this error +const ErrCodeInvalidAvatarProvider = 1018 + +// HTTPError holds the http error description +func (err ErrInvalidAvatarProvider) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeInvalidAvatarProvider, + Message: "Invalid avatar provider setting. See docs for valid types.", + } +} diff --git a/pkg/user/user.go b/pkg/user/user.go index 7ff26ac5..15eac8e9 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -55,6 +55,9 @@ type User struct { PasswordResetToken string `xorm:"varchar(450) null" json:"-"` EmailConfirmToken string `xorm:"varchar(450) null" json:"-"` + AvatarProvider string `xorm:"varchar(255) null" json:"-"` + AvatarFileID int64 `xorn:"null" json:"-"` + // A timestamp when this task was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this task was last updated. You cannot change this value. @@ -269,6 +272,8 @@ func CreateUser(user *User) (newUser *User, err error) { newUser.EmailConfirmToken = utils.MakeRandomString(60) } + newUser.AvatarProvider = "initials" + // Insert it _, err = x.Insert(newUser) if err != nil { @@ -323,6 +328,16 @@ func UpdateUser(user *User) (updatedUser *User, err error) { user.Password = theUser.Password // set the password to the one in the database to not accedently resetting it + // Validate the avatar type + if user.AvatarProvider != "" { + if user.AvatarProvider != "default" && + user.AvatarProvider != "gravatar" && + user.AvatarProvider != "initials" && + user.AvatarProvider != "upload" { + return updatedUser, &ErrInvalidAvatarProvider{AvatarProvider: user.AvatarProvider} + } + } + // Update it _, err = x.ID(user.ID).Update(user) if err != nil {