mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Config: Refactor frontend URI handling for configurable base paths
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
bcb6d39332
commit
18061fb536
34 changed files with 272 additions and 68 deletions
|
|
@ -94,3 +94,5 @@ Thumbs.db
|
|||
/AGENTS_*
|
||||
/REPOS.md
|
||||
/CLAUDE.md
|
||||
join_token
|
||||
client_secret
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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("/"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
internal/config/pwa/url_test.go
Normal file
23
internal/config/pwa/url_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue