Security: Add http rate limiter and auto tls mode #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-10-11 22:44:11 +02:00
parent 20904041f9
commit 6abbc39017
51 changed files with 1262 additions and 453 deletions

View file

@ -13,8 +13,9 @@ services:
- seccomp:unconfined
- apparmor:unconfined
ports:
- "2342:2342" # default HTTP port (host:container)
- "2343:2343" # acceptance Test HTTP port (host:container)
- "2342:2342" # Default HTTP port (host:container)
- "2443:2443" # Default TLS port (host:container)
- "2343:2343" # Acceptance Test HTTP port (host:container)
- "40000:40000" # Go Debugger (host:container)
shm_size: "2gb"
links:

View file

@ -92,8 +92,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
# define default directory and user
WORKDIR /photoprism
# expose default http port 2342
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# keep container running
CMD ["tail", "-f", "/dev/null"]

View file

@ -130,8 +130,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# Declare container entrypoint script.
ENTRYPOINT ["/scripts/entrypoint.sh"]

View file

@ -99,8 +99,8 @@ RUN apt-get update && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# copy dist files
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism

View file

@ -99,8 +99,8 @@ RUN apt-get update && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# copy dist files
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism

View file

@ -147,8 +147,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# Declare container entrypoint script.
ENTRYPOINT ["/scripts/entrypoint.sh"]

View file

@ -145,8 +145,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# Declare container entrypoint script.
ENTRYPOINT ["/scripts/entrypoint.sh"]

View file

@ -100,8 +100,8 @@ RUN apt-get update && \
# Default working directory.
WORKDIR /photoprism
# Expose HTTP port.
EXPOSE 2342
# Expose HTTP(S) ports.
EXPOSE 2342 2443
# Copy app files.
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism

11
go.mod
View file

@ -18,7 +18,7 @@ require (
github.com/gin-gonic/gin v1.8.1
github.com/go-playground/validator/v10 v10.11.1 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8
github.com/google/open-location-code/go v0.0.0-20221010173056-817c0086479a
github.com/gorilla/websocket v1.5.0
github.com/gosimple/slug v1.13.1
github.com/jinzhu/gorm v1.9.16
@ -47,7 +47,7 @@ require (
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.10
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2
golang.org/x/net v0.0.0-20221004154528-8021a29435af
gonum.org/v1/gonum v0.12.0
gopkg.in/photoprism/go-tz.v2 v2.1.1
@ -103,11 +103,16 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af
)
go 1.17

12
go.sum
View file

@ -264,8 +264,8 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8 h1:k2VIEPX7uDmceLb5cOKws0cHrvIMak1TT+Le4WcFreU=
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20221010173056-817c0086479a h1:73RF0aJQNQ3GIIQTTbTKZr0wWHw2F1Winh0NzVJg/wA=
github.com/google/open-location-code/go v0.0.0-20221010173056-817c0086479a/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@ -479,8 +479,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2 h1:x8vtB3zMecnlqZIwJNUUpwYKYSqCz5jXbiyv0ZJJZeI=
golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -620,6 +620,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -708,6 +710,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -11,12 +11,14 @@ func ClientIP(c *gin.Context) (ip string) {
if c == nil {
// Should never happen.
return UnknownIP
} else if ip = c.ClientIP(); ip == "" {
// Unit tests often do not set a client IP.
return UnknownIP
} else if ip = c.ClientIP(); ip != "" {
return ip
} else if ip = c.RemoteIP(); ip != "" {
return ip
}
return ip
// Tests may not specify an IP address.
return UnknownIP
}
// UserAgent returns the user agent from the request context or an empty string if it is unknown.

View file

@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -28,6 +29,12 @@ func ChangePassword(router *gin.RouterGroup) {
return
}
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(ClientIP(c)) {
limiter.AbortJSON(c)
return
}
// Get session.
s := Auth(c, acl.ResourcePassword, acl.ActionUpdate)
@ -57,7 +64,8 @@ func ChangePassword(router *gin.RouterGroup) {
}
// Verify that the old password is correct.
if m.InvalidPassword(f.OldPassword) {
if m.WrongPassword(f.OldPassword) {
limiter.Auth.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
}

View file

@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/internal/service"
)
@ -16,6 +17,12 @@ import (
// POST /api/v1/session
func CreateSession(router *gin.RouterGroup) {
router.POST("/session", func(c *gin.Context) {
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(ClientIP(c)) {
limiter.AbortJSON(c)
return
}
var f form.Login
if err := c.BindJSON(&f); err != nil {
@ -37,8 +44,8 @@ func CreateSession(router *gin.RouterGroup) {
isNew = true
}
// Sign in and save session.
if err := sess.SignIn(f, c); err != nil {
// Try to log in and save session if successful.
if err := sess.LogIn(f, c); err != nil {
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if sess, err = service.Session().Save(sess); err != nil {

View file

@ -31,7 +31,7 @@ func showConfigAction(ctx *cli.Context) error {
rows, cols := conf.Report()
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
result, err := report.Render(rows, cols, report.Options{Format: report.CliFormat(ctx), NoWrap: true})
fmt.Println(result)

View file

@ -0,0 +1,102 @@
package config
import (
"reflect"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/txt"
)
// ApplyCliContext applies the values of the cli context based on the "flag" annotations.
func ApplyCliContext(c interface{}, ctx *cli.Context) error {
v := reflect.ValueOf(c).Elem()
// Iterate through all config fields.
for i := 0; i < v.NumField(); i++ {
fieldValue := v.Field(i)
tagValue := v.Type().Field(i).Tag.Get("flag")
// Assign value to field with "flag" tag.
if tagValue != "" && tagValue != "-" {
switch t := fieldValue.Interface().(type) {
case time.Duration:
var s string
// Get duration string.
if ctx.IsSet(tagValue) {
s = ctx.String(tagValue)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Interface().(time.Duration) == 0 {
s = ctx.GlobalString(tagValue)
}
// Parse duration string.
if s == "" {
// Omit.
} else if sec := txt.UInt(s); sec > 0 {
fieldValue.Set(reflect.ValueOf(time.Duration(sec) * time.Second))
} else if d, err := time.ParseDuration(s); err == nil {
fieldValue.Set(reflect.ValueOf(d))
}
case float64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Float64(tagValue)
fieldValue.SetFloat(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Float() == 0 {
f := ctx.GlobalFloat64(tagValue)
fieldValue.SetFloat(f)
}
case int, int64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Int64(tagValue)
fieldValue.SetInt(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Int() == 0 {
f := ctx.GlobalInt64(tagValue)
fieldValue.SetInt(f)
}
case uint, uint64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Uint64(tagValue)
fieldValue.SetUint(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Uint() == 0 {
f := ctx.GlobalUint64(tagValue)
fieldValue.SetUint(f)
}
case string:
// Only if explicitly set or current value is empty (use default)
if ctx.IsSet(tagValue) {
f := ctx.String(tagValue)
fieldValue.SetString(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.String() == "" {
f := ctx.GlobalString(tagValue)
fieldValue.SetString(f)
}
case []string:
if ctx.IsSet(tagValue) {
f := reflect.ValueOf(ctx.StringSlice(tagValue))
fieldValue.Set(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Len() == 0 {
f := reflect.ValueOf(ctx.GlobalStringSlice(tagValue))
fieldValue.Set(f)
}
case bool:
if ctx.IsSet(tagValue) {
f := ctx.Bool(tagValue)
fieldValue.SetBool(f)
} else if ctx.GlobalIsSet(tagValue) {
f := ctx.GlobalBool(tagValue)
fieldValue.SetBool(f)
}
default:
log.Warnf("cannot assign value of type %s from cli flag %s", t, tagValue)
}
}
}
return nil
}

View file

@ -44,9 +44,10 @@ var TotalMem uint64
// Config holds database, cache and all parameters of photoprism
type Config struct {
once sync.Once
db *gorm.DB
cliCtx *cli.Context
options *Options
settings *customize.Settings
db *gorm.DB
hub *hub.Config
token string
serial string
@ -97,6 +98,7 @@ func NewConfig(ctx *cli.Context) *Config {
// Initialize options from config file and CLI context.
c := &Config{
cliCtx: ctx,
options: NewOptions(ctx),
token: rnd.GenerateToken(8),
env: os.Getenv("DOCKER_ENV"),
@ -121,6 +123,15 @@ func (c *Config) Unsafe() bool {
return c.options.Unsafe
}
// CliContext returns the cli context if set.
func (c *Config) CliContext() *cli.Context {
if c.cliCtx == nil {
log.Warnf("config: cli context not set - possible bug")
}
return c.cliCtx
}
// Options returns the raw config options.
func (c *Config) Options() *Options {
if c.options == nil {
@ -375,6 +386,15 @@ func (c *Config) SiteUrl() string {
return strings.TrimRight(c.options.SiteUrl, "/") + "/"
}
// SiteHttps checks if the site URL uses HTTPS.
func (c *Config) SiteHttps() bool {
if c.options.SiteUrl == "" {
return false
}
return strings.HasPrefix(c.options.SiteUrl, "https://")
}
// SiteDomain returns the public server domain.
func (c *Config) SiteDomain() string {
if u, err := url.Parse(c.SiteUrl()); err != nil {

View file

@ -125,6 +125,12 @@ func (c *Config) CreateDirectories() error {
return createError(c.ConfigPath(), err)
}
if c.CertsConfigPath() == "" {
return notFoundError("certs config")
} else if err := os.MkdirAll(c.CertsConfigPath(), os.ModePerm); err != nil {
return createError(c.CertsConfigPath(), err)
}
if c.TempPath() == "" {
return notFoundError("temp")
} else if err := os.MkdirAll(c.TempPath(), os.ModePerm); err != nil {
@ -187,6 +193,11 @@ func (c *Config) ConfigPath() string {
return fs.Abs(c.options.ConfigPath)
}
// CertsConfigPath returns the certificate config path
func (c *Config) CertsConfigPath() string {
return filepath.Join(c.ConfigPath(), "certs")
}
// OptionsYaml returns the config options YAML filename.
func (c *Config) OptionsYaml() string {
return filepath.Join(c.ConfigPath(), "options.yml")

View file

@ -4,8 +4,9 @@ import (
"strings"
"testing"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestConfig_FindExecutable(t *testing.T) {
@ -87,6 +88,15 @@ func TestConfig_TempPath(t *testing.T) {
}
}
func TestConfig_CertsConfigPath(t *testing.T) {
c := NewConfig(CliTestContext())
if dir := c.CertsConfigPath(); dir == "" {
t.Fatal("cert config path is empty")
} else if !strings.HasPrefix(dir, c.ConfigPath()) {
t.Fatalf("unexpected cert config path: %s", dir)
}
}
func TestConfig_CmdCachePath(t *testing.T) {
c := NewConfig(CliTestContext())
if dir := c.CmdCachePath(); dir == "" {

View file

@ -50,6 +50,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"albums-path", c.AlbumsPath()},
{"backup-path", c.BackupPath()},
{"cache-path", c.CachePath()},
{"cert-cache-path", c.CertsConfigPath()},
{"cmd-cache-path", c.CmdCachePath()},
{"thumb-cache-path", c.ThumbCachePath()},
{"import-path", c.ImportPath()},
@ -122,11 +123,19 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"api-uri", c.ApiUri()},
{"base-uri", c.BaseUri("/")},
// HTTP(S) Proxy.
{"proxy", c.Proxy()},
{"proxy-proto-header", strings.Join(c.ProxyProtoHeader(), ", ")},
{"proxy-proto-https", strings.Join(c.ProxyProtoHttps(), ", ")},
// Web Server.
{"http-host", c.HttpHost()},
{"http-port", fmt.Sprintf("%d", c.HttpPort())},
{"http-mode", c.HttpMode()},
{"http-compression", c.HttpCompression()},
{"http-host", c.HttpHost()},
{"http-port", fmt.Sprintf("%d", c.HttpPort())},
{"auto-tls", c.AutoTLS()},
{"https-port", fmt.Sprintf("%d", c.HttpsPort())},
{"https-redirect", fmt.Sprintf("%d", c.HttpsRedirect())},
// Database.
{"database-driver", c.DatabaseDriver()},

View file

@ -5,6 +5,7 @@ import (
"regexp"
"strings"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -13,22 +14,47 @@ func (c *Config) DetachServer() bool {
return c.options.DetachServer
}
// HttpHost returns the built-in HTTP server host name or IP address (empty for all interfaces).
func (c *Config) HttpHost() string {
if c.options.HttpHost == "" {
return "0.0.0.0"
}
return c.options.HttpHost
// Proxies returns proxy server ranges from which client and protocol headers can be trusted.
func (c *Config) Proxies() []string {
return c.options.Proxy
}
// HttpPort returns the built-in HTTP server port.
func (c *Config) HttpPort() int {
if c.options.HttpPort == 0 {
return 2342
// Proxy returns the list of trusted proxy servers as comma-separated list.
func (c *Config) Proxy() string {
return strings.Join(c.options.Proxy, ", ")
}
// ProxyProtoHeader returns the proxy protocol header names.
func (c *Config) ProxyProtoHeader() []string {
return c.options.ProxyProtoHeader
}
// ProxyProtoHttps returns the proxy protocol header HTTPS values.
func (c *Config) ProxyProtoHttps() []string {
return c.options.ProxyProtoHttps
}
// ProxyHttpsHeaders returns a map with the proxy https protocol headers.
func (c *Config) ProxyHttpsHeaders() map[string]string {
p := len(c.options.ProxyProtoHeader)
h := make(map[string]string, p+1)
if p == 0 {
h[header.ForwardedProto] = header.ProtoHttps
return h
}
return c.options.HttpPort
for k, v := range c.options.ProxyProtoHeader {
if l := len(c.options.ProxyProtoHttps); l == 0 {
h[v] = header.ProtoHttps
} else if l > k {
h[v] = c.options.ProxyProtoHttps[k]
} else {
h[v] = c.options.ProxyProtoHttps[0]
}
}
return h
}
// HttpMode returns the server mode.
@ -49,6 +75,24 @@ func (c *Config) HttpCompression() string {
return strings.ToLower(strings.TrimSpace(c.options.HttpCompression))
}
// HttpHost returns the built-in HTTP server host name or IP address (empty for all interfaces).
func (c *Config) HttpHost() string {
if c.options.HttpHost == "" {
return "0.0.0.0"
}
return c.options.HttpHost
}
// HttpPort returns the HTTP server port number.
func (c *Config) HttpPort() int {
if c.options.HttpPort == 0 {
return 2342
}
return c.options.HttpPort
}
// TemplatesPath returns the server templates path.
func (c *Config) TemplatesPath() string {
return filepath.Join(c.AssetsPath(), "templates")

View file

@ -0,0 +1,77 @@
package config
import (
"path/filepath"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// AutoTLS returns the email address for enabling automatic HTTPS via Let's Encrypt.
func (c *Config) AutoTLS() string {
return clean.Email(c.options.AutoTLS)
}
// TLSKey returns the HTTPS private key filename.
func (c *Config) TLSKey() string {
if c.options.TLSKey == "" {
return ""
} else if fs.FileExistsNotEmpty(c.options.TLSKey) {
return c.options.TLSKey
} else if fileName := filepath.Join(c.CertsConfigPath(), c.options.TLSKey); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLSCert returns the HTTPS certificate filename.
func (c *Config) TLSCert() string {
if c.options.TLSCert == "" {
return ""
} else if fs.FileExistsNotEmpty(c.options.TLSCert) {
return c.options.TLSCert
} else if fileName := filepath.Join(c.CertsConfigPath(), c.options.TLSCert); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLS returns the HTTPS certificate and private key file name.
func (c *Config) TLS() (certFile, privateKey string) {
certFile = c.TLSCert()
privateKey = c.TLSKey()
if c.options.TLSCert == "" || privateKey == "" {
return "", ""
}
return certFile, privateKey
}
// HttpsPort returns the HTTPS server port number.
func (c *Config) HttpsPort() int {
if !c.SiteHttps() {
return -1
}
if c.options.HttpsPort == 0 {
return 2443
}
return c.options.HttpsPort
}
// HttpsRedirect returns the HTTPS redirect status code.
func (c *Config) HttpsRedirect() int {
if !c.SiteHttps() {
return -1
}
if c.options.HttpsRedirect > 0 && c.options.HttpsRedirect < 300 && c.options.HttpsRedirect >= 400 {
return 301
}
return c.options.HttpsRedirect
}

View file

@ -0,0 +1,52 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfig_AutoTLS(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.AutoTLS())
c.options.AutoTLS = "hello@example.com"
assert.Equal(t, "hello@example.com", c.AutoTLS())
c.options.AutoTLS = "hello"
assert.Equal(t, "", c.AutoTLS())
c.options.AutoTLS = ""
assert.Equal(t, "", c.AutoTLS())
}
func TestConfig_TLS(t *testing.T) {
c := NewConfig(CliTestContext())
cert, key := c.TLS()
assert.Equal(t, "", cert)
assert.Equal(t, "", key)
}
func TestConfig_TLSKey(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSKey())
}
func TestConfig_TLSCert(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSCert())
}
func TestConfig_HttpsPort(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, -1, c.HttpsPort())
}
func TestConfig_HttpsRedirect(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, -1, c.HttpsRedirect())
}

View file

@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"os"
"reflect"
"time"
"github.com/urfave/cli"
@ -12,7 +11,6 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// Options hold the global configuration values without further validation or processing.
@ -33,7 +31,7 @@ type Options struct {
LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"`
Prod bool `yaml:"Prod" json:"Prod" flag:"prod"`
Debug bool `yaml:"Debug" json:"Debug" flag:"debug"`
Trace bool `yaml:"Trace" json:"Trace" flag:"Trace"`
Trace bool `yaml:"Trace" json:"Trace" flag:"trace"`
Test bool `yaml:"-" json:"Test,omitempty" flag:"test"`
Unsafe bool `yaml:"-" json:"-" flag:"unsafe"`
Demo bool `yaml:"Demo" json:"-" flag:"demo"`
@ -81,6 +79,8 @@ type Options struct {
AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"`
AppName string `yaml:"AppName" json:"AppName" flag:"app-name"`
AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"`
Imprint string `yaml:"Imprint" json:"Imprint" flag:"imprint"`
ImprintUrl string `yaml:"ImprintUrl" json:"ImprintUrl" flag:"imprint-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"`
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
@ -89,8 +89,18 @@ type Options struct {
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
Imprint string `yaml:"Imprint" json:"Imprint" flag:"imprint"`
ImprintUrl string `yaml:"ImprintUrl" json:"ImprintUrl" flag:"imprint-url"`
Proxy []string `yaml:"Proxy" json:"-" flag:"proxy"`
ProxyProtoHeader []string `yaml:"ProxyProtoHeader" json:"-" flag:"proxy-proto-header"`
ProxyProtoHttps []string `yaml:"ProxyProtoHttps" json:"-" flag:"proxy-proto-https"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
AutoTLS string `yaml:"AutoTLS" json:"AutoTLS" flag:"auto-tls"` // AutoTLS enabled automatic HTTPS via Let's Encrypt if set a valid email address.
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"`
HttpsPort int `yaml:"HttpsPort" json:"HttpsPort" flag:"https-port"` // HttpsPort is the port number to be used for HTTPS connections.
HttpsRedirect int `yaml:"HttpsRedirect" json:"HttpsRedirect" flag:"https-redirect"`
DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"`
@ -99,10 +109,6 @@ type Options struct {
DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"`
DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"`
DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"`
HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"`
DarktableCachePath string `yaml:"DarktableCachePath" json:"-" flag:"darktable-cache-path"`
DarktableConfigPath string `yaml:"DarktableConfigPath" json:"-" flag:"darktable-config-path"`
@ -142,7 +148,7 @@ type Options struct {
//
// 1. Load: This will initialize options from a yaml config file.
//
// 2. SetContext: Which comes after Load and overrides
// 2. ApplyCliContext: Which comes after Load and overrides
// any previous options giving an option two override file configs through the CLI.
func NewOptions(ctx *cli.Context) *Options {
c := &Options{}
@ -175,7 +181,7 @@ func NewOptions(ctx *cli.Context) *Options {
log.Warnf("config: failed loading defaults from %s (%s)", clean.Log(c.DefaultsYaml), err)
}
if err := c.SetContext(ctx); err != nil {
if err := c.ApplyCliContext(ctx); err != nil {
log.Error(err)
}
@ -215,87 +221,8 @@ func (c *Options) Load(fileName string) error {
return yaml.Unmarshal(yamlConfig, c)
}
// SetContext uses options from the CLI to setup configuration overrides
// ApplyCliContext uses options from the CLI to setup configuration overrides
// for the entity.
func (c *Options) SetContext(ctx *cli.Context) error {
v := reflect.ValueOf(c).Elem()
// Iterate through all config fields.
for i := 0; i < v.NumField(); i++ {
fieldValue := v.Field(i)
tagValue := v.Type().Field(i).Tag.Get("flag")
// Assign value to field with "flag" tag.
if tagValue != "" {
switch t := fieldValue.Interface().(type) {
case time.Duration:
var s string
// Get duration string.
if ctx.IsSet(tagValue) {
s = ctx.String(tagValue)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Interface().(time.Duration) == 0 {
s = ctx.GlobalString(tagValue)
}
// Parse duration string.
if s == "" {
// Omit.
} else if sec := txt.UInt(s); sec > 0 {
fieldValue.Set(reflect.ValueOf(time.Duration(sec) * time.Second))
} else if d, err := time.ParseDuration(s); err == nil {
fieldValue.Set(reflect.ValueOf(d))
}
case float64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Float64(tagValue)
fieldValue.SetFloat(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Float() == 0 {
f := ctx.GlobalFloat64(tagValue)
fieldValue.SetFloat(f)
}
case int, int64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Int64(tagValue)
fieldValue.SetInt(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Int() == 0 {
f := ctx.GlobalInt64(tagValue)
fieldValue.SetInt(f)
}
case uint, uint64:
// Only if explicitly set or current value is empty (use default).
if ctx.IsSet(tagValue) {
f := ctx.Uint64(tagValue)
fieldValue.SetUint(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.Uint() == 0 {
f := ctx.GlobalUint64(tagValue)
fieldValue.SetUint(f)
}
case string:
// Only if explicitly set or current value is empty (use default)
if ctx.IsSet(tagValue) {
f := ctx.String(tagValue)
fieldValue.SetString(f)
} else if ctx.GlobalIsSet(tagValue) || fieldValue.String() == "" {
f := ctx.GlobalString(tagValue)
fieldValue.SetString(f)
}
case bool:
if ctx.IsSet(tagValue) {
f := ctx.Bool(tagValue)
fieldValue.SetBool(f)
} else if ctx.GlobalIsSet(tagValue) {
f := ctx.GlobalBool(tagValue)
fieldValue.SetBool(f)
}
default:
log.Warnf("cannot assign value of type %s from cli flag %s", t, tagValue)
}
}
}
return nil
func (c *Options) ApplyCliContext(ctx *cli.Context) error {
return ApplyCliContext(c, ctx)
}

View file

@ -6,140 +6,121 @@ import (
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/internal/thumb"
)
// Flags configures the global command-line interface (CLI) parameters.
var Flags = CliFlags{
CliFlag{
{
Flag: cli.StringFlag{
Name: "auth-mode, a",
Usage: "authentication `MODE` (public, password)",
Value: "password",
EnvVar: "PHOTOPRISM_AUTH_MODE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "public, p",
Hidden: true,
Usage: "disable authentication, advanced settings, and WebDAV remote access",
EnvVar: "PHOTOPRISM_PUBLIC",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "admin-user, login",
Usage: "admin login `USERNAME`",
EnvVar: "PHOTOPRISM_ADMIN_USER",
Value: "admin",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "admin-password, pw",
Usage: "initial admin `PASSWORD`, must have at least 8 characters",
EnvVar: "PHOTOPRISM_ADMIN_PASSWORD",
}},
CliFlag{
}}, {
Flag: cli.Int64Flag{
Name: "sess-maxage",
Value: DefaultSessMaxAge,
Usage: "time in `SECONDS` until user sessions expire automatically (-1 to disable)",
EnvVar: "PHOTOPRISM_SESS_MAXAGE",
}},
CliFlag{
}}, {
Flag: cli.Int64Flag{
Name: "sess-timeout",
Value: DefaultSessTimeout,
Usage: "time in `SECONDS` until user sessions expire due to inactivity (-1 to disable)",
EnvVar: "PHOTOPRISM_SESS_TIMEOUT",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "log-level, l",
Usage: "log message verbosity `LEVEL` (trace, debug, info, warning, error, fatal, panic)",
Value: "info",
EnvVar: "PHOTOPRISM_LOG_LEVEL",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "prod",
Hidden: true,
Usage: "enable production mode, hide non-essential log messages",
EnvVar: "PHOTOPRISM_PROD",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "debug",
Usage: "enable debug mode, show non-essential log messages",
EnvVar: "PHOTOPRISM_DEBUG",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "trace",
Usage: "enable trace mode, show all log messages",
EnvVar: "PHOTOPRISM_TRACE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "test",
Hidden: true,
Usage: "enable test mode",
},
},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "unsafe",
Hidden: true,
Usage: "disable safety checks",
EnvVar: "PHOTOPRISM_UNSAFE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "demo",
Hidden: true,
Usage: "enable demo mode",
EnvVar: "PHOTOPRISM_DEMO",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "sponsor",
Hidden: true,
Usage: "your continuous support helps to pay for development and operating expenses",
EnvVar: "PHOTOPRISM_SPONSOR",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "partner-id",
Hidden: true,
Usage: "hosting partner id",
EnvVar: "PHOTOPRISM_PARTNER_ID",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "config-path, c",
Usage: "config storage `PATH`, values in options.yml override CLI flags and environment variables if present",
EnvVar: "PHOTOPRISM_CONFIG_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "defaults-yaml, y",
Usage: "load config defaults from `FILE` if exists, does not override CLI flags and environment variables",
Value: "/etc/photoprism/defaults.yml",
EnvVar: "PHOTOPRISM_DEFAULTS_YAML",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "originals-path, o",
Usage: "storage `PATH` of your original media files (photos and videos)",
EnvVar: "PHOTOPRISM_ORIGINALS_PATH",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "originals-limit, mb",
Value: 1000,
Usage: "maximum size of media files in `MB` (1-100000; -1 to disable)",
EnvVar: "PHOTOPRISM_ORIGINALS_LIMIT",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "resolution-limit, mp",
Value: DefaultResolutionLimit,
@ -148,624 +129,548 @@ var Flags = CliFlags{
},
Tags: []string{EnvSponsor},
},
CliFlag{
{
Flag: cli.StringFlag{
Name: "storage-path, s",
Usage: "writable storage `PATH` for sidecar, cache, and database files",
EnvVar: "PHOTOPRISM_STORAGE_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "sidecar-path, sc",
Usage: "custom relative or absolute sidecar `PATH`*optional*",
EnvVar: "PHOTOPRISM_SIDECAR_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "backup-path, ba",
Usage: "custom backup `PATH` for index backup files*optional*",
EnvVar: "PHOTOPRISM_BACKUP_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "cache-path, ca",
Usage: "custom cache `PATH` for sessions and thumbnail files*optional*",
EnvVar: "PHOTOPRISM_CACHE_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "import-path, im",
Usage: "base `PATH` from which files can be imported to originals*optional*",
EnvVar: "PHOTOPRISM_IMPORT_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "import-dest",
Usage: "relative originals `PATH` to which the files should be imported by default*optional*",
EnvVar: "PHOTOPRISM_IMPORT_DEST",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "assets-path, as",
Usage: "assets `PATH` containing static resources like icons, models, and translations",
EnvVar: "PHOTOPRISM_ASSETS_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "temp-path, tmp",
Usage: "temporary file `PATH`*optional*",
EnvVar: "PHOTOPRISM_TEMP_PATH",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "workers, w",
Usage: "maximum `NUMBER` of indexing workers, default depends on the number of physical cores",
EnvVar: "PHOTOPRISM_WORKERS",
Value: cpuid.CPU.PhysicalCores / 2,
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "wakeup-interval, i",
Usage: "`DURATION` between worker runs required for face recognition and index maintenance (1-86400s)",
Value: DefaultWakeupInterval.String(),
EnvVar: "PHOTOPRISM_WAKEUP_INTERVAL",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "auto-index",
Usage: "WebDAV auto index safety delay in `SECONDS` (-1 to disable)",
Value: DefaultAutoIndexDelay,
EnvVar: "PHOTOPRISM_AUTO_INDEX",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "auto-import",
Usage: "WebDAV auto import safety delay in `SECONDS` (-1 to disable)",
Value: DefaultAutoImportDelay,
EnvVar: "PHOTOPRISM_AUTO_IMPORT",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "read-only, r",
Usage: "disable import, upload, delete, and all other operations that require write permissions",
EnvVar: "PHOTOPRISM_READONLY",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "experimental, e",
Usage: "enable experimental features",
EnvVar: "PHOTOPRISM_EXPERIMENTAL",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-webdav",
Usage: "disable built-in WebDAV server",
EnvVar: "PHOTOPRISM_DISABLE_WEBDAV",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-settings",
Usage: "disable settings UI and API",
EnvVar: "PHOTOPRISM_DISABLE_SETTINGS",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-places",
Usage: "disable reverse geocoding and maps",
EnvVar: "PHOTOPRISM_DISABLE_PLACES",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-backups",
Usage: "disable backing up albums and photo metadata to YAML files",
EnvVar: "PHOTOPRISM_DISABLE_BACKUPS",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-tensorflow",
Usage: "disable all features depending on TensorFlow",
EnvVar: "PHOTOPRISM_DISABLE_TENSORFLOW",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-faces",
Usage: "disable face detection and recognition (requires TensorFlow)",
EnvVar: "PHOTOPRISM_DISABLE_FACES",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-classification",
Usage: "disable image classification (requires TensorFlow)",
EnvVar: "PHOTOPRISM_DISABLE_CLASSIFICATION",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-ffmpeg",
Usage: "disable video transcoding and thumbnail extraction with FFmpeg",
EnvVar: "PHOTOPRISM_DISABLE_FFMPEG",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-exiftool",
Usage: "disable creating JSON metadata sidecar files with ExifTool",
EnvVar: "PHOTOPRISM_DISABLE_EXIFTOOL",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-heifconvert",
Usage: "disable conversion of HEIC/HEIF files",
EnvVar: "PHOTOPRISM_DISABLE_HEIFCONVERT",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-darktable",
Usage: "disable conversion of RAW files with Darktable",
EnvVar: "PHOTOPRISM_DISABLE_DARKTABLE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-rawtherapee",
Usage: "disable conversion of RAW files with RawTherapee",
EnvVar: "PHOTOPRISM_DISABLE_RAWTHERAPEE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-sips",
Usage: "disable conversion of RAW files with Sips*macOS only*",
EnvVar: "PHOTOPRISM_DISABLE_SIPS",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "disable-raw",
Usage: "disable indexing and conversion of RAW files",
EnvVar: "PHOTOPRISM_DISABLE_RAW",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "raw-presets",
Usage: "enables applying user presets when converting RAW files (reduces performance)",
EnvVar: "PHOTOPRISM_RAW_PRESETS",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "exif-bruteforce",
Usage: "always perform a brute-force search if no Exif headers were found",
EnvVar: "PHOTOPRISM_EXIF_BRUTEFORCE",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "detect-nsfw",
Usage: "automatically flag photos as private that MAY be offensive (requires TensorFlow)",
EnvVar: "PHOTOPRISM_DETECT_NSFW",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "upload-nsfw, n",
Usage: "allow uploads that MAY be offensive (no effect without TensorFlow)",
EnvVar: "PHOTOPRISM_UPLOAD_NSFW",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "default-locale, lang",
Usage: "standard user interface language `CODE`",
Value: i18n.Default.Locale(),
EnvVar: "PHOTOPRISM_DEFAULT_LOCALE",
},
},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "default-theme",
Usage: "standard user interface theme `NAME`",
EnvVar: "PHOTOPRISM_DEFAULT_THEME",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "app-mode",
Usage: "progressive web app `MODE` (fullscreen, standalone, minimal-ui, browser)",
Value: "standalone",
EnvVar: "PHOTOPRISM_APP_MODE",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "app-icon",
Usage: "progressive web app `ICON` (logo, app, crisp, mint, bold)",
EnvVar: "PHOTOPRISM_APP_ICON",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "app-name",
Usage: "progressive web app `NAME` when installed on a device",
Value: "",
EnvVar: "PHOTOPRISM_APP_NAME",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "imprint",
Usage: "legal `INFORMATION`, displayed in the page footer",
Value: "",
EnvVar: "PHOTOPRISM_IMPRINT",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "imprint-url",
Usage: "legal information `URL`",
Value: "",
EnvVar: "PHOTOPRISM_IMPRINT_URL",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "wallpaper-uri",
Usage: "login screen background image `URI`",
EnvVar: "PHOTOPRISM_WALLPAPER_URI",
Value: "",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "cdn-url",
Usage: "content delivery network `URL`",
EnvVar: "PHOTOPRISM_CDN_URL",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "site-url, url",
Usage: "public site `URL`",
Value: "http://localhost:2342/",
EnvVar: "PHOTOPRISM_SITE_URL",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "site-author",
Usage: "site `OWNER`, copyright, or artist",
EnvVar: "PHOTOPRISM_SITE_AUTHOR",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "site-title",
Usage: "site `TITLE`",
Value: "",
EnvVar: "PHOTOPRISM_SITE_TITLE",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "site-caption",
Usage: "site `CAPTION`",
Value: "AI-Powered Photos App",
EnvVar: "PHOTOPRISM_SITE_CAPTION",
},
},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "site-description",
Usage: "site `DESCRIPTION`*optional*",
EnvVar: "PHOTOPRISM_SITE_DESCRIPTION",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "site-preview",
Usage: "sharing preview image `URL`",
EnvVar: "PHOTOPRISM_SITE_PREVIEW",
},
Tags: []string{EnvSponsor},
},
CliFlag{
}, Tags: []string{EnvSponsor}}, {
Flag: cli.StringSliceFlag{
Name: "proxy",
Usage: "proxy server `IP` range from which client and protocol headers can be trusted",
Value: &cli.StringSlice{header.CidrDockerInternal},
EnvVar: "PHOTOPRISM_PROXY",
}}, {
Flag: cli.StringSliceFlag{
Name: "proxy-proto-header",
Usage: "forwarded protocol `HEADER`",
Value: &cli.StringSlice{header.ForwardedProto},
EnvVar: "PHOTOPRISM_PROXY_PROTO_HEADER",
}}, {
Flag: cli.StringSliceFlag{
Name: "proxy-proto-https",
Usage: "forwarded HTTPS protocol `NAME`",
Value: &cli.StringSlice{header.ProtoHttps},
EnvVar: "PHOTOPRISM_PROXY_PROTO_HTTPS",
}}, {
Flag: cli.StringFlag{
Name: "http-mode, mode",
Usage: "HTTP server `MODE` (debug, release, or test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
}}, {
Flag: cli.StringFlag{
Name: "http-compression, z",
Usage: "HTTP server compression `METHOD` (none or gzip)",
EnvVar: "PHOTOPRISM_HTTP_COMPRESSION",
}}, {
Flag: cli.StringFlag{
Name: "http-host, ip",
Usage: "HTTP server `IP` address",
EnvVar: "PHOTOPRISM_HTTP_HOST",
}}, {
Flag: cli.IntFlag{
Name: "http-port, port",
Value: 2342,
Usage: "http server port `NUMBER`",
Usage: "HTTP server port `NUMBER`",
EnvVar: "PHOTOPRISM_HTTP_PORT",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "http-host, ip",
Usage: "http server `IP` address",
EnvVar: "PHOTOPRISM_HTTP_HOST",
}},
CliFlag{
Flag: cli.StringFlag{
Name: "http-mode, mode",
Usage: "http server `MODE` (debug, release, or test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
}},
CliFlag{
Flag: cli.StringFlag{
Name: "http-compression, z",
Usage: "http server compression `METHOD` (none or gzip)",
EnvVar: "PHOTOPRISM_HTTP_COMPRESSION",
}},
CliFlag{
Name: "auto-tls",
Usage: "`EMAIL` address to enable automatic HTTPS via Let's Encrypt",
EnvVar: "PHOTOPRISM_AUTO_TLS",
}}, {
Flag: cli.IntFlag{
Name: "https-port",
Value: 2443,
Usage: "HTTPS server port `NUMBER` if site URL uses HTTPS",
EnvVar: "PHOTOPRISM_HTTPS_PORT",
}}, {
Flag: cli.IntFlag{
Name: "https-redirect",
Value: 301,
Usage: "status `CODE` for redirect if site URL uses HTTPS (300-399 or 0 to disable)",
EnvVar: "PHOTOPRISM_HTTPS_REDIRECT",
}}, {
Flag: cli.StringFlag{
Name: "database-driver, db",
Usage: "database `DRIVER` (sqlite, mysql)",
Value: "sqlite",
EnvVar: "PHOTOPRISM_DATABASE_DRIVER",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "database-dsn, dsn",
Usage: "database connection `DSN` (sqlite file, optional for mysql)",
EnvVar: "PHOTOPRISM_DATABASE_DSN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "database-name, db-name",
Value: "photoprism",
Usage: "database schema `NAME`",
EnvVar: "PHOTOPRISM_DATABASE_NAME",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "database-server, db-server",
Usage: "database `HOST` incl. port e.g. \"mariadb:3306\" (or socket path)",
EnvVar: "PHOTOPRISM_DATABASE_SERVER",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "database-user, db-user",
Value: "photoprism",
Usage: "database user `NAME`",
EnvVar: "PHOTOPRISM_DATABASE_USER",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "database-password, db-pass",
Usage: "database user `PASSWORD`",
EnvVar: "PHOTOPRISM_DATABASE_PASSWORD",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "database-conns",
Usage: "maximum `NUMBER` of open database connections",
EnvVar: "PHOTOPRISM_DATABASE_CONNS",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "database-conns-idle",
Usage: "maximum `NUMBER` of idle database connections",
EnvVar: "PHOTOPRISM_DATABASE_CONNS_IDLE",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "darktable-bin",
Usage: "Darktable CLI `COMMAND` for RAW to JPEG conversion",
Value: "darktable-cli",
EnvVar: "PHOTOPRISM_DARKTABLE_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "darktable-blacklist",
Usage: "do not use Darktable to convert files with these `EXTENSIONS`",
Value: "",
EnvVar: "PHOTOPRISM_DARKTABLE_BLACKLIST",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "darktable-cache-path",
Usage: "custom Darktable cache `PATH`",
Value: "",
EnvVar: "PHOTOPRISM_DARKTABLE_CACHE_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "darktable-config-path",
Usage: "custom Darktable config `PATH`",
Value: "",
EnvVar: "PHOTOPRISM_DARKTABLE_CONFIG_PATH",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "rawtherapee-bin",
Usage: "RawTherapee CLI `COMMAND` for RAW to JPEG conversion",
Value: "rawtherapee-cli",
EnvVar: "PHOTOPRISM_RAWTHERAPEE_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "rawtherapee-blacklist",
Usage: "do not use RawTherapee to convert files with these `EXTENSIONS`",
Value: "dng",
EnvVar: "PHOTOPRISM_RAWTHERAPEE_BLACKLIST",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "sips-bin",
Usage: "Sips `COMMAND` for RAW to JPEG conversion*macOS only*",
Value: "sips",
EnvVar: "PHOTOPRISM_SIPS_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "sips-blacklist",
Usage: "do not use Sips to convert files with these `EXTENSIONS`*macOS only*",
Value: "avif,avifs",
EnvVar: "PHOTOPRISM_SIPS_BLACKLIST",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "heifconvert-bin",
Usage: "HEIC/HEIF/AVIF image conversion `COMMAND`",
Value: "heif-convert",
EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "ffmpeg-bin",
Usage: "FFmpeg `COMMAND` for video transcoding and thumbnail extraction",
Value: "ffmpeg",
EnvVar: "PHOTOPRISM_FFMPEG_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "ffmpeg-encoder, vc",
Usage: "FFmpeg AVC encoder `NAME`",
Value: "libx264",
EnvVar: "PHOTOPRISM_FFMPEG_ENCODER",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.IntFlag{
Name: "ffmpeg-bitrate, vb",
Usage: "maximum FFmpeg encoding `BITRATE` (Mbit/s)",
Value: 50,
EnvVar: "PHOTOPRISM_FFMPEG_BITRATE",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "exiftool-bin",
Usage: "ExifTool `COMMAND` for extracting metadata",
Value: "exiftool",
EnvVar: "PHOTOPRISM_EXIFTOOL_BIN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "download-token",
Usage: "`SECRET` download URL token for originals (default: random)",
EnvVar: "PHOTOPRISM_DOWNLOAD_TOKEN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "preview-token",
Usage: "`SECRET` thumbnail and video streaming URL token (default: random)",
EnvVar: "PHOTOPRISM_PREVIEW_TOKEN",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "thumb-color",
Usage: "standard color `PROFILE` for thumbnails (leave blank to disable)",
Value: "sRGB",
EnvVar: "PHOTOPRISM_THUMB_COLOR",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "thumb-filter, filter",
Usage: "image downscaling filter `NAME` (best to worst: blackman, lanczos, cubic, linear)",
Value: "lanczos",
EnvVar: "PHOTOPRISM_THUMB_FILTER",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "thumb-size",
Usage: "maximum size of thumbnails created during indexing in `PIXELS` (720-7680)",
Value: 2048,
EnvVar: "PHOTOPRISM_THUMB_SIZE",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "thumb-size-uncached",
Usage: "maximum size of missing thumbnails created on demand in `PIXELS` (720-7680)",
Value: 7680,
EnvVar: "PHOTOPRISM_THUMB_SIZE_UNCACHED",
}},
CliFlag{
}}, {
Flag: cli.BoolFlag{
Name: "thumb-uncached, u",
Usage: "enable on-demand creation of missing thumbnails (high memory and cpu usage)",
EnvVar: "PHOTOPRISM_THUMB_UNCACHED",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "jpeg-quality, q",
Usage: "a higher value increases the `QUALITY` and file size of JPEG images and thumbnails (25-100)",
Value: thumb.JpegQuality.String(),
EnvVar: "PHOTOPRISM_JPEG_QUALITY",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "jpeg-size",
Usage: "maximum size of created JPEG sidecar files in `PIXELS` (720-30000)",
Value: 7680,
EnvVar: "PHOTOPRISM_JPEG_SIZE",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "face-size",
Usage: "minimum size of faces in `PIXELS` (20-10000)",
Value: face.SizeThreshold,
EnvVar: "PHOTOPRISM_FACE_SIZE",
}},
CliFlag{
}}, {
Flag: cli.Float64Flag{
Name: "face-score",
Usage: "minimum face `QUALITY` score (1-100)",
Value: face.ScoreThreshold,
EnvVar: "PHOTOPRISM_FACE_SCORE",
}},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "face-overlap",
Usage: "face area overlap threshold in `PERCENT` (1-100)",
Value: face.OverlapThreshold,
EnvVar: "PHOTOPRISM_FACE_OVERLAP",
},
},
CliFlag{
}}, {
Flag: cli.IntFlag{
Name: "face-cluster-size",
Usage: "minimum size of automatically clustered faces in `PIXELS` (20-10000)",
Value: face.ClusterSizeThreshold,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_SIZE",
},
Tags: []string{EnvSponsor},
},
CliFlag{
}, Tags: []string{EnvSponsor}}, {
Flag: cli.IntFlag{
Name: "face-cluster-score",
Usage: "minimum `QUALITY` score of automatically clustered faces (1-100)",
Value: face.ClusterScoreThreshold,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_SCORE",
},
Tags: []string{EnvSponsor},
},
CliFlag{
}, Tags: []string{EnvSponsor}}, {
Flag: cli.IntFlag{
Name: "face-cluster-core",
Usage: "`NUMBER` of faces forming a cluster core (1-100)",
Value: face.ClusterCore,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_CORE",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.Float64Flag{
Name: "face-cluster-dist",
Usage: "similarity `DISTANCE` of faces forming a cluster core (0.1-1.5)",
Value: face.ClusterDist,
EnvVar: "PHOTOPRISM_FACE_CLUSTER_DIST",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.Float64Flag{
Name: "face-match-dist",
Usage: "similarity `OFFSET` for matching faces with existing clusters (0.1-1.5)",
Value: face.MatchDist,
EnvVar: "PHOTOPRISM_FACE_MATCH_DIST",
},
Tags: []string{EnvSponsor},
},
CliFlag{
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "pid-filename",
Usage: "process id `FILE`*daemon-mode only*",
EnvVar: "PHOTOPRISM_PID_FILENAME",
}},
CliFlag{
}}, {
Flag: cli.StringFlag{
Name: "log-filename",
Usage: "server log `FILE`*daemon-mode only*",

View file

@ -8,11 +8,12 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
)
// SignIn checks user authentication based on the login form.
func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
// LogIn performs authentication checks against the specified login form.
func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
if c != nil {
m.SetContext(c)
}
@ -29,6 +30,7 @@ func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
// User found?
if user == nil {
message := "account not found"
limiter.Auth.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
@ -45,8 +47,9 @@ func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
}
// Password valid?
if user.InvalidPassword(f.Password) {
if user.WrongPassword(f.Password) {
message := "incorrect password"
limiter.Auth.Reserve(m.IP())
event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
@ -66,6 +69,7 @@ func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
// Redeem token.
if user.IsRegistered() {
if shares := user.RedeemToken(f.AuthToken); shares == 0 {
limiter.Auth.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.AuthToken))
m.Status = http.StatusNotFound
return i18n.Error(i18n.ErrInvalidLink)
@ -76,6 +80,7 @@ func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
m.Status = http.StatusInternalServerError
return i18n.Error(i18n.ErrUnexpected)
} else if shares := data.RedeemToken(f.AuthToken); shares == 0 {
limiter.Auth.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.AuthToken))
event.LoginError(m.IP(), "api", "", m.UserAgent, "invalid share token")
m.Status = http.StatusNotFound

View file

@ -0,0 +1,76 @@
package entity
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/form"
)
func TestSessionLogIn(t *testing.T) {
const clientIp = "1.2.3.4"
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
t.Run("Admin", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m.SetClientIP(clientIp)
// Create login form.
frm := form.Login{
UserName: "admin",
Password: "photoprism",
}
// Create HTTP request.
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/session", form.AsReader(frm))
ctx.Request.RemoteAddr = "1.2.3.4"
// Try to log in.
if err := m.LogIn(frm, ctx); err != nil {
t.Fatal(err)
}
})
t.Run("WrongPassword", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m.SetClientIP(clientIp)
// Create login form.
frm := form.Login{
UserName: "admin",
Password: "wrong",
}
// Create HTTP request.
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/session", form.AsReader(frm))
ctx.Request.RemoteAddr = "1.2.3.4"
// Try to log in.
if err := m.LogIn(frm, ctx); err == nil {
t.Fatal("login should fail")
}
})
t.Run("InvalidUser", func(t *testing.T) {
m := NewSession(UnixDay, UnixHour*6)
m.SetClientIP(clientIp)
// Create login form.
frm := form.Login{
UserName: "foo",
Password: "password",
}
// Create HTTP request.
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/session", form.AsReader(frm))
ctx.Request.RemoteAddr = "1.2.3.4"
// Try to log in.
if err := m.LogIn(frm, ctx); err == nil {
t.Fatal("login should fail")
}
})
}

View file

@ -522,7 +522,7 @@ func (m *User) DeleteSessions(omit []string) (deleted int) {
// SetPassword sets a new password stored as hash.
func (m *User) SetPassword(password string) error {
if !m.IsRegistered() {
return fmt.Errorf("only registered users may change their password")
return fmt.Errorf("only registered users can change their password")
}
if len(password) < LenPasswordMin {
@ -534,16 +534,21 @@ func (m *User) SetPassword(password string) error {
return pw.Save()
}
// InvalidPassword returns true if the given password does not match the hash.
func (m *User) InvalidPassword(password string) bool {
// HasPassword checks if the user has the specified password and the account is registered.
func (m *User) HasPassword(s string) bool {
return !m.WrongPassword(s)
}
// WrongPassword checks if the given password is incorrect or the account is not registered.
func (m *User) WrongPassword(s string) bool {
// Registered user?
if !m.IsRegistered() {
log.Warn("only registered users may change their password")
log.Warn("only registered users can log in")
return true
}
// Empty password?
if password == "" {
if s == "" {
return true
}
@ -556,7 +561,7 @@ func (m *User) InvalidPassword(password string) bool {
}
// Invalid?
if pw.InvalidPassword(password) {
if pw.IsWrong(s) {
return true
}
@ -567,7 +572,7 @@ func (m *User) InvalidPassword(password string) bool {
func (m *User) Validate() (err error) {
// Empty name?
if m.Name() == "" {
return errors.New("username cannot be empty")
return errors.New("username must not be empty")
}
// Name too short?

View file

@ -151,7 +151,7 @@ func TestUser_SetName(t *testing.T) {
})
}
func TestUser_InvalidPassword(t *testing.T) {
func TestUser_WrongPassword(t *testing.T) {
t.Run("admin", func(t *testing.T) {
m := FindUserByName("admin")
@ -159,7 +159,7 @@ func TestUser_InvalidPassword(t *testing.T) {
t.Fatal("result should not be nil")
}
assert.False(t, m.InvalidPassword("photoprism"))
assert.False(t, m.WrongPassword("photoprism"))
})
t.Run("admin invalid password", func(t *testing.T) {
m := FindUserByName("admin")
@ -168,7 +168,7 @@ func TestUser_InvalidPassword(t *testing.T) {
t.Fatal("result should not be nil")
}
assert.True(t, m.InvalidPassword("wrong-password"))
assert.True(t, m.WrongPassword("wrong-password"))
})
t.Run("no password existing", func(t *testing.T) {
p := User{UserUID: "u000000000000010", UserName: "Hans", DisplayName: ""}
@ -176,16 +176,16 @@ func TestUser_InvalidPassword(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.True(t, p.InvalidPassword("abcdef"))
assert.True(t, p.WrongPassword("abcdef"))
})
t.Run("NotRegistered", func(t *testing.T) {
p := User{UserUID: "u12", UserName: "", DisplayName: ""}
assert.True(t, p.InvalidPassword("abcdef"))
assert.True(t, p.WrongPassword("abcdef"))
})
t.Run("PasswordEmpty", func(t *testing.T) {
p := User{UserUID: "u000000000000011", UserName: "User", DisplayName: ""}
assert.True(t, p.InvalidPassword(""))
assert.True(t, p.WrongPassword(""))
})
}

View file

@ -144,7 +144,7 @@ func (m *Link) InvalidPassword(password string) bool {
return password != ""
}
return pw.InvalidPassword(password)
return pw.IsWrong(password)
}
// Save updates the record in the database or inserts a new record if it does not already exist.

View file

@ -46,13 +46,18 @@ func (m *Password) SetPassword(password string) error {
}
}
// InvalidPassword returns true if the given password does not match the hash.
func (m *Password) InvalidPassword(password string) bool {
if m.Hash == "" && password == "" {
// Is checks if the password is correct.
func (m *Password) Is(s string) bool {
return !m.IsWrong(s)
}
// IsWrong checks if the specified password is incorrect.
func (m *Password) IsWrong(s string) bool {
if m.Hash == "" && s == "" {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(password))
err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(s))
return err != nil
}

View file

@ -28,18 +28,33 @@ func TestPassword_SetPassword(t *testing.T) {
})
}
func TestPassword_InvalidPasswordPassword(t *testing.T) {
t.Run("false", func(t *testing.T) {
func TestPassword_Is(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
p := Password{Hash: ""}
assert.False(t, p.InvalidPassword(""))
assert.True(t, p.Is(""))
})
t.Run("false", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := NewPassword("abc567", "")
assert.False(t, p.InvalidPassword(""))
assert.True(t, p.Is(""))
})
t.Run("true", func(t *testing.T) {
t.Run("Wrong", func(t *testing.T) {
p := NewPassword("abc567", "passwd")
assert.True(t, p.InvalidPassword("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
assert.False(t, p.Is("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
})
}
func TestPassword_IsWrong(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
p := Password{Hash: ""}
assert.False(t, p.IsWrong(""))
})
t.Run("Ok", func(t *testing.T) {
p := NewPassword("abc567", "")
assert.False(t, p.IsWrong(""))
})
t.Run("Wrong", func(t *testing.T) {
p := NewPassword("abc567", "passwd")
assert.True(t, p.IsWrong("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
})
}
@ -73,14 +88,14 @@ func TestFindPassword(t *testing.T) {
if p := FindPassword("uqxetse3cy5eo9z2"); p == nil {
t.Fatal("password not found")
} else {
assert.False(t, p.InvalidPassword("Alice123!"))
assert.False(t, p.IsWrong("Alice123!"))
}
})
t.Run("bob", func(t *testing.T) {
if p := FindPassword("uqxc08w3d0ej2283"); p == nil {
t.Fatal("password not found")
} else {
assert.False(t, p.InvalidPassword("Bobbob123!"))
assert.False(t, p.IsWrong("Bobbob123!"))
}
})
}

19
internal/form/json.go Normal file
View file

@ -0,0 +1,19 @@
package form
import (
"encoding/json"
"io"
"strings"
)
// AsJson returns the form data as a JSON string or an empty string in case of error.
func AsJson(frm interface{}) string {
s, _ := json.Marshal(frm)
return string(s)
}
// AsReader returns the form data as io.Reader, e.g. for use in tests.
func AsReader(frm interface{}) io.Reader {
return strings.NewReader(AsJson(frm))
}

View file

@ -0,0 +1,36 @@
package server
import (
"fmt"
"strings"
"golang.org/x/crypto/acme/autocert"
"github.com/photoprism/photoprism/internal/config"
)
// AutoTLS enables automatic HTTPS via Let's Encrypt.
func AutoTLS(conf *config.Config) (*autocert.Manager, error) {
var siteDomain, tlsEmail, certDir string
// Enable automatic HTTPS via Let's Encrypt?
if !conf.SiteHttps() {
return nil, fmt.Errorf("default site url does not use https")
} else if siteDomain = conf.SiteDomain(); !strings.Contains(siteDomain, ".") {
return nil, fmt.Errorf("no fully qualified site domain")
} else if tlsEmail = conf.AutoTLS(); tlsEmail == "" {
return nil, fmt.Errorf("automatic tls disabled")
} else if certDir = conf.CertsConfigPath(); certDir == "" {
return nil, fmt.Errorf("https certificate cache directory is missing")
}
// Create Let's Encrypt cert manager.
m := &autocert.Manager{
Email: tlsEmail,
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(siteDomain),
Cache: autocert.DirCache(certDir),
}
return m, nil
}

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -55,9 +56,13 @@ func BasicAuth() gin.HandlerFunc {
}
return func(c *gin.Context) {
name, password, key, valid := validate(c)
if c == nil {
return
}
if valid {
name, password, key, ok := validate(c)
if ok {
// Already authenticated.
return
} else if key == "" {
@ -67,6 +72,15 @@ func BasicAuth() gin.HandlerFunc {
return
}
// Get client IP address.
clientIp := api.ClientIP(c)
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(clientIp) {
limiter.Abort(c)
return
}
basicAuthMutex.Lock()
defer basicAuthMutex.Unlock()
@ -75,24 +89,26 @@ func BasicAuth() gin.HandlerFunc {
// Username not found.
message := "account not found"
event.AuditWarn([]string{api.ClientIP(c), "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(api.ClientIP(c), "webdav", name, api.UserAgent(c), message)
limiter.Auth.Reserve(clientIp)
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if !user.SyncAllowed() {
// Sync disabled for this account.
message := "sync disabled"
event.AuditWarn([]string{api.ClientIP(c), "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(api.ClientIP(c), "webdav", name, api.UserAgent(c), message)
} else if valid = !user.InvalidPassword(password); !valid {
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if ok = user.HasPassword(password); !ok {
// Wrong password.
message := "incorrect password"
event.AuditErr([]string{api.ClientIP(c), "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(api.ClientIP(c), "webdav", name, api.UserAgent(c), message)
limiter.Auth.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else {
// Successfully authenticated.
event.AuditInfo([]string{api.ClientIP(c), "webdav login as %s", "succeeded"}, clean.LogQuote(name))
event.LoginInfo(api.ClientIP(c), "webdav", name, api.UserAgent(c))
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(name))
event.LoginInfo(clientIp, "webdav", name, api.UserAgent(c))
// Cache successful authentication.
basicAuthCache.SetDefault(key, user)

View file

@ -0,0 +1,5 @@
package header
const (
CidrDockerInternal = "172.16.0.0/12"
)

View file

@ -0,0 +1,6 @@
package header
var (
ProtoHttp = "http"
ProtoHttps = "https"
)

View file

@ -0,0 +1,17 @@
package limiter
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Abort cancels the request with error 429 (too many requests).
func Abort(c *gin.Context) {
c.AbortWithStatus(http.StatusTooManyRequests)
}
// AbortJSON cancels the request with error 429 (too many requests).
func AbortJSON(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded", "code": http.StatusTooManyRequests})
}

View file

@ -0,0 +1,10 @@
package limiter
import (
"time"
"golang.org/x/time/rate"
)
// Auth limits failed authentication requests (one per minute).
var Auth = NewLimit(rate.Every(time.Minute), 10)

View file

@ -0,0 +1,60 @@
package limiter
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuth(t *testing.T) {
clientIp := "192.0.2.42"
for i := 0; i < 9; i++ {
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
assert.True(t, Auth.IP(clientIp).Allow())
}
assert.True(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +1min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute)))
t.Logf("tokens +2min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)))
t.Logf("tokens +3min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)))
t.Logf("tokens +4min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)))
t.Logf("tokens +5min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
for i := 0; i < 30; i++ {
assert.False(t, Auth.IP(clientIp).Allow())
}
assert.False(t, Auth.IP(clientIp).Allow())
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +5min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
}

View file

@ -0,0 +1,69 @@
package limiter
import (
"sync"
"golang.org/x/time/rate"
)
// Limit represents an IP request rate limit.
type Limit struct {
limiters map[string]*rate.Limiter
mu *sync.RWMutex
rateLimit rate.Limit
burstSize int
}
// NewLimit returns a new Limit with the specified request and burst rate limit per second.
func NewLimit(r rate.Limit, b int) *Limit {
i := &Limit{
limiters: make(map[string]*rate.Limiter),
mu: &sync.RWMutex{},
rateLimit: r,
burstSize: b,
}
return i
}
// AddIP adds a new rate limiter for the specified IP address.
func (i *Limit) AddIP(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter := rate.NewLimiter(i.rateLimit, i.burstSize)
i.limiters[ip] = limiter
return limiter
}
// IP returns the rate limiter for the specified IP address.
func (i *Limit) IP(ip string) *rate.Limiter {
i.mu.Lock()
limiter, exists := i.limiters[ip]
if !exists {
i.mu.Unlock()
return i.AddIP(ip)
}
i.mu.Unlock()
return limiter
}
// Allow reports whether the request is allowed at this time and increments the request counter.
func (i *Limit) Allow(ip string) bool {
return i.IP(ip).Allow()
}
// Reserve increments the request counter and returns a rate.Reservation.
func (i *Limit) Reserve(ip string) *rate.Reservation {
return i.IP(ip).Reserve()
}
// Reject reports whether the request limit has been exceeded, but does not change the request counter.
func (i *Limit) Reject(ip string) bool {
return i.IP(ip).Tokens() < 1
}

View file

@ -0,0 +1,76 @@
package limiter
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewLimit(t *testing.T) {
clientIp := "192.0.2.1"
t.Run("BelowLimit", func(t *testing.T) {
// 10 per minute.
l := NewLimit(0.166, 10)
for i := 0; i < 9; i++ {
assert.True(t, l.IP(clientIp).Allow())
}
})
t.Run("AboveLimit", func(t *testing.T) {
// 10 per minute.
l := NewLimit(0.166, 10)
for i := 0; i < 10; i++ {
assert.True(t, l.IP(clientIp).Allow())
}
assert.False(t, l.IP(clientIp).Allow())
})
t.Run("MultipleIPs", func(t *testing.T) {
// 10 per minute.
l := NewLimit(0.166, 10)
for i := 0; i < 100; i++ {
assert.True(t, l.IP(fmt.Sprintf("192.0.2.%d", i)).Allow())
}
})
t.Run("Reject", func(t *testing.T) {
// 10 per minute.
l := NewLimit(0.166, 10)
// Request counter not increased.
for i := 0; i < 20; i++ {
assert.False(t, l.Reject(clientIp))
}
// Request counter checked and increased.
for i := 0; i < 10; i++ {
assert.True(t, l.Allow(clientIp))
}
// Limit exceeded.
for i := 0; i < 10; i++ {
assert.True(t, l.Reject(clientIp))
assert.False(t, l.Allow(clientIp))
}
})
t.Run("Reserve", func(t *testing.T) {
// 10 per minute.
l := NewLimit(0.166, 10)
// Request counter not increased.
for i := 0; i < 20; i++ {
assert.False(t, l.Reject(clientIp))
}
// Request counter checked and increased.
for i := 0; i < 10; i++ {
assert.False(t, l.Reject(clientIp))
l.Reserve(clientIp)
}
// Limit exceeded.
for i := 0; i < 10; i++ {
l.Reserve(clientIp)
assert.True(t, l.Reject(clientIp))
}
})
}

View file

@ -0,0 +1,25 @@
/*
Package limiter provides an IP request rate limiter with Gin middleware.
Copyright (c) 2018 - 2022 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://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 limiter

View file

@ -0,0 +1,17 @@
package limiter
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Middleware registers the IP rate limiter middleware.
func Middleware(ip *Limit) gin.HandlerFunc {
return func(c *gin.Context) {
if l := ip.IP(c.ClientIP()); !l.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
}
}

View file

@ -4,6 +4,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -22,7 +23,7 @@ func Logger() gin.HandlerFunc {
end := time.Now()
latency := end.Sub(start)
// clientIP := c.ClientIP()
// clientIp := c.ClientIP()
method := c.Request.Method
statusCode := c.Writer.Status()

View file

@ -6,6 +6,9 @@ import (
"net/http"
"time"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/sync/errgroup"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
@ -14,6 +17,7 @@ import (
)
var log = event.Log
var httpsRedirect = http.StatusMovedPermanently
// Start the REST API server using the configuration provided
func Start(ctx context.Context, conf *config.Config) {
@ -35,8 +39,13 @@ func Start(ctx context.Context, conf *config.Config) {
// Create new HTTP router engine without standard middleware.
router := gin.New()
// Set proxy addresses from which headers related to the client and protocol can be trusted
if err := router.SetTrustedProxies(conf.Proxies()); err != nil {
log.Warnf("server: %s", err)
}
// Register common middleware.
router.Use(Logger(), Recovery(), Security(conf))
router.Use(Recovery(), Security(conf), Logger())
// Initialize package extensions.
Ext().Init(router, conf)
@ -63,24 +72,37 @@ func Start(ctx context.Context, conf *config.Config) {
// Register HTTP route handlers.
registerRoutes(router, conf)
// Create new HTTP server instance.
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()),
Handler: router,
}
var tlsErr error
var tlsManager *autocert.Manager
var server *http.Server
// Start HTTP server.
go func() {
log.Infof("server: listening on %s [%s]", server.Addr, time.Since(start))
if err := server.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Info("server: shutdown complete")
} else {
log.Errorf("server: %s", err)
}
// Enable TLS?
if tlsManager, tlsErr = AutoTLS(conf); tlsErr == nil {
httpsRedirect = conf.HttpsRedirect()
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpsPort()),
TLSConfig: tlsManager.TLSConfig(),
Handler: router,
}
}()
log.Infof("server: starting in auto tls mode on %s [%s]", server.Addr, time.Since(start))
go StartAutoTLS(server, tlsManager, conf)
} else if httpsCert, privateKey := conf.TLS(); httpsCert != "" && privateKey != "" {
log.Infof("server: starting in manual tls mode")
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpsPort()),
Handler: router,
}
log.Infof("server: listening on %s [%s]", server.Addr, time.Since(start))
go StartTLS(server, httpsCert, privateKey)
} else {
log.Infof("server: %s", tlsErr)
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()),
Handler: router,
}
log.Infof("server: listening on %s [%s]", server.Addr, time.Since(start))
go StartHttp(server)
}
// Graceful HTTP server shutdown.
<-ctx.Done()
@ -90,3 +112,52 @@ func Start(ctx context.Context, conf *config.Config) {
log.Errorf("server: shutdown failed (%s)", err)
}
}
// StartHttp starts the web server in http mode.
func StartHttp(s *http.Server) {
if err := s.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Info("server: shutdown complete")
} else {
log.Errorf("server: %s", err)
}
}
}
// StartTLS starts the web server in https mode.
func StartTLS(s *http.Server, httpsCert, privateKey string) {
if err := s.ListenAndServeTLS(httpsCert, privateKey); err != nil {
if err == http.ErrServerClosed {
log.Info("server: shutdown complete")
} else {
log.Errorf("server: %s", err)
}
}
}
// StartAutoTLS starts the web server with auto tls enabled.
func StartAutoTLS(s *http.Server, m *autocert.Manager, conf *config.Config) {
var g errgroup.Group
g.Go(func() error {
return http.ListenAndServe(fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()), m.HTTPHandler(http.HandlerFunc(redirect)))
})
g.Go(func() error {
return s.ListenAndServeTLS("", "")
})
if err := g.Wait(); err != nil {
if err == http.ErrServerClosed {
log.Info("server: shutdown complete")
} else {
log.Errorf("server: %s", err)
}
}
}
func redirect(w http.ResponseWriter, req *http.Request) {
target := "https://" + req.Host + req.RequestURI
http.Redirect(w, req, target, httpsRedirect)
}

13
scripts/openssl/ca.conf Normal file
View file

@ -0,0 +1,13 @@
[req]
default_bits = 4096
default_md = sha256
distinguished_name = dn
prompt = no
[dn]
C = DE
ST = Berlin
L = Berlin
O = Self-Signed
emailAddress = hello@photoprism.local
CN = photoprism.local

6
scripts/openssl/create-all.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname "$0")
"$SCRIPT_DIR/create-ca.sh"
"$SCRIPT_DIR/create-certs.sh"

20
scripts/openssl/create-ca.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# shellcheck disable=SC2164
SCRIPT_PATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
CERTS_PATH="${SCRIPT_PATH}/../../storage/config/certs"
echo "OpenSSL Scripts: ${SCRIPT_PATH}"
echo "HTTPS Cert Path: ${CERTS_PATH}"
mkdir -p "${CERTS_PATH}"
openssl genrsa -out "$CERTS_PATH/ca.key" 4096
openssl req -x509 -new -nodes -key "$CERTS_PATH/ca.key" -sha256 -days 365 -out "$CERTS_PATH/ca.pem" -passin pass: -passout pass: -config "$SCRIPT_PATH/ca.conf"
openssl x509 -outform der -in "$CERTS_PATH/ca.pem" -out "$CERTS_PATH/ca.crt"
# To add this to the local cert list:
# sudo cp ./certs/ca.crt /usr/local/share/ca-certificates/local-ca.crt
# sudo update-ca-certificates

19
scripts/openssl/create-certs.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# shellcheck disable=SC2164
SCRIPT_PATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
CERTS_PATH="${SCRIPT_PATH}/../../storage/config/certs"
echo "OpenSSL Scripts: ${SCRIPT_PATH}"
echo "HTTPS Cert Path: ${CERTS_PATH}"
mkdir -p "${CERTS_PATH}"
openssl genrsa -out "$CERTS_PATH/local.key" 4096
openssl req -new -config "$SCRIPT_PATH/openssl.conf" -key "$CERTS_PATH/local.key" -out "$CERTS_PATH/local.csr"
openssl x509 -req -in "$CERTS_PATH/local.csr" -CA "$CERTS_PATH/ca.pem" -CAkey "$CERTS_PATH/ca.key" -CAcreateserial \
-out "$CERTS_PATH/local.crt" -days 365 -sha256 -extfile "$SCRIPT_PATH/local.conf"
openssl pkcs12 -export -in "$CERTS_PATH/local.crt" -inkey "$CERTS_PATH/local.key" -out "$CERTS_PATH/local.pfx" -passin pass: -passout pass:

View file

@ -0,0 +1,8 @@
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.photoprism.local
DNS.2 = photoprism.local

View file

@ -0,0 +1,25 @@
[req]
default_bits = 4096
prompt = no
default_md = sha256
x509_extensions = v3_req
distinguished_name = dn
[dn]
C = DE
ST = Berlin
L = Berlin
O = PhotoPrism
OU = Local
emailAddress = hello@photoprism.local
CN = photoprism.local
[v3_req]
subjectAltName = @alt_names
[SAN]
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.photoprism.local
DNS.2 = photoprism.local