Security: Add http rate limiter and auto tls mode #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
20904041f9
commit
6abbc39017
51 changed files with 1262 additions and 453 deletions
|
@ -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:
|
||||
|
|
|
@ -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"]
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
11
go.mod
|
@ -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
12
go.sum
|
@ -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=
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
102
internal/config/cli_context.go
Normal file
102
internal/config/cli_context.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 == "" {
|
||||
|
|
|
@ -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()},
|
||||
|
|
|
@ -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")
|
||||
|
|
77
internal/config/config_server_tls.go
Normal file
77
internal/config/config_server_tls.go
Normal 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
|
||||
}
|
52
internal/config/config_server_tls_test.go
Normal file
52
internal/config/config_server_tls_test.go
Normal 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())
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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*",
|
||||
|
|
|
@ -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
|
76
internal/entity/auth_session_login_test.go
Normal file
76
internal/entity/auth_session_login_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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?
|
||||
|
|
|
@ -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(""))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
19
internal/form/json.go
Normal 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))
|
||||
}
|
36
internal/server/autotls.go
Normal file
36
internal/server/autotls.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
|
5
internal/server/header/cidr.go
Normal file
5
internal/server/header/cidr.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package header
|
||||
|
||||
const (
|
||||
CidrDockerInternal = "172.16.0.0/12"
|
||||
)
|
6
internal/server/header/proto.go
Normal file
6
internal/server/header/proto.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package header
|
||||
|
||||
var (
|
||||
ProtoHttp = "http"
|
||||
ProtoHttps = "https"
|
||||
)
|
17
internal/server/limiter/abort.go
Normal file
17
internal/server/limiter/abort.go
Normal 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})
|
||||
}
|
10
internal/server/limiter/auth.go
Normal file
10
internal/server/limiter/auth.go
Normal 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)
|
60
internal/server/limiter/auth_test.go
Normal file
60
internal/server/limiter/auth_test.go
Normal 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)
|
||||
}
|
69
internal/server/limiter/limit.go
Normal file
69
internal/server/limiter/limit.go
Normal 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
|
||||
}
|
76
internal/server/limiter/limit_test.go
Normal file
76
internal/server/limiter/limit_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
25
internal/server/limiter/limiter.go
Normal file
25
internal/server/limiter/limiter.go
Normal 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
|
17
internal/server/limiter/middleware.go
Normal file
17
internal/server/limiter/middleware.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
13
scripts/openssl/ca.conf
Normal 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
6
scripts/openssl/create-all.sh
Executable 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
20
scripts/openssl/create-ca.sh
Executable 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
19
scripts/openssl/create-certs.sh
Executable 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:
|
8
scripts/openssl/local.conf
Normal file
8
scripts/openssl/local.conf
Normal 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
|
25
scripts/openssl/openssl.conf
Normal file
25
scripts/openssl/openssl.conf
Normal 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
|
Loading…
Reference in a new issue