API: Improve request parameter sanitation #1814

This commit is contained in:
Michael Mayer
2021-12-14 18:34:52 +01:00
parent 9a8144c046
commit 4e94919030
34 changed files with 338 additions and 115 deletions

8
go.sum
View File

@@ -84,13 +84,10 @@ github.com/esimov/pigo v1.4.5 h1:ySG0QqMh02VNALvHnx04L1ScRu66N6XA5vLLga8GiLg=
github.com/esimov/pigo v1.4.5/go.mod h1:SGkOUpm4wlEmQQJKlaymAkThY8/8iP+XE0gFo7g8G6w=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gin-contrib/gzip v0.0.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k=
github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc=
github.com/gin-contrib/gzip v0.0.5 h1:mhnVU32YnnBh2LPH2iqRqsA/eR7SAqRaD388jL2s/j0=
github.com/gin-contrib/gzip v0.0.5/go.mod h1:OPIK6HR0Um2vNmBUTlayD7qle4yVVRZT0PyhdUigrKk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
@@ -114,7 +111,6 @@ github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
@@ -164,8 +160,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosimple/slug v1.11.2 h1:MxFR0TmQ/qz0KvIrBbf4phu+G0RBgpwxOn6jPKFKFOw=
github.com/gosimple/slug v1.11.2/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc=
github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
@@ -419,8 +413,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211213223007-03aa0b5f6827 h1:A0Qkn7Z/n8zC1xd9LTw17AiKlBRK64tw3ejWQiEqca0=
golang.org/x/sys v0.0.0-20211213223007-03aa0b5f6827/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=

View File

@@ -17,7 +17,9 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/workers"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/sanitize"
)
// Namespaces for caching and logs.
@@ -47,7 +49,7 @@ func GetAccount(router *gin.RouterGroup) {
return
}
id := ParseUint(c.Param("id"))
id := sanitize.IdUint(c.Param("id"))
if m, err := query.AccountByID(id); err == nil {
c.JSON(http.StatusOK, m)
@@ -80,7 +82,7 @@ func GetAccountFolders(router *gin.RouterGroup) {
}
start := time.Now()
id := ParseUint(c.Param("id"))
id := sanitize.IdUint(c.Param("id"))
cache := service.FolderCache()
cacheKey := fmt.Sprintf("%s:%d", accountFolder, id)
@@ -128,7 +130,7 @@ func ShareWithAccount(router *gin.RouterGroup) {
return
}
id := ParseUint(c.Param("id"))
id := sanitize.IdUint(c.Param("id"))
m, err := query.AccountByID(id)
@@ -237,7 +239,7 @@ func UpdateAccount(router *gin.RouterGroup) {
return
}
id := ParseUint(c.Param("id"))
id := sanitize.IdUint(c.Param("id"))
m, err := query.AccountByID(id)
@@ -306,7 +308,7 @@ func DeleteAccount(router *gin.RouterGroup) {
return
}
id := ParseUint(c.Param("id"))
id := sanitize.IdUint(c.Param("id"))
m, err := query.AccountByID(id)

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
@@ -17,7 +18,9 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -51,7 +54,7 @@ func GetAlbum(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -114,7 +117,7 @@ func UpdateAlbum(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(uid)
if err != nil {
@@ -166,7 +169,7 @@ func DeleteAlbum(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(id)
@@ -217,7 +220,7 @@ func LikeAlbum(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -255,7 +258,7 @@ func DislikeAlbum(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -290,7 +293,7 @@ func CloneAlbums(router *gin.RouterGroup) {
return
}
a, err := query.AlbumByUID(c.Param("uid"))
a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
@@ -355,7 +358,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
a, err := query.AlbumByUID(uid)
if err != nil {
@@ -415,7 +418,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
return
}
a, err := query.AlbumByUID(c.Param("uid"))
a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
@@ -453,7 +456,7 @@ func DownloadAlbum(router *gin.RouterGroup) {
}
start := time.Now()
a, err := query.AlbumByUID(c.Param("uid"))
a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)

View File

@@ -35,6 +35,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
@@ -64,7 +65,7 @@ func UpdateClientConfig() {
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api: abort %s with code %d (%s)", c.FullPath(), code, resp.String())
log.Debugf("api: abort %s with code %d (%s)", txt.LogParam(c.FullPath()), code, resp.String())
c.AbortWithStatusJSON(code, resp)
}
@@ -74,7 +75,7 @@ func Error(c *gin.Context, code int, err error, id i18n.Message, params ...inter
if err != nil {
resp.Details = err.Error()
log.Errorf("api: error %s with code %d in %s (%s)", txt.LogParam(err.Error()), code, c.FullPath(), resp.String())
log.Errorf("api: error %s with code %d in %s (%s)", txt.LogParam(err.Error()), code, txt.LogParam(c.FullPath()), resp.String())
}
c.AbortWithStatusJSON(code, resp)

View File

@@ -3,17 +3,19 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// BatchPhotosArchive moves multiple photos to the archive.
@@ -40,7 +42,7 @@ func BatchPhotosArchive(router *gin.RouterGroup) {
return
}
log.Infof("photos: archiving %s", f.String())
log.Infof("photos: archiving %s", txt.LogParam(f.String()))
if service.Config().BackupYaml() {
photos, err := query.PhotoSelection(f)
@@ -103,7 +105,7 @@ func BatchPhotosRestore(router *gin.RouterGroup) {
return
}
log.Infof("photos: restoring %s", f.String())
log.Infof("photos: restoring %s", txt.LogParam(f.String()))
if service.Config().BackupYaml() {
photos, err := query.PhotoSelection(f)
@@ -165,7 +167,7 @@ func BatchPhotosApprove(router *gin.RouterGroup) {
return
}
log.Infof("photos: approving %s", f.String())
log.Infof("photos: approving %s", txt.LogParam(f.String()))
photos, err := query.PhotoSelection(f)
@@ -217,7 +219,7 @@ func BatchAlbumsDelete(router *gin.RouterGroup) {
return
}
log.Infof("albums: deleting %s", f.String())
log.Infof("albums: deleting %s", txt.LogParam(f.String()))
entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.Album{})
entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.PhotoAlbum{})
@@ -254,7 +256,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
return
}
log.Infof("photos: updating private flag for %s", f.String())
log.Infof("photos: updating private flag for %s", txt.LogParam(f.String()))
if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil {
@@ -307,7 +309,7 @@ func BatchLabelsDelete(router *gin.RouterGroup) {
return
}
log.Infof("labels: deleting %s", f.String())
log.Infof("labels: deleting %s", txt.LogParam(f.String()))
var labels entity.Labels
@@ -359,7 +361,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
return
}
log.Infof("photos: deleting %s", f.String())
log.Infof("photos: deleting %s", txt.LogParam(f.String()))
photos, err := query.PhotoSelection(f)

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
@@ -37,13 +39,13 @@ func AlbumCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
thumbName := thumb.Name(c.Param("size"))
uid := c.Param("uid")
thumbName := thumb.Name(sanitize.Token(c.Param("size")))
uid := sanitize.IdString(c.Param("uid"))
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("%s: invalid size %s", albumCover, thumbName)
log.Errorf("%s: invalid size %s", albumCover, txt.LogParam(thumbName.String()))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
@@ -84,7 +86,7 @@ func AlbumCover(router *gin.RouterGroup) {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: found no original for %s", albumCover, fileName)
log.Errorf("%s: found no original for %s", albumCover, txt.LogParam(fileName))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
@@ -149,13 +151,13 @@ func LabelCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
thumbName := thumb.Name(c.Param("size"))
uid := c.Param("uid")
thumbName := thumb.Name(sanitize.Token(c.Param("size")))
uid := sanitize.IdString(c.Param("uid"))
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("%s: invalid size %s", labelCover, thumbName)
log.Errorf("%s: invalid size %s", labelCover, txt.LogParam(thumbName.String()))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}

View File

@@ -3,16 +3,16 @@ package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/pkg/txt"
)
// TODO: GET /api/v1/dl/file/:hash
@@ -44,7 +44,7 @@ func GetDownload(router *gin.RouterGroup) {
return
}
fileHash := c.Param("hash")
fileHash := sanitize.Token(c.Param("hash"))
f, err := query.FileByHash(fileHash)

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
@@ -56,7 +58,7 @@ func UpdateFace(router *gin.RouterGroup) {
return
}
faceId := c.Param("id")
faceId := sanitize.Token(c.Param("id"))
m := entity.FindFace(faceId)
if m == nil {

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/query"
@@ -22,7 +24,7 @@ func GetFile(router *gin.RouterGroup) {
return
}
p, err := query.FileByHash(c.Param("hash"))
p, err := query.FileByHash(sanitize.Token(c.Param("hash")))
if err != nil {
AbortEntityNotFound(c)

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"path/filepath"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
@@ -35,8 +37,8 @@ func DeleteFile(router *gin.RouterGroup) {
return
}
photoUID := c.Param("uid")
fileUID := c.Param("file_uid")
photoUID := sanitize.IdString(c.Param("uid"))
fileUID := sanitize.IdString(c.Param("file_uid"))
file, err := query.FileByUID(fileUID)

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
@@ -36,7 +38,7 @@ func FolderCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
uid := c.Param("uid")
thumbName := thumb.Name(c.Param("size"))
thumbName := thumb.Name(sanitize.Token(c.Param("size")))
download := c.Query("download") != ""
size, ok := thumb.Sizes[thumbName]

View File

@@ -23,7 +23,7 @@ func AddCoverCacheHeader(c *gin.Context) {
AddCacheHeader(c, CoverCacheTTL)
}
// AddCacheHeader adds thumbnail cache control headers to the response.
// AddThumbCacheHeader adds thumbnail cache control headers to the response.
func AddThumbCacheHeader(c *gin.Context) {
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform, immutable", ThumbCacheTTL.String()))
}

View File

@@ -77,7 +77,7 @@ func StartImport(router *gin.RouterGroup) {
}
if len(f.Albums) > 0 {
log.Debugf("import: adding files to album %s", strings.Join(f.Albums, " and "))
log.Debugf("import: adding files to album %s", txt.LogParam(strings.Join(f.Albums, " and ")))
opt.Albums = f.Albums
}

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
@@ -33,7 +35,7 @@ func UpdateLabel(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
m, err := query.LabelByUID(id)
if err != nil {
@@ -67,7 +69,7 @@ func LikeLabel(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
label, err := query.LabelByUID(id)
if err != nil {
@@ -107,7 +109,7 @@ func DislikeLabel(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
label, err := query.LabelByUID(id)
if err != nil {

View File

@@ -5,12 +5,15 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -30,7 +33,7 @@ func UpdateLink(c *gin.Context) {
return
}
link := entity.FindLink(c.Param("link"))
link := entity.FindLink(sanitize.Token(c.Param("link")))
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
@@ -70,7 +73,7 @@ func DeleteLink(c *gin.Context) {
return
}
link := entity.FindLink(c.Param("link"))
link := entity.FindLink(sanitize.Token(c.Param("link")))
if err := link.Delete(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
@@ -102,7 +105,7 @@ func CreateLink(c *gin.Context) {
return
}
link := entity.NewLink(c.Param("uid"), f.CanComment, f.CanEdit)
link := entity.NewLink(sanitize.IdString(c.Param("uid")), f.CanComment, f.CanEdit)
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
@@ -132,7 +135,7 @@ func CreateLink(c *gin.Context) {
// POST /api/v1/albums/:uid/links
func CreateAlbumLink(router *gin.RouterGroup) {
router.POST("/albums/:uid/links", func(c *gin.Context) {
if _, err := query.AlbumByUID(c.Param("uid")); err != nil {
if _, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))); err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
@@ -158,7 +161,7 @@ func DeleteAlbumLink(router *gin.RouterGroup) {
// GET /api/v1/albums/:uid/links
func GetAlbumLinks(router *gin.RouterGroup) {
router.GET("/albums/:uid/links", func(c *gin.Context) {
m, err := query.AlbumByUID(c.Param("uid"))
m, err := query.AlbumByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
@@ -172,7 +175,7 @@ func GetAlbumLinks(router *gin.RouterGroup) {
// POST /api/v1/photos/:uid/links
func CreatePhotoLink(router *gin.RouterGroup) {
router.POST("/photos/:uid/links", func(c *gin.Context) {
if _, err := query.PhotoByUID(c.Param("uid")); err != nil {
if _, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))); err != nil {
AbortEntityNotFound(c)
return
}
@@ -198,7 +201,7 @@ func DeletePhotoLink(router *gin.RouterGroup) {
// GET /api/v1/photos/:uid/links
func GetPhotoLinks(router *gin.RouterGroup) {
router.GET("/photos/:uid/links", func(c *gin.Context) {
m, err := query.PhotoByUID(c.Param("uid"))
m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
@@ -212,7 +215,7 @@ func GetPhotoLinks(router *gin.RouterGroup) {
// POST /api/v1/labels/:uid/links
func CreateLabelLink(router *gin.RouterGroup) {
router.POST("/labels/:uid/links", func(c *gin.Context) {
if _, err := query.LabelByUID(c.Param("uid")); err != nil {
if _, err := query.LabelByUID(sanitize.IdString(c.Param("uid"))); err != nil {
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
return
}
@@ -238,7 +241,7 @@ func DeleteLabelLink(router *gin.RouterGroup) {
// GET /api/v1/labels/:uid/links
func GetLabelLinks(router *gin.RouterGroup) {
router.GET("/labels/:uid/links", func(c *gin.Context) {
m, err := query.LabelByUID(c.Param("uid"))
m, err := query.LabelByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)

View File

@@ -14,7 +14,9 @@ import (
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -50,7 +52,7 @@ func GetPhoto(router *gin.RouterGroup) {
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
@@ -73,7 +75,7 @@ func UpdatePhoto(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
m, err := query.PhotoByUID(uid)
if err != nil {
@@ -135,7 +137,7 @@ func GetPhotoDownload(router *gin.RouterGroup) {
return
}
f, err := query.FileByPhotoUID(c.Param("uid"))
f, err := query.FileByPhotoUID(sanitize.IdString(c.Param("uid")))
if err != nil {
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
@@ -171,7 +173,7 @@ func GetPhotoYaml(router *gin.RouterGroup) {
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
@@ -186,7 +188,7 @@ func GetPhotoYaml(router *gin.RouterGroup) {
}
if c.Query("download") != "" {
AddDownloadHeader(c, c.Param("uid")+fs.YamlExt)
AddDownloadHeader(c, sanitize.IdString(c.Param("uid"))+fs.YamlExt)
}
c.Data(http.StatusOK, "text/x-yaml; charset=utf-8", data)
@@ -206,7 +208,7 @@ func ApprovePhoto(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
m, err := query.PhotoByUID(id)
if err != nil {
@@ -241,7 +243,7 @@ func LikePhoto(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
m, err := query.PhotoByUID(id)
if err != nil {
@@ -276,7 +278,7 @@ func DislikePhoto(router *gin.RouterGroup) {
return
}
id := c.Param("uid")
id := sanitize.IdString(c.Param("uid"))
m, err := query.PhotoByUID(id)
if err != nil {
@@ -312,8 +314,8 @@ func PhotoPrimary(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
fileUID := c.Param("file_uid")
uid := sanitize.IdString(c.Param("uid"))
fileUID := sanitize.IdString(c.Param("file_uid"))
err := query.SetPhotoPrimary(uid, fileUID)
if err != nil {

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"strconv"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/classify"
@@ -27,7 +29,7 @@ func AddPhotoLabel(router *gin.RouterGroup) {
return
}
m, err := query.PhotoByUID(c.Param("uid"))
m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
@@ -68,7 +70,7 @@ func AddPhotoLabel(router *gin.RouterGroup) {
}
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
@@ -102,14 +104,14 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
return
}
m, err := query.PhotoByUID(c.Param("uid"))
m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
return
}
labelId, err := strconv.Atoi(c.Param("id"))
labelId, err := strconv.Atoi(sanitize.Token(c.Param("id")))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
@@ -130,7 +132,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
logError("label", entity.Db().Save(&label).Error)
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
@@ -144,7 +146,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
PublishPhotoEvent(EntityUpdated, sanitize.IdString(c.Param("uid")), c)
event.Success("label removed")
@@ -168,14 +170,14 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
// TODO: Code clean-up, simplify
m, err := query.PhotoByUID(c.Param("uid"))
m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
return
}
labelId, err := strconv.Atoi(c.Param("id"))
labelId, err := strconv.Atoi(sanitize.Token(c.Param("id")))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
@@ -199,7 +201,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid")))
if err != nil {
AbortEntityNotFound(c)
@@ -211,7 +213,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
PublishPhotoEvent(EntityUpdated, sanitize.IdString(c.Param("uid")), c)
event.Success("label saved")

View File

@@ -5,15 +5,16 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/internal/crop"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/crop"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -37,16 +38,16 @@ func GetThumb(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
download := c.Query("download") != ""
fileHash, cropArea := crop.ParseThumb(c.Param("thumb"))
fileHash, cropArea := crop.ParseThumb(sanitize.Token(c.Param("thumb")))
// Is cropped thumbnail?
if cropArea != "" {
cropName := crop.Name(c.Param("size"))
cropName := crop.Name(sanitize.Token(c.Param("size")))
cropSize, ok := crop.Sizes[cropName]
if !ok {
log.Errorf("%s: invalid size %s", logPrefix, cropName)
log.Errorf("%s: invalid size %s", logPrefix, txt.LogParam(string(cropName)))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
@@ -74,12 +75,12 @@ func GetThumb(router *gin.RouterGroup) {
return
}
thumbName := thumb.Name(c.Param("size"))
thumbName := thumb.Name(sanitize.Token(c.Param("size")))
size, ok := thumb.Sizes[thumbName]
if !ok {
log.Errorf("%s: invalid size %s", logPrefix, thumbName)
log.Errorf("%s: invalid size %s", logPrefix, txt.LogParam(thumbName.String()))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"path/filepath"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
@@ -31,7 +33,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
}
conf := service.Config()
fileUID := c.Param("file_uid")
fileUID := sanitize.IdString(c.Param("file_uid"))
file, err := query.FileByUID(fileUID)
if err != nil {

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
@@ -59,7 +61,7 @@ func SearchGeo(router *gin.RouterGroup) {
var resp []byte
// Render JSON response.
switch c.Param("format") {
switch sanitize.Token(c.Param("format")) {
case "view":
conf := service.Config()
resp, err = photos.ViewerJSON(conf.ContentUri(), conf.ApiUri(), conf.PreviewToken(), conf.DownloadToken())

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
@@ -89,7 +91,7 @@ func CreateSession(router *gin.RouterGroup) {
// DELETE /api/v1/session/:id
func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := c.Param("id")
id := sanitize.Token(c.Param("id"))
service.Session().Delete(id)
@@ -126,7 +128,7 @@ func Auth(id string, resource acl.Resource, action acl.Action) session.Data {
// InvalidPreviewToken returns true if the token is invalid.
func InvalidPreviewToken(c *gin.Context) bool {
token := c.Param("token")
token := sanitize.Token(c.Param("token"))
if token == "" {
token = c.Query("t")

View File

@@ -9,6 +9,8 @@ import (
"path"
"time"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
@@ -29,8 +31,8 @@ func SharePreview(router *gin.RouterGroup) {
router.GET("/:token/:share/preview", func(c *gin.Context) {
conf := service.Config()
token := c.Param("token")
share := c.Param("share")
token := sanitize.Token(c.Param("token"))
share := sanitize.Token(c.Param("share"))
links := entity.FindLinks(token, share)
if len(links) != 1 {
@@ -51,13 +53,13 @@ func SharePreview(router *gin.RouterGroup) {
yesterday := time.Now().Add(-24 * time.Hour)
if info, err := os.Stat(previewFilename); err != nil {
log.Debugf("share: creating new preview for %s", share)
log.Debugf("share: creating new preview for %s", txt.LogParam(share))
} else if info.ModTime().After(yesterday) {
log.Debugf("share: using cached preview for %s", share)
log.Debugf("share: using cached preview for %s", txt.LogParam(share))
c.File(previewFilename)
return
} else if err := os.Remove(previewFilename); err != nil {
log.Errorf("share: could not remove old preview of %s", share)
log.Errorf("share: could not remove old preview of %s", txt.LogParam(share))
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
@@ -26,7 +28,7 @@ func GetSubject(router *gin.RouterGroup) {
return
}
if subj := entity.FindSubject(c.Param("uid")); subj == nil {
if subj := entity.FindSubject(sanitize.IdString(c.Param("uid"))); subj == nil {
Abort(c, http.StatusNotFound, i18n.ErrSubjectNotFound)
return
} else {
@@ -54,7 +56,7 @@ func UpdateSubject(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
m := entity.FindSubject(uid)
if m == nil {
@@ -107,7 +109,7 @@ func LikeSubject(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
subj := entity.FindSubject(uid)
if subj == nil {
@@ -141,7 +143,7 @@ func DislikeSubject(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
subj := entity.FindSubject(uid)
if subj == nil {

View File

@@ -63,7 +63,7 @@ func Upload(router *gin.RouterGroup) {
log.Debugf("upload: saving file %s", txt.LogParam(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
log.Errorf("upload: failed saving file %s", filepath.Base(file.Filename))
log.Errorf("upload: failed saving file %s", txt.LogParam(filepath.Base(file.Filename)))
AbortBadRequest(c)
return
}

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
@@ -28,7 +30,7 @@ func ChangePassword(router *gin.RouterGroup) {
return
}
uid := c.Param("uid")
uid := sanitize.IdString(c.Param("uid"))
m := entity.FindUserByUID(uid)
if s.User.UserUID != m.UserUID {

View File

@@ -3,6 +3,8 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
@@ -24,8 +26,8 @@ func GetVideo(router *gin.RouterGroup) {
return
}
fileHash := c.Param("hash")
typeName := c.Param("type")
fileHash := sanitize.Token(c.Param("hash"))
typeName := sanitize.Token(c.Param("type"))
videoType, ok := video.Types[typeName]

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
@@ -20,9 +20,8 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
)
// POST /api/v1/zip
@@ -146,7 +145,7 @@ func DownloadZip(router *gin.RouterGroup) {
zipFileName := path.Join(zipPath, zipBaseName)
if !fs.FileExists(zipFileName) {
log.Errorf("could not find zip file: %s", zipFileName)
log.Errorf("could not find zip file: %s", txt.LogParam(zipFileName))
c.Data(404, "image/svg+xml", photoIconSvg)
return
}

25
pkg/sanitize/hex.go Normal file
View File

@@ -0,0 +1,25 @@
package sanitize
import (
"strings"
)
// Hex removes invalid character from a hex string and makes it lowercase.
func Hex(s string) string {
if s == "" {
return s
}
s = strings.ToLower(s)
// Remove all invalid characters.
s = strings.Map(func(r rune) rune {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return -1
}
return r
}, s)
return s
}

19
pkg/sanitize/hex_test.go Normal file
View File

@@ -0,0 +1,19 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHex(t *testing.T) {
t.Run("UUID", func(t *testing.T) {
assert.Equal(t, "123e4567e89b12d3a456426614174000", Hex("123e4567-e89b-12d3-A456-426614174000 "))
})
t.Run("ThumbSize", func(t *testing.T) {
assert.Equal(t, "ef224", Hex("left_224"))
})
t.Run("SHA1", func(t *testing.T) {
assert.Equal(t, "5c50ae14f339364eb8224f23c2d3abc7e79016f3eaded", Hex("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md"))
})
}

41
pkg/sanitize/id.go Normal file
View File

@@ -0,0 +1,41 @@
package sanitize
import (
"strconv"
"strings"
)
// IdString removes invalid character from an id string.
func IdString(s string) string {
if s == "" || len(s) > 256 {
return ""
}
s = strings.ToLower(s)
// Remove all invalid characters.
s = strings.Map(func(r rune) rune {
if (r < '0' || r > '9') && (r < 'a' || r > 'z') && r != '-' && r != '_' && r != ':' {
return -1
}
return r
}, s)
return s
}
// IdUint converts the string converted to an unsigned integer and 0 if the string is invalid.
func IdUint(s string) uint {
if s == "" || len(s) > 64 {
return 0
}
result, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0
}
return uint(result)
}

31
pkg/sanitize/id_test.go Normal file
View File

@@ -0,0 +1,31 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIdString(t *testing.T) {
t.Run("UUID", func(t *testing.T) {
assert.Equal(t, "123e4567-e89b-12d3-a456-426614174000", IdString("123e4567-e89b-12d3-A456-426614174000 "))
})
t.Run("ThumbSize", func(t *testing.T) {
assert.Equal(t, "left_224", IdString("left_224"))
})
t.Run("SHA1", func(t *testing.T) {
assert.Equal(t, "5c50ae14f339364eb8224f23c2d3abc7e79016f3readmemd", IdString("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md"))
})
}
func TestIdUint(t *testing.T) {
t.Run("12334545", func(t *testing.T) {
assert.Equal(t, uint(12334545), IdUint("12334545"))
})
t.Run("ThumbSize", func(t *testing.T) {
assert.Equal(t, uint(0), IdUint("left_224"))
})
t.Run("SHA1", func(t *testing.T) {
assert.Equal(t, uint(0), IdUint("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md"))
})
}

32
pkg/sanitize/sanitize.go Normal file
View File

@@ -0,0 +1,32 @@
/*
Package sanitize provides input value sanitation functions
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.app>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required
to describe our software, run your own server, for educational purposes, but not for
offering commercial goods, products, or services without prior written permission.
In other words, please ask.
Feel free to send an e-mail to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
https://docs.photoprism.app/developer-guide/
*/
package sanitize

23
pkg/sanitize/token.go Normal file
View File

@@ -0,0 +1,23 @@
package sanitize
import (
"strings"
)
// Token removes invalid character from a token string.
func Token(s string) string {
if s == "" {
return s
}
// Remove all invalid characters.
s = strings.Map(func(r rune) rune {
if (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && r != '-' && r != '_' && r != ':' {
return -1
}
return r
}, s)
return s
}

View File

@@ -0,0 +1,19 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestToken(t *testing.T) {
t.Run("UUID", func(t *testing.T) {
assert.Equal(t, "123e4567-e89b-12d3-A456-426614174000", Token("123e4567-e89b-12d3-A456-426614174000 "))
})
t.Run("ThumbSize", func(t *testing.T) {
assert.Equal(t, "left_224", Token("left_224"))
})
t.Run("SHA1", func(t *testing.T) {
assert.Equal(t, "5c50ae14f339364eb8224f23c2d3abc7e79016f3READMEmd", Token("5c50ae14f339364eb8224f23c2d3abc7e79016f3 README.md"))
})
}