Source code for oarepo_config.ui
#!/usr/bin/env python3
#
# Copyright (c) 2026 CESNET z.s.p.o.
#
# This file is a part of oarepo-config (see https://github.com/oarepo/oarepo-config).
#
# oarepo-config is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
#
"""Configuration for the repository UI."""
from __future__ import annotations
import dataclasses
from typing import Any, ClassVar
from invenio_i18n import lazy_gettext as _
from .base import (
get_constant_from_caller,
load_configuration_variables,
set_constants_in_caller,
)
[docs]
@dataclasses.dataclass
class CSP:
"""Content Security Policy configuration."""
default_src: list[str] = dataclasses.field(default_factory=list)
"""Fallback for any directive below that is left unset."""
script_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for JavaScript."""
script_src_elem: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for ``<script>`` elements. Falls back to :attr:`script_src`."""
script_src_attr: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for inline event handler attributes. Falls back to :attr:`script_src`."""
style_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for CSS."""
style_src_elem: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for ``<style>`` elements. Falls back to :attr:`style_src`."""
style_src_attr: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for inline style attributes. Falls back to :attr:`style_src`."""
img_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for images."""
font_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for fonts."""
connect_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for fetch/XHR/WebSocket/EventSource calls (e.g. REST API endpoints)."""
object_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for ``<object>``, ``<embed>`` and ``<applet>`` plugins."""
media_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for ``<audio>``, ``<video>`` and ``<embed>`` media."""
frame_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for nested browsing contexts (``<iframe>``)."""
frame_ancestors: list[str] = dataclasses.field(default_factory=list)
"""Sources allowed to embed this page; the modern replacement for ``X-Frame-Options``."""
child_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for nested browsing contexts and workers."""
worker_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for Web Workers."""
manifest_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for the application manifest."""
prefetch_src: list[str] = dataclasses.field(default_factory=list)
"""Valid sources that may be prefetched."""
form_action: list[str] = dataclasses.field(default_factory=list)
"""Valid targets for ``<form>`` submissions."""
base_uri: list[str] = dataclasses.field(default_factory=list)
"""Valid sources for the document's ``<base>`` element."""
report_to: list[str] = dataclasses.field(default_factory=list)
"""``Report-To`` group name(s) to send CSP reports to; the modern replacement for :attr:`report_uri`."""
report_uri: str | None = None
"""URL to send CSP violation reports to."""
report_only: bool = False
"""Report violations without enforcing the policy. Requires :attr:`report_uri`."""
#: Fields that map to Talisman's standalone CSP options rather than to a
#: directive inside the ``content_security_policy`` dict.
_NON_DIRECTIVE_FIELDS: ClassVar[frozenset[str]] = frozenset({"report_uri", "report_only"})
#: Directives that CSP does not let ``default-src`` stand in for.
_NO_DEFAULT_SRC: ClassVar[frozenset[str]] = frozenset({"base-uri", "form-action", "frame-ancestors", "report-to"})
#: Directives that CSP resolves through a more specific directive before
#: it ever considers ``default-src``.
_FALLBACK_PARENT: ClassVar[dict[str, str]] = {
"script-src-elem": "script-src",
"script-src-attr": "script-src",
"style-src-elem": "style-src",
"style-src-attr": "style-src",
"worker-src": "child-src",
"frame-src": "child-src",
}
[docs]
def apply_defaults(self, headers: dict[str, Any]) -> dict[str, Any]:
"""Resolve this policy against the ``content_security_policy`` already in *headers*.
Directives set here are merged with the sources already configured for
them (or, lacking those, with the sources of the directive they'd fall
back to, e.g. ``default-src`` for ``connect_src``), so that setting one
directive doesn't silently drop defaults the app still needs. Unset
directives keep the configured value as-is. Same for ``report_uri``,
``report_only``.
The result replaces the CSP keys in *headers* rather than merging into
them: ``headers.update(csp.apply_defaults(headers))``.
Raises:
ValueError: If report-only mode ends up enabled without any
``report_uri``, which Flask-Talisman rejects.
"""
existing = self._existing_policy(headers)
resolved = self._resolve_policy(existing)
options: dict[str, Any] = {}
if resolved:
options["content_security_policy"] = resolved
report_uri = self.report_uri or headers.get("content_security_policy_report_uri")
report_only = self.report_only or bool(headers.get("content_security_policy_report_only"))
if report_only and not report_uri:
raise ValueError(
"report_only requires a report_uri, set on the CSP or already configured in APP_DEFAULT_SECURE_HEADERS."
)
if report_uri:
options["content_security_policy_report_uri"] = report_uri
if report_only:
options["content_security_policy_report_only"] = True
return options
def _resolve_policy(self, existing: dict[str, list[str]]) -> dict[str, list[str]]:
"""Combine the configured sources with the ones set on this policy."""
resolved: dict[str, list[str]] = {}
for field in dataclasses.fields(self):
if field.name in self._NON_DIRECTIVE_FIELDS:
continue
directive = field.name.replace("_", "-")
configured = existing.get(directive, [])
own = getattr(self, field.name)
if own:
# an explicit directive overrides the fallback it would
# otherwise inherit, so bring those sources back into it
defaults = configured or self._inherited_sources(directive, existing)
sources = list(dict.fromkeys([*own, *defaults]))
else:
sources = configured
if sources:
resolved[directive] = sources
# keep directives Talisman understands but this class does not model,
# including the source-less ones such as 'upgrade-insecure-requests'
for directive, sources in existing.items():
resolved.setdefault(directive, list(sources))
return resolved
def _inherited_sources(self, directive: str, existing: dict[str, list[str]]) -> list[str]:
"""Return the sources of the first directive *directive* falls back to."""
if directive in self._NO_DEFAULT_SRC:
return []
seen = {directive}
current = self._FALLBACK_PARENT.get(directive, "default-src")
while current not in seen:
seen.add(current)
if existing.get(current):
return existing[current]
current = self._FALLBACK_PARENT.get(current, "default-src")
return []
@staticmethod
def _existing_policy(headers: dict[str, Any]) -> dict[str, list[str]]:
"""Read the configured policy from *headers*, normalised to source lists."""
policy = headers.get("content_security_policy") or {}
if isinstance(policy, str):
raise TypeError("only dict policies are supported, got str")
return {
directive: sources.split() if isinstance(sources, str) else list(sources)
for directive, sources in policy.items()
}
[docs]
def configure_ui( # noqa PLR0915
code: str = "myrepo",
name: str | Any = "My Repository",
subtitle: str = "",
description: str = "",
support_contact: str = "",
keywords: str = "",
use_default_frontpage: bool = False,
show_frontpage_intro: bool = True,
analytics: str | bool = False,
languages: tuple[tuple[str, str], ...] = (("en", "English"),),
csp: CSP | None = None,
) -> None:
"""Set up the repository's branding, name and general look-and-feel.
Configures what visitors see: the repository's name and description
shown in the browser tab/search results/front page, the theme, the
page layout templates, and (optionally) analytics tracking.
Args:
code: A short, technical, all-lowercase identifier for this
repository (e.g. ``"myrepo"``); used internally to select
the repository's own theme files.
name: The repository's display name, shown in the browser title
and on the front page, e.g. ``_("My Repository")``.
subtitle: A short subtitle/tagline shown next to the name.
description: A longer description of the repository, used for
SEO and on the front page.
support_contact: Contact information (e.g. an e-mail address)
shown to users who need help.
keywords: Keywords describing the repository's content, used for
SEO.
use_default_frontpage: Whether to use Invenio's generic front
page. Most repositories provide a custom front page, so this
defaults to ``False``.
show_frontpage_intro: Whether to show the introductory text
section on the front page.
analytics: Set to ``"matomo"`` to enable Matomo web analytics
tracking (only takes effect on deployed, non-local
instances; requires the Matomo URL/site ID to be configured
in the deployment's environment). ``False`` (the default)
disables analytics.
languages: Extra UI languages to offer, as a tuple of
``(language_code, label)`` pairs. English is always
available and does not need to be listed. This should
normally match what was passed to
:func:`configure_generic_parameters`.
csp: Content Security Policy configuration. Only the sources passed
here are recorded; the ones the deployment already allows (set up
by :func:`configure_generic_parameters`) are added on top of them
by :meth:`CSP.apply_defaults`.
Should be called after :func:`configure_generic_parameters` (it
builds on settings that function prepares) and before
:func:`configure_oai` (which needs the repository name set here).
Example:
.. code-block:: python
config.configure_ui(
code="myrepo",
name=_("My Repository"),
description=_(
"A repository for my data"
),
support_contact="support@example.com",
csp=CSP(
# where the UI's JavaScript may fetch data from,
# e.g. an external REST API
connect_src=[
"'self'",
"https://api.example.com",
],
),
)
Invenio configuration variables set, grouped by area:
Versioning
* ``DEPLOYMENT_VERSION``
Theme & security
* ``APP_THEME``
* ``APP_DEFAULT_SECURE_HEADERS`` - extends the value prepared by
:func:`configure_generic_parameters`.
Page layout templates
* ``INSTANCE_THEME_FILE``
* ``BASE_TEMPLATE``, ``ADMINISTRATION_THEME_BASE_TEMPLATE``,
``COVER_TEMPLATE``
* ``THEME_CSS_TEMPLATE``, ``THEME_JAVASCRIPT_TEMPLATE``
* ``HEADER_TEMPLATE``, ``THEME_HEADER_TEMPLATE``,
``THEME_HEADER_LOGIN_TEMPLATE``, ``THEME_FOOTER_TEMPLATE``
* ``THEME_TRACKINGCODE_TEMPLATE``, ``THEME_FRONTPAGE_TEMPLATE``
* ``SETTINGS_TEMPLATE``, ``SEARCH_UI_SEARCH_TEMPLATE``
Analytics
* ``MATOMO_ANALYTICS_TEMPLATE``, ``MATOMO_ANALYTICS_URL``,
``MATOMO_ANALYTICS_SITE_ID`` - only if ``analytics="matomo"``
and this is not a local-development deployment.
Branding
* ``THEME_FRONTPAGE``, ``THEME_LOGO``, ``THEME_FRONTPAGE_LOGO``
* ``THEME_SITENAME``, ``THEME_FRONTPAGE_TITLE``,
``THEME_SHOW_FRONTPAGE_INTRO_SECTION``
Repository metadata (SEO & front page)
* ``REPOSITORY_NAME``, ``REPOSITORY_DESCRIPTION``,
``REPOSITORY_SUPPORT_CONTACT``, ``REPOSITORY_SUBTITLE``,
``REPOSITORY_KEYWORDS``
Build pipeline
* ``JAVASCRIPT_PACKAGES_MANAGER``, ``ASSETS_BUILDER``,
``WEBPACKEXT_NPM_PKG_CLS``, ``WEBPACKEXT_PROJECT``
Records & deposit UI
* ``RECORDS_UI_ENDPOINTS``,
``APP_RDM_DEPOSIT_NG_FILES_UI_ENABLED``,
``DASHBOARD_RECORD_CREATE_URL``
* ``APP_RDM_DETAIL_SIDE_BAR_TEMPLATES``
Search UI
* ``THEME_SEARCH_ENDPOINT``, ``SEARCH_UI_SEARCH_VIEW``
"""
env = load_configuration_variables()
DEPLOYMENT_VERSION = env.get("INVENIO_DEPLOYMENT_VERSION", "local development")
APP_THEME = [code, "oarepo", "semantic-ui"]
APP_DEFAULT_SECURE_HEADERS: dict[str, Any] = get_constant_from_caller("APP_DEFAULT_SECURE_HEADERS", {})
APP_DEFAULT_SECURE_HEADERS["content_security_policy"]["default-src"].append(
# Fix for displaying images from another source (this one is for licenses specifically)
"https://licensebuttons.net/"
)
INSTANCE_THEME_FILE = "./less/theme.less"
# Template config
BASE_TEMPLATE = "page.html"
ADMINISTRATION_THEME_BASE_TEMPLATE = "invenio_app_rdm/administration_page.html"
COVER_TEMPLATE = "invenio_app_rdm/page_cover.html"
THEME_CSS_TEMPLATE = "css.html"
THEME_JAVASCRIPT_TEMPLATE = "javascript.html"
HEADER_TEMPLATE = "header.html"
THEME_HEADER_TEMPLATE = "header.html"
THEME_HEADER_LOGIN_TEMPLATE = "invenio_app_rdm/header_login.html"
THEME_FOOTER_TEMPLATE = "footer.html"
THEME_TRACKINGCODE_TEMPLATE = "oarepo_ui/trackingcode.html"
THEME_FRONTPAGE_TEMPLATE = "frontpage.html"
# Invenio has problems with order of loading templates. If invenio-userprofiles is loaded
# before invenio-theme, the userprofile page will not work because base settings page
# will be taken from userprofiles/semantic-ui/userprofiles/settings/base.html which is faulty.
# If invenio-theme is loaded first, SETTINGS_TEMPLATE is filled, then userprofiles will use
# it and the UI loads correctly.
# This line just makes sure that SETTINGS_TEMPLATE and HEADER_TEMPLATE is always set up.
SETTINGS_TEMPLATE = "invenio_theme/page_settings.html"
HEADER_TEMPLATE = "invenio_theme/header.html"
SEARCH_UI_SEARCH_TEMPLATE = "invenio_app_rdm/records/search.html"
if analytics == "matomo" and DEPLOYMENT_VERSION != "local development":
MATOMO_ANALYTICS_TEMPLATE = "oarepo_ui/matomo_analytics.html"
MATOMO_ANALYTICS_URL = env.INVENIO_MATOMO_ANALYTICS_URL
MATOMO_ANALYTICS_SITE_ID = env.INVENIO_MATOMO_ANALYTICS_SITE_ID
APP_DEFAULT_SECURE_HEADERS["content_security_policy"]["default-src"].append(env.INVENIO_MATOMO_ANALYTICS_URL)
# UI Branding & copywriting
THEME_FRONTPAGE = use_default_frontpage
THEME_LOGO = "images/invenio-rdm.svg"
THEME_FRONTPAGE_LOGO = None
THEME_SITENAME = _(name)
THEME_FRONTPAGE_TITLE = name
THEME_SHOW_FRONTPAGE_INTRO_SECTION = show_frontpage_intro
# SEO & Front-page information
REPOSITORY_NAME = name
REPOSITORY_DESCRIPTION = description
REPOSITORY_SUPPORT_CONTACT = support_contact
REPOSITORY_SUBTITLE = subtitle
REPOSITORY_KEYWORDS = keywords
# Build pipeline config
JAVASCRIPT_PACKAGES_MANAGER = "pnpm"
ASSETS_BUILDER = "rspack"
WEBPACKEXT_NPM_PKG_CLS = "pynpm:PNPMPackage"
WEBPACKEXT_PROJECT = "oarepo_ui.webpack:project"
# Do not add default records UI as we provide our own compatibility layer
RECORDS_UI_ENDPOINTS: list = [] # type: ignore[var-annotated]
# UPPY uploader is default for us
APP_RDM_DEPOSIT_NG_FILES_UI_ENABLED = True
WEBPACKEXT_NPM_PKG_CLS = "pynpm:PNPMPackage"
DASHBOARD_RECORD_CREATE_URL = "/uploads/new"
# TODO: consult using app_rdm ones @mirekys
APP_RDM_DETAIL_SIDE_BAR_TEMPLATES = [
"invenio_app_rdm/records/details/side_bar/external_resources.html",
"invenio_app_rdm/records/details/side_bar/keywords_subjects.html",
"invenio_app_rdm/records/details/side_bar/details.html",
"invenio_app_rdm/records/details/side_bar/licenses.html",
"oarepo_ui/record_detail/side_bar/export.html",
"invenio_app_rdm/records/details/side_bar/citations.html",
"invenio_app_rdm/records/details/side_bar/manage_menu.html",
"invenio_app_rdm/records/details/side_bar/technical_metadata.html",
]
from invenio_search_ui import config as search_ui_config
from invenio_theme import config as theme_config
THEME_LOGO = "images/theme-logo.png"
THEME_SEARCH_ENDPOINT = theme_config.THEME_SEARCH_ENDPOINT
SEARCH_UI_SEARCH_VIEW = search_ui_config.SEARCH_UI_SEARCH_VIEW
if csp is not None:
APP_DEFAULT_SECURE_HEADERS.update(csp.apply_defaults(APP_DEFAULT_SECURE_HEADERS))
set_constants_in_caller(locals())