This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 3e62b8c064 feat(config-service): move site_settings API from
texera-web to config-service (#6116)
3e62b8c064 is described below
commit 3e62b8c06430bdee00250ee0c149c6c0b6d99e92
Author: Yicong Huang <[email protected]>
AuthorDate: Sat Jul 18 09:31:24 2026 -0700
feat(config-service): move site_settings API from texera-web to
config-service (#6116)
### What changes were proposed in this PR?
Moves the `site_settings` API from texera-web's `AdminSettingsResource`
into config-service's `ConfigResource` — config-service already seeds
the table and has the JWT stack registered. New surface:
- `GET /config/settings/public` — anonymous, all user-visible keys
(branding, sidebar tabs, upload limits) in one payload, replacing the
request-per-key pattern. Anonymous by design: these values render on the
logged-out shell, and the SPA login never recreates the dashboard shell,
so gating them would blank public pages and leave freshly logged-in
users unbranded until a refresh.
- `GET /config/settings` — `ADMIN`, all rows in one payload; the admin
settings page now loads with one request instead of 20.
- `GET/PUT /config/settings/{key}`, `POST /config/settings/reset/{key}`
— `ADMIN`. `PUT` returns 400 for a null/empty body or a null value, and
for a key with no `default.conf` entry, so writes stay within the
known-default namespace (mirroring `reset`).
The public whitelist is derived from the `gui`/`dataset` sections of
`default.conf` (new `DefaultsConfig.keysUnderSections`, pinned by a
spec) instead of a hand-maintained list; keys outside those sections
stay management-only. `DefaultsConfig` also fails fast when two settings
collapse to the same `site_settings` row key, and operator-tunable keys
move to a new `operator` section (e.g. `csv_parser_max_columns`) that is
deliberately excluded from the anonymous whitelist. These endpoints
complement the HOCON-backed `/config/*` reads (deploy-time, frozen per
JVM) with no key overlap.
All `site_settings` access — resource queries, startup seeder,
`dao.SiteSettings` — now uses the generated jOOQ `SITE_SETTINGS` table
instead of string-based lookups. Writes go through new
`dao.SiteSettings.upsert`/`insertIfAbsent` helpers so the resource and
the seeder share one column/audit-stamp shape, and `ConfigService`
declares its direct `DAO` dependency.
Frontend: `AdminSettingsService` moves to `/api/config/settings`
(existing routing covers it; `/settings/public` is excluded from JWT
injection). `getPublicSetting` reads from one shared cached request
typed `string | null` and invalidates the cache on save/reset; the
per-key `getSetting` is gone. Numeric settings parse through
`parseIntOrDefault` (a stored `0` is preserved), the admin page blocks a
save until a load has succeeded, and the dashboard keeps no copy of the
tab defaults — every tab stays hidden until `/config/settings/public`
loads, so the backend is the single source of the tab defaults.
### Any related issues, documentation, discussions?
Closes #6115
### How was this PR tested?
- `sbt ConfigService/test` — `ConfigResourceSpec` covers the HTTP auth
gates and the endpoint bodies against embedded Postgres, including the
whitelist-derivation pin, the null-value / null-body / unknown-key 400s,
upsert-on-conflict, and reset; `DefaultsConfigSpec` covers
`keysUnderSections`.
- Frontend: `admin-settings.service.spec.ts` (shared cache, error retry,
invalidation on save/reset); component specs cover the admin bulk load
(defaults on missing/unparsable values, a stored `0`, error toast,
save-blocked-until-loaded) and the dashboard branding/tab fallbacks; `ng
test`, `tsc --noEmit`, prettier, and eslint clean.
- Manual end-to-end on the local dev stack: anonymous `GET
/settings/public` serves the whitelisted map, `GET /settings` and `GET
/settings/{key}` reject anonymous callers with 401, old
`/api/admin/settings/{key}` 404s.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (claude-fable-5, claude-opus-4-8)
---------
Co-authored-by: Claude Fable 5 <[email protected]>
---
.../apache/texera/web/TexeraWebApplication.scala | 2 -
.../admin/settings/AdminSettingsResource.scala | 108 -----
build.sbt | 3 +-
common/config/src/main/resources/default.conf | 9 +
.../texera/common/config/DefaultsConfig.scala | 41 +-
.../texera/common/config/DefaultsConfigSpec.scala | 36 ++
.../scala/org/apache/texera/dao/SiteSettings.scala | 53 ++-
.../org/apache/texera/service/ConfigService.scala | 18 +-
.../texera/service/resource/ConfigResource.scala | 132 +++++-
.../service/resource/ConfigResourceAuthSpec.scala | 230 ----------
.../service/resource/ConfigResourceSpec.scala | 481 +++++++++++++++++++++
frontend/src/app/app.module.ts | 2 +-
frontend/src/app/common/util/format.util.ts | 10 +
.../settings/admin-settings.component.spec.ts | 76 +++-
.../admin/settings/admin-settings.component.ts | 105 +++--
.../component/dashboard.component.spec.ts | 64 ++-
.../app/dashboard/component/dashboard.component.ts | 49 ++-
.../files-uploader.component.spec.ts | 18 +-
.../files-uploader/files-uploader.component.ts | 9 +-
.../dataset-detail.component.spec.ts | 20 +-
.../dataset-detail.component.ts | 25 +-
.../admin/settings/admin-settings.service.spec.ts | 104 +++--
.../admin/settings/admin-settings.service.ts | 51 ++-
23 files changed, 1145 insertions(+), 501 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index 20ce01008b..73e473ba7a 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -36,7 +36,6 @@ import org.apache.texera.web.resource._
import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource}
import org.apache.texera.web.resource.dashboard.DashboardResource
import
org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource
-import
org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource
import org.apache.texera.web.resource.dashboard.admin.user.AdminUserResource
import org.apache.texera.web.resource.dashboard.hub.HubResource
import org.apache.texera.web.resource.dashboard.user.UserResource
@@ -159,7 +158,6 @@ class TexeraWebApplication
environment.jersey.register(classOf[GmailResource])
environment.jersey.register(classOf[AdminExecutionResource])
environment.jersey.register(classOf[UserQuotaResource])
- environment.jersey.register(classOf[AdminSettingsResource])
environment.jersey.register(classOf[AIAssistantResource])
environment.jersey.register(classOf[HuggingFaceModelResource])
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/settings/AdminSettingsResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/settings/AdminSettingsResource.scala
deleted file mode 100644
index a1880f3c3c..0000000000
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/settings/AdminSettingsResource.scala
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.texera.web.resource.dashboard.admin.settings
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import io.dropwizard.auth.Auth
-import org.apache.texera.auth.SessionUser
-import org.apache.texera.common.config.DefaultsConfig
-import org.apache.texera.dao.SqlServer
-import org.jooq.impl.DSL
-
-import javax.annotation.security.RolesAllowed
-import javax.ws.rs._
-import javax.ws.rs.core.{MediaType, Response}
-
-case class AdminSettingsPojo(
- @JsonProperty("key") settingKey: String,
- @JsonProperty("value") settingValue: String
-)
-
-@Path("/admin/settings")
-@Produces(Array(MediaType.APPLICATION_JSON))
-class AdminSettingsResource {
-
- private def ctx = SqlServer.getInstance().createDSLContext()
- private val siteSettings = DSL.table("site_settings")
- private val key = DSL.field("key", classOf[String])
- private val value = DSL.field("value", classOf[String])
- private val updatedBy = DSL.field("updated_by", classOf[String])
-
- @GET
- @Path("{key}")
- def getSetting(@PathParam("key") keyParam: String): AdminSettingsPojo = {
- ctx
- .select(key, value)
- .from(siteSettings)
- .where(key.eq(keyParam))
- .fetchOneInto(classOf[AdminSettingsPojo])
- }
-
- @PUT
- @Path("{key}")
- @RolesAllowed(Array("ADMIN"))
- @Consumes(Array(MediaType.APPLICATION_JSON))
- def updateSetting(
- @Auth currentUser: SessionUser,
- @PathParam("key") keyParam: String,
- setting: AdminSettingsPojo
- ): Response = {
- if (setting.settingValue != null && keyParam.nonEmpty) {
- upsertSetting(keyParam, setting.settingValue, currentUser.getName)
- }
- Response.ok().build()
- }
-
- /**
- * Resets the specified configuration key to its default value defined in
default.conf.
- */
- @POST
- @Path("/reset/{key}")
- @RolesAllowed(Array("ADMIN"))
- def resetSetting(
- @Auth currentUser: SessionUser,
- @PathParam("key") keyParam: String
- ): Response = {
- DefaultsConfig.allDefaults.get(keyParam) match {
- case Some(defaultValue) =>
- upsertSetting(keyParam, defaultValue, currentUser.getName)
- Response.ok().build()
- case None =>
- Response
- .status(Response.Status.NOT_FOUND)
- .entity(s"No default for key '$keyParam'")
- .build()
- }
- }
-
- private def upsertSetting(keyParam: String, valueParam: String, userName:
String): Unit = {
- ctx
- .insertInto(siteSettings)
- .set(key, keyParam)
- .set(value, valueParam)
- .set(updatedBy, userName)
- .onConflict(key)
- .doUpdate()
- .set(value, valueParam)
- .set(DSL.field("updated_by", classOf[String]), userName)
- .set(DSL.field("updated_at", classOf[java.sql.Timestamp]),
DSL.currentTimestamp())
- .execute()
- }
-}
diff --git a/build.sbt b/build.sbt
index 47aea23e8b..479bfe4631 100644
--- a/build.sbt
+++ b/build.sbt
@@ -126,7 +126,8 @@ lazy val Auth = (project in file("common/auth"))
.dependsOn(DAO, Config)
.dependsOn(DAO % "test->test") // reuse MockTexeraDB embedded Postgres in
tests
lazy val ConfigService = (project in file("config-service"))
- .dependsOn(Auth, Config, Resource)
+ .dependsOn(Auth, Config, DAO, Resource)
+ .dependsOn(DAO % "test->test") // reuse MockTexeraDB embedded Postgres in
tests
.settings(commonModuleSettings)
.settings(
dependencyOverrides ++= Seq(
diff --git a/common/config/src/main/resources/default.conf
b/common/config/src/main/resources/default.conf
index 62e533e694..6b83f50f6b 100644
--- a/common/config/src/main/resources/default.conf
+++ b/common/config/src/main/resources/default.conf
@@ -94,3 +94,12 @@ dataset {
multipart_upload_chunk_size_mib = 50
multipart_upload_chunk_size_mib = ${?DATASET_MULTIPART_UPLOAD_CHUNK_SIZE_MIB}
}
+
+# Operator-level defaults. These are management-only site settings (edited from
+# the admin page, read by the engine) and are deliberately NOT under
gui/dataset,
+# so they stay out of the anonymous /config/settings/public whitelist.
+operator {
+ # Upper bound on the number of columns the CSV scan source will parse.
+ csv_parser_max_columns = 512
+ csv_parser_max_columns = ${?OPERATOR_CSV_PARSER_MAX_COLUMNS}
+}
diff --git
a/common/config/src/main/scala/org/apache/texera/common/config/DefaultsConfig.scala
b/common/config/src/main/scala/org/apache/texera/common/config/DefaultsConfig.scala
index 965dcb8c0c..3a00e8da5d 100644
---
a/common/config/src/main/scala/org/apache/texera/common/config/DefaultsConfig.scala
+++
b/common/config/src/main/scala/org/apache/texera/common/config/DefaultsConfig.scala
@@ -27,20 +27,53 @@ object DefaultsConfig {
val reinit: Boolean =
conf.getBoolean("config-service.always-reset-configurations-to-default-values")
+ // site_settings rows are keyed by the last path segment of a HOCON key, so
+ // this is the single place that turns a config path into a row key.
+ private def shortKey(fullKey: String): String = fullKey.split("\\.").last
+
val allDefaults: Map[String, String] = {
- conf
+ val entries = conf
.entrySet()
.asScala
+ .toSeq
.map { entry =>
- val shortKey = entry.getKey.split("\\.").last
val value = entry.getValue.valueType() match {
case ConfigValueType.STRING | ConfigValueType.NUMBER |
ConfigValueType.BOOLEAN =>
entry.getValue.unwrapped().toString
case _ =>
entry.getValue.render(ConfigRenderOptions.concise())
}
- shortKey -> value
+ shortKey(entry.getKey) -> value
}
- .toMap
+
+ // Since the row key is only the last path segment, two entries under
+ // different sections that share a last segment would silently collapse
into
+ // one site_settings row (and one whitelist entry). Fail fast at load time
+ // rather than serving a wrong default or an unaddressable key.
+ val collisions = entries.groupBy(_._1).collect { case (k, vs) if vs.size >
1 => k }
+ require(
+ collisions.isEmpty,
+ s"default.conf declares settings whose last path segment collides: " +
+ s"${collisions.toSeq.sorted.mkString(", ")}. site_settings keys are
the last path " +
+ "segment, so give these a unique leaf name."
+ )
+
+ entries.toMap
}
+
+ /**
+ * Short keys (last path segment, matching the site_settings row keys) of
every
+ * default declared under the given top-level sections of default.conf. Lets
+ * callers derive key groups (e.g. the user-visible gui/dataset settings)
from
+ * the file that already defines them, instead of maintaining a parallel
list.
+ */
+ def keysUnderSections(sections: Set[String]): Set[String] =
+ conf
+ .entrySet()
+ .asScala
+ .collect {
+ case entry if sections.contains(entry.getKey.takeWhile(_ != '.')) =>
+ shortKey(entry.getKey)
+ }
+ .toSet
}
diff --git
a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
index 3da26e1ff8..e7bf3fb6ab 100644
---
a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
+++
b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
@@ -46,7 +46,43 @@ class DefaultsConfigSpec extends AnyFlatSpec with Matchers {
defaults.get("single_file_upload_max_size_mib") shouldBe Some("20")
)
ifUnset("GUI_TABS_HUB_ENABLED")(defaults.get("hub_enabled") shouldBe
Some("true"))
+ // management-only keys are flattened too (used by reset + the startup
seeder)
+ ifUnset("OPERATOR_CSV_PARSER_MAX_COLUMNS")(
+ defaults.get("csv_parser_max_columns") shouldBe Some("512")
+ )
// every value is rendered as a String
defaults.values.foreach(_ shouldBe a[String])
}
+
+ it should "keep management-only keys out of the public gui/dataset
whitelist" in {
+ // csv_parser_max_columns is seeded and resettable (present in allDefaults)
+ // but lives under `operator`, so it must never reach the anonymous
+ // /config/settings/public payload.
+ DefaultsConfig.allDefaults.keySet should contain("csv_parser_max_columns")
+ DefaultsConfig.keysUnderSections(
+ Set("gui", "dataset")
+ ) should not contain "csv_parser_max_columns"
+ }
+
+ "DefaultsConfig.keysUnderSections" should "collect the short keys of the
requested sections only" in {
+ val guiKeys = DefaultsConfig.keysUnderSections(Set("gui"))
+ guiKeys should contain allOf ("logo", "mini_logo", "favicon",
"hub_enabled")
+ // keys from other sections are excluded
+ guiKeys should not contain "single_file_upload_max_size_mib"
+ guiKeys should not contain "always-reset-configurations-to-default-values"
+
+ val datasetKeys = DefaultsConfig.keysUnderSections(Set("dataset"))
+ datasetKeys should contain("single_file_upload_max_size_mib")
+ datasetKeys should not contain "logo"
+ }
+
+ it should "union multiple sections and be empty for an unknown section" in {
+ val union = DefaultsConfig.keysUnderSections(Set("gui", "dataset"))
+ union should contain allOf ("logo", "single_file_upload_max_size_mib")
+ // every returned key exists in allDefaults under the same short name
+ union.subsetOf(DefaultsConfig.allDefaults.keySet) shouldBe true
+
+ DefaultsConfig.keysUnderSections(Set("no-such-section")) shouldBe empty
+ DefaultsConfig.keysUnderSections(Set.empty) shouldBe empty
+ }
}
diff --git a/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala
b/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala
index a1d70ea841..d818f0f056 100644
--- a/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala
+++ b/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala
@@ -18,19 +18,25 @@
package org.apache.texera.dao
+import org.apache.texera.dao.jooq.generated.Tables.SITE_SETTINGS
+import org.jooq.DSLContext
import org.jooq.impl.DSL
import scala.util.Try
/**
- * Read-side accessor for the `site_settings` key/value table that admin pages
- * write through. Centralises the "look up by key, parse, fall back on any
- * failure" pattern that previously lived inline in ConfigResource,
- * CSVScanSourceOpExec, and DatasetResource.
+ * Accessor for the `site_settings` key/value table that admin pages write
+ * through. Centralises the "look up by key, parse, fall back on any failure"
+ * read pattern (previously inline in ConfigResource, CSVScanSourceOpExec, and
+ * DatasetResource) and the write shape used by the admin API and the
+ * config-service startup seeder, so the column set / audit stamping live in
+ * one place.
*
- * Failures swallowed by the outer Try include: SqlServer not initialised
- * (e.g. on workers in distributed mode), no row for the key, and value that
- * can't be parsed. In all of these cases the caller's default takes over.
+ * Failures swallowed by the outer Try on the read path include: SqlServer not
+ * initialised (e.g. on workers in distributed mode), no row for the key, and
+ * value that can't be parsed. In all of these cases the caller's default
takes
+ * over. The write helpers take an explicit [[DSLContext]] so callers can run
+ * them inside their own transaction and surface failures.
*/
object SiteSettings {
@@ -40,6 +46,33 @@ object SiteSettings {
def getLong(key: String, default: => Long): Long =
readAndParse(key, default)(_.toLong)
+ /** Insert or overwrite the row for `key`, stamping who/when. */
+ def upsert(ctx: DSLContext, key: String, value: String, updatedBy: String):
Unit =
+ ctx
+ .insertInto(SITE_SETTINGS)
+ .set(SITE_SETTINGS.KEY, key)
+ .set(SITE_SETTINGS.VALUE, value)
+ .set(SITE_SETTINGS.UPDATED_BY, updatedBy)
+ .onConflict(SITE_SETTINGS.KEY)
+ .doUpdate()
+ .set(SITE_SETTINGS.VALUE, value)
+ .set(SITE_SETTINGS.UPDATED_BY, updatedBy)
+ .set(SITE_SETTINGS.UPDATED_AT, DSL.currentTimestamp())
+ .execute()
+
+ /** Seed the row for `key` only if it does not already exist (never
overwrites
+ * an admin-edited value). Used by the startup default seeder.
+ */
+ def insertIfAbsent(ctx: DSLContext, key: String, value: String, updatedBy:
String): Unit =
+ ctx
+ .insertInto(SITE_SETTINGS)
+ .set(SITE_SETTINGS.KEY, key)
+ .set(SITE_SETTINGS.VALUE, value)
+ .set(SITE_SETTINGS.UPDATED_BY, updatedBy)
+ .set(SITE_SETTINGS.UPDATED_AT, DSL.currentTimestamp())
+ .onDuplicateKeyIgnore()
+ .execute()
+
private[dao] def parseOrDefault[T](raw: Option[String], default: T)(parse:
String => T): T =
raw.flatMap(s => Try(parse(s.trim)).toOption).getOrElse(default)
@@ -48,9 +81,9 @@ object SiteSettings {
val raw = SqlServer
.getInstance()
.createDSLContext()
- .select(DSL.field("value", classOf[String]))
- .from(DSL.table(DSL.name("texera_db", "site_settings")))
- .where(DSL.field("key", classOf[String]).eq(key))
+ .select(SITE_SETTINGS.VALUE)
+ .from(SITE_SETTINGS)
+ .where(SITE_SETTINGS.KEY.eq(key))
.fetchOneInto(classOf[String])
parseOrDefault(Option(raw), default)(parse)
}.getOrElse(default)
diff --git
a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
index b45c6ce62b..a7e9b61d99 100644
---
a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
+++
b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
@@ -26,10 +26,10 @@ import io.dropwizard.core.Application
import io.dropwizard.core.setup.{Bootstrap, Environment}
import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter,
RoleAnnotationEnforcer}
import org.apache.texera.common.config.{DefaultsConfig, StorageConfig}
-import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.{SiteSettings, SqlServer}
+import org.apache.texera.dao.jooq.generated.Tables.SITE_SETTINGS
import org.apache.texera.service.resource.{ConfigResource, HealthCheckResource}
import org.eclipse.jetty.server.session.SessionHandler
-import org.jooq.impl.DSL
import java.nio.file.Path
@@ -73,22 +73,12 @@ class ConfigService extends
Application[ConfigServiceConfiguration] with LazyLog
SqlServer.withTransaction(ctx) { tx =>
if (DefaultsConfig.reinit) {
- tx.deleteFrom(DSL.table("site_settings")).execute()
+ tx.deleteFrom(SITE_SETTINGS).execute()
}
DefaultsConfig.allDefaults.foreach {
case (key, value) =>
- tx
- .insertInto(DSL.table("site_settings"))
- .columns(
- DSL.field("key"),
- DSL.field("value"),
- DSL.field("updated_by"),
- DSL.field("updated_at")
- )
- .values(key, value, "texera", DSL.currentTimestamp())
- .onDuplicateKeyIgnore()
- .execute()
+ SiteSettings.insertIfAbsent(tx, key, value, "texera")
}
}
} catch {
diff --git
a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index 72f346349a..440d7ca7e0 100644
---
a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++
b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -19,21 +19,40 @@
package org.apache.texera.service.resource
+import com.fasterxml.jackson.annotation.JsonProperty
+import io.dropwizard.auth.Auth
import jakarta.annotation.security.{PermitAll, RolesAllowed}
-import jakarta.ws.rs.core.MediaType
-import jakarta.ws.rs.{GET, Path, Produces}
+import jakarta.ws.rs.core.{MediaType, Response}
+import jakarta.ws.rs.{Consumes, GET, POST, PUT, Path, PathParam, Produces}
+import org.apache.texera.auth.SessionUser
import org.apache.texera.common.config.{
ApplicationConfig,
AuthConfig,
ComputingUnitConfig,
+ DefaultsConfig,
GuiConfig,
UserSystemConfig
}
+import org.apache.texera.dao.{SiteSettings, SqlServer}
+import org.apache.texera.dao.jooq.generated.Tables.SITE_SETTINGS
+import org.jooq.Condition
+import org.jooq.impl.DSL
+
+import scala.jdk.CollectionConverters._
+
+// Wire DTO for /config/settings: the JSON contract is exactly {key, value};
+// the generated jOOQ pojo would also expose updated_by/updated_at.
+case class ConfigSettingPojo(
+ @JsonProperty("key") settingKey: String,
+ @JsonProperty("value") settingValue: String
+)
@Path("/config")
@Produces(Array(MediaType.APPLICATION_JSON))
class ConfigResource {
+ private def ctx = SqlServer.getInstance().createDSLContext()
+
// Anonymous endpoint loaded by the frontend's APP_INITIALIZER before any
user has
// logged in. Only fields that the login page (or the logged-out branches of
the
// dashboard shell) actually need belong here — anything else lives on /gui
or
@@ -99,4 +118,113 @@ class ConfigResource {
// flags from the user-system.conf
"inviteOnly" -> UserSystemConfig.inviteOnly
)
+
+ // The site_settings keys that non-admin pages consume: dashboard branding,
+ // sidebar tab toggles, and dataset upload limits — exactly the gui.* and
+ // dataset.* sections of default.conf, which is also where the seeding
+ // pipeline gets them. Keys declared outside those sections (e.g.
+ // csv_parser_max_columns) are management-only. Deriving the set from the
+ // file keeps "which section does this default live in" the single place
+ // where visibility is decided.
+ private val publicSettingKeys: Set[String] =
+ DefaultsConfig.keysUnderSections(Set("gui", "dataset"))
+
+ // SECURITY: every key returned here is served anonymously (see
+ // /settings/public below), so `publicSettingKeys` is the anonymous-exposure
+ // surface. It is derived from the gui/dataset sections of default.conf and
+ // pinned by ConfigResourceSpec/DefaultsConfigSpec — adding a key under those
+ // sections (or moving one in) changes what unauthenticated callers can read
+ // and MUST be reviewed there. Never place a secret under gui/dataset.
+
+ private def fetchSettings(condition: Condition): Map[String, String] =
+ ctx
+ .select(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
+ .from(SITE_SETTINGS)
+ .where(condition)
+ .fetchMap(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
+ .asScala
+ .toMap
+
+ // Read side for the public keys in one payload, so the dashboard doesn't
+ // fire a request per key. Anonymous by design: these values render on the
+ // logged-out shell (custom logo/favicon, Hub/About sidebar entries), so
+ // gating them behind a login would blank the public landing pages.
+ @GET
+ @PermitAll
+ @Path("/settings/public")
+ def getPublicSettings: Map[String, String] =
+ fetchSettings(SITE_SETTINGS.KEY.in(publicSettingKeys.asJava))
+
+ // Management read over the site_settings table this service seeds at
+ // startup: every row, including the ones not exposed through
+ // /settings/public, in one payload for the admin settings page.
+ @GET
+ @RolesAllowed(Array("ADMIN"))
+ @Path("/settings")
+ def getAllSettings: Map[String, String] =
+ fetchSettings(DSL.noCondition())
+
+ // Single-key management read, kept for API completeness alongside the bulk
+ // read above.
+ @GET
+ @RolesAllowed(Array("ADMIN"))
+ @Path("/settings/{key}")
+ def getSetting(@PathParam("key") keyParam: String): ConfigSettingPojo = {
+ ctx
+ .select(SITE_SETTINGS.KEY, SITE_SETTINGS.VALUE)
+ .from(SITE_SETTINGS)
+ .where(SITE_SETTINGS.KEY.eq(keyParam))
+ .fetchOneInto(classOf[ConfigSettingPojo])
+ }
+
+ @PUT
+ @RolesAllowed(Array("ADMIN"))
+ @Path("/settings/{key}")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def updateSetting(
+ @Auth currentUser: SessionUser,
+ @PathParam("key") keyParam: String,
+ setting: ConfigSettingPojo
+ ): Response = {
+ if (setting == null || setting.settingValue == null) {
+ return Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity("Setting value must not be null")
+ .build()
+ }
+ // Only keys backed by a default.conf entry are writable, mirroring
+ // resetSetting. This keeps site_settings within the known-default
+ // namespace: an arbitrary key would be un-resettable and would pollute
+ // getAllSettings forever.
+ if (!DefaultsConfig.allDefaults.contains(keyParam)) {
+ return Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(s"Unknown setting key '$keyParam'")
+ .build()
+ }
+ SiteSettings.upsert(ctx, keyParam, setting.settingValue,
currentUser.getName)
+ Response.ok().build()
+ }
+
+ /**
+ * Resets the specified configuration key to its default value defined in
default.conf.
+ */
+ @POST
+ @RolesAllowed(Array("ADMIN"))
+ @Path("/settings/reset/{key}")
+ def resetSetting(
+ @Auth currentUser: SessionUser,
+ @PathParam("key") keyParam: String
+ ): Response = {
+ DefaultsConfig.allDefaults.get(keyParam) match {
+ case Some(defaultValue) =>
+ SiteSettings.upsert(ctx, keyParam, defaultValue, currentUser.getName)
+ Response.ok().build()
+ case None =>
+ Response
+ .status(Response.Status.NOT_FOUND)
+ .entity(s"No default for key '$keyParam'")
+ .build()
+ }
+ }
}
diff --git
a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
deleted file mode 100644
index e395f5f89d..0000000000
---
a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.texera.service.resource
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.scala.DefaultScalaModule
-import io.dropwizard.jackson.Jackson
-import io.dropwizard.testing.junit5.ResourceExtension
-import jakarta.annotation.security.RolesAllowed
-import jakarta.ws.rs.core.MediaType
-import jakarta.ws.rs.{GET, Path, Produces}
-import org.apache.texera.auth.{JwtAuth, JwtAuthFilter,
UnauthorizedExceptionMapper}
-import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
-import org.apache.texera.dao.jooq.generated.tables.pojos.User
-import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
-import org.scalatest.BeforeAndAfterAll
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-
-// Wires ConfigResource through the same Jersey auth pipeline production uses
-// (JwtAuthFilter + RolesAllowedDynamicFeature) and fires HTTP requests with
and
-// without an Authorization header. /config/pre-login is the only @PermitAll
-// endpoint and must answer unauthenticated callers (bootstrap regression
guard,
-// same shape as the break that caused PR #5049 to be reverted in #5173).
-// /config/gui and /config/user-system are @RolesAllowed; they must reject
-// anonymous traffic with a 401 (now from JwtAuthFilter's eager check, not
-// from a downstream RolesAllowedRequestFilter 403) and accept callers with a
-// valid Bearer token.
-class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with
BeforeAndAfterAll {
-
- // Mirror production's mapper: ConfigService bootstraps Dropwizard's default
mapper
- // (Jackson.newObjectMapper) and registers DefaultScalaModule on top. Same
call here.
- private val testMapper: ObjectMapper =
- Jackson.newObjectMapper().registerModule(DefaultScalaModule)
-
- private val resources: ResourceExtension = ResourceExtension
- .builder()
- .setMapper(testMapper)
- .addProvider(classOf[JwtAuthFilter])
- .addProvider(classOf[UnauthorizedExceptionMapper])
- .addProvider(classOf[RolesAllowedDynamicFeature])
- .addResource(new ConfigResource)
- .addResource(new ConfigResourceAuthSpec.ProtectedProbe)
- .build()
-
- override protected def beforeAll(): Unit = resources.before()
- override protected def afterAll(): Unit = resources.after()
-
- private def regularToken(): String = {
- val u = new User()
- u.setUid(2)
- u.setName("test-regular")
- u.setEmail("[email protected]")
- u.setGoogleId(null)
- u.setRole(UserRoleEnum.REGULAR)
- JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
- }
-
- private def adminToken(): String = {
- val u = new User()
- u.setUid(1)
- u.setName("test-admin")
- u.setEmail("[email protected]")
- u.setGoogleId(null)
- u.setRole(UserRoleEnum.ADMIN)
- JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
- }
-
- "GET /config/pre-login" should "return 200 without an Authorization header"
in {
- val response =
resources.target("/config/pre-login").request(MediaType.APPLICATION_JSON).get()
- response.getStatus shouldBe 200
- }
-
- it should "expose exactly the fields the login UI needs and nothing else" in
{
- // Locking down the payload keeps anonymous callers from reading workspace
flags,
- // feature toggles, or session timers. If a new field is needed before
login, it
- // must be added here explicitly; the assertion forces that decision into
review.
- val payload = resources
- .target("/config/pre-login")
- .request(MediaType.APPLICATION_JSON)
- .get(classOf[Map[String, Any]])
- payload.keySet shouldBe Set(
- "localLogin",
- "googleLogin",
- "defaultLocalUser",
- "attributionEnabled",
- "deploymentVersionCheckEnabled",
- "inviteOnly"
- )
- }
-
- "GET /config/gui" should "return 401 with a Bearer challenge without an
Authorization header" in {
- val response =
resources.target("/config/gui").request(MediaType.APPLICATION_JSON).get()
- response.getStatus shouldBe 401
- response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
- }
-
- it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
- val response = resources
- .target("/config/gui")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get()
- response.getStatus shouldBe 200
- }
-
- it should "not leak any pre-login field through the authenticated payload"
in {
- // The split is only meaningful if /gui drops the fields that /pre-login
owns.
- // Without this, a future refactor could re-add them under the
@RolesAllowed
- // endpoint, doubling the surface and creating two sources of truth.
- val payload = resources
- .target("/config/gui")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get(classOf[Map[String, Any]])
- payload.keySet should contain noneOf (
- "localLogin",
- "googleLogin",
- "defaultLocalUser",
- "attributionEnabled"
- )
- }
-
- "GET /config/user-system" should "return 401 with a Bearer challenge without
an Authorization header" in {
- val response =
-
resources.target("/config/user-system").request(MediaType.APPLICATION_JSON).get()
- response.getStatus shouldBe 401
- response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
- }
-
- it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
- val response = resources
- .target("/config/user-system")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get()
- response.getStatus shouldBe 200
- }
-
- "GET /config/amber" should "return 401 with a Bearer challenge without an
Authorization header" in {
- val response =
-
resources.target("/config/amber").request(MediaType.APPLICATION_JSON).get()
- response.getStatus shouldBe 401
- response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
- }
-
- it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
- val response = resources
- .target("/config/amber")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get()
- response.getStatus shouldBe 200
- }
-
- it should "expose the engine config separated from the gui payload" in {
- // The endpoint exists to keep engine configs out of /config/gui (see PR
#5545).
- // Pin that defaultDataTransferBatchSize is served here and not folded
back into gui.
- val amberPayload = resources
- .target("/config/amber")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get(classOf[Map[String, Any]])
- amberPayload.keySet should contain("defaultDataTransferBatchSize")
-
- val guiPayload = resources
- .target("/config/gui")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${regularToken()}")
- .get(classOf[Map[String, Any]])
- guiPayload.keySet should not contain "defaultDataTransferBatchSize"
- }
-
- "GET an @RolesAllowed probe endpoint" should "return 401 without an
Authorization header" in {
- // Sanity: JwtAuthFilter is now eager — missing Authorization is rejected
- // by the filter itself with a 401 + Bearer challenge, before
- // RolesAllowedDynamicFeature ever sees the request. Pre-eager behavior
- // here was a 403 from the role filter; the test pins the new contract.
- val response =
- resources.target("/auth-probe").request(MediaType.APPLICATION_JSON).get()
- response.getStatus shouldBe 401
- response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
- }
-
- it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
- // Positive-direction sibling to the previous test. Without this, a filter-
- // priority bug that lets RolesAllowedRequestFilter run *before*
JwtAuthFilter
- // is invisible to the spec: the no-auth case still 403s, and the only path
- // that actually exercises auth → authz ordering is "valid JWT → 200".
Manual
- // integration testing of PR #5199 found this: a real admin JWT was getting
- // 403 on every @RolesAllowed endpoint until JwtAuthFilter was pinned to
- // Priorities.AUTHENTICATION.
- val response = resources
- .target("/auth-probe")
- .request(MediaType.APPLICATION_JSON)
- .header("Authorization", s"Bearer ${adminToken()}")
- .get()
- response.getStatus shouldBe 200
- }
-}
-
-object ConfigResourceAuthSpec {
- // A deliberately @RolesAllowed companion to ConfigResource, so the same
setup also
- // proves the feature actually rejects when it should — a 200 on the
@PermitAll
- // endpoint would otherwise be consistent with the feature being silently
no-op'd.
- @Path("/auth-probe")
- @Produces(Array(MediaType.APPLICATION_JSON))
- class ProtectedProbe {
- @GET
- @RolesAllowed(Array("REGULAR", "ADMIN"))
- def probe: String = "should never reach this"
- }
-}
diff --git
a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
new file mode 100644
index 0000000000..f27f9463d8
--- /dev/null
+++
b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
@@ -0,0 +1,481 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.resource
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.dropwizard.auth.AuthValueFactoryProvider
+import io.dropwizard.jackson.Jackson
+import io.dropwizard.testing.junit5.ResourceExtension
+import jakarta.annotation.security.RolesAllowed
+import jakarta.ws.rs.client.Entity
+import jakarta.ws.rs.core.MediaType
+import jakarta.ws.rs.{GET, Path, Produces}
+import org.apache.texera.auth.{JwtAuth, JwtAuthFilter, SessionUser,
UnauthorizedExceptionMapper}
+import org.apache.texera.common.config.DefaultsConfig
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.SITE_SETTINGS
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+// Covers ConfigResource from two directions against one embedded database.
+//
+// HTTP auth gates: wires the resource through the same Jersey auth pipeline
+// production uses (JwtAuthFilter + RolesAllowedDynamicFeature) and fires
+// requests with and without an Authorization header. /config/pre-login and
+// /config/settings/public are the @PermitAll endpoints and must answer
+// unauthenticated callers (bootstrap regression guard, same shape as the break
+// that caused PR #5049 to be reverted in #5173); the remaining endpoints are
+// @RolesAllowed and must reject anonymous traffic with a 401 from
+// JwtAuthFilter's eager check.
+//
+// Endpoint bodies: calls the resource methods directly for the positive
+// read/write paths — read-miss, insert, upsert-on-conflict, null-value 400,
+// public-whitelist filtering, reset-to-default.
+class ConfigResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with MockTexeraDB {
+
+ // Mirror production's mapper: ConfigService bootstraps Dropwizard's default
mapper
+ // (Jackson.newObjectMapper) and registers DefaultScalaModule on top. Same
call here.
+ private val testMapper: ObjectMapper =
+ Jackson.newObjectMapper().registerModule(DefaultScalaModule)
+
+ private val resources: ResourceExtension = ResourceExtension
+ .builder()
+ .setMapper(testMapper)
+ .addProvider(classOf[JwtAuthFilter])
+ .addProvider(classOf[UnauthorizedExceptionMapper])
+ .addProvider(classOf[RolesAllowedDynamicFeature])
+ // Production (AuthFeatures.register) binds this so @Auth SessionUser
+ // parameters resolve; without it the /config/settings write endpoints
+ // fail resource-model validation at startup.
+ .addProvider(new AuthValueFactoryProvider.Binder(classOf[SessionUser]))
+ .addResource(new ConfigResource)
+ .addResource(new ConfigResourceSpec.ProtectedProbe)
+ .build()
+
+ // Direct-call handle for the endpoint-body tests below.
+ private val resource = new ConfigResource
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ resources.before()
+ }
+
+ override protected def afterAll(): Unit = {
+ resources.after()
+ shutdownDB()
+ }
+
+ private def adminSession(name: String = "test-admin"): SessionUser = {
+ val u = new User()
+ u.setUid(1)
+ u.setName(name)
+ new SessionUser(u)
+ }
+
+ private def regularToken(): String = {
+ val u = new User()
+ u.setUid(2)
+ u.setName("test-regular")
+ u.setEmail("[email protected]")
+ u.setGoogleId(null)
+ u.setRole(UserRoleEnum.REGULAR)
+ JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
+ }
+
+ private def adminToken(): String = {
+ val u = new User()
+ u.setUid(1)
+ u.setName("test-admin")
+ u.setEmail("[email protected]")
+ u.setGoogleId(null)
+ u.setRole(UserRoleEnum.ADMIN)
+ JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
+ }
+
+ "GET /config/pre-login" should "return 200 without an Authorization header"
in {
+ val response =
resources.target("/config/pre-login").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 200
+ }
+
+ it should "expose exactly the fields the login UI needs and nothing else" in
{
+ // Locking down the payload keeps anonymous callers from reading workspace
flags,
+ // feature toggles, or session timers. If a new field is needed before
login, it
+ // must be added here explicitly; the assertion forces that decision into
review.
+ val payload = resources
+ .target("/config/pre-login")
+ .request(MediaType.APPLICATION_JSON)
+ .get(classOf[Map[String, Any]])
+ payload.keySet shouldBe Set(
+ "localLogin",
+ "googleLogin",
+ "defaultLocalUser",
+ "attributionEnabled",
+ "deploymentVersionCheckEnabled",
+ "inviteOnly"
+ )
+ }
+
+ "GET /config/gui" should "return 401 with a Bearer challenge without an
Authorization header" in {
+ val response =
resources.target("/config/gui").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
+ val response = resources
+ .target("/config/gui")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get()
+ response.getStatus shouldBe 200
+ }
+
+ it should "not leak any pre-login field through the authenticated payload"
in {
+ // The split is only meaningful if /gui drops the fields that /pre-login
owns.
+ // Without this, a future refactor could re-add them under the
@RolesAllowed
+ // endpoint, doubling the surface and creating two sources of truth.
+ val payload = resources
+ .target("/config/gui")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get(classOf[Map[String, Any]])
+ payload.keySet should contain noneOf (
+ "localLogin",
+ "googleLogin",
+ "defaultLocalUser",
+ "attributionEnabled"
+ )
+ }
+
+ "GET /config/user-system" should "return 401 with a Bearer challenge without
an Authorization header" in {
+ val response =
+
resources.target("/config/user-system").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
+ val response = resources
+ .target("/config/user-system")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get()
+ response.getStatus shouldBe 200
+ }
+
+ "GET /config/amber" should "return 401 with a Bearer challenge without an
Authorization header" in {
+ val response =
+
resources.target("/config/amber").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
+ val response = resources
+ .target("/config/amber")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get()
+ response.getStatus shouldBe 200
+ }
+
+ it should "expose the engine config separated from the gui payload" in {
+ // The endpoint exists to keep engine configs out of /config/gui (see PR
#5545).
+ // Pin that defaultDataTransferBatchSize is served here and not folded
back into gui.
+ val amberPayload = resources
+ .target("/config/amber")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get(classOf[Map[String, Any]])
+ amberPayload.keySet should contain("defaultDataTransferBatchSize")
+
+ val guiPayload = resources
+ .target("/config/gui")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get(classOf[Map[String, Any]])
+ guiPayload.keySet should not contain "defaultDataTransferBatchSize"
+ }
+
+ // /config/settings is the site_settings API: /settings/public serves the
+ // user-visible keys (gui/dataset sections of default.conf) to anonymous
+ // callers — the values render on the logged-out shell (custom logo,
Hub/About
+ // sidebar entries) — while the bulk read, single-key read, and all mutation
+ // are ADMIN-only. These tests pin the auth gates; the endpoint bodies are
+ // covered by the direct-call tests further down.
+ "GET /config/settings/public" should "return 200 without an Authorization
header (anonymous branding read)" in {
+ val response =
+
resources.target("/config/settings/public").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 200
+ }
+
+ "GET /config/settings" should "return 401 with a Bearer challenge without an
Authorization header" in {
+ val response =
+
resources.target("/config/settings").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 403 for a REGULAR user (bulk management read is
ADMIN-only)" in {
+ val response = resources
+ .target("/config/settings")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get()
+ response.getStatus shouldBe 403
+ }
+
+ "GET /config/settings/{key}" should "return 401 with a Bearer challenge
without an Authorization header" in {
+ val response =
+
resources.target("/config/settings/logo").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 403 for a REGULAR user (management read is ADMIN-only)" in
{
+ val response = resources
+ .target("/config/settings/logo")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .get()
+ response.getStatus shouldBe 403
+ }
+
+ "PUT /config/settings/{key}" should "return 401 without an Authorization
header" in {
+ val response = resources
+ .target("/config/settings/logo")
+ .request(MediaType.APPLICATION_JSON)
+ .put(Entity.json("""{"key":"logo","value":"x"}"""))
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 403 for a REGULAR user" in {
+ val response = resources
+ .target("/config/settings/logo")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .put(Entity.json("""{"key":"logo","value":"x"}"""))
+ response.getStatus shouldBe 403
+ }
+
+ "POST /config/settings/reset/{key}" should "return 403 for a REGULAR user"
in {
+ val response = resources
+ .target("/config/settings/reset/logo")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${regularToken()}")
+ .post(Entity.json("{}"))
+ response.getStatus shouldBe 403
+ }
+
+ it should "pass the role gate for an ADMIN (404 for a key with no default)"
in {
+ val response = resources
+ .target("/config/settings/reset/no-such-key")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${adminToken()}")
+ .post(Entity.json("{}"))
+ response.getStatus shouldBe 404
+ }
+
+ "GET an @RolesAllowed probe endpoint" should "return 401 without an
Authorization header" in {
+ // Sanity: JwtAuthFilter is now eager — missing Authorization is rejected
+ // by the filter itself with a 401 + Bearer challenge, before
+ // RolesAllowedDynamicFeature ever sees the request. Pre-eager behavior
+ // here was a 403 from the role filter; the test pins the new contract.
+ val response =
+ resources.target("/auth-probe").request(MediaType.APPLICATION_JSON).get()
+ response.getStatus shouldBe 401
+ response.getHeaderString("WWW-Authenticate") shouldBe
JwtAuthFilter.BearerChallenge
+ }
+
+ it should "return 200 with a valid Bearer token whose role matches
@RolesAllowed" in {
+ // Positive-direction sibling to the previous test. Without this, a filter-
+ // priority bug that lets RolesAllowedRequestFilter run *before*
JwtAuthFilter
+ // is invisible to the spec: the no-auth case still 403s, and the only path
+ // that actually exercises auth → authz ordering is "valid JWT → 200".
Manual
+ // integration testing of PR #5199 found this: a real admin JWT was getting
+ // 403 on every @RolesAllowed endpoint until JwtAuthFilter was pinned to
+ // Priorities.AUTHENTICATION.
+ val response = resources
+ .target("/auth-probe")
+ .request(MediaType.APPLICATION_JSON)
+ .header("Authorization", s"Bearer ${adminToken()}")
+ .get()
+ response.getStatus shouldBe 200
+ }
+
+ // ----- endpoint bodies, called directly against the embedded database -----
+
+ "getSetting" should "return null for a key that has no row" in {
+ resource.getSetting("no-such-key") shouldBe null
+ }
+
+ "updateSetting" should "insert a new row and record who wrote it" in {
+ val response =
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"custom.png"))
+ response.getStatus shouldBe 200
+
+ val stored = resource.getSetting("logo")
+ stored.settingKey shouldBe "logo"
+ stored.settingValue shouldBe "custom.png"
+
+ getDSLContext
+ .select(SITE_SETTINGS.UPDATED_BY)
+ .from(SITE_SETTINGS)
+ .where(SITE_SETTINGS.KEY.eq("logo"))
+ .fetchOne(SITE_SETTINGS.UPDATED_BY) shouldBe "test-admin"
+ }
+
+ it should "update the existing row on a repeated PUT (upsert conflict path)"
in {
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"v1.png"))
+ resource.updateSetting(
+ adminSession("second-admin"),
+ "logo",
+ ConfigSettingPojo("logo", "v2.png")
+ )
+
+ resource.getSetting("logo").settingValue shouldBe "v2.png"
+ getDSLContext.fetchCount(SITE_SETTINGS, SITE_SETTINGS.KEY.eq("logo"))
shouldBe 1
+ getDSLContext
+ .select(SITE_SETTINGS.UPDATED_BY)
+ .from(SITE_SETTINGS)
+ .where(SITE_SETTINGS.KEY.eq("logo"))
+ .fetchOne(SITE_SETTINGS.UPDATED_BY) shouldBe "second-admin"
+ }
+
+ it should "reject a null value with 400 and leave the stored row untouched"
in {
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"kept.png"))
+ val response = resource.updateSetting(adminSession(), "logo",
ConfigSettingPojo("logo", null))
+ response.getStatus shouldBe 400
+ resource.getSetting("logo").settingValue shouldBe "kept.png"
+ }
+
+ it should "reject a null body with 400 rather than a 500 (empty/malformed
request)" in {
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"kept.png"))
+ val response = resource.updateSetting(adminSession(), "logo", null)
+ response.getStatus shouldBe 400
+ resource.getSetting("logo").settingValue shouldBe "kept.png"
+ }
+
+ it should "reject a key with no default.conf entry with 400 and write
nothing" in {
+ val response =
+ resource.updateSetting(adminSession(), "no-such-key",
ConfigSettingPojo("no-such-key", "x"))
+ response.getStatus shouldBe 400
+ resource.getSetting("no-such-key") shouldBe null
+ }
+
+ "getPublicSettings" should "serve whitelisted keys and hide management-only
ones" in {
+ resource.updateSetting(adminSession(), "favicon",
ConfigSettingPojo("favicon", "fav.ico"))
+ resource.updateSetting(
+ adminSession(),
+ "csv_parser_max_columns",
+ ConfigSettingPojo("csv_parser_max_columns", "4096")
+ )
+
+ val publicSettings = resource.getPublicSettings
+ publicSettings("favicon") shouldBe "fav.ico"
+ publicSettings should not contain key("csv_parser_max_columns")
+ }
+
+ // The public whitelist is derived from the gui/dataset sections of
+ // default.conf. This pins the derived set, so moving a key between sections
+ // (or adding one) forces the visibility decision into review here.
+ it should "expose exactly the gui and dataset section keys of default.conf"
in {
+ DefaultsConfig.keysUnderSections(Set("gui", "dataset")) shouldBe Set(
+ "logo",
+ "mini_logo",
+ "favicon",
+ "hub_enabled",
+ "home_enabled",
+ "workflow_enabled",
+ "dataset_enabled",
+ "your_work_enabled",
+ "projects_enabled",
+ "workflows_enabled",
+ "datasets_enabled",
+ "compute_enabled",
+ "quota_enabled",
+ "forum_enabled",
+ "about_enabled",
+ "single_file_upload_max_size_mib",
+ "multipart_upload_chunk_size_mib",
+ "max_number_of_concurrent_uploading_file",
+ "max_number_of_concurrent_uploading_file_chunks"
+ )
+ }
+
+ "getAllSettings" should "serve every stored row, including management-only
keys" in {
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"all.png"))
+ resource.updateSetting(
+ adminSession(),
+ "csv_parser_max_columns",
+ ConfigSettingPojo("csv_parser_max_columns", "2048")
+ )
+
+ val allSettings = resource.getAllSettings
+ allSettings("logo") shouldBe "all.png"
+ allSettings("csv_parser_max_columns") shouldBe "2048"
+ }
+
+ "resetSetting" should "restore the default.conf value for a known key" in {
+ resource.updateSetting(adminSession(), "logo", ConfigSettingPojo("logo",
"overridden.png"))
+
+ val response = resource.resetSetting(adminSession(), "logo")
+ response.getStatus shouldBe 200
+ resource.getSetting("logo").settingValue shouldBe
DefaultsConfig.allDefaults("logo")
+ }
+
+ it should "return 404 for a key that has no default" in {
+ resource.resetSetting(adminSession(), "no-such-key").getStatus shouldBe 404
+ }
+
+ it should "restore a management-only key (csv_parser_max_columns) that is
seeded from default.conf" in {
+ resource.updateSetting(
+ adminSession(),
+ "csv_parser_max_columns",
+ ConfigSettingPojo("csv_parser_max_columns", "4096")
+ )
+ resource.resetSetting(adminSession(), "csv_parser_max_columns").getStatus
shouldBe 200
+ resource.getSetting("csv_parser_max_columns").settingValue shouldBe
+ DefaultsConfig.allDefaults("csv_parser_max_columns")
+ }
+}
+
+object ConfigResourceSpec {
+ // A deliberately @RolesAllowed companion to ConfigResource, so the same
setup also
+ // proves the feature actually rejects when it should — a 200 on the
@PermitAll
+ // endpoint would otherwise be consistent with the feature being silently
no-op'd.
+ @Path("/auth-probe")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ class ProtectedProbe {
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def probe: String = "should never reach this"
+ }
+}
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 8f5023b50a..3180ae187f 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -211,7 +211,7 @@ registerLocaleData(en);
tokenGetter: AuthService.getAccessToken,
skipWhenExpired: true,
throwNoTokenError: false,
- disallowedRoutes: ["forum/api/users", "api/config/pre-login"],
+ disallowedRoutes: ["forum/api/users", "api/config/pre-login",
"api/config/settings/public"],
},
}),
BrowserAnimationsModule,
diff --git a/frontend/src/app/common/util/format.util.ts
b/frontend/src/app/common/util/format.util.ts
index 9046db5fa3..8393559aaa 100644
--- a/frontend/src/app/common/util/format.util.ts
+++ b/frontend/src/app/common/util/format.util.ts
@@ -90,3 +90,13 @@ export const formatCount = (count: number): string => {
}
return count.toString();
};
+
+/**
+ * Parse an integer setting value, falling back when the raw value is missing
or
+ * unparsable. Unlike `parseInt(raw) || fallback`, a legitimately stored 0 is
+ * preserved (0 is falsy, so the `||` idiom would silently drop it).
+ */
+export const parseIntOrDefault = (raw: string | null | undefined, fallback:
number): number => {
+ const parsed = parseInt(raw ?? "", 10);
+ return Number.isNaN(parsed) ? fallback : parsed;
+};
diff --git
a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
index 7217fe6f9a..69daa21716 100644
---
a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
+++
b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
@@ -19,12 +19,16 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { AdminSettingsComponent } from "./admin-settings.component";
-import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
import { NzCardModule } from "ng-zorro-antd/card";
+import { NzMessageService } from "ng-zorro-antd/message";
describe("AdminSettingsComponent", () => {
let component: AdminSettingsComponent;
let fixture: ComponentFixture<AdminSettingsComponent>;
+ let httpTestingController: HttpTestingController;
+
+ const SETTINGS_URL = "/api/config/settings";
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -33,6 +37,7 @@ describe("AdminSettingsComponent", () => {
});
beforeEach(() => {
+ httpTestingController = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(AdminSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -49,4 +54,73 @@ describe("AdminSettingsComponent", () => {
expect(el.textContent?.trim()).toBe("MiB");
});
});
+
+ it("loads every form field from one bulk settings request", () => {
+ const req = httpTestingController.expectOne(SETTINGS_URL);
+ expect(req.request.method).toBe("GET");
+ req.flush({
+ logo: "logo.png",
+ mini_logo: "mini.png",
+ favicon: "fav.ico",
+ hub_enabled: "true",
+ home_enabled: "false",
+ max_number_of_concurrent_uploading_file: "5",
+ single_file_upload_max_size_mib: "128",
+ max_number_of_concurrent_uploading_file_chunks: "7",
+ multipart_upload_chunk_size_mib: "64",
+ csv_parser_max_columns: "4096",
+ });
+
+ expect(component.logoData).toBe("logo.png");
+ expect(component.miniLogoData).toBe("mini.png");
+ expect(component.faviconData).toBe("fav.ico");
+ expect(component.sidebarTabs.hub_enabled).toBe(true);
+ expect(component.sidebarTabs.home_enabled).toBe(false);
+ expect(component.maxConcurrentFiles).toBe(5);
+ expect(component.maxFileSizeMiB).toBe(128);
+ expect(component.maxConcurrentChunks).toBe(7);
+ expect(component.chunkSizeMiB).toBe(64);
+ expect(component.csvMaxColumns).toBe(4096);
+ });
+
+ it("keeps the initializer defaults for missing or unparsable values", () => {
+ httpTestingController.expectOne(SETTINGS_URL).flush({
+ single_file_upload_max_size_mib: "not-a-number",
+ });
+
+ expect(component.logoData).toBeNull();
+ expect(component.maxFileSizeMiB).toBe(20);
+ expect(component.maxConcurrentFiles).toBe(3);
+ expect(component.csvMaxColumns).toBe(512);
+ });
+
+ it("surfaces a load failure through the message service", () => {
+ const message = TestBed.inject(NzMessageService);
+ const errorSpy = vi.spyOn(message, "error").mockReturnValue({} as
ReturnType<NzMessageService["error"]>);
+
+ httpTestingController.expectOne(SETTINGS_URL).flush("boom", { status: 500,
statusText: "Server Error" });
+
+ expect(errorSpy).toHaveBeenCalledWith("Failed to load settings.");
+ });
+
+ it("preserves a legitimately stored 0 instead of falling back to the
default", () => {
+ httpTestingController.expectOne(SETTINGS_URL).flush({
+ max_number_of_concurrent_uploading_file: "0",
+ csv_parser_max_columns: "0",
+ });
+
+ expect(component.maxConcurrentFiles).toBe(0);
+ expect(component.csvMaxColumns).toBe(0);
+ });
+
+ it("blocks a tab save when the bulk load failed (no destructive all-off
write)", () => {
+ const message = TestBed.inject(NzMessageService);
+ const errorSpy = vi.spyOn(message, "error").mockReturnValue({} as
ReturnType<NzMessageService["error"]>);
+ httpTestingController.expectOne(SETTINGS_URL).flush("boom", { status: 500,
statusText: "Server Error" });
+
+ component.saveTabs();
+
+ httpTestingController.expectNone((req: { method: string }) => req.method
=== "PUT");
+ expect(errorSpy).toHaveBeenCalledWith("Settings have not loaded; refresh
before saving.");
+ });
});
diff --git
a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
index 691ae79f6f..3714ac0de8 100644
---
a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
+++
b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
@@ -23,6 +23,7 @@ import { NzMessageService } from "ng-zorro-antd/message";
import { NotificationService } from
"../../../../common/service/notification/notification.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { SidebarTabs } from "../../../../common/type/gui-config";
+import { parseIntOrDefault } from "../../../../common/util/format.util";
import { forkJoin } from "rxjs";
import { NzCardComponent } from "ng-zorro-antd/card";
import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
@@ -93,61 +94,49 @@ export class AdminSettingsComponent implements OnInit {
private readonly RELOAD_DELAY = 1000;
+ // Guards the save buttons: a failed bulk load leaves every field at its
+ // initializer, so saving would persist those placeholders (e.g. disabling
+ // every sidebar tab). Only allow saves once a load has actually succeeded.
+ private settingsLoaded = false;
+
constructor(
private adminSettingsService: AdminSettingsService,
private message: NzMessageService,
private notificationService: NotificationService
) {}
ngOnInit(): void {
- this.loadBranding();
- this.loadTabs();
- this.loadDatasetSettings();
- this.loadCsvSettings();
+ this.loadSettings();
}
- private loadBranding(): void {
- this.adminSettingsService
- .getSetting("logo")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.logoData = value || null));
-
- this.adminSettingsService
- .getSetting("mini_logo")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.miniLogoData = value || null));
-
- this.adminSettingsService
- .getSetting("favicon")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.faviconData = value || null));
- }
-
- private loadTabs(): void {
- (Object.keys(this.sidebarTabs) as (keyof SidebarTabs)[]).forEach(tab => {
- this.adminSettingsService
- .getSetting(tab)
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.sidebarTabs[tab] = value === "true"));
- });
- }
-
- private loadDatasetSettings(): void {
- this.adminSettingsService
- .getSetting("max_number_of_concurrent_uploading_file")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.maxConcurrentFiles = parseInt(value)));
- this.adminSettingsService
- .getSetting("single_file_upload_max_size_mib")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.maxFileSizeMiB = parseInt(value)));
+ // One bulk read instead of a request per key; missing or unparsable values
+ // keep the field initializers above as their defaults.
+ private loadSettings(): void {
this.adminSettingsService
- .getSetting("max_number_of_concurrent_uploading_file_chunks")
+ .getAllSettings()
.pipe(untilDestroyed(this))
- .subscribe(value => (this.maxConcurrentChunks = parseInt(value)));
- this.adminSettingsService
- .getSetting("multipart_upload_chunk_size_mib")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.chunkSizeMiB = parseInt(value)));
+ .subscribe({
+ next: settings => {
+ this.logoData = settings["logo"] || null;
+ this.miniLogoData = settings["mini_logo"] || null;
+ this.faviconData = settings["favicon"] || null;
+ (Object.keys(this.sidebarTabs) as (keyof SidebarTabs)[]).forEach(
+ tab => (this.sidebarTabs[tab] = settings[tab] === "true")
+ );
+ this.maxConcurrentFiles = parseIntOrDefault(
+ settings["max_number_of_concurrent_uploading_file"],
+ this.maxConcurrentFiles
+ );
+ this.maxFileSizeMiB =
parseIntOrDefault(settings["single_file_upload_max_size_mib"],
this.maxFileSizeMiB);
+ this.maxConcurrentChunks = parseIntOrDefault(
+ settings["max_number_of_concurrent_uploading_file_chunks"],
+ this.maxConcurrentChunks
+ );
+ this.chunkSizeMiB =
parseIntOrDefault(settings["multipart_upload_chunk_size_mib"],
this.chunkSizeMiB);
+ this.csvMaxColumns =
parseIntOrDefault(settings["csv_parser_max_columns"], this.csvMaxColumns);
+ this.settingsLoaded = true;
+ },
+ error: () => this.message.error("Failed to load settings."),
+ });
}
onFileChange(type: "logo" | "mini_logo" | "favicon", event: Event): void {
@@ -205,6 +194,10 @@ export class AdminSettingsComponent implements OnInit {
}
saveTabs(): void {
+ if (!this.settingsLoaded) {
+ this.message.error("Settings have not loaded; refresh before saving.");
+ return;
+ }
const saveRequests = (Object.keys(this.sidebarTabs) as (keyof
SidebarTabs)[]).map(tab =>
this.adminSettingsService.updateSetting(tab,
this.sidebarTabs[tab].toString())
);
@@ -242,6 +235,10 @@ export class AdminSettingsComponent implements OnInit {
}
saveDatasetSettings(): void {
+ if (!this.settingsLoaded) {
+ this.message.error("Settings have not loaded; refresh before saving.");
+ return;
+ }
if (
this.maxFileSizeMiB < 1 ||
this.maxConcurrentFiles < 1 ||
@@ -293,14 +290,11 @@ export class AdminSettingsComponent implements OnInit {
setTimeout(() => window.location.reload(), this.RELOAD_DELAY);
}
- private loadCsvSettings(): void {
- this.adminSettingsService
- .getSetting("csv_parser_max_columns")
- .pipe(untilDestroyed(this))
- .subscribe(value => (this.csvMaxColumns = parseInt(value) || 512));
- }
-
saveCsvSettings(): void {
+ if (!this.settingsLoaded) {
+ this.message.error("Settings have not loaded; refresh before saving.");
+ return;
+ }
const saveRequests = [
this.adminSettingsService.updateSetting("csv_parser_max_columns",
this.csvMaxColumns.toString()),
];
@@ -314,7 +308,12 @@ export class AdminSettingsComponent implements OnInit {
}
resetCsvSettings(): void {
-
this.adminSettingsService.resetSetting("csv_parser_max_columns").pipe(untilDestroyed(this)).subscribe({});
+ this.adminSettingsService
+ .resetSetting("csv_parser_max_columns")
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ error: () => this.notificationService.error("Could not reset result
panel settings."),
+ });
this.notificationService.info("Resetting result panel settings...");
setTimeout(() => window.location.reload(), this.RELOAD_DELAY);
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index 2e7d7c4b1c..19b6a298ad 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -130,7 +130,7 @@ describe("DashboardComponent", () => {
};
adminSettingsServiceMock = {
- getSetting: vi.fn().mockReturnValue(EMPTY),
+ getPublicSetting: vi.fn().mockReturnValue(EMPTY),
};
activatedRouteMock = {
@@ -337,4 +337,66 @@ describe("DashboardComponent", () => {
expect(quota!.componentInstance.nzMatchRouter).toBe(true);
});
});
+
+ describe("public settings consumption", () => {
+ it("applies logo, mini logo, and favicon from the public settings", () => {
+ const favicon = document.createElement("link");
+ favicon.setAttribute("rel", "icon");
+ document.head.appendChild(favicon);
+ const values: Record<string, string> = {
+ logo: "custom-logo.png",
+ mini_logo: "custom-mini.png",
+ favicon: "custom-fav.ico",
+ };
+ (adminSettingsServiceMock.getPublicSetting as
Mock).mockImplementation((key: string) => of(values[key] ?? null));
+
+ component.loadLogos();
+
+ expect(component.logo).toBe("custom-logo.png");
+ expect(component.miniLogo).toBe("custom-mini.png");
+ expect(favicon.href).toContain("custom-fav.ico");
+ favicon.remove();
+ });
+
+ it("keeps the default branding when the settings are missing or the fetch
fails", () => {
+ component.logo = "";
+ component.miniLogo = "";
+ (adminSettingsServiceMock.getPublicSetting as
Mock).mockReturnValue(of(null));
+ component.loadLogos();
+ expect(component.logo).toBe("");
+ expect(component.miniLogo).toBe("");
+
+ (adminSettingsServiceMock.getPublicSetting as
Mock).mockReturnValue(throwError(() => new Error("401")));
+ expect(() => component.loadLogos()).not.toThrow();
+ expect(component.logo).toBe("");
+ });
+
+ it("toggles sidebar tabs from the public settings and treats missing keys
as disabled", () => {
+ (adminSettingsServiceMock.getPublicSetting as
Mock).mockImplementation((key: string) =>
+ of(key === "hub_enabled" ? "true" : key === "about_enabled" ? "false"
: null)
+ );
+
+ component.loadTabs();
+
+ expect(component.sidebarTabs.hub_enabled).toBe(true);
+ expect(component.sidebarTabs.about_enabled).toBe(false);
+ expect(component.sidebarTabs.forum_enabled).toBe(false);
+ });
+
+ it("leaves tabs unchanged when the settings fetch fails", () => {
+ component.sidebarTabs.hub_enabled = true;
+ (adminSettingsServiceMock.getPublicSetting as
Mock).mockReturnValue(throwError(() => new Error("500")));
+
+ expect(() => component.loadTabs()).not.toThrow();
+ expect(component.sidebarTabs.hub_enabled).toBe(true);
+ });
+
+ it("hides every tab until the settings load (no hardcoded default copy)",
() => {
+ // The default mock returns EMPTY, so loadTabs never overrides. The
+ // frontend keeps no copy of the default.conf tab defaults: every tab
+ // starts false so an unfetched or failed load shows no tabs, and the
+ // backend /config/settings/public is the single source of the real
values.
+ expect(Object.values(component.sidebarTabs).every(enabled => enabled ===
false)).toBe(true);
+ });
+ });
});
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts
b/frontend/src/app/dashboard/component/dashboard.component.ts
index 88dc04bb69..598959edd8 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -92,6 +92,10 @@ export class DashboardComponent implements OnInit {
showLinks: boolean = false;
logo: string = "";
miniLogo: string = "";
+ // Every tab starts hidden; loadTabs turns on the ones
/config/settings/public
+ // reports as enabled. The frontend keeps no copy of the default.conf
gui.tabs
+ // defaults, so an unfetched or failed load shows no tabs (each *ngIf sees
+ // false) rather than a guessed set — the backend stays the single source.
sidebarTabs: SidebarTabs = {
hub_enabled: false,
home_enabled: false,
@@ -174,36 +178,57 @@ export class DashboardComponent implements OnInit {
this.loadTabs();
}
+ // A missing key or a failed settings fetch keeps the branding/tab defaults;
+ // the error callbacks stop a single failed shared request from surfacing as
+ // one unhandled RxJS error per subscription.
loadLogos(): void {
this.adminSettingsService
- .getSetting("logo")
+ .getPublicSetting("logo")
.pipe(untilDestroyed(this))
- .subscribe(dataUri => {
- this.logo = dataUri;
+ .subscribe({
+ next: dataUri => {
+ if (dataUri) {
+ this.logo = dataUri;
+ }
+ },
+ error: () => {},
});
this.adminSettingsService
- .getSetting("mini_logo")
+ .getPublicSetting("mini_logo")
.pipe(untilDestroyed(this))
- .subscribe(dataUri => {
- this.miniLogo = dataUri;
+ .subscribe({
+ next: dataUri => {
+ if (dataUri) {
+ this.miniLogo = dataUri;
+ }
+ },
+ error: () => {},
});
this.adminSettingsService
- .getSetting("favicon")
+ .getPublicSetting("favicon")
.pipe(untilDestroyed(this))
- .subscribe(dataUri => {
- document.querySelectorAll("link[rel*='icon']").forEach(el => ((el as
HTMLLinkElement).href = dataUri));
+ .subscribe({
+ next: dataUri => {
+ if (dataUri) {
+ document.querySelectorAll("link[rel*='icon']").forEach(el => ((el
as HTMLLinkElement).href = dataUri));
+ }
+ },
+ error: () => {},
});
}
loadTabs(): void {
(Object.keys(this.sidebarTabs) as (keyof SidebarTabs)[]).forEach(tab => {
this.adminSettingsService
- .getSetting(tab)
+ .getPublicSetting(tab)
.pipe(untilDestroyed(this))
- .subscribe(value => {
- this.sidebarTabs[tab] = value === "true";
+ .subscribe({
+ next: value => {
+ this.sidebarTabs[tab] = value === "true";
+ },
+ error: () => {},
});
});
}
diff --git
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
index 098aea82cb..ecbd203f68 100644
---
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
@@ -72,7 +72,7 @@ describe("FilesUploaderComponent", () => {
}),
} as unknown as NzModalService;
const adminSettingsService = {
- getSetting: vi.fn().mockReturnValue(of("20")),
+ getPublicSetting: vi.fn().mockReturnValue(of("20")),
} as unknown as AdminSettingsService;
datasetService = {
listMultipartUploads: vi.fn().mockReturnValue(of(["failed.csv"])),
@@ -90,6 +90,22 @@ describe("FilesUploaderComponent", () => {
component.did = 7;
});
+ it("keeps the default upload size limit when the public setting is missing,
and parses it when present", () => {
+ const build = (value: string | null) =>
+ new FilesUploaderComponent(
+ { error: vi.fn() } as unknown as NotificationService,
+ { getPublicSetting: vi.fn().mockReturnValue(of(value)) } as unknown as
AdminSettingsService,
+ datasetService as unknown as DatasetService,
+ { create: vi.fn() } as unknown as NzModalService
+ );
+
+ expect(build(null).singleFileUploadMaxSizeMiB).toBe(20);
+ expect(build("128").singleFileUploadMaxSizeMiB).toBe(128);
+ // an unparsable value keeps the default, but a stored 0 is honoured
+ expect(build("nope").singleFileUploadMaxSizeMiB).toBe(20);
+ expect(build("0").singleFileUploadMaxSizeMiB).toBe(0);
+ });
+
it("asks to resume failed multipart files and skip completed matching files
in one retry batch", async () => {
const emitted = new Promise<FileUploadItem[]>(resolve =>
component.uploadedFiles.subscribe(resolve));
diff --git
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
index 0a2f1c6c79..5673e55878 100644
---
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
+++
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
@@ -28,6 +28,7 @@ import { AdminSettingsService } from
"../../../service/admin/settings/admin-sett
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DatasetService } from "../../../service/user/dataset/dataset.service";
import { formatSize } from "../../../../common/util/size-formatter.util";
+import { parseIntOrDefault } from "../../../../common/util/format.util";
import {
ConflictingFileModalContentComponent,
ConflictingFileModalData,
@@ -82,10 +83,14 @@ export class FilesUploaderComponent {
private datasetService: DatasetService,
private modal: NzModalService
) {
+ // A missing key or failed fetch keeps the initializer default above.
this.adminSettingsService
- .getSetting("single_file_upload_max_size_mib")
+ .getPublicSetting("single_file_upload_max_size_mib")
.pipe(untilDestroyed(this))
- .subscribe(value => (this.singleFileUploadMaxSizeMiB = parseInt(value)));
+ .subscribe({
+ next: value => (this.singleFileUploadMaxSizeMiB =
parseIntOrDefault(value, this.singleFileUploadMaxSizeMiB)),
+ error: () => {},
+ });
}
private markForceRestart(item: FileUploadItem): void {
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index 3e3e027309..f5ebba48ac 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -105,7 +105,7 @@ describe("DatasetDetailComponent upload queue", () => {
isLiked: vi.fn(() => of([{ isLiked: false }])),
},
},
- { provide: AdminSettingsService, useValue: { getSetting: vi.fn(() =>
of("3")) } },
+ { provide: AdminSettingsService, useValue: { getPublicSetting:
vi.fn(() => of("3")) } },
{ provide: MarkdownService, useValue: { parse: vi.fn(() => "") } },
...commonTestProviders,
],
@@ -446,7 +446,7 @@ describe("DatasetDetailComponent behavior", () => {
postLike: vi.fn(() => of(true)),
postUnlike: vi.fn(() => of(true)),
};
- adminSettingsServiceStub = { getSetting: vi.fn(() => of("50")) };
+ adminSettingsServiceStub = { getPublicSetting: vi.fn(() => of("50")) };
});
describe("ngOnInit", () => {
@@ -465,7 +465,7 @@ describe("DatasetDetailComponent behavior", () => {
expect(component.likeCount).toBe(7);
expect(component.viewCount).toBe(42);
expect(hubServiceStub.isLiked).not.toHaveBeenCalled();
- expect(adminSettingsServiceStub.getSetting).not.toHaveBeenCalled();
+ expect(adminSettingsServiceStub.getPublicSetting).not.toHaveBeenCalled();
});
it("fetches liked status and upload settings for a logged-in user", () => {
@@ -477,7 +477,19 @@ describe("DatasetDetailComponent behavior", () => {
expect(hubServiceStub.isLiked).toHaveBeenCalled();
expect(component.isLiked).toBe(true);
- expect(adminSettingsServiceStub.getSetting).toHaveBeenCalled();
+ expect(adminSettingsServiceStub.getPublicSetting).toHaveBeenCalled();
+ });
+
+ it("keeps the default upload settings when the public settings are
missing", () => {
+ adminSettingsServiceStub.getPublicSetting.mockReturnValue(of(null));
+
+ createComponent({ did: 5 });
+ login();
+ fixture.detectChanges();
+
+ expect(component.chunkSizeMiB).toBe(50);
+ expect(component.maxConcurrentChunks).toBe(10);
+ expect(component.maxConcurrentFiles).toBe(3);
});
it("makes no hub calls when the route carries no did", () => {
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index c3b5c9a80f..bdef5e0168 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -41,7 +41,7 @@ import { NzModalService } from "ng-zorro-antd/modal";
import { AdminSettingsService } from
"../../../../service/admin/settings/admin-settings.service";
import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http";
import { Subscription } from "rxjs";
-import { formatCount, formatSpeed, formatTime } from
"src/app/common/util/format.util";
+import { formatCount, formatSpeed, formatTime, parseIntOrDefault } from
"src/app/common/util/format.util";
import { format } from "date-fns";
import { NgIf, NgClass, NgFor } from "@angular/common";
import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card";
@@ -503,20 +503,29 @@ export class DatasetDetailComponent implements OnInit {
return fileName;
}
+ // A missing key or failed fetch keeps the field defaults; NaN here would
+ // silently stall the upload queue (`activeUploads < NaN` is always false).
private loadUploadSettings(): void {
this.adminSettingsService
- .getSetting("multipart_upload_chunk_size_mib")
+ .getPublicSetting("multipart_upload_chunk_size_mib")
.pipe(untilDestroyed(this))
- .subscribe(value => (this.chunkSizeMiB = parseInt(value)));
+ .subscribe({
+ next: value => (this.chunkSizeMiB = parseIntOrDefault(value,
this.chunkSizeMiB)),
+ error: () => {},
+ });
this.adminSettingsService
- .getSetting("max_number_of_concurrent_uploading_file_chunks")
+ .getPublicSetting("max_number_of_concurrent_uploading_file_chunks")
.pipe(untilDestroyed(this))
- .subscribe(value => (this.maxConcurrentChunks = parseInt(value)));
+ .subscribe({
+ next: value => (this.maxConcurrentChunks = parseIntOrDefault(value,
this.maxConcurrentChunks)),
+ error: () => {},
+ });
this.adminSettingsService
- .getSetting("max_number_of_concurrent_uploading_file")
+ .getPublicSetting("max_number_of_concurrent_uploading_file")
.pipe(untilDestroyed(this))
- .subscribe(value => {
- this.maxConcurrentFiles = parseInt(value);
+ .subscribe({
+ next: value => (this.maxConcurrentFiles = parseIntOrDefault(value,
this.maxConcurrentFiles)),
+ error: () => {},
});
}
diff --git
a/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.spec.ts
b/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.spec.ts
index f42654c400..a2255b10f2 100644
---
a/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.spec.ts
+++
b/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.spec.ts
@@ -17,76 +17,104 @@
* under the License.
*/
+import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
import { AdminSettingsService } from "./admin-settings.service";
-import { TestBed } from "@angular/core/testing";
describe("AdminSettingsService", () => {
- let httpMock: HttpTestingController;
let service: AdminSettingsService;
+ let httpTestingController: HttpTestingController;
- const BASE_URL = "/api/admin/settings";
- const EXAMPLE_SETTING = "multipart_upload_chunk_size_mib";
- const EXAMPLE_SETTING_VALUE = "4";
+ const PUBLIC_URL = "/api/config/settings/public";
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AdminSettingsService],
});
- httpMock = TestBed.inject(HttpTestingController);
+
+ httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(AdminSettingsService);
});
afterEach(() => {
- httpMock.verify();
+ httpTestingController.verify();
});
- it("an empty getSetting body should map to null", () => {
- let result: string | null | undefined;
- service.getSetting(EXAMPLE_SETTING).subscribe(res => {
- result = res;
- });
+ it("shares one public-settings request across per-key subscribers", () => {
+ let logo: string | null | undefined;
+ let hubEnabled: string | null | undefined;
- const req = httpMock.expectOne(`${BASE_URL}/${EXAMPLE_SETTING}`);
+ service.getPublicSetting("logo").subscribe(value => (logo = value));
+ service.getPublicSetting("hub_enabled").subscribe(value => (hubEnabled =
value));
- req.flush(null);
+ const req = httpTestingController.expectOne(PUBLIC_URL);
+ req.flush({ logo: "custom.png", hub_enabled: "true" });
- expect(req.request.method).toBe("GET");
- expect(result).toBeNull();
+ expect(logo).toEqual("custom.png");
+ expect(hubEnabled).toEqual("true");
});
- it("a getSetting body should map to its value", () => {
- let result: string | undefined;
- service.getSetting(EXAMPLE_SETTING).subscribe(res => {
- result = res;
- });
+ it("emits null for a key absent from the public payload", () => {
+ let value: string | null | undefined;
+ service.getPublicSetting("no-such-key").subscribe(v => (value = v));
- const req = httpMock.expectOne(`${BASE_URL}/${EXAMPLE_SETTING}`);
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "custom.png" });
+
+ expect(value).toBeNull();
+ });
+
+ it("drops the cached observable on error so the next read retries", () => {
+ let firstError = false;
+ service.getPublicSetting("logo").subscribe({ error: () => (firstError =
true) });
+ httpTestingController.expectOne(PUBLIC_URL).flush("boom", { status: 500,
statusText: "Server Error" });
+ expect(firstError).toBeTruthy();
+
+ let logo: string | null | undefined;
+ service.getPublicSetting("logo").subscribe(value => (logo = value));
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "recovered.png"
});
+ expect(logo).toEqual("recovered.png");
+ });
- req.flush({ key: EXAMPLE_SETTING, value: EXAMPLE_SETTING_VALUE });
- expect(req.request.method).toBe("GET");
- expect(result).toBe(EXAMPLE_SETTING_VALUE);
+ it("invalidates the public-settings cache after a save", () => {
+ service.getPublicSetting("logo").subscribe();
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "old.png" });
+
+ service.updateSetting("logo", "new.png").subscribe();
+ const put = httpTestingController.expectOne("/api/config/settings/logo");
+ expect(put.request.method).toEqual("PUT");
+ expect(put.request.body).toEqual({ value: "new.png" });
+ expect(put.request.withCredentials).toBe(true);
+ put.flush(null);
+
+ let logo: string | null | undefined;
+ service.getPublicSetting("logo").subscribe(value => (logo = value));
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "new.png" });
+ expect(logo).toEqual("new.png");
});
- it("updateSetting issues a PUT request with value and credentials", () => {
- service.updateSetting(EXAMPLE_SETTING, EXAMPLE_SETTING_VALUE).subscribe();
+ it("invalidates the public-settings cache after a reset", () => {
+ service.getPublicSetting("logo").subscribe();
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "old.png" });
- const req = httpMock.expectOne(`${BASE_URL}/${EXAMPLE_SETTING}`);
+ service.resetSetting("logo").subscribe();
+ const post =
httpTestingController.expectOne("/api/config/settings/reset/logo");
+ expect(post.request.method).toEqual("POST");
+ expect(post.request.body).toEqual({});
+ post.flush(null);
- req.flush(null);
- expect(req.request.method).toBe("PUT");
- expect(req.request.body).toEqual({ value: EXAMPLE_SETTING_VALUE });
- expect(req.request.withCredentials).toBe(true);
+ service.getPublicSetting("logo").subscribe();
+ httpTestingController.expectOne(PUBLIC_URL).flush({ logo: "default.png" });
});
- it("resetSetting issues a POST request with an empty body", () => {
- service.resetSetting(EXAMPLE_SETTING).subscribe();
+ it("reads every stored setting through the bulk management endpoint", () => {
+ let settings: Record<string, string> | undefined;
+ service.getAllSettings().subscribe(value => (settings = value));
- const req = httpMock.expectOne(`${BASE_URL}/reset/${EXAMPLE_SETTING}`);
+ const req = httpTestingController.expectOne("/api/config/settings");
+ expect(req.request.method).toEqual("GET");
+ req.flush({ logo: "a.png", csv_parser_max_columns: "4096" });
- req.flush(null);
- expect(req.request.method).toBe("POST");
- expect(req.request.body).toEqual({});
+ expect(settings).toEqual({ logo: "a.png", csv_parser_max_columns: "4096"
});
});
});
diff --git
a/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.ts
b/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.ts
index 9a9b40d3da..d71777929f 100644
---
a/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.ts
+++
b/frontend/src/app/dashboard/service/admin/settings/admin-settings.service.ts
@@ -19,8 +19,8 @@
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
-import { Observable } from "rxjs";
-import { map } from "rxjs/operators";
+import { Observable, throwError } from "rxjs";
+import { catchError, map, shareReplay, tap } from "rxjs/operators";
/**
* Service for managing site-wide settings (key-value pairs) via REST API.
@@ -31,20 +31,53 @@ import { map } from "rxjs/operators";
providedIn: "root",
})
export class AdminSettingsService {
- private readonly BASE_URL = "/api/admin/settings";
+ private readonly BASE_URL = "/api/config/settings";
+
+ // One request for all user-visible settings, shared by every consumer.
+ // Invalidated by the write methods below, so a save through this service
+ // never leaves the cache stale.
+ private publicSettings$?: Observable<Record<string, string>>;
+
constructor(private http: HttpClient) {}
- getSetting(key: string): Observable<string> {
- return this.http
- .get<{ key: string; value: string }>(`${this.BASE_URL}/${key}`)
- .pipe(map(resp => resp?.value ?? null));
+ /**
+ * Reads one of the user-visible settings (branding, sidebar tabs, upload
+ * limits) through the aggregated anonymous endpoint. Emits null when the
+ * key is absent from the payload, so callers must apply their own default.
+ */
+ getPublicSetting(key: string): Observable<string | null> {
+ if (!this.publicSettings$) {
+ this.publicSettings$ = this.http.get<Record<string,
string>>(`${this.BASE_URL}/public`).pipe(
+ // shareReplay would otherwise cache a failed fetch and replay the
+ // error to every consumer forever; drop the cached observable so the
+ // next getPublicSetting call retries the request.
+ catchError((err: unknown) => {
+ this.publicSettings$ = undefined;
+ return throwError(() => err);
+ }),
+ shareReplay(1)
+ );
+ }
+ return this.publicSettings$.pipe(map(settings => settings[key] ?? null));
+ }
+
+ /**
+ * Reads every stored setting (including management-only keys) in one
+ * payload. ADMIN-only; used by the admin settings page.
+ */
+ getAllSettings(): Observable<Record<string, string>> {
+ return this.http.get<Record<string, string>>(this.BASE_URL);
}
updateSetting(key: string, value: string): Observable<void> {
- return this.http.put<void>(`${this.BASE_URL}/${key}`, { value }, {
withCredentials: true });
+ return this.http
+ .put<void>(`${this.BASE_URL}/${key}`, { value }, { withCredentials: true
})
+ .pipe(tap(() => (this.publicSettings$ = undefined)));
}
resetSetting(key: string): Observable<void> {
- return this.http.post<void>(`${this.BASE_URL}/reset/${key}`, {});
+ return this.http
+ .post<void>(`${this.BASE_URL}/reset/${key}`, {})
+ .pipe(tap(() => (this.publicSettings$ = undefined)));
}
}