diff --git a/.dockerignore b/.dockerignore index 2513d55ea..9f0fa18e8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -94,3 +94,5 @@ Thumbs.db /AGENTS_* /REPOS.md /CLAUDE.md +join_token +client_secret diff --git a/.gitignore b/.gitignore index 18303d40e..6e46dcafc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f289e46cd..2ba4fabc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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("", 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 diff --git a/CODEMAP.md b/CODEMAP.md index 7b2b2046c..f1fbe8140 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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. diff --git a/frontend/CODEMAP.md b/frontend/CODEMAP.md index 4c4de8679..8ad3f7479 100644 --- a/frontend/CODEMAP.md +++ b/frontend/CODEMAP.md @@ -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` diff --git a/frontend/src/app.js b/frontend/src/app.js index 7d4f6b346..39afea576 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -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) { diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js index 0e6e75111..67f465402 100644 --- a/frontend/src/common/config.js +++ b/frontend/src/common/config.js @@ -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; diff --git a/frontend/tests/vitest/common/config.test.js b/frontend/tests/vitest/common/config.test.js index 8c31704ee..247e29bde 100644 --- a/frontend/tests/vitest/common/config.test.js +++ b/frontend/tests/vitest/common/config.test.js @@ -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" }; diff --git a/frontend/tests/vitest/config.js b/frontend/tests/vitest/config.js index cb21a2568..f72512eac 100644 --- a/frontend/tests/vitest/config.js +++ b/frontend/tests/vitest/config.js @@ -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", diff --git a/internal/api/share.go b/internal/api/share.go index 12c394582..700578df2 100644 --- a/internal/api/share.go +++ b/internal/api/share.go @@ -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}) }) diff --git a/internal/config/cli_flag.go b/internal/config/cli_flag.go index 14b2d8950..49b6f77e5 100644 --- a/internal/config/cli_flag.go +++ b/internal/config/cli_flag.go @@ -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): diff --git a/internal/config/client_config.go b/internal/config/client_config.go index d1ce2b17c..84958d4d7 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -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, diff --git a/internal/config/client_config_test.go b/internal/config/client_config_test.go index b7000e03e..edd5c736e 100644 --- a/internal/config/client_config_test.go +++ b/internal/config/client_config_test.go @@ -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) diff --git a/internal/config/config_app.go b/internal/config/config_app.go index 5d56d55b5..009859c5e 100644 --- a/internal/config/config_app.go +++ b/internal/config/config_app.go @@ -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("/"), diff --git a/internal/config/config_app_test.go b/internal/config/config_app_test.go index fde2e8834..1e577c903 100644 --- a/internal/config/config_app_test.go +++ b/internal/config/config_app_test.go @@ -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)) diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go index 6f5242ff2..d65b2c1de 100644 --- a/internal/config/config_auth.go +++ b/internal/config/config_auth.go @@ -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 diff --git a/internal/config/config_auth_test.go b/internal/config/config_auth_test.go index 66ad2bcb3..3198fea03 100644 --- a/internal/config/config_auth_test.go +++ b/internal/config/config_auth_test.go @@ -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) { diff --git a/internal/config/config_const.go b/internal/config/config_const.go index 415f0e0da..9f7f27204 100644 --- a/internal/config/config_const.go +++ b/internal/config/config_const.go @@ -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. diff --git a/internal/config/config_site.go b/internal/config/config_site.go index f6a94a7af..bbbc12e06 100644 --- a/internal/config/config_site.go +++ b/internal/config/config_site.go @@ -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 diff --git a/internal/config/config_site_test.go b/internal/config/config_site_test.go index 7769f7d7f..c308c0ee3 100644 --- a/internal/config/config_site_test.go +++ b/internal/config/config_site_test.go @@ -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) { diff --git a/internal/config/flags.go b/internal/config/flags.go index 98fc4558d..e04ef842c 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -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", diff --git a/internal/config/options.go b/internal/config/options.go index 768548f4f..1661fb605 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -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"` diff --git a/internal/config/options_report.go b/internal/config/options_report.go index b82049182..a65fcc215 100644 --- a/internal/config/options_report.go +++ b/internal/config/options_report.go @@ -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 } diff --git a/internal/config/options_report_test.go b/internal/config/options_report_test.go index c8d3cf318..3186a947a 100644 --- a/internal/config/options_report_test.go +++ b/internal/config/options_report_test.go @@ -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)) + }) +} diff --git a/internal/config/pwa/config.go b/internal/config/pwa/config.go index b84acda4d..a46f2893f 100644 --- a/internal/config/pwa/config.go +++ b/internal/config/pwa/config.go @@ -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"` diff --git a/internal/config/pwa/manifest.go b/internal/config/pwa/manifest.go index 3a1d8b573..642322bf6 100644 --- a/internal/config/pwa/manifest.go +++ b/internal/config/pwa/manifest.go @@ -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, diff --git a/internal/config/pwa/manifest_test.go b/internal/config/pwa/manifest_test.go index 76b178c09..6d479fc0e 100644 --- a/internal/config/pwa/manifest_test.go +++ b/internal/config/pwa/manifest_test.go @@ -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)) diff --git a/internal/config/pwa/url.go b/internal/config/pwa/url.go index 1dd92b632..385a73328 100644 --- a/internal/config/pwa/url.go +++ b/internal/config/pwa/url.go @@ -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"), }, } } diff --git a/internal/config/pwa/url_test.go b/internal/config/pwa/url_test.go new file mode 100644 index 000000000..3d30c377c --- /dev/null +++ b/internal/config/pwa/url_test.go @@ -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) +} diff --git a/internal/config/report.go b/internal/config/report.go index 9c8d2014c..8a621cf4c 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -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()}, diff --git a/internal/config/report_test.go b/internal/config/report_test.go index a65aabea7..02213621d 100644 --- a/internal/config/report_test.go +++ b/internal/config/report_test.go @@ -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) { diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 9002449d4..e194eba9c 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -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, "Temporary Redirect.\n\n", w.Body.String()) + assert.Equal(t, "Temporary Redirect.\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) { diff --git a/internal/server/routes_webapp.go b/internal/server/routes_webapp.go index 217b0700b..7323a17bf 100644 --- a/internal/server/routes_webapp.go +++ b/internal/server/routes_webapp.go @@ -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) { diff --git a/internal/server/security_test.go b/internal/server/security_test.go index eaa8ef4ae..9a97e48c9 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -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")