Config: Add StaticBuildFile() and StaticImgFile() functions #5274

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-18 09:09:56 +02:00
parent 703f355c09
commit bf6d7e4f94
15 changed files with 75 additions and 32 deletions

View File

@@ -135,7 +135,7 @@ func (a *ClientAssets) readFile(fileName string) string {
// ClientAssets returns the frontend build assets. // ClientAssets returns the frontend build assets.
func (c *Config) ClientAssets() *ClientAssets { func (c *Config) ClientAssets() *ClientAssets {
result := NewClientAssets(c.BuildPath(), c.StaticUri()) result := NewClientAssets(c.StaticBuildPath(), c.StaticUri())
if err := result.Load("assets.json"); err != nil { if err := result.Load("assets.json"); err != nil {
log.Debugf("frontend: %s", err) log.Debugf("frontend: %s", err)

View File

@@ -277,16 +277,28 @@ func (c *Config) StaticFile(fileName string) string {
return filepath.Join(c.AssetsPath(), fs.StaticDir, fileName) return filepath.Join(c.AssetsPath(), fs.StaticDir, fileName)
} }
// BuildPath returns the static build path. // StaticBuildPath returns the static build path.
func (c *Config) BuildPath() string { func (c *Config) StaticBuildPath() string {
return filepath.Join(c.StaticPath(), fs.BuildDir) return filepath.Join(c.StaticPath(), fs.BuildDir)
} }
// ImgPath returns the path to static image files. // StaticBuildFile joins the static build directory with the given relative path and
func (c *Config) ImgPath() string { // returns an absolute file system location (e.g. hashed bundles or sw.js).
func (c *Config) StaticBuildFile(fileName string) string {
return filepath.Join(c.StaticBuildPath(), fileName)
}
// StaticImgPath returns the path to static image files.
func (c *Config) StaticImgPath() string {
return filepath.Join(c.StaticPath(), fs.ImgDir) return filepath.Join(c.StaticPath(), fs.ImgDir)
} }
// StaticImgFile joins the static image directory with the given relative path and
// returns an absolute file system location (e.g. icons or wallpapers).
func (c *Config) StaticImgFile(fileName string) string {
return filepath.Join(c.StaticImgPath(), fileName)
}
// ThemePath returns the path to static theme files. // ThemePath returns the path to static theme files.
func (c *Config) ThemePath() string { func (c *Config) ThemePath() string {
if c.options.CustomThemePath != "" { if c.options.CustomThemePath != "" {

View File

@@ -143,12 +143,12 @@ func (c *Config) SiteFavicon() string {
return c.options.SiteFavicon return c.options.SiteFavicon
} else if fileName := filepath.Join(c.ThemePath(), strings.TrimPrefix(c.options.SiteFavicon, ThemeUri)); fs.FileExistsNotEmpty(fileName) { } else if fileName := filepath.Join(c.ThemePath(), strings.TrimPrefix(c.options.SiteFavicon, ThemeUri)); fs.FileExistsNotEmpty(fileName) {
return fileName return fileName
} else if fileName = filepath.Join(c.ImgPath(), c.options.SiteFavicon); fs.FileExistsNotEmpty(fileName) { } else if fileName = c.StaticImgFile(c.options.SiteFavicon); fs.FileExistsNotEmpty(fileName) {
return fileName return fileName
} }
} }
return filepath.Join(c.ImgPath(), "favicon.ico") return c.StaticImgFile("favicon.ico")
} }
// SitePreview returns the site preview image URL for sharing. // SitePreview returns the site preview image URL for sharing.

View File

@@ -203,7 +203,7 @@ func (c *Config) CreateDirectories() error {
} }
// Create frontend build path if it doesn't exist yet. // Create frontend build path if it doesn't exist yet.
if dir := c.BuildPath(); dir == "" { if dir := c.StaticBuildPath(); dir == "" {
return notFoundError("build") return notFoundError("build")
} else if err := fs.MkdirAll(dir); err != nil { } else if err := fs.MkdirAll(dir); err != nil {
return createError(dir, err) return createError(dir, err)

View File

@@ -256,20 +256,34 @@ func TestConfig_StaticFile(t *testing.T) {
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/video/404.mp4", path) assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/video/404.mp4", path)
} }
func TestConfig_BuildPath(t *testing.T) { func TestConfig_StaticBuildPath(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
path := c.BuildPath() path := c.StaticBuildPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/build", path) assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/build", path)
} }
func TestConfig_ImgPath(t *testing.T) { func TestConfig_StaticBuildFile(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
result := c.ImgPath() assert.Equal(t, filepath.Join(c.StaticBuildPath(), fs.SwJsFile), c.StaticBuildFile(fs.SwJsFile))
assert.Equal(t, filepath.Join(c.StaticBuildPath(), "chunk/app.js"), c.StaticBuildFile("chunk/app.js"))
}
func TestConfig_StaticImgPath(t *testing.T) {
c := NewConfig(CliTestContext())
result := c.StaticImgPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/img", result) assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/static/img", result)
} }
func TestConfig_StaticImgFile(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, filepath.Join(c.StaticImgPath(), "favicon.ico"), c.StaticImgFile("favicon.ico"))
assert.Equal(t, filepath.Join(c.StaticImgPath(), "wallpapers/default.jpg"), c.StaticImgFile("/wallpapers/default.jpg"))
}
func TestConfig_ThemePath(t *testing.T) { func TestConfig_ThemePath(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())

View File

@@ -2,6 +2,7 @@ package pwa
import ( import (
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/list" "github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@@ -51,7 +52,7 @@ func NewManifest(c Config) (m *Manifest) {
StartUrl: c.BaseUri + "library/", StartUrl: c.BaseUri + "library/",
Shortcuts: Shortcuts(c.BaseUri), Shortcuts: Shortcuts(c.BaseUri),
Serviceworker: Serviceworker{ Serviceworker: Serviceworker{
Src: "sw.js", Src: fs.SwJsFile,
Scope: c.BaseUri, Scope: c.BaseUri,
UseCache: true, UseCache: true,
}, },

View File

@@ -80,8 +80,8 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"assets-path", c.AssetsPath()}, {"assets-path", c.AssetsPath()},
{"models-path", c.ModelsPath()}, {"models-path", c.ModelsPath()},
{"static-path", c.StaticPath()}, {"static-path", c.StaticPath()},
{"build-path", c.BuildPath()}, {"static-build-path", c.StaticBuildPath()},
{"img-path", c.ImgPath()}, {"static-img-path", c.StaticImgPath()},
{"templates-path", c.TemplatesPath()}, {"templates-path", c.TemplatesPath()},
// Sidecar Files. // Sidecar Files.

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestStaticRoutes(t *testing.T) { func TestStaticRoutes(t *testing.T) {
@@ -95,14 +96,14 @@ func TestWebAppRoutes(t *testing.T) {
}) })
t.Run("GetServiceWorker", func(t *testing.T) { t.Run("GetServiceWorker", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/sw.js", nil) req, _ := http.NewRequest("GET", "/"+fs.SwJsFile, nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code) assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body) assert.NotEmpty(t, w.Body)
}) })
t.Run("HeadServiceWorker", func(t *testing.T) { t.Run("HeadServiceWorker", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("HEAD", "/sw.js", nil) req, _ := http.NewRequest("HEAD", "/"+fs.SwJsFile, nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code) assert.Equal(t, 200, w.Code)
assert.Empty(t, w.Body) assert.Empty(t, w.Body)

View File

@@ -2,13 +2,12 @@ package server
import ( import (
"net/http" "net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/api" "github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header" "github.com/photoprism/photoprism/pkg/service/http/header"
) )
@@ -54,23 +53,25 @@ func registerWebAppRoutes(router *gin.Engine, conf *config.Config) {
swWorker := func(c *gin.Context) { swWorker := func(c *gin.Context) {
c.Header(header.CacheControl, header.CacheControlNoStore) c.Header(header.CacheControl, header.CacheControlNoStore)
if swBuildPath := filepath.Join(conf.BuildPath(), "sw.js"); swBuildPath != "" { // Serve the Workbox-generated service worker when the frontend build has
if _, err := os.Stat(swBuildPath); err == nil { // produced one (default for production builds).
c.File(swBuildPath) if swFile := conf.StaticBuildFile(fs.SwJsFile); fs.FileExistsNotEmpty(swFile) {
return c.File(swFile)
} return
} }
// Fall back to the embedded no-op service worker so tests and dev builds
// still receive a valid response.
if len(fallbackServiceWorker) > 0 { if len(fallbackServiceWorker) > 0 {
c.Data(http.StatusOK, "application/javascript", fallbackServiceWorker) c.Data(http.StatusOK, header.ContentTypeJavaScript, fallbackServiceWorker)
return return
} }
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
} }
router.Any("/sw.js", swWorker) router.Any("/"+fs.SwJsFile, swWorker)
if swUri := conf.BaseUri("/sw.js"); swUri != "/sw.js" { if swUri := conf.BaseUri("/" + fs.SwJsFile); swUri != "/"+fs.SwJsFile {
router.Any(swUri, swWorker) router.Any(swUri, swWorker)
} }
} }

View File

@@ -2,9 +2,10 @@ package server
import _ "embed" import _ "embed"
// fallbackServiceWorker contains a minimal no-op service worker used when a // fallbackServiceWorker is a tiny service worker embedded in the binary so the
// Workbox-generated sw.js is not available (for example during tests or when // server can respond to /sw.js even when the frontend assets (and thus the
// frontend assets have not been built yet). // Workbox-generated service worker) have not been built yet. It keeps tests and
// development environments functional without affecting production builds.
// //
//go:embed sw_fallback.js //go:embed sw_fallback.js
var fallbackServiceWorker []byte var fallbackServiceWorker []byte

View File

@@ -348,7 +348,10 @@ func mergeOptionsYaml(c *config.Config, updates Values) error {
// local theme directory is missing or lacks an app.js file. // local theme directory is missing or lacks an app.js file.
func installThemeIfMissing(c *config.Config, portal *url.URL, token string) error { func installThemeIfMissing(c *config.Config, portal *url.URL, token string) error {
themeDir := c.ThemePath() themeDir := c.ThemePath()
need := !fs.PathExists(themeDir) || (cluster.BootstrapThemeInstallOnlyIfMissingJS && !fs.FileExists(filepath.Join(themeDir, "app.js")))
need := !fs.PathExists(themeDir) ||
(cluster.BootstrapThemeInstallOnlyIfMissingJS && !fs.FileExists(filepath.Join(themeDir, fs.AppJsFile)))
if !need && !cluster.BootstrapAllowThemeOverwrite { if !need && !cluster.BootstrapAllowThemeOverwrite {
return nil return nil
} }

View File

@@ -247,7 +247,7 @@ func TestThemeInstall_SkipWhenAppJsExists(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tempTheme) }() defer func() { _ = os.RemoveAll(tempTheme) }()
c.SetThemePath(tempTheme) c.SetThemePath(tempTheme)
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("// app\n"), fs.ModeFile)) assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, fs.AppJsFile), []byte("// app\n"), fs.ModeFile))
assert.NoError(t, InitConfig(c)) assert.NoError(t, InitConfig(c))
// Should have skipped request because app.js already exists. // Should have skipped request because app.js already exists.

View File

@@ -38,3 +38,9 @@ const (
UsersDir = "users" UsersDir = "users"
ZipDir = "zip" ZipDir = "zip"
) )
// Common file names used across packages (sorted by name).
const (
AppJsFile = "app.js"
SwJsFile = "sw.js"
)

View File

@@ -28,6 +28,8 @@ func TestContent(t *testing.T) {
assert.Equal(t, "multipart/form-data", ContentTypeMultipart) assert.Equal(t, "multipart/form-data", ContentTypeMultipart)
assert.Equal(t, "application/json", ContentTypeJson) assert.Equal(t, "application/json", ContentTypeJson)
assert.Equal(t, "application/json; charset=utf-8", ContentTypeJsonUtf8) assert.Equal(t, "application/json; charset=utf-8", ContentTypeJsonUtf8)
assert.Equal(t, "application/javascript", ContentTypeJavaScript)
assert.Equal(t, "text/css", ContentTypeCSS)
assert.Equal(t, "text/html; charset=utf-8", ContentTypeHtml) assert.Equal(t, "text/html; charset=utf-8", ContentTypeHtml)
assert.Equal(t, "text/plain; charset=utf-8", ContentTypeText) assert.Equal(t, "text/plain; charset=utf-8", ContentTypeText)
assert.Equal(t, "image/png", ContentTypePng) assert.Equal(t, "image/png", ContentTypePng)

View File

@@ -101,6 +101,8 @@ const (
ContentTypeMultipart = "multipart/form-data" ContentTypeMultipart = "multipart/form-data"
ContentTypeJson = "application/json" ContentTypeJson = "application/json"
ContentTypeJsonUtf8 = "application/json; charset=utf-8" ContentTypeJsonUtf8 = "application/json; charset=utf-8"
ContentTypeJavaScript = "application/javascript"
ContentTypeCSS = "text/css"
ContentTypeXml = "text/xml" ContentTypeXml = "text/xml"
ContentTypeHtml = "text/html; charset=utf-8" ContentTypeHtml = "text/html; charset=utf-8"
ContentTypeText = "text/plain; charset=utf-8" ContentTypeText = "text/plain; charset=utf-8"