Config: Refactor frontend URI handling for configurable base paths

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-02-25 00:55:16 +01:00
parent bcb6d39332
commit 18061fb536
34 changed files with 272 additions and 68 deletions

View file

@ -94,3 +94,5 @@ Thumbs.db
/AGENTS_*
/REPOS.md
/CLAUDE.md
join_token
client_secret

2
.gitignore vendored
View file

@ -70,6 +70,8 @@ frontend/coverage/
/REPOS.md
/CLAUDE.md
/TODO.md
join_token
client_secret
# Files created automatically by editors and/or operating systems:
.DS_Store

View file

@ -208,7 +208,7 @@ Note: Across our public documentation, official images, and in production, the c
### Playwright MCP Usage
- **Endpoint & Navigation** — Playwright MCP is preconfigured to reach the dev server at `http://localhost:2342/`.
Use `playwright__browser_navigate` to open `/library/login`, sign in, and then call `playwright__browser_take_screenshot` to capture the page state.
Use `playwright__browser_navigate` to open the login route under the configured frontend URI (default `/library/login`), sign in, and then call `playwright__browser_take_screenshot` to capture the page state.
- **Viewport Defaults** — Desktop sessions open with a `1280×900` viewport by default.
Use `playwright__browser_resize` if the viewport is not preconfigured or you need to adjust it mid-run.
- **Mobile Workflows** — When testing responsive layouts, use the `playwright_mobile` server (for example, `playwright_mobile__browser_navigate`).
@ -352,7 +352,7 @@ Note: Across our public documentation, official images, and in production, the c
- `PhotoFixtures.Get()` and similar helpers return value copies; when a test needs the database-backed row (with associations preloaded), re-query by UID/ID using helpers like `entity.FindPhoto(fixture)` so updates observe persisted IDs and in-memory caches stay coherent.
- For slimmer tests that only need config objects, prefer the new helpers in `internal/config/test.go`: `NewMinimalTestConfig(t.TempDir())` when no database is needed, or `NewMinimalTestConfigWithDb("<pkg>", t.TempDir())` to spin up an isolated SQLite schema without seeding all fixtures.
- When you need illustrative credentials (join tokens, client IDs/secrets, etc.), reuse the shared `Example*` constants (see `internal/service/cluster/examples.go`) so tests, docs, and examples stay consistent.
- Hidden error UI checks for `/library/hidden` require both `files.file_error` and `photos.photo_quality = -1`; hidden searches are quality-gated, so setting only `file_error` will not surface the row in Hidden results.
- Hidden error UI checks for the hidden route under the frontend URI (default `/library/hidden`) require both `files.file_error` and `photos.photo_quality = -1`; hidden searches are quality-gated, so setting only `file_error` will not surface the row in Hidden results.
### Roles & ACL

View file

@ -63,7 +63,7 @@ HTTP API
- `make swag-json` runs a stabilization step (`swaggerfix`) removing duplicated enums for `time.Duration`; API uses integer nanoseconds for durations.
- `/api/v1/metrics` (see `internal/api/metrics.go`) exposes Prometheus metrics, including cached filesystem/account usage derived from `config.Usage()`, registered user/guest totals, and portal cluster node counts when `NodeRole=portal`; the handler returns the standard Prometheus exposition content type (`text/plain; version=0.0.4`).
- Common groups in `routes.go`: sessions, OAuth/OIDC, config, users, services, thumbnails, video, downloads/zip, index/import, photos/files/labels/subjects/faces, batch ops, cluster, technical (metrics, status, echo).
- Hidden search behavior (used by `/library/hidden`) is implemented in `internal/entity/search/photos.go`:
- Hidden search behavior (used by the hidden route under the configured frontend URI, default `/library/hidden`) is implemented in `internal/entity/search/photos.go`:
- `frm.Hidden` enforces `photos.photo_quality = -1` and `photos.deleted_at IS NULL`.
- Non-hidden searches exclude errored files by default (`files.file_error = ''`) unless `frm.Error` is explicitly set.
- Search DTOs in `internal/entity/search/photos_results.go` expose `FileError` (`files.file_error`) so clients can render hidden reasons without loading full file details first.

View file

@ -35,7 +35,7 @@ Startup Templates & Splash Screen
Runtime & Plugins
- Vue 3 + Vuetify 3 (`createVuetify`) with MDI icons; themes from `src/options/themes.js`
- Router: Vue Router 4, history base at `$config.baseUri + "/library/"`
- Router: Vue Router 4, history base at `$config.frontendUri` (default `/library`)
- I18n: `vue3-gettext` via `common/gettext.js`; canonical extraction via root `make gettext-extract` (scans `frontend/src` plus available overlays in `plus/frontend`, `pro/frontend`, and `portal/frontend`), compile with `npm run gettext-compile`
- HTML sanitization: `vue-3-sanitize` + `vue-sanitize-directive`
- Tooltips: `floating-vue`

View file

@ -174,9 +174,9 @@ $config.update().finally(() => {
}
});
// Configure the Vue Router; base path mirrors the library prefix so deep links behave correctly.
// Configure the Vue Router; base path mirrors the configured frontend URI so deep links behave correctly.
const router = createRouter({
history: createWebHistory($config.baseUri + "/library/"),
history: createWebHistory(`${$config.frontendUri.replace(/\/+$/, "")}/`),
routes: routes,
// Apply the last saved scroll position when navigating within the SPA.
scrollBehavior(to, from, savedPosition) {

View file

@ -33,6 +33,20 @@ import { ref, reactive } from "vue";
onInit();
// normalizeFrontendUri returns a normalized frontend base URI with a leading slash.
const normalizeFrontendUri = (baseUri, frontendUri) => {
const fallbackUri = `${baseUri || ""}/library`;
const rawUri = typeof frontendUri === "string" && frontendUri.trim() !== "" ? frontendUri : fallbackUri;
const prefixedUri = rawUri.startsWith("/") ? rawUri : `/${rawUri}`;
return prefixedUri.replace(/\/+$/, "");
};
// frontendLoginUri returns the default login URI below the frontend base URI.
const frontendLoginUri = (frontendUri) => {
return `${frontendUri.replace(/\/+$/, "")}/login`;
};
export default class Config {
/**
* @param {Storage} storage
@ -65,8 +79,9 @@ export default class Config {
this.theme = themes.Get("default");
this.themeName = "";
this.baseUri = "";
this.frontendUri = "/library";
this.staticUri = "/static";
this.loginUri = "/library/login";
this.loginUri = frontendLoginUri(this.frontendUri);
this.apiUri = "/api/v1";
this.contentUri = this.apiUri;
this.videoUri = this.apiUri;
@ -81,8 +96,9 @@ export default class Config {
return;
} else {
this.baseUri = values.baseUri ? values.baseUri : "";
this.frontendUri = normalizeFrontendUri(this.baseUri, values.frontendUri);
this.staticUri = values.staticUri ? values.staticUri : this.baseUri + "/static";
this.loginUri = values.loginUri ? values.loginUri : this.baseUri + "/library/login";
this.loginUri = values.loginUri ? values.loginUri : frontendLoginUri(this.frontendUri);
this.apiUri = values.apiUri ? values.apiUri : this.baseUri + "/api/v1";
this.contentUri = values.contentUri ? values.contentUri : this.apiUri;
this.videoUri = values.videoUri ? values.videoUri : this.apiUri;

View file

@ -83,9 +83,36 @@ describe("common/config", () => {
const config = new Config(storage, values);
expect(config.debug).toBe(true);
expect(config.demo).toBe(false);
expect(config.frontendUri).toBe("/library");
expect(config.loginUri).toBe("/library/login");
expect(config.apiUri).toBe("/api/v1");
});
it("derives login uri from configured frontend uri", () => {
const storage = new StorageShim();
const values = {
siteTitle: "Foo",
baseUri: "/portal",
frontendUri: "/portal/admin/",
};
const config = new Config(storage, values);
expect(config.frontendUri).toBe("/portal/admin");
expect(config.loginUri).toBe("/portal/admin/login");
});
it("uses base uri fallback when frontend uri is missing", () => {
const storage = new StorageShim();
const values = {
siteTitle: "Foo",
baseUri: "/portal",
};
const config = new Config(storage, values);
expect(config.frontendUri).toBe("/portal/library");
expect(config.loginUri).toBe("/portal/library/login");
});
it("should store values", () => {
const storage = new StorageShim();
const values = { siteTitle: "Foo", country: "Germany", city: "Hamburg" };

View file

@ -7,6 +7,7 @@ const clientConfig = {
copyright: "(c) 2018-2025 PhotoPrism UG. All rights reserved.",
flags: "public debug develop experimental settings",
baseUri: "",
frontendUri: "/library",
storageNamespace: "6d65cd87947148b5d7bf88e5c41dce8c167746a37e0abb7eb052dc911a3880bf",
staticUri: "/static",
apiUri: "/api/v1",

View file

@ -38,7 +38,7 @@ func ShareToken(router *gin.RouterGroup) {
clientConfig := conf.ClientShare()
clientConfig.SiteUrl += path.Join("s", token)
uri := conf.LibraryUri("/albums")
uri := conf.FrontendUri("/albums")
c.HTML(http.StatusOK, "share.gohtml", gin.H{"shared": gin.H{"token": token, "uri": uri}, "config": clientConfig})
})
}
@ -82,7 +82,7 @@ func ShareTokenShared(router *gin.RouterGroup) {
}
}
uri := conf.LibraryUri(path.Join("/albums", uid, "view"))
uri := conf.FrontendUri(path.Join("/albums", uid, "view"))
c.HTML(http.StatusOK, "share.gohtml", gin.H{"shared": gin.H{"token": token, "uri": uri}, "config": clientConfig})
})

View file

@ -102,6 +102,8 @@ func (f CliFlag) Usage() string {
switch {
case list.Contains(f.Tags, EnvSponsor):
return f.Flag.GetUsage() + "*members only*"
case list.Contains(f.Tags, Portal):
return f.Flag.GetUsage() + "*portal*"
case list.Contains(f.Tags, Pro):
return f.Flag.GetUsage() + "*pro*"
case list.Contains(f.Tags, Plus):

View file

@ -33,6 +33,7 @@ type ClientConfig struct {
Copyright string `json:"copyright"`
Flags string `json:"flags"`
BaseUri string `json:"baseUri"`
FrontendUri string `json:"frontendUri"`
StorageNamespace string `json:"storageNamespace"`
StaticUri string `json:"staticUri"`
ClientAssets *ClientAssets `json:"-"`
@ -284,6 +285,7 @@ func (c *Config) ClientPublic() *ClientConfig {
About: c.About(),
Edition: c.Edition(),
BaseUri: c.BaseUri(""),
FrontendUri: c.FrontendUri(""),
StorageNamespace: c.StorageNamespace(),
StaticUri: c.StaticUri(),
ClientAssets: a,
@ -381,6 +383,7 @@ func (c *Config) ClientShare() *ClientConfig {
About: c.About(),
Edition: c.Edition(),
BaseUri: c.BaseUri(""),
FrontendUri: c.FrontendUri(""),
StorageNamespace: c.StorageNamespace(),
StaticUri: c.StaticUri(),
ClientAssets: a,
@ -486,6 +489,7 @@ func (c *Config) ClientUser(withSettings bool) *ClientConfig {
About: c.About(),
Edition: c.Edition(),
BaseUri: c.BaseUri(""),
FrontendUri: c.FrontendUri(""),
StorageNamespace: c.StorageNamespace(),
StaticUri: c.StaticUri(),
ClientAssets: a,

View file

@ -16,7 +16,8 @@ func TestConfig_ClientConfig(t *testing.T) {
result := c.ClientPublic()
assert.IsType(t, &ClientConfig{}, result)
assert.Equal(t, AuthModePublic, result.AuthMode)
assert.Equal(t, c.LibraryUri("/"), result.LoginUri)
assert.Equal(t, c.FrontendUri(""), result.FrontendUri)
assert.Equal(t, c.LoginUri(), result.LoginUri)
assert.Equal(t, "", result.LoginInfo)
assert.Equal(t, "", result.RegisterUri)
assert.Equal(t, 0, result.PasswordLength)

View file

@ -91,6 +91,7 @@ func (c *Config) AppConfig() pwa.Config {
DefaultLocale: c.DefaultLocale(),
Mode: c.AppMode(),
BaseUri: c.BaseUri("/"),
FrontendUri: c.FrontendUri(""),
StaticUri: c.StaticUri(),
SiteUrl: c.SiteUrl(),
CdnUrl: c.CdnUrl("/"),

View file

@ -76,6 +76,7 @@ func TestConfig_AppConfig(t *testing.T) {
assert.Equal(t, c.AppIcon(), result.Icon)
assert.Equal(t, c.SiteDescription(), result.Description)
assert.Equal(t, c.BaseUri("/"), result.BaseUri)
assert.Equal(t, c.FrontendUri(""), result.FrontendUri)
assert.Equal(t, c.StaticUri(), result.StaticUri)
}
@ -92,7 +93,7 @@ func TestConfig_AppManifest(t *testing.T) {
assert.Equal(t, appConf.Name, result.ShortName)
assert.Equal(t, appConf.Description, result.Description)
assert.Equal(t, appConf.BaseUri, result.Scope)
assert.Equal(t, appConf.BaseUri+"library/", result.StartUrl)
assert.Equal(t, appConf.FrontendUri, result.StartUrl)
assert.Len(t, result.Icons, len(pwa.IconSizes))
assert.Len(t, result.Categories, len(pwa.Categories))
assert.Len(t, result.Permissions, len(pwa.Permissions))
@ -103,7 +104,7 @@ func TestConfig_AppManifest(t *testing.T) {
assert.Equal(t, appConf.Name, cached.ShortName)
assert.Equal(t, appConf.Description, cached.Description)
assert.Equal(t, appConf.BaseUri, cached.Scope)
assert.Equal(t, appConf.BaseUri+"library/", cached.StartUrl)
assert.Equal(t, appConf.FrontendUri, cached.StartUrl)
assert.Len(t, cached.Icons, len(pwa.IconSizes))
assert.Len(t, cached.Categories, len(pwa.Categories))
assert.Len(t, cached.Permissions, len(pwa.Permissions))

View file

@ -158,11 +158,11 @@ func (c *Config) RegisterUri() string {
// LoginUri returns the user authentication page URI.
func (c *Config) LoginUri() string {
if c.Public() {
return c.LibraryUri("/")
return c.FrontendUri("/")
}
if c.options.LoginUri == "" {
return c.LibraryUri("/login")
return c.FrontendUri("/login")
}
return c.options.LoginUri

View file

@ -118,6 +118,8 @@ func TestConfig_RegisterUri(t *testing.T) {
func TestConfig_LoginUri(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "/library/login", c.LoginUri())
c.options.FrontendUri = "/portal/admin/"
assert.Equal(t, "/portal/admin/login", c.LoginUri())
}
func TestConfig_LoginInfo(t *testing.T) {

View file

@ -9,16 +9,19 @@ import (
// ApiUri defines the standard path for handling REST requests.
const ApiUri = "/api/v1"
// DownloadUri defines the file download URI based on the ApiUri.
// DownloadUri defines the default file download URI based on the ApiUri.
const DownloadUri = ApiUri + "/dl"
// LibraryUri defines the path for user interface routes.
const LibraryUri = "/library"
// DefaultFrontendUri specifies the default base path for accessing the web interface.
const DefaultFrontendUri = "/library"
// StaticUri defines the standard path for serving static content.
// FrontendUri specifies the default base path used by FrontendPath() when no custom path is configured.
var FrontendUri = DefaultFrontendUri
// StaticUri defines the URI path for serving static content.
const StaticUri = "/static"
// CustomStaticUri defines the standard path for serving custom static content.
// CustomStaticUri defines the URI path for serving custom static content.
const CustomStaticUri = "/c/static"
// ThemeUri defines the optional theme URI path for serving theme assets.

View file

@ -54,9 +54,35 @@ func (c *Config) ApiUri() string {
return c.BaseUri(ApiUri)
}
// LibraryUri returns the user interface URI for the given resource.
func (c *Config) LibraryUri(res string) string {
return c.BaseUri(LibraryUri + res)
// FrontendPath returns the normalized frontend base path without a trailing slash.
func (c *Config) FrontendPath() string {
frontendPath := normalizeFrontendPath(c.options.FrontendUri)
if frontendPath != "" {
return frontendPath
}
frontendPath = normalizeFrontendPath(FrontendUri)
if frontendPath != "" {
return frontendPath
}
return DefaultFrontendUri
}
// normalizeFrontendPath sanitizes and normalizes a configured frontend base path.
func normalizeFrontendPath(value string) string {
if value = clean.UserPath(value); value == "" {
return ""
}
return "/" + value
}
// FrontendUri returns the user interface URI for the given resource.
func (c *Config) FrontendUri(res string) string {
return c.BaseUri(c.FrontendPath() + res)
}
// ContentUri returns the content delivery URI based on the CdnUrl and the ApiUri.
@ -208,7 +234,11 @@ func (c *Config) LegalUrl() string {
func (c *Config) RobotsTxt() ([]byte, error) {
if c.Demo() && c.Public() {
// Allow public demo instances to be indexed.
return fmt.Appendf(nil, "User-agent: *\nDisallow: /\nAllow: %s/\nAllow: %s/\nAllow: .js\nAllow: .css", LibraryUri, StaticUri), nil
return fmt.Appendf(nil,
"User-agent: *\nDisallow: /\nAllow: %s/\nAllow: %s/\nAllow: .js\nAllow: .css",
c.BaseUri(c.FrontendPath()),
c.BaseUri(StaticUri),
), nil
} else if c.Public() {
// Do not allow other instances to be indexed when public mode is enabled.
return robotsTxt, nil

View file

@ -52,20 +52,36 @@ func TestConfig_ApiUri(t *testing.T) {
assert.Equal(t, "/foo"+ApiUri, c.ApiUri())
}
func TestConfig_LibraryUri(t *testing.T) {
func TestConfig_FrontendUri(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "/library", c.LibraryUri(""))
assert.Equal(t, "/library/", c.LibraryUri("/"))
assert.Equal(t, "/library/browse", c.LibraryUri("/browse"))
assert.Equal(t, "/library", c.FrontendUri(""))
assert.Equal(t, "/library/", c.FrontendUri("/"))
assert.Equal(t, "/library/browse", c.FrontendUri("/browse"))
c.options.SiteUrl = "http://superhost:2342/"
assert.Equal(t, "/library", c.LibraryUri(""))
assert.Equal(t, "/library/", c.LibraryUri("/"))
assert.Equal(t, "/library/browse", c.LibraryUri("/browse"))
assert.Equal(t, "/library", c.FrontendUri(""))
assert.Equal(t, "/library/", c.FrontendUri("/"))
assert.Equal(t, "/library/browse", c.FrontendUri("/browse"))
c.options.SiteUrl = "http://foo:2342/foo/"
assert.Equal(t, "/foo/library", c.LibraryUri(""))
assert.Equal(t, "/foo/library/", c.LibraryUri("/"))
assert.Equal(t, "/foo/library/browse", c.LibraryUri("/browse"))
assert.Equal(t, "/foo/library", c.FrontendUri(""))
assert.Equal(t, "/foo/library/", c.FrontendUri("/"))
assert.Equal(t, "/foo/library/browse", c.FrontendUri("/browse"))
c.options.FrontendUri = "/portal/admin/"
assert.Equal(t, "/foo/portal/admin", c.FrontendUri(""))
assert.Equal(t, "/foo/portal/admin/", c.FrontendUri("/"))
assert.Equal(t, "/foo/portal/admin/browse", c.FrontendUri("/browse"))
c.options.FrontendUri = " "
assert.Equal(t, "/foo/library", c.FrontendUri(""))
}
func TestNormalizeFrontendPath(t *testing.T) {
assert.Equal(t, "", normalizeFrontendPath(""))
assert.Equal(t, "", normalizeFrontendPath(" "))
assert.Equal(t, "/library", normalizeFrontendPath("/library"))
assert.Equal(t, "/portal/admin", normalizeFrontendPath("portal/admin/"))
assert.Equal(t, "", normalizeFrontendPath("../admin"))
}
func TestConfig_ContentUri(t *testing.T) {

View file

@ -566,7 +566,7 @@ var Flags = CliFlags{
Usage: "download `URL` for installing a custom theme if none is installed",
EnvVars: EnvVars("THEME_URL"),
Hidden: true,
}, Tags: []string{Pro}}, {
}, Tags: []string{Portal, Pro}}, {
Flag: &cli.StringFlag{
Name: "places-locale",
Usage: "location details language `CODE`, e.g. en, de, or local",

View file

@ -30,12 +30,12 @@ type Options struct {
NoHub bool `yaml:"-" json:"-" flag:"no-hub"`
AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"`
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
AdminScope string `yaml:"AdminScope" json:"-" flag:"admin-scope" tags:"pro"`
AdminScope string `yaml:"AdminScope" json:"-" flag:"admin-scope" tags:"portal,pro"`
PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri" tags:"plus,pro"`
RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri" tags:"pro"`
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri" tags:"plus,portal,pro"`
RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri" tags:"portal,pro"`
LoginUri string `yaml:"-" json:"-" flag:"login-uri"`
LoginInfo string `yaml:"LoginInfo" json:"-" flag:"login-info" tags:"plus,pro"`
LoginInfo string `yaml:"LoginInfo" json:"-" flag:"login-info" tags:"plus,portal,pro"`
OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"`
OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"`
OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"`
@ -45,11 +45,11 @@ type Options struct {
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
OIDCGroupClaim string `yaml:"OIDCGroupClaim" json:"-" flag:"oidc-group-claim" tags:"pro"`
OIDCGroup []string `yaml:"OIDCGroup" json:"-" flag:"oidc-group" tags:"pro"`
OIDCGroupRole []string `yaml:"OIDCGroupRole" json:"-" flag:"oidc-group-role" tags:"pro"`
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"pro"`
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"`
OIDCGroupClaim string `yaml:"OIDCGroupClaim" json:"-" flag:"oidc-group-claim" tags:"portal,pro"`
OIDCGroup []string `yaml:"OIDCGroup" json:"-" flag:"oidc-group" tags:"portal,pro"`
OIDCGroupRole []string `yaml:"OIDCGroupRole" json:"-" flag:"oidc-group-role" tags:"portal,pro"`
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"portal,pro"`
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"portal,pro"`
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"`
SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"`
@ -81,14 +81,14 @@ type Options struct {
CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"`
AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"`
CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path" tags:"plus,pro"`
CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path" tags:"plus,portal,pro"`
CustomThemePath string `yaml:"-" json:"-" flag:"theme-path"`
ModelsPath string `yaml:"ModelsPath" json:"-" flag:"models-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"`
UsageInfo bool `yaml:"UsageInfo" json:"UsageInfo" flag:"usage-info"`
FilesQuota uint64 `yaml:"FilesQuota" json:"-" flag:"files-quota"`
UsersQuota int `yaml:"UsersQuota" json:"-" flag:"users-quota" tags:"pro"`
UsersQuota int `yaml:"UsersQuota" json:"-" flag:"users-quota" tags:"portal,pro"`
BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
BackupSchedule string `yaml:"BackupSchedule" json:"BackupSchedule" flag:"backup-schedule"`
BackupRetain int `yaml:"BackupRetain" json:"BackupRetain" flag:"backup-retain"`
@ -126,7 +126,7 @@ type Options struct {
DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"`
DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"`
DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"`
ThemeUrl string `yaml:"-" json:"-" flag:"theme-url" tags:"pro"`
ThemeUrl string `yaml:"-" json:"-" flag:"theme-url" tags:"portal,pro"`
PlacesLocale string `yaml:"PlacesLocale" json:"PlacesLocale" flag:"places-locale"`
AppName string `yaml:"AppName" json:"AppName" flag:"app-name"`
AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"`
@ -136,6 +136,7 @@ type Options struct {
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
FrontendUri string `yaml:"FrontendUri" json:"-" flag:"frontend-uri" tags:"portal,pro"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`

View file

@ -3,13 +3,20 @@ package config
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/list"
)
// Report returns global config values as a table for reporting.
func (o Options) Report() (rows [][]string, cols []string) {
v := reflect.ValueOf(o)
activeTags := []string{Features}
if o.NodeRole == cluster.RolePortal {
activeTags = append(activeTags, Portal)
}
cols = []string{"Name", "Type", "CLI Flag"}
rows = make([][]string, 0, v.NumField())
@ -28,7 +35,7 @@ func (o Options) Report() (rows [][]string, cols []string) {
// Skip options by feature set if tags are set.
if tags := v.Type().Field(i).Tag.Get("tags"); tags == "" {
// Report.
} else if !slices.Contains(strings.Split(tags, ","), Features) {
} else if !list.ContainsAny(strings.Split(tags, ","), activeTags) {
// Skip.
continue
}

View file

@ -4,6 +4,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/service/cluster"
)
func TestOptions_Report(t *testing.T) {
@ -11,3 +13,43 @@ func TestOptions_Report(t *testing.T) {
r, _ := m.Report()
assert.GreaterOrEqual(t, len(r), 1)
}
func TestOptions_ReportFrontendUriVisibility(t *testing.T) {
hasFrontendUri := func(rows [][]string) bool {
for _, row := range rows {
if len(row) > 0 && row[0] == "FrontendUri" {
return true
}
}
return false
}
originalFeatures := Features
t.Cleanup(func() { Features = originalFeatures })
t.Run("CommunityInstance", func(t *testing.T) {
Features = Community
m := Options{NodeRole: cluster.RoleInstance}
rows, _ := m.Report()
assert.False(t, hasFrontendUri(rows))
})
t.Run("ProInstance", func(t *testing.T) {
Features = Pro
m := Options{NodeRole: cluster.RoleInstance}
rows, _ := m.Report()
assert.True(t, hasFrontendUri(rows))
})
t.Run("ProPortalNode", func(t *testing.T) {
Features = Pro
m := Options{NodeRole: cluster.RolePortal}
rows, _ := m.Report()
assert.True(t, hasFrontendUri(rows))
})
t.Run("CommunityPortalNode", func(t *testing.T) {
Features = Community
m := Options{NodeRole: cluster.RolePortal}
rows, _ := m.Report()
assert.True(t, hasFrontendUri(rows))
})
}

View file

@ -9,6 +9,7 @@ type Config struct {
Color string `json:"color"`
Mode string `json:"mode"`
BaseUri string `json:"baseUri"`
FrontendUri string `json:"frontendUri"`
StaticUri string `json:"staticUri"`
SiteUrl string `json:"siteUrl"`
CdnUrl string `json:"cdnUrl"`

View file

@ -49,8 +49,8 @@ func NewManifest(c Config) (m *Manifest) {
ThemeColor: clean.Color(c.Color),
BackgroundColor: clean.Color(c.Color),
Scope: c.BaseUri,
StartUrl: c.BaseUri + "library/",
Shortcuts: Shortcuts(c.BaseUri),
StartUrl: c.FrontendUri,
Shortcuts: Shortcuts(c.FrontendUri),
Serviceworker: Serviceworker{
Src: fs.SwJsFile,
Scope: c.BaseUri,

View file

@ -14,6 +14,7 @@ func TestNewManifest(t *testing.T) {
Description: "App's Description",
Mode: "fullscreen",
BaseUri: "/",
FrontendUri: "/library",
StaticUri: "/static",
}
@ -24,7 +25,8 @@ func TestNewManifest(t *testing.T) {
assert.Equal(t, c.Name, result.ShortName)
assert.Equal(t, c.Description, result.Description)
assert.Equal(t, c.BaseUri, result.Scope)
assert.Equal(t, c.BaseUri+"library/", result.StartUrl)
assert.Equal(t, c.FrontendUri, result.StartUrl)
assert.Equal(t, "/library/browse", result.Shortcuts[0].Url)
assert.Len(t, result.Icons, len(IconSizes))
assert.Len(t, result.Categories, len(Categories))
assert.Len(t, result.Permissions, len(Permissions))

View file

@ -1,5 +1,7 @@
package pwa
import "strings"
// Url represents a URL with a name.
type Url struct {
Name string `json:"name"`
@ -9,25 +11,30 @@ type Url struct {
// Urls represents a set of URLs.
type Urls []Url
// shortcutUrl joins a route with the frontend base URI used by the SPA.
func shortcutUrl(frontendUri string, route string) string {
return strings.TrimRight(frontendUri, "/") + "/" + strings.TrimLeft(route, "/")
}
// Shortcuts specifies links to key tasks or pages within the web application,
// see https://developer.mozilla.org/en-US/docs/Web/Manifest/Reference/shortcuts.
func Shortcuts(baseUri string) Urls {
func Shortcuts(frontendUri string) Urls {
return Urls{
{
Name: "Search",
Url: baseUri + "library/browse",
Url: shortcutUrl(frontendUri, "browse"),
},
{
Name: "Albums",
Url: baseUri + "library/albums",
Url: shortcutUrl(frontendUri, "albums"),
},
{
Name: "Places",
Url: baseUri + "library/places",
Url: shortcutUrl(frontendUri, "places"),
},
{
Name: "Settings",
Url: baseUri + "library/settings",
Url: shortcutUrl(frontendUri, "settings"),
},
}
}

View file

@ -0,0 +1,23 @@
package pwa
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestShortcutUrl(t *testing.T) {
assert.Equal(t, "/library/browse", shortcutUrl("/library/", "browse"))
assert.Equal(t, "/portal/admin/settings", shortcutUrl("/portal/admin", "/settings"))
assert.Equal(t, "/browse", shortcutUrl("", "browse"))
}
func TestShortcuts(t *testing.T) {
result := Shortcuts("/portal/admin/")
assert.Equal(t, 4, len(result))
assert.Equal(t, "/portal/admin/browse", result[0].Url)
assert.Equal(t, "/portal/admin/albums", result[1].Url)
assert.Equal(t, "/portal/admin/places", result[2].Url)
assert.Equal(t, "/portal/admin/settings", result[3].Url)
}

View file

@ -180,6 +180,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// URIs.
{"base-uri", c.BaseUri("/")},
{"frontend-uri", c.FrontendUri("")},
{"api-uri", c.ApiUri()},
{"static-uri", c.StaticUri()},
{"content-uri", c.ContentUri()},

View file

@ -13,6 +13,18 @@ func TestConfig_Report(t *testing.T) {
m := NewConfig(CliTestContext())
r, _ := m.Report()
assert.GreaterOrEqual(t, len(r), 1)
values := make(map[string]string, len(r))
for _, row := range r {
if len(row) < 2 {
continue
}
values[row[0]] = row[1]
}
assert.Equal(t, m.FrontendUri(""), values["frontend-uri"])
}
func TestConfig_ReportDatabaseSection(t *testing.T) {

View file

@ -35,7 +35,7 @@ func TestStaticRoutes(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 307, w.Code)
assert.Equal(t, "<a href=\"/library/\">Temporary Redirect</a>.\n\n", w.Body.String())
assert.Equal(t, "<a href=\""+conf.FrontendUri("/")+"\">Temporary Redirect</a>.\n\n", w.Body.String())
})
t.Run("HeadRoot", func(t *testing.T) {
w := httptest.NewRecorder()
@ -137,28 +137,28 @@ func TestWebAppRoutes(t *testing.T) {
// Bootstrapping.
t.Run("GetLibrary", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", conf.LibraryUri("/"), nil)
req, _ := http.NewRequest("GET", conf.FrontendUri("/"), nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body)
})
t.Run("HeadLibrary", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("HEAD", conf.LibraryUri("/"), nil)
req, _ := http.NewRequest("HEAD", conf.FrontendUri("/"), nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body)
})
t.Run("GetLibraryBrowse", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", conf.LibraryUri("/browse"), nil)
req, _ := http.NewRequest("GET", conf.FrontendUri("/browse"), nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body)
})
t.Run("HeadLibraryBrowse", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("HEAD", conf.LibraryUri("/browse"), nil)
req, _ := http.NewRequest("HEAD", conf.FrontendUri("/browse"), nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
})
@ -171,7 +171,7 @@ func TestWebAppRoutes(t *testing.T) {
manifest := w.Body.String()
t.Logf("PWA Manifest: %s", manifest)
assert.True(t, strings.Contains(manifest, `"scope": "/",`))
assert.True(t, strings.Contains(manifest, `"start_url": "/library/",`))
assert.True(t, strings.Contains(manifest, `"start_url": "`+conf.FrontendUri(``)+`",`))
assert.True(t, strings.Contains(manifest, "/static/icons/logo/128.png"))
})
t.Run("GetServiceWorker", func(t *testing.T) {

View file

@ -23,7 +23,7 @@ func registerWebAppRoutes(router *gin.Engine, conf *config.Config) {
return
}
// Serve user interface bootstrap template on all routes starting with "/library".
// Serve user interface bootstrap template on all routes under the configured frontend base path.
ui := func(c *gin.Context) {
// Prevent CDNs from caching this endpoint.
if header.IsCdn(c.Request) {
@ -45,8 +45,8 @@ func registerWebAppRoutes(router *gin.Engine, conf *config.Config) {
c.HTML(http.StatusOK, conf.TemplateName(), values)
}
// HTML bootstrap for the SPA (served from /library/**).
router.Any(conf.LibraryUri("/*path"), ui)
// HTML bootstrap for the SPA (served from FrontendUri/**).
router.Any(conf.FrontendUri("/*path"), ui)
// Serve the user interface manifest file.
manifest := func(c *gin.Context) {

View file

@ -22,8 +22,8 @@ func TestSecurityMiddlewareSkipsPortalProxy(t *testing.T) {
r := gin.New()
r.Use(Security(conf))
proxyPath := conf.BaseUri(proxy.PathPrefix + "test/library/login")
regularPath := conf.BaseUri("/library/login")
proxyPath := conf.BaseUri(proxy.PathPrefix + "test" + conf.FrontendUri("/login"))
regularPath := conf.FrontendUri("/login")
r.GET(proxyPath, func(c *gin.Context) {
c.String(http.StatusOK, "proxy")