diff --git a/Makefile b/Makefile index 50765c35da..c7ed8a5a97 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 3897f7dbac..e86b344b86 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 0888d93cdf..283e8c82b4 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 95fef49caf..f64cb68c6d 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 60884618ab..275e1294f8 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 eb8210327b..d4625694f0 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 229e01e6a5..aa861f073c 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 d314cff39e..90763508a0 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 08434c3f74..af3c72821d 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 0000000000..dc9363005c --- /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 5b59472f85..1f08253286 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 0000000000..727c8b0dd5 --- /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 0000000000..b2dd5deca6 --- /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 0b5430c923..c4a462e6fc 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 d9b85b0962..c580e3b731 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 f0b02f44a1..b2789ef03a 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 0000000000..b7d29413e9 --- /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 f5d4205e32..b9e1cde105 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 0ea998291f..ec14643100 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 8a3dcc3495..533f9cae9d 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 5fa6ac2d43..524c69b9e9 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 fab06d91de..c40e6b06f9 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 7ff26ac59e..15eac8e951 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 {