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 {