Config: Add an option to disable the web user interface #5111

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-07-14 19:30:24 +02:00
parent 88126e3e48
commit f7a6b0fa6c
15 changed files with 82 additions and 23 deletions

View file

@ -21,7 +21,11 @@ var brokenVideo []byte
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api-v1: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, strings.ToLower(resp.String()))
if code >= 400 {
log.Debugf("api: aborting request with error code %d (%s)", code, strings.ToLower(resp.String()))
} else {
log.Debugf("api: aborting request with response code %d (%s)", code, strings.ToLower(resp.String()))
}
c.AbortWithStatusJSON(code, resp)
}
@ -31,7 +35,12 @@ func Error(c *gin.Context, code int, err error, id i18n.Message, params ...inter
if err != nil {
resp.Details = err.Error()
log.Errorf("api-v1: error %s with code %d in %s (%s)", clean.Error(err), code, clean.Log(c.FullPath()), strings.ToLower(resp.String()))
if reqPath := c.FullPath(); reqPath == "" {
log.Errorf("api: error %d %s (%s)", code, clean.Error(err), strings.ToLower(resp.String()))
} else {
log.Errorf("api: error %d %s in %s (%s)", code, clean.Error(err), clean.Log(reqPath), strings.ToLower(resp.String()))
}
}
c.AbortWithStatusJSON(code, resp)
@ -107,7 +116,7 @@ func AbortBadRequest(c *gin.Context, errs ...error) {
for _, err := range errs {
if err != nil {
// Add error message to the debug logs.
log.Debugf("api-v1: %s", err)
log.Debugf("api: %s", err)
// Attach error to the current context
_ = c.Error(err)

View file

@ -22,10 +22,18 @@ func GetClientConfig(router *gin.RouterGroup) {
sess := Session(ClientIP(c), AuthToken(c))
conf := get.Config()
if sess == nil {
c.JSON(http.StatusOK, conf.ClientPublic())
} else {
// Check authentication.
if sess != nil {
// Return custom client config for authenticated user.
c.JSON(http.StatusOK, conf.ClientSession(sess))
return
} else if conf.DisableFrontend() {
// Abort if not authenticated, and the web frontend is disabled.
AbortUnauthorized(c)
return
}
// Return public client config for loading the web frontend
c.JSON(http.StatusOK, conf.ClientPublic())
})
}

View file

@ -59,7 +59,7 @@ func AlbumCover(router *gin.RouterGroup) {
cacheKey := CacheKey(albumCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
@ -177,7 +177,7 @@ func LabelCover(router *gin.RouterGroup) {
cacheKey := CacheKey(labelCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View file

@ -68,7 +68,7 @@ func FolderCover(router *gin.RouterGroup) {
cacheKey := CacheKey(folderCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View file

@ -85,7 +85,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string)
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(FoldersResponse)
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return

View file

@ -91,7 +91,7 @@ func GetServiceFolders(router *gin.RouterGroup) {
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(fs.FileInfos)
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return
@ -278,7 +278,7 @@ func DeleteService(router *gin.RouterGroup) {
return
}
if err := m.Delete(); err != nil {
if err = m.Delete(); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed)
return
}

View file

@ -105,7 +105,7 @@ func GetThumb(router *gin.RouterGroup) {
cacheKey := CacheKey("thumbs", fileHash, string(sizeName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View file

@ -9,6 +9,11 @@ import (
var Sponsor = Env(EnvDemo, EnvSponsor, EnvTest)
var Features = Community
// DisableFrontend checks if the web user interface routes should be disabled.
func (c *Config) DisableFrontend() bool {
return c.options.DisableFrontend
}
// DisableSettings checks if users should not be allowed to change settings.
func (c *Config) DisableSettings() bool {
return c.options.DisableSettings

View file

@ -6,6 +6,16 @@ import (
"github.com/stretchr/testify/assert"
)
func TestConfig_DisableFrontend(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.DisableFrontend())
}
func TestConfig_DisableSettings(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.DisableSettings())
}
func TestConfig_DisableWebDAV(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -392,9 +392,14 @@ var Flags = CliFlags{
Usage: "enable new features that may be incomplete or unstable",
EnvVars: EnvVars("EXPERIMENTAL"),
}}, {
Flag: &cli.BoolFlag{
Name: "disable-frontend",
Usage: "disable the web user interface so that only the service API endpoints are accessible",
EnvVars: EnvVars("DISABLE_FRONTEND"),
}}, {
Flag: &cli.BoolFlag{
Name: "disable-settings",
Usage: "disable the settings user interface and server API, e.g. in combination with public mode",
Usage: "disable the settings frontend and related API endpoints, e.g. in combination with public mode",
EnvVars: EnvVars("DISABLE_SETTINGS"),
}}, {
Flag: &cli.BoolFlag{

View file

@ -94,6 +94,7 @@ type Options struct {
AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"`
ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"`
Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"`
DisableFrontend bool `yaml:"DisableFrontend" json:"-" flag:"disable-frontend"`
DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"`
DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"`
DisableRestart bool `yaml:"DisableRestart" json:"-" flag:"disable-restart"`

View file

@ -110,6 +110,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"read-only", fmt.Sprintf("%t", c.ReadOnly())},
{"develop", fmt.Sprintf("%t", c.Develop())},
{"experimental", fmt.Sprintf("%t", c.Experimental())},
{"disable-frontend", fmt.Sprintf("%t", c.DisableFrontend())},
{"disable-settings", fmt.Sprintf("%t", c.DisableSettings())},
{"disable-backups", fmt.Sprintf("%t", c.DisableBackups())},
{"disable-restart", fmt.Sprintf("%t", c.DisableRestart())},

View file

@ -9,6 +9,11 @@ import (
// registerSharingRoutes adds routes for link sharing.
func registerSharingRoutes(router *gin.Engine, conf *config.Config) {
// Return if the web user interface is disabled.
if conf.DisableFrontend() {
return
}
s := router.Group(conf.BaseUri("/s"))
{
api.Shares(s)

View file

@ -7,20 +7,12 @@ import (
"github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
// registerStaticRoutes adds routes for serving static content and templates.
func registerStaticRoutes(router *gin.Engine, conf *config.Config) {
// Redirects to the login page.
login := func(c *gin.Context) {
if conf.OIDCEnabled() && conf.OIDCRedirect() {
c.Redirect(http.StatusTemporaryRedirect, conf.OIDCLoginUri())
} else {
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
}
}
// Control how crawlers index the site by serving a "robots.txt" file in addition
// to the "X-Robots-Tag" response header set in the Security middleware:
// https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt
@ -35,6 +27,24 @@ func registerStaticRoutes(router *gin.Engine, conf *config.Config) {
}
})
// Return if the web user interface is disabled.
if conf.DisableFrontend() {
log.Info("frontend: disabled")
router.NoRoute(func(c *gin.Context) {
api.Abort(c, http.StatusNotFound, i18n.ErrNotFound)
})
return
}
// Redirects to the login page.
login := func(c *gin.Context) {
if conf.OIDCEnabled() && conf.OIDCRedirect() {
c.Redirect(http.StatusTemporaryRedirect, conf.OIDCLoginUri())
} else {
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
}
}
router.Any(conf.BaseUri("/"), login)
// Shows "Page Not found" error if no other handler is registered.

View file

@ -13,6 +13,11 @@ import (
// registerWebAppRoutes adds routes for the web user interface.
func registerWebAppRoutes(router *gin.Engine, conf *config.Config) {
// Return if the web user interface is disabled.
if conf.DisableFrontend() {
return
}
// Serve user interface bootstrap template on all routes starting with "/library".
ui := func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.