diff --git a/go.mod b/go.mod index 01adc7377..67ec2453d 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/ThreeDotsLabs/watermill v1.1.1 github.com/adlio/trello v1.9.0 github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef + github.com/bbrks/go-blurhash v1.1.1 github.com/beevik/etree v1.1.0 // indirect github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 github.com/coreos/go-oidc/v3 v3.1.0 diff --git a/go.sum b/go.sum index 0d53dc01a..272909f9b 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M= +github.com/bbrks/go-blurhash v1.1.1/go.mod h1:lkAsdyXp+EhARcUo85yS2G1o+Sh43I2ebF5togC4bAY= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -531,6 +533,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index b2b0d3b54..6af4068d3 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -78,15 +78,15 @@ func FullInit() { LightInit() + // Initialize the files handler + files.InitFileHandler() + // Run the migrations migration.Migrate(nil) // Set Engine InitEngines() - // Initialize the files handler - files.InitFileHandler() - // Start the mail daemon mail.StartMailDaemon() diff --git a/pkg/migration/20211212210054.go b/pkg/migration/20211212210054.go new file mode 100644 index 000000000..a85503162 --- /dev/null +++ b/pkg/migration/20211212210054.go @@ -0,0 +1,95 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "image" + + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "github.com/bbrks/go-blurhash" + "golang.org/x/image/draw" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type lists20211212210054 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"list"` + BackgroundFileID int64 `xorm:"null" json:"-"` + BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"` +} + +func (lists20211212210054) TableName() string { + return "lists" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20211212210054", + Description: "Add blurHash to list backgrounds.", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(lists20211212210054{}) + if err != nil { + return err + } + + lists := []*lists20211212210054{} + err = tx.Where("background_file_id is not null AND background_file_id != ?", 0).Find(&lists) + if err != nil { + return err + } + + log.Infof("Creating BlurHash for %d list backgrounds, this might take a while...", len(lists)) + + for _, l := range lists { + bgFile := &files.File{ + ID: l.BackgroundFileID, + } + if err := bgFile.LoadFileByID(); err != nil { + return err + } + + src, _, err := image.Decode(bgFile.File) + if err != nil { + return err + } + + dst := image.NewRGBA(image.Rect(0, 0, 32, 32)) + draw.NearestNeighbor.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) + + hash, err := blurhash.Encode(4, 3, dst) + if err != nil { + return err + } + + l.BackgroundBlurHash = hash + _, err = tx.Where("id = ?", l.ID). + Cols("background_blur_hash"). + Update(l) + if err != nil { + return err + } + log.Debugf("Created BlurHash for list %d", l.ID) + } + + return nil + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/list.go b/pkg/models/list.go index 513f388b2..8857050fa 100644 --- a/pkg/models/list.go +++ b/pkg/models/list.go @@ -59,6 +59,8 @@ type List struct { BackgroundFileID int64 `xorm:"null" json:"-"` // Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /lists/{listID}/background BackgroundInformation interface{} `xorm:"-" json:"background_information"` + // Contains a very small version of the list background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works. + BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"` // True if a list is a favorite. Favorite lists show up in a separate namespace. This value depends on the user making the call to the api. IsFavorite bool `xorm:"-" json:"is_favorite"` @@ -638,7 +640,7 @@ func UpdateList(s *xorm.Session, list *List, auth web.Auth, updateListBackground } if updateListBackground { - colsToUpdate = append(colsToUpdate, "background_file_id") + colsToUpdate = append(colsToUpdate, "background_file_id", "background_blur_hash") } wasFavorite, err := isFavorite(s, list.ID, auth, FavoriteKindList) @@ -799,14 +801,15 @@ func (l *List) Delete(s *xorm.Session, a web.Auth) (err error) { } // SetListBackground sets a background file as list background in the db -func SetListBackground(s *xorm.Session, listID int64, background *files.File) (err error) { +func SetListBackground(s *xorm.Session, listID int64, background *files.File, blurHash string) (err error) { l := &List{ - ID: listID, - BackgroundFileID: background.ID, + ID: listID, + BackgroundFileID: background.ID, + BackgroundBlurHash: blurHash, } _, err = s. Where("id = ?", l.ID). - Cols("background_file_id"). + Cols("background_file_id", "background_blur_hash"). Update(l) return } diff --git a/pkg/models/list_duplicate.go b/pkg/models/list_duplicate.go index 33cf7e2cf..5b1e51acb 100644 --- a/pkg/models/list_duplicate.go +++ b/pkg/models/list_duplicate.go @@ -144,7 +144,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { } } - if err := SetListBackground(s, ld.List.ID, file); err != nil { + if err := SetListBackground(s, ld.List.ID, file, ld.List.BackgroundBlurHash); err != nil { return err } diff --git a/pkg/modules/background/background.go b/pkg/modules/background/background.go index 057a446c7..bd6cc2ab1 100644 --- a/pkg/modules/background/background.go +++ b/pkg/modules/background/background.go @@ -24,9 +24,10 @@ import ( // Image represents an image which can be used as a list background type Image struct { - ID string `json:"id"` - URL string `json:"url"` - Thumb string `json:"thumb,omitempty"` + ID string `json:"id"` + URL string `json:"url"` + Thumb string `json:"thumb,omitempty"` + BlurHash string `json:"blur_hash"` // This can be used to supply extra information from an image provider to clients Info interface{} `json:"info,omitempty"` } diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index 5ea40a808..282e0578d 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -17,24 +17,31 @@ package handler import ( + "image" + _ "image/gif" // To make sure the decoder used for generating blurHashes recognizes gifs + _ "image/jpeg" // To make sure the decoder used for generating blurHashes recognizes jpgs + _ "image/png" // To make sure the decoder used for generating blurHashes recognizes pngs "io" "net/http" "strconv" "strings" "code.vikunja.io/api/pkg/db" - "xorm.io/xorm" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/background" "code.vikunja.io/api/pkg/modules/background/unsplash" + "code.vikunja.io/api/pkg/modules/background/upload" "code.vikunja.io/web" "code.vikunja.io/web/handler" + + "github.com/bbrks/go-blurhash" "github.com/gabriel-vasile/mimetype" "github.com/labstack/echo/v4" + "golang.org/x/image/draw" + "xorm.io/xorm" ) // BackgroundProvider represents a thing which holds a background provider @@ -134,6 +141,18 @@ func (bp *BackgroundProvider) SetBackground(c echo.Context) error { return c.JSON(http.StatusOK, list) } +func CreateBlurHash(srcf io.Reader) (hash string, err error) { + src, _, err := image.Decode(srcf) + if err != nil { + return "", err + } + + dst := image.NewRGBA(image.Rect(0, 0, 32, 32)) + draw.NearestNeighbor.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) + + return blurhash.Encode(4, 3, dst) +} + // UploadBackground uploads a background and passes the id of the uploaded file as an Image to the Set function of the BackgroundProvider. func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { s := db.NewSession() @@ -145,23 +164,21 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { return handler.HandleHTTPError(err, c) } - p := bp.Provider() - // Get + upload the image file, err := c.FormFile("background") if err != nil { _ = s.Rollback() return err } - src, err := file.Open() + srcf, err := file.Open() if err != nil { _ = s.Rollback() return err } - defer src.Close() + defer srcf.Close() // Validate we're dealing with an image - mime, err := mimetype.DetectReader(src) + mime, err := mimetype.DetectReader(srcf) if err != nil { _ = s.Rollback() return handler.HandleHTTPError(err, c) @@ -170,10 +187,8 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { _ = s.Rollback() 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()) + err = SaveBackgroundFile(s, auth, list, srcf, file.Filename, uint64(file.Size)) if err != nil { _ = s.Rollback() if files.IsErrFileIsTooLarge(err) { @@ -183,14 +198,6 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { return handler.HandleHTTPError(err, c) } - image := &background.Image{ID: strconv.FormatInt(f.ID, 10)} - - err = p.Set(s, image, list, auth) - if err != nil { - _ = s.Rollback() - return handler.HandleHTTPError(err, c) - } - if err := s.Commit(); err != nil { _ = s.Rollback() return handler.HandleHTTPError(err, c) @@ -199,6 +206,27 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { return c.JSON(http.StatusOK, list) } +func SaveBackgroundFile(s *xorm.Session, auth web.Auth, list *models.List, srcf io.ReadSeeker, filename string, filesize uint64) (err error) { + _, _ = srcf.Seek(0, io.SeekStart) + f, err := files.Create(srcf, filename, filesize, auth) + if err != nil { + return err + } + + // Generate a blurHash + _, _ = srcf.Seek(0, io.SeekStart) + list.BackgroundBlurHash, err = CreateBlurHash(srcf) + if err != nil { + return err + } + + // Save it + p := upload.Provider{} + img := &background.Image{ID: strconv.FormatInt(f.ID, 10)} + err = p.Set(s, img, list, auth) + return err +} + func checkListBackgroundRights(s *xorm.Session, c echo.Context) (list *models.List, auth web.Auth, err error) { auth, err = auth2.GetAuthFromClaims(c) if err != nil { @@ -300,6 +328,7 @@ func RemoveListBackground(c echo.Context) error { list.BackgroundFileID = 0 list.BackgroundInformation = nil + list.BackgroundBlurHash = "" err = models.UpdateList(s, list, auth, true) if err != nil { return err diff --git a/pkg/modules/background/unsplash/unsplash.go b/pkg/modules/background/unsplash/unsplash.go index f097c6da0..1ee15ea5f 100644 --- a/pkg/modules/background/unsplash/unsplash.go +++ b/pkg/modules/background/unsplash/unsplash.go @@ -61,6 +61,7 @@ type Photo struct { Height int `json:"height"` Color string `json:"color"` Description string `json:"description"` + BlurHash string `json:"blur_hash"` User struct { Username string `json:"username"` Name string `json:"name"` @@ -178,8 +179,9 @@ func (p *Provider) Search(s *xorm.Session, search string, page int64) (result [] result = []*background.Image{} for _, p := range collectionResult { result = append(result, &background.Image{ - ID: p.ID, - URL: getImageID(p.Urls.Raw), + ID: p.ID, + URL: getImageID(p.Urls.Raw), + BlurHash: p.BlurHash, Info: &models.UnsplashPhoto{ UnsplashID: p.ID, Author: p.User.Username, @@ -213,8 +215,9 @@ func (p *Provider) Search(s *xorm.Session, search string, page int64) (result [] result = []*background.Image{} for _, p := range searchResult.Results { result = append(result, &background.Image{ - ID: p.ID, - URL: getImageID(p.Urls.Raw), + ID: p.ID, + URL: getImageID(p.Urls.Raw), + BlurHash: p.BlurHash, Info: &models.UnsplashPhoto{ UnsplashID: p.ID, Author: p.User.Username, @@ -315,7 +318,7 @@ func (p *Provider) Set(s *xorm.Session, image *background.Image, list *models.Li list.BackgroundInformation = unsplashPhoto // Set it as the list background - return models.SetListBackground(s, list.ID, file) + return models.SetListBackground(s, list.ID, file, photo.BlurHash) } // Pingback pings the unsplash api if an unsplash photo has been accessed. diff --git a/pkg/modules/background/upload/upload.go b/pkg/modules/background/upload/upload.go index 2597cb897..3bd3d5fea 100644 --- a/pkg/modules/background/upload/upload.go +++ b/pkg/modules/background/upload/upload.go @@ -52,7 +52,7 @@ func (p *Provider) Search(s *xorm.Session, search string, page int64) (result [] // @Failure 404 {object} models.Message "The list does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{id}/backgrounds/upload [put] -func (p *Provider) Set(s *xorm.Session, image *background.Image, list *models.List, auth web.Auth) (err error) { +func (p *Provider) Set(s *xorm.Session, img *background.Image, list *models.List, auth web.Auth) (err error) { // Remove the old background if one exists if list.BackgroundFileID != 0 { file := files.File{ID: list.BackgroundFileID} @@ -62,12 +62,12 @@ func (p *Provider) Set(s *xorm.Session, image *background.Image, list *models.Li } file := &files.File{} - file.ID, err = strconv.ParseInt(image.ID, 10, 64) + file.ID, err = strconv.ParseInt(img.ID, 10, 64) if err != nil { return } list.BackgroundInformation = &models.ListBackgroundType{Type: models.ListBackgroundUpload} - return models.SetListBackground(s, list.ID, file) + return models.SetListBackground(s, list.ID, file, list.BackgroundBlurHash) } diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 7905e80fa..e45224612 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -20,10 +20,11 @@ import ( "bytes" "io/ioutil" + "code.vikunja.io/api/pkg/modules/background/handler" + "xorm.io/xorm" "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" @@ -106,21 +107,19 @@ func insertFromStructure(s *xorm.Session, str []*models.NamespaceWithListsAndTas log.Debugf("[creating structure] Created list %d", l.ID) - backgroundFile, is := originalBackgroundInformation.(*bytes.Buffer) + bf, is := originalBackgroundInformation.(*bytes.Buffer) if is { + + backgroundFile := bytes.NewReader(bf.Bytes()) + log.Debugf("[creating structure] Creating a background file for list %d", l.ID) - file, err := files.Create(backgroundFile, "", uint64(backgroundFile.Len()), user) + err = handler.SaveBackgroundFile(s, user, &l.List, backgroundFile, "", uint64(backgroundFile.Len())) if err != nil { return err } - err = models.SetListBackground(s, l.ID, file) - if err != nil { - return err - } - - log.Debugf("[creating structure] Created a background file as new file %d for list %d", file.ID, l.ID) + log.Debugf("[creating structure] Created a background file for list %d", l.ID) } // Create all buckets diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index ab411d3ff..5b2b39e2e 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -7437,6 +7437,9 @@ var doc = `{ "background.Image": { "type": "object", "properties": { + "blur_hash": { + "type": "string" + }, "id": { "type": "string" }, @@ -7901,6 +7904,10 @@ var doc = `{ "models.List": { "type": "object", "properties": { + "background_blur_hash": { + "description": "Contains a very small version of the list background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works.", + "type": "string" + }, "background_information": { "description": "Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /lists/{listID}/background" }, diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 01a96a8a0..72a597300 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -7421,6 +7421,9 @@ "background.Image": { "type": "object", "properties": { + "blur_hash": { + "type": "string" + }, "id": { "type": "string" }, @@ -7885,6 +7888,10 @@ "models.List": { "type": "object", "properties": { + "background_blur_hash": { + "description": "Contains a very small version of the list background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works.", + "type": "string" + }, "background_information": { "description": "Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /lists/{listID}/background" }, diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index b3e1849a5..adb7e0d50 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -7,6 +7,8 @@ definitions: type: object background.Image: properties: + blur_hash: + type: string id: type: string info: @@ -389,6 +391,11 @@ definitions: type: object models.List: properties: + background_blur_hash: + description: Contains a very small version of the list background to use as + a blurry preview until the actual background is loaded. Check out https://blurha.sh/ + to learn how it works. + type: string background_information: description: Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can