Config: Add PHOTOPRISM_HTTP_CORS option for CDN users #3931 #3940

In addition, the Access-Control-Allow-Origin header is set to the same
URL if an Origin header is found in the request (experimental).

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-15 13:06:27 +01:00
parent e44262d4ea
commit c5f6a28448
18 changed files with 212 additions and 38 deletions

View file

@ -83,6 +83,21 @@ func (c *Config) HttpCompression() string {
return strings.ToLower(strings.TrimSpace(c.options.HttpCompression))
}
// HttpCORS checks of Cross-Origin Resource Sharing (CORS) should be allowed,
// so the "Access-Control-Allow-Origin" response header should be set to "*".
func (c *Config) HttpCORS() bool {
return c.options.HttpCORS
}
// HttpCachePublic checks whether static content may be cached by a CDN or caching proxy.
func (c *Config) HttpCachePublic() bool {
if c.options.HttpCachePublic {
return true
}
return c.options.CdnUrl != ""
}
// HttpCacheMaxAge returns the time in seconds until cached content expires.
func (c *Config) HttpCacheMaxAge() ttl.Duration {
// Return default cache maxage?
@ -109,15 +124,6 @@ func (c *Config) HttpVideoMaxAge() ttl.Duration {
return ttl.Duration(c.options.HttpVideoMaxAge)
}
// HttpCachePublic checks whether static content may be cached by a CDN or caching proxy.
func (c *Config) HttpCachePublic() bool {
if c.options.HttpCachePublic {
return true
}
return c.options.CdnUrl != ""
}
// HttpHost returns the built-in HTTP server host name or IP address (empty for all interfaces).
func (c *Config) HttpHost() string {
// when unix socket used as host, make host as default value. or http client will act weirdly.

View file

@ -62,6 +62,36 @@ func TestConfig_HttpCompression(t *testing.T) {
assert.Equal(t, "", c.HttpCompression())
}
func TestConfig_HttpCORS(t *testing.T) {
c := NewConfig(CliTestContext())
c.Options().CdnUrl = ""
c.Options().HttpCORS = false
assert.False(t, c.HttpCORS())
c.Options().CdnUrl = "https://cdn.com/"
assert.False(t, c.HttpCORS())
c.Options().CdnUrl = ""
assert.False(t, c.HttpCORS())
c.Options().HttpCORS = true
assert.True(t, c.HttpCORS())
c.Options().HttpCORS = false
assert.False(t, c.HttpCORS())
}
func TestConfig_HttpCachePublic(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.HttpCachePublic())
c.Options().CdnUrl = "https://cdn.com/"
assert.True(t, c.HttpCachePublic())
c.Options().CdnUrl = ""
assert.False(t, c.HttpCachePublic())
c.Options().HttpCachePublic = true
assert.True(t, c.HttpCachePublic())
c.Options().HttpCachePublic = false
assert.False(t, c.HttpCachePublic())
}
func TestConfig_HttpCacheMaxAge(t *testing.T) {
c := NewConfig(CliTestContext())
@ -85,17 +115,3 @@ func TestConfig_HttpVideoMaxAge(t *testing.T) {
c.Options().HttpVideoMaxAge = 0
assert.Equal(t, ttl.CacheVideo, c.HttpVideoMaxAge())
}
func TestConfig_HttpCachePublic(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.HttpCachePublic())
c.Options().CdnUrl = "https://cdn.com/"
assert.True(t, c.HttpCachePublic())
c.Options().CdnUrl = ""
assert.False(t, c.HttpCachePublic())
c.Options().HttpCachePublic = true
assert.True(t, c.HttpCachePublic())
c.Options().HttpCachePublic = false
assert.False(t, c.HttpCachePublic())
}

View file

@ -491,6 +491,11 @@ var Flags = CliFlags{
Usage: "Web server compression `METHOD` (gzip, none)",
EnvVar: EnvVar("HTTP_COMPRESSION"),
}}, {
Flag: cli.BoolFlag{
Name: "http-cors",
Usage: "allow Cross-Origin Resource Sharing (CORS)",
EnvVar: EnvVar("HTTP_CORS"),
}}, {
Flag: cli.BoolFlag{
Name: "http-cache-public",
Usage: "allow static content to be cached by a CDN or caching proxy",

View file

@ -113,6 +113,7 @@ type Options struct {
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpCORS bool `yaml:"HttpCORS" json:"-" flag:"http-cors"`
HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"`
HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"`
HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"`

View file

@ -160,6 +160,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"tls-key", c.TLSKey()},
{"http-mode", c.HttpMode()},
{"http-compression", c.HttpCompression()},
{"http-cors", fmt.Sprintf("%t", c.HttpCORS())},
{"http-cache-public", fmt.Sprintf("%t", c.HttpCachePublic())},
{"http-cache-maxage", fmt.Sprintf("%d", c.HttpCacheMaxAge())},
{"http-video-maxage", fmt.Sprintf("%d", c.HttpVideoMaxAge())},

View file

@ -4,13 +4,30 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
)
// Security adds common HTTP security headers to the response.
var Security = func(conf *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set(header.ContentSecurityPolicy, header.DefaultContentSecurityPolicy)
c.Writer.Header().Set(header.FrameOptions, header.DefaultFrameOptions)
// Allow Cross-Origin Resource Sharing (CORS)?
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#cors_and_caching
if conf.HttpCORS() {
c.Header(header.AccessControlAllowOrigin, header.Any)
} else if origin := c.GetHeader(header.Origin); origin != "" {
// Automatically set the "Access-Control-Allow-Origin" response header
// based on the "Origin" value of the request.
c.Header(header.AccessControlAllowOrigin, clean.Header(origin))
c.Header(header.Vary, header.Origin)
}
// Set Content Security Policy.
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
c.Header(header.ContentSecurityPolicy, header.DefaultContentSecurityPolicy)
// Set Frame Options.
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
c.Header(header.FrameOptions, header.DefaultFrameOptions)
}
}

View file

@ -2,6 +2,10 @@ package clean
// ASCII removes all non-ascii characters from a string and returns it.
func ASCII(s string) string {
if s == "" {
return ""
}
result := make([]rune, 0, len(s))
for _, r := range s {

34
pkg/clean/ascii_test.go Normal file
View file

@ -0,0 +1,34 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestASCII(t *testing.T) {
t.Run("URL", func(t *testing.T) {
result := ASCII("https://docs.photoprism.app/getting-started/config-options/#file-converters")
assert.Equal(t, "https://docs.photoprism.app/getting-started/config-options/#file-converters", result)
})
t.Run("Emoji", func(t *testing.T) {
result := ASCII("Hello 👍")
assert.Equal(t, "Hello ", result)
})
t.Run("EmojiURL", func(t *testing.T) {
result := ASCII("https://docs.photoprism.app/getting-started 👍/config-options/#file-converters")
assert.Equal(t, "https://docs.photoprism.app/getting-started /config-options/#file-converters", result)
})
}
func BenchmarkASCII(b *testing.B) {
for n := 0; n < b.N; n++ {
ASCII("https://docs.photoprism.app/getting-started 👍/config-options/#file-converters")
}
}
func BenchmarkASCIIEmpty(b *testing.B) {
for n := 0; n < b.N; n++ {
ASCII("")
}
}

18
pkg/clean/header.go Normal file
View file

@ -0,0 +1,18 @@
package clean
// Header sanitizes a string for use in request or response headers.
func Header(s string) string {
if s == "" || len(s) > MaxLength {
return ""
}
result := make([]rune, 0, len(s))
for _, r := range s {
if r > 32 && r < 127 {
result = append(result, r)
}
}
return string(result)
}

35
pkg/clean/header_test.go Normal file
View file

@ -0,0 +1,35 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHeader(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
expected := "https://docs.photoprism.app/getting-started/config-options/#file-converters"
result := Header(expected)
assert.Equal(t, expected, result)
})
t.Run("Tabs", func(t *testing.T) {
result := Header("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters")
assert.Equal(t, "https://..docs.photoprism.app/gettin\\g-started/config-options/file-converters", result)
})
t.Run("Emoji", func(t *testing.T) {
result := Header("Hello 👍")
assert.Equal(t, "Hello", result)
})
}
func BenchmarkHeader(b *testing.B) {
for n := 0; n < b.N; n++ {
Header("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters")
}
}
func BenchmarkHeaderEmpty(b *testing.B) {
for n := 0; n < b.N; n++ {
Header("")
}
}

View file

@ -7,7 +7,9 @@ import (
// Uri removes invalid character from an uri string.
func Uri(s string) string {
if s == "" || reject(s, 512) || strings.Contains(s, "..") {
if s == "" || len(s) > MaxLength {
return ""
} else if strings.Contains(s, "..") {
return ""
}

View file

@ -15,4 +15,20 @@ func TestUri(t *testing.T) {
result := Uri("https://..docs.photoprism.app/gettin\\g-started/config-options/\tfile-converters")
assert.Equal(t, "", result)
})
t.Run("Emoji", func(t *testing.T) {
result := Uri("Hello 👍")
assert.Equal(t, "Hello%20%F0%9F%91%8D", result)
})
}
func BenchmarkUri(b *testing.B) {
for n := 0; n < b.N; n++ {
Uri("https://docs.photoprism.app/getting-started/config-options/#file-converters")
}
}
func BenchmarkUriEmpty(b *testing.B) {
for n := 0; n < b.N; n++ {
Uri("")
}
}

View file

@ -12,11 +12,11 @@ import (
)
const (
XAuthToken = "X-Auth-Token"
XSessionID = "X-Session-ID"
Auth = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
AuthBasic = "Basic"
AuthBearer = "Bearer"
XAuthToken = "X-Auth-Token"
XSessionID = "X-Session-ID"
)
// AuthToken returns the client authentication token from the request context,

8
pkg/header/cors.go Normal file
View file

@ -0,0 +1,8 @@
package header
const (
AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
AccessControlAllowCredentials = "Access-Control-Allow-Credentials" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
AccessControlAllowHeaders = "Access-Control-Allow-Headers" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
AccessControlAllowMethods = "Access-Control-Allow-Methods" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
)

View file

@ -2,5 +2,5 @@ package header
var (
DefaultContentSecurityPolicy = "frame-ancestors 'none';"
DefaultFrameOptions = "DENY"
DefaultFrameOptions = Deny
)

View file

@ -1,9 +1,8 @@
package header
const (
AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
ContentSecurityPolicy = "Content-Security-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
StrictTransportSecurity = "Strict-Transport-Security" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
ContentSecurityPolicy = "Content-Security-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
CrossOriginOpenerPolicy = "Cross-Origin-Opener-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy
ReferrerPolicy = "Referrer-Policy" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
ContentTypeOptions = "X-Content-Type-Options" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options

6
pkg/header/values.go Normal file
View file

@ -0,0 +1,6 @@
package header
const (
Any = "*"
Deny = "DENY"
)

6
pkg/header/vary.go Normal file
View file

@ -0,0 +1,6 @@
package header
const (
Vary = "Vary"
Origin = "Origin"
)