photoprism/internal/api/api_response_headers_test.go
Michael Mayer 2c95c8302a Auth: Deliver signed download tokens to every session #5733 #5743
A configured PHOTOPRISM_DOWNLOAD_TOKEN was delivered to every client, so download
requests could not be attributed to the session that made them. Unlike preview
tokens, a download token is a query parameter on a one-shot transfer rather than
part of the URL path, so it is not part of the cache key and a per-session value
costs no cache sharing. There is accordingly no reason to trade the session
binding for it.

Sessions now always receive a signed, session-bound token. The configured value
is still accepted, so permanent URLs keep working, and it is delivered only to
callers with no session to sign for.

No value is generated when the option is blank either, so an instance that was
never configured for permanent URLs has none to accept: Config.DownloadToken
returns an empty string rather than a random one, and tokens.DownloadStatic is
no longer needed.

A session without its own preview token now receives no download token, in both
ClientSession and AddTokenHeaders, since the download token is the higher-value
credential — it also authorizes originals, and a coarse one is accepted for
previews.

Corrects the download-token and download-token-maxage usage strings and the
startup notice, which all described the previous delivery policy, and notes on
the frontend download helper that its anchor path cannot observe an expired
token.
2026-07-25 12:20:13 +00:00

63 lines
1.9 KiB
Go

package api
import (
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/auth/tokens"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestAddTokenHeaders(t *testing.T) {
sess := &entity.Session{
ID: rnd.SessionID("add-token-headers-test"),
PreviewToken: "prev123",
}
t.Run("SignedDownloadToken", func(t *testing.T) {
conf := get.Config()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
// Pin the delivery policy to signed (not public, no configured static token).
origPublic, origCoarse := tokens.PublicMode, tokens.CoarseDownload
tokens.PublicMode, tokens.CoarseDownload = false, ""
defer func() { tokens.PublicMode, tokens.CoarseDownload = origPublic, origCoarse }()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
AddTokenHeaders(c, sess)
assert.Equal(t, "prev123", w.Header().Get("X-Preview-Token"))
// The download token is the signed, session-bound compact value (<expires>.<sid>.<token>),
// delivered as the bare "?t=" value.
downloadToken := w.Header().Get("X-Download-Token")
parts := strings.SplitN(downloadToken, ".", 3)
assert.Len(t, parts, 3)
expires, err := strconv.ParseInt(parts[0], 10, 64)
assert.NoError(t, err)
gotID, ok := tokens.VerifyDownload(expires, parts[1], parts[2])
assert.True(t, ok)
assert.Equal(t, sess.ID, gotID)
})
t.Run("PublicModeEmitsNothing", func(t *testing.T) {
conf := get.Config()
conf.SetAuthMode(config.AuthModePublic)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
AddTokenHeaders(c, sess)
assert.Empty(t, w.Header().Get("X-Preview-Token"))
assert.Empty(t, w.Header().Get("X-Download-Token"))
})
}