1
0

More avatar providers (#622)

Don't fail if the last avatar file does not exist when deleting it

Fix lint

Remove old global avatar setting and update docs

Generate docs

Invalidate the avatar cache when uploading a new one

Add debug logs

Add caching for upload avatars

Add cache locks

Fix encoding

Resize the uploaded image to a max of 1024 pixels

Remove the old uploaded avatar if one already exists

Add mimetype check for images

Set avatar provider to upload when uploading an avatar

Add upload avatar provider

Make font size smaller to let the initials still look good in smaller sizes

Add debug log

Add cache and resizing of initials avatars

Make font size depend on avatar size

Add drawing initials avatar

Add initials provider

Make the initials avatar provider the default

Add routes

Add user avatar settings handler methods

Add user avatar provider field

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/622
This commit is contained in:
konrad
2020-08-02 17:16:58 +00:00
parent c9117dd037
commit dfb7730b63
24 changed files with 1287 additions and 33 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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()
}

View File

@ -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

View File

@ -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."

View File

@ -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."})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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."})
}

View File

@ -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)

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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:

View File

@ -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.",
}
}

View File

@ -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 {