API: Add download package to allow temporary file downloads #127 #1090

This is necessary to provide external services with temporary access to
specific media files without giving them permanent access, a regular
download, or an access token.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-07 23:56:35 +02:00
parent 0f76186663
commit 42897bde35
9 changed files with 165 additions and 19 deletions

View File

@@ -0,0 +1,14 @@
package download
import (
"time"
gc "github.com/patrickmn/go-cache"
)
var cache = gc.New(time.Minute*15, 5*time.Minute)
// Flush resets the download cache.
func Flush() {
cache.Flush()
}

View File

@@ -0,0 +1,31 @@
/*
Package download allows to register files so that they can be temporarily made available for download.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
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.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email 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 download
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log

View File

@@ -0,0 +1,24 @@
package download
import (
"fmt"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Find returns the fileName for the given download id or an error if the id is invalid.
func Find(uniqueId string) (fileName string, err error) {
if uniqueId == "" || !rnd.IsUUID(uniqueId) {
return fileName, fmt.Errorf("id has an invalid format")
}
// Cached?
if cacheData, hit := cache.Get(uniqueId); hit {
log.Tracef("download: cache hit for %s", uniqueId)
return cacheData.(string), nil
}
return "", fmt.Errorf("invalid id %s", clean.LogQuote(uniqueId))
}

View File

@@ -0,0 +1,22 @@
package download
import (
"fmt"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Register makes the specified file available for download with the
// returned id until the cache expires, or the server is restarted.
func Register(fileName string) (string, error) {
if !fs.FileExists(fileName) {
return "", fmt.Errorf("%s does not exists", clean.Log(fileName))
}
uniqueId := rnd.UUID()
cache.SetDefault(uniqueId, fileName)
return uniqueId, nil
}

View File

@@ -0,0 +1,37 @@
package download
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestRegister(t *testing.T) {
t.Run("Success", func(t *testing.T) {
fileName := fs.Abs("./testdata/image.jpg")
uniqueId, err := Register(fileName)
assert.NoError(t, err)
assert.True(t, rnd.IsUUID(uniqueId))
findName, findErr := Find(uniqueId)
assert.NoError(t, findErr)
assert.Equal(t, fileName, findName)
Flush()
findName, findErr = Find(uniqueId)
assert.Error(t, findErr)
assert.Equal(t, "", findName)
})
t.Run("NotFound", func(t *testing.T) {
fileName := fs.Abs("./testdata/invalid.jpg")
uniqueId, err := Register(fileName)
assert.Error(t, err)
assert.Equal(t, "", uniqueId)
})
}

BIN
internal/api/download/testdata/image.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -2,17 +2,18 @@ package api
import ( import (
"net/http" "net/http"
"path/filepath"
"github.com/photoprism/photoprism/internal/config/customize"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/api/download"
"github.com/photoprism/photoprism/internal/config/customize"
"github.com/photoprism/photoprism/internal/entity/query" "github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
) )
// TODO: GET /api/v1/dl/file/:hash // TODO: GET /api/v1/dl/file/:hash
@@ -41,18 +42,35 @@ func DownloadName(c *gin.Context) customize.DownloadName {
// @Produce application/octet-stream // @Produce application/octet-stream
// @Failure 403,404 {file} image/svg+xml // @Failure 403,404 {file} image/svg+xml
// @Success 200 {file} application/octet-stream // @Success 200 {file} application/octet-stream
// @Param hash path string true "File Hash" // @Param file path string true "file hash or unique download id"
// @Router /api/v1/dl/{hash} [get] // @Router /api/v1/dl/{file} [get]
func GetDownload(router *gin.RouterGroup) { func GetDownload(router *gin.RouterGroup) {
router.GET("/dl/:hash", func(c *gin.Context) { router.GET("/dl/:file", func(c *gin.Context) {
id := clean.Token(c.Param("file"))
// Check for temporary download if the file is identified by a UUID string.
if rnd.IsUUID(id) {
fileName, fileErr := download.Find(id)
if fileErr != nil {
AbortForbidden(c)
return
} else if !fs.FileExists(fileName) {
AbortNotFound(c)
return
}
c.FileAttachment(fileName, filepath.Base(fileName))
return
}
// If the file is identified by its hash, a valid download token is required.
if InvalidDownloadToken(c) { if InvalidDownloadToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg) c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return return
} }
fileHash := clean.Token(c.Param("hash")) f, err := query.FileByHash(id)
f, err := query.FileByHash(fileHash)
if err != nil { if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) c.AbortWithStatusJSON(404, gin.H{"error": err.Error()})

View File

@@ -44,7 +44,7 @@ func FolderCover(router *gin.RouterGroup) {
conf := get.Config() conf := get.Config()
uid := c.Param("uid") uid := c.Param("uid")
thumbName := thumb.Name(clean.Token(c.Param("size"))) thumbName := thumb.Name(clean.Token(c.Param("size")))
download := c.Query("download") != "" attachment := c.Query("download") != ""
size, ok := thumb.Sizes[thumbName] size, ok := thumb.Sizes[thumbName]
@@ -80,7 +80,7 @@ func FolderCover(router *gin.RouterGroup) {
AddCoverCacheHeader(c) AddCoverCacheHeader(c)
if download { if attachment {
c.FileAttachment(cached.FileName, cached.ShareName) c.FileAttachment(cached.FileName, cached.ShareName)
} else { } else {
c.File(cached.FileName) c.File(cached.FileName)
@@ -110,7 +110,7 @@ func FolderCover(router *gin.RouterGroup) {
} }
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157 // Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if size.ExceedsLimit() && !download { if size.ExceedsLimit() && !attachment {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", folderCover, size.Width, size.Height) log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", folderCover, size.Width, size.Height)
AddCoverCacheHeader(c) AddCoverCacheHeader(c)
c.File(fileName) c.File(fileName)
@@ -140,7 +140,7 @@ func FolderCover(router *gin.RouterGroup) {
AddCoverCacheHeader(c) AddCoverCacheHeader(c)
if download { if attachment {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0)) c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else { } else {
c.File(thumbnail) c.File(thumbnail)

View File

@@ -42,7 +42,7 @@ func GetThumb(router *gin.RouterGroup) {
start := time.Now() start := time.Now()
conf := get.Config() conf := get.Config()
download := c.Query("download") != "" attachment := c.Query("download") != ""
fileHash, cropArea := crop.ParseThumb(clean.Token(c.Param("thumb"))) fileHash, cropArea := crop.ParseThumb(clean.Token(c.Param("thumb")))
// Is cropped thumbnail? // Is cropped thumbnail?
@@ -72,7 +72,7 @@ func GetThumb(router *gin.RouterGroup) {
// Add HTTP cache header. // Add HTTP cache header.
AddImmutableCacheHeader(c) AddImmutableCacheHeader(c)
if download { if attachment {
c.FileAttachment(fileName, cropName.Jpeg()) c.FileAttachment(fileName, cropName.Jpeg())
} else { } else {
c.File(fileName) c.File(fileName)
@@ -118,7 +118,7 @@ func GetThumb(router *gin.RouterGroup) {
// Add HTTP cache header. // Add HTTP cache header.
AddImmutableCacheHeader(c) AddImmutableCacheHeader(c)
if download { if attachment {
c.FileAttachment(cached.FileName, cached.ShareName) c.FileAttachment(cached.FileName, cached.ShareName)
} else { } else {
c.File(cached.FileName) c.File(cached.FileName)
@@ -128,7 +128,7 @@ func GetThumb(router *gin.RouterGroup) {
} }
// Return existing thumbs straight away. // Return existing thumbs straight away.
if !download { if !attachment {
if fileName, err := size.ResolvedName(fileHash, conf.ThumbCachePath()); err == nil { if fileName, err := size.ResolvedName(fileHash, conf.ThumbCachePath()); err == nil {
// Add HTTP cache header. // Add HTTP cache header.
AddImmutableCacheHeader(c) AddImmutableCacheHeader(c)
@@ -188,7 +188,7 @@ func GetThumb(router *gin.RouterGroup) {
} }
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157 // Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if size.ExceedsLimit() && !download { if size.ExceedsLimit() && !attachment {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", logPrefix, size.Width, size.Height) log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", logPrefix, size.Width, size.Height)
// Add HTTP cache header. // Add HTTP cache header.
@@ -228,7 +228,7 @@ func GetThumb(router *gin.RouterGroup) {
AddImmutableCacheHeader(c) AddImmutableCacheHeader(c)
// Return requested content. // Return requested content.
if download { if attachment {
c.FileAttachment(thumbName, f.DownloadName(DownloadName(c), 0)) c.FileAttachment(thumbName, f.DownloadName(DownloadName(c), 0))
} else { } else {
c.File(thumbName) c.File(thumbName)