Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5dd986b
feat: support flag parameters without valuesAllow flags to work with …
hjn0415a May 19, 2026
b0e682e
ci(docker): publish multi-arch (amd64 + arm64) images to GHCR
claude May 25, 2026
d32c1b9
ci(docker): extend apptainer/nginx/traefik tests to cover arm64
claude May 25, 2026
1d73b67
fix(arm): use two-pass cmake so TOPP links against system libstdc++
claude May 25, 2026
f11bc99
fix(arm): install cmake via apt so pass-1 cmake is on plain bash PATH
claude May 25, 2026
5185c3e
fix(arm): call mamba cmake by full path in pass 1 (apt 3.22 is too old)
claude May 25, 2026
0bab3ae
ci: free disk space at the start of each integration test job
claude May 25, 2026
4790d46
fix(arm): keep CMakeFiles/ between make TOPP and make pyopenms
claude May 25, 2026
c7fdf00
ci(apptainer): drop arm64 — no upstream aarch64 .deb
claude May 25, 2026
f466229
fix(ci): keep /opt/hostedtoolcache when freeing disk space
claude May 26, 2026
f0d1db1
ci: dump cluster state on test-nginx/test-traefik failure
claude May 26, 2026
7aadb06
fix(ci): load images into kind via image-archive, drop docker load
claude May 26, 2026
6119021
ci: re-trigger workflow run
claude May 26, 2026
88dcdb0
ci: re-trigger workflow run after outage
claude May 26, 2026
39d6d25
fix(ci): match kind image to what the kustomize overlay references
claude May 26, 2026
ba05e6a
Merge pull request #393 from OpenMS/claude/gallant-mendel-K2jDd
t0mdavid-m May 27, 2026
0ad89fa
fix(ci): disable provenance on build-amd64 to keep pushes single-mani…
claude May 27, 2026
6aac16b
Merge pull request #394 from OpenMS/claude/fix-amd64-provenance
t0mdavid-m May 27, 2026
e9d162a
Merge pull request #391 from hjn0415a/feature/flag-parameter
t0mdavid-m Jun 11, 2026
a703e05
Merge pull request #396 from OpenMS/claude/laughing-fermat-ppiw0s
t0mdavid-m Jun 17, 2026
76a793e
fix treafik ingress
t0mdavid-m Jun 17, 2026
b9422d7
Merge upstream OpenMS/streamlit-template into FLASHApp
t0mdavid-m Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ gdpr_consent/node_modules/
*~
.streamlit/secrets.toml
docs/superpowers/
.venv/
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,20 @@ After it has been built you can run the image with:
`docker run -p 8501:8501 flashapp:latest`

Navigate to `http://localhost:8501` in your browser.

## Legal pages (Impressum, Privacy Policy, Terms of Use)

Every page shows **Impressum**, **Privacy Policy** and **Terms of Use** links in the
sidebar footer, and the GDPR consent banner links to the privacy policy. By default
these point to the official OpenMS pages (`https://openms.de/impressum`, `/privacy`,
`/terms`). To override them — for example when self-hosting or deploying FLASHApp
under a different operator — set `legal_links` in `settings.json`:

"legal_links": {
"impressum": "https://your-domain.example/impressum",
"privacy": "https://your-domain.example/privacy",
"terms": "https://your-domain.example/terms"
}

Any link you omit falls back to its OpenMS default. The `privacy` URL is reused for the
consent banner's privacy-policy link, so consent and policy stay in sync.
2 changes: 1 addition & 1 deletion gdpr_consent/dist/bundle.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions gdpr_consent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let klaroConfig: {
mustConsent: boolean;
acceptAll: boolean;
services: Service[];
translations?: Record<string, any>;
} = {
mustConsent: true,
acceptAll: true,
Expand Down Expand Up @@ -125,6 +126,18 @@ function onRender(event: Event): void {
)
}

// Link the consent banner to the privacy policy. Setting privacyPolicyUrl
// on the 'zz' fallback language makes Klaro render its default
// "To learn more, please read our privacy policy." text with the URL,
// regardless of the browser locale.
if (data.args['privacy_policy']) {
klaroConfig.translations = {
zz: {
privacyPolicyUrl: data.args['privacy_policy']
}
}
}

// Create a new script element
var script = document.createElement('script')

Expand Down
4 changes: 3 additions & 1 deletion k8s/base/traefik-ingressroute.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ metadata:
name: streamlit-traefik
spec:
entryPoints:
- web
- web # 301-redirects to websecure
- websecure
routes:
- match: PathPrefix(`/`)
kind: Rule
Expand All @@ -16,3 +17,4 @@ spec:
name: stroute
httpOnly: true
sameSite: lax
secure: true # only send the affinity cookie over HTTPS
5 changes: 5 additions & 0 deletions settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"github-user": "OpenMS",
"version": "1.0.0",
"repository-name": "FLASHApp",
"legal_links": {
"impressum": "https://openms.de/impressum",
"privacy": "https://openms.de/privacy",
"terms": "https://openms.de/terms"
},
"analytics": {
"google-analytics": {
"enabled": false,
Expand Down
11 changes: 9 additions & 2 deletions src/common/captcha_.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def add_page(main_script_path_str: str, page_name: str) -> None:


# define the function for the captcha control
def captcha_control():
def captcha_control(privacy_policy_url: str = ""):
"""
Control and verification of a CAPTCHA to ensure the user is not a robot.

Expand All @@ -199,6 +199,10 @@ def captcha_control():

The CAPTCHA text is generated as a session state and should not change during refreshes.

Args:
privacy_policy_url (str, optional): URL shown as the privacy policy link
in the GDPR consent banner. Defaults to "".

Returns:
None
"""
Expand All @@ -214,7 +218,10 @@ def captcha_control():
with st.spinner():
# Ask for consent
st.session_state.tracking_consent = consent_component(
google_analytics=ga, piwik_pro=pp, matomo=mt
google_analytics=ga,
piwik_pro=pp,
matomo=mt,
privacy_policy=privacy_policy_url,
)
if st.session_state.tracking_consent is None:
# No response by user yet
Expand Down
48 changes: 46 additions & 2 deletions src/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@
# Detect system platform
OS_PLATFORM = sys.platform

# Default legal/GDPR page links. These point to the centrally maintained
# official OpenMS pages. Forks that self-host should override them via the
# "legal_links" key in settings.json (an Impressum must name the actual
# operator). The defaults live here too — not only in settings.json — so that
# downstream apps built from an older settings.json without a "legal_links"
# key still inherit working legal links by default.
DEFAULT_LEGAL_LINKS = {
"impressum": "https://openms.de/impressum",
"privacy": "https://openms.de/privacy",
"terms": "https://openms.de/terms",
}


def get_legal_links() -> dict[str, str]:
"""
Return the legal page URLs (Impressum, Privacy Policy, Terms of Use).

Values from the "legal_links" object in settings.json override the
built-in OpenMS defaults. Empty override values are ignored so a blank
entry can't erase a default.

Returns:
dict[str, str]: Mapping of "impressum", "privacy" and "terms" to URLs.
"""
overrides = (
st.session_state.settings.get("legal_links", {})
if "settings" in st.session_state
else {}
)
return {**DEFAULT_LEGAL_LINKS, **{k: v for k, v in overrides.items() if v}}


def is_safe_workspace_name(name: str) -> bool:
"""
Expand Down Expand Up @@ -549,7 +580,7 @@ def page_setup(page: str = "") -> dict[str, Any]:
# Render the sidebar
params = render_sidebar(page)

captcha_control()
captcha_control(privacy_policy_url=get_legal_links()["privacy"])

# If run in hosted mode, show captcha as long as it has not been solved
# if not "local" in sys.argv:
Expand All @@ -562,7 +593,7 @@ def page_setup(page: str = "") -> dict[str, Any]:
"controllo" in params.keys() and params["controllo"] == False
):
# Apply captcha by calling the captcha_control function
captcha_control()
captcha_control(privacy_policy_url=get_legal_links()["privacy"])

return params

Expand Down Expand Up @@ -794,6 +825,19 @@ def change_workspace():
f'<div class="version-box">{app_name}<br>Version: {version_info}</div>',
unsafe_allow_html=True,
)

# Legal links (Impressum, Privacy Policy, Terms of Use), shown on every
# page. URLs are configurable via "legal_links" in settings.json.
links = get_legal_links()
st.markdown(
'<div style="text-align:center; font-size:0.8rem; '
'margin-top:0.5rem; color:#a4a5ad;">'
f'<a href="{links["impressum"]}" target="_blank" rel="noopener" style="white-space:nowrap">Impressum</a> &middot; '
f'<a href="{links["privacy"]}" target="_blank" rel="noopener" style="white-space:nowrap">Privacy Policy</a> &middot; '
f'<a href="{links["terms"]}" target="_blank" rel="noopener" style="white-space:nowrap">Terms of Use</a>'
"</div>",
unsafe_allow_html=True,
)
return params


Expand Down
55 changes: 41 additions & 14 deletions src/workflow/CommandExecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import threading
from pathlib import Path
from .Logger import Logger
from .ParameterManager import ParameterManager
from .ParameterManager import ParameterManager, bool_param_paths_from_param_xml_ini
import sys
import importlib.util
import json
Expand Down Expand Up @@ -216,7 +216,7 @@ def read_stderr():
stdout_thread.join()
stderr_thread.join()

def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> bool:
def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}, tool_instance_name: str = None) -> bool:
"""
Constructs and executes commands for the specified tool OpenMS TOPP tool based on the given
input and output configurations. Ensures that all input/output file lists
Expand All @@ -234,6 +234,10 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b
tool (str): The executable name or path of the tool.
input_output (dict): A dictionary specifying the input/output parameter names (as key) and their corresponding file paths (as value).
custom_params (dict): A dictionary of custom parameters to pass to the tool.
tool_instance_name (str, optional): Key for ``params.json`` when it differs
from ``tool`` (e.g. multiple instances). Defaults to ``tool``.
Custom parameters whose keys appear in the tool's ParamXML ``type="bool"``
entries are passed as valueless CLI flags (``-name`` only when enabled).

Returns:
bool: True if all commands succeeded, False if any failed.
Expand Down Expand Up @@ -261,8 +265,15 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b

commands = []

# Load parameters for non-defaults
params = self.parameter_manager.get_parameters_from_json()

topp_tool_ini_path = Path(self.parameter_manager.ini_dir, f"{tool}.ini")
# Keys of type="bool" in the .ini: TOPP treats these as on/off flags (omit value when off)
topp_bool_flag_param_keys = (
bool_param_paths_from_param_xml_ini(topp_tool_ini_path, tool)
if topp_tool_ini_path.exists()
else set()
)
# Construct commands for each process
for i in range(n_processes):
command = [tool]
Expand All @@ -284,24 +295,40 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> b
# Add non-default TOPP tool parameters
if tool in params.keys():
for k, v in params[tool].items():
# Boolean flag handling: 'true' -> flag only, 'false' -> skip entirely
if isinstance(v, str) and v == 'true':
command += [f"-{k}"]
elif isinstance(v, str) and v == 'false':
pass
elif v == "" or v is None:
# Empty string or None: flag only (no value)
command += [f"-{k}"]
else:
command += [f"-{k}"]
# Note: 0 and 0.0 are valid values, so use explicit check above
# Boolean flag handling. A parameter is emitted as a
# valueless TOPP on/off flag when EITHER the tool's ParamXML
# .ini marks the key type="bool" (upstream key-based
# detection via topp_bool_flag_param_keys) OR the stored
# value is itself boolean. The value-based branch is the
# fallback FLASHApp relies on: checkbox widgets persist a
# Python bool (True/False) to params.json, and pyOpenMS /
# presets may surface the 'true'/'false' string form. This
# keeps flags correct even when the .ini bool set is empty
# (e.g. the .ini was not written).
is_bool_value = isinstance(v, bool) or (
isinstance(v, str) and v.lower() in ("true", "false")
)
if (k in topp_bool_flag_param_keys and v != "") or is_bool_value:
# CLI flag: include "-k" only when enabled, never a value.
if isinstance(v, str):
is_enabled = v.lower() == "true"
else:
is_enabled = bool(v)
if is_enabled:
command += [f"-{k}"]
continue
command += [f"-{k}"]
# Skip only empty strings (pass flag with no value)
# Note: 0 and 0.0 are valid values, so use explicit check
if v != "" and v is not None:
if isinstance(v, str) and "\n" in v:
command += v.split("\n")
else:
command += [str(v)]
# Add custom parameters
for k, v in custom_params.items():
command += [f"-{k}"]

# Skip only empty strings (pass flag with no value)
# Note: 0 and 0.0 are valid values, so use explicit check
if v != "" and v is not None:
Expand Down
70 changes: 70 additions & 0 deletions src/workflow/ParameterManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,52 @@
import shutil
import subprocess
import streamlit as st
import xml.etree.ElementTree as ET
from pathlib import Path


def bool_param_paths_from_param_xml_ini(ini_path: Path, tool_stem: str) -> set[str]:
"""
Return short parameter paths for every ``<ITEM type="bool">`` in a ParamXML .ini file.

Paths match the suffix after ``Tool:1:`` in pyOpenMS (e.g. ``algorithm:epd:masstrace_snr_filtering``).
"""
try:
root = ET.parse(ini_path).getroot()
except (ET.ParseError, OSError):
Comment on lines +6 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f -i 'requirements.*\.txt|pyproject\.toml|setup\.py|environment\.ya?ml' . \
  | xargs -r rg -n 'defusedxml' || true

rg -n 'xml\.etree\.ElementTree|from defusedxml' src/workflow/ParameterManager.py

Repository: OpenMS/FLASHApp

Length of output: 190


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== manifests =="
git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|setup\.py|environment\.ya?ml|Pipfile|poetry\.lock)$' || true

echo "== defusedxml references =="
rg -n 'defusedxml' . || true

echo "== ParameterManager snippet =="
sed -n '1,170p' src/workflow/ParameterManager.py | cat -n

Repository: OpenMS/FLASHApp

Length of output: 8526


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== requirements.txt =="
sed -n '1,220p' requirements.txt | cat -n

echo "== callers of bool_param_paths_from_param_xml_ini =="
rg -n 'bool_param_paths_from_param_xml_ini|_merge_bool_params_from_ini|create_ini\(' src/workflow/ParameterManager.py

echo "== ini path sources =="
rg -n 'self\.ini_dir|write_ini|ParamXMLFile\(\)\.load|\.ini' src/workflow/ParameterManager.py

Repository: OpenMS/FLASHApp

Length of output: 5228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('requirements.txt')
print("exists:", p.exists())
if p.exists():
    txt = p.read_text().splitlines()
    for i, line in enumerate(txt, 1):
        if 'defusedxml' in line.lower() or 'xml' in line.lower():
            print(f"{i}: {line}")
PY

Repository: OpenMS/FLASHApp

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== requirements.txt =="
sed -n '1,220p' requirements.txt | cat -n

echo "== callers of bool_param_paths_from_param_xml_ini =="
rg -n 'bool_param_paths_from_param_xml_ini|_merge_bool_params_from_ini|create_ini\(' src/workflow/ParameterManager.py

echo "== ini path sources =="
rg -n 'self\.ini_dir|write_ini|ParamXMLFile\(\)\.load|\.ini' src/workflow/ParameterManager.py

Repository: OpenMS/FLASHApp

Length of output: 5228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('requirements.txt')
print("exists:", p.exists())
if p.exists():
    for i, line in enumerate(p.read_text().splitlines(), 1):
        if 'defusedxml' in line.lower() or 'xml' in line.lower():
            print(f"{i}: {line}")
PY

Repository: OpenMS/FLASHApp

Length of output: 166


Use defusedxml for ParamXML ini parsing

src/workflow/ParameterManager.py should switch from xml.etree.ElementTree to defusedxml, and the dependency needs to be added to the project manifest as well — requirements.txt does not declare it. Keep the empty-set fallback for malformed/blocked XML.

🧰 Tools
🪛 Ruff (0.15.18)

[error] 17-17: Using xml to parse untrusted data is known to be vulnerable to XML attacks; use defusedxml equivalents

(S314)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/workflow/ParameterManager.py` around lines 6 - 18,
`bool_param_paths_from_param_xml_ini` currently parses ParamXML with
`xml.etree.ElementTree`, but it should use `defusedxml` to block unsafe XML
parsing while preserving the existing empty-set fallback on parse/load failures.
Update the import and parsing path in `ParameterManager.py` to use the defused
XML API, and add the missing `defusedxml` dependency to the project manifest so
the module is available at runtime.

Source: Linters/SAST tools

return set()

def local_tag(el: ET.Element) -> str:
t = el.tag
return t.rsplit("}", 1)[-1] if isinstance(t, str) and "}" in t else str(t)

out: set[str] = set()

def walk(el: ET.Element, parts: tuple[str, ...]) -> None:
for ch in el:
lt = local_tag(ch)
if lt == "NODE":
nm = ch.get("name") or ""
walk(ch, parts + (nm,))
elif lt == "ITEM" and (ch.get("type") or "").lower() == "bool":
nm = ch.get("name") or ""
segs = [p for p in parts if p]
if nm:
segs.append(nm)
if not segs:
continue
# Strip tool root NODE name and instance NODE "1" (not part of pyOpenMS short keys)
while segs and segs[0] in (tool_stem, "1"):
segs.pop(0)
if segs:
out.add(":".join(segs))

for ch in root:
if local_tag(ch) == "NODE":
walk(ch, ())
return out


class ParameterManager:
"""
Manages the parameters for a workflow, including saving parameters to a JSON file,
Expand All @@ -29,6 +73,29 @@ def __init__(self, workflow_dir: Path, workflow_name: str = None):
# Store workflow name for preset loading; default to directory stem if not provided
self.workflow_name = workflow_name or workflow_dir.stem

def bool_pairs_session_key(self) -> str:
"""Session state key holding a set of (tool name, param path) for bool TOPP params."""
return f"{self.ini_dir.parent.stem}-topp-bool-pairs"

def get_bool_param_pairs(self) -> set:
"""Return the cached set of (tool, param path) bool params; empty set if none."""
return st.session_state.get(self.bool_pairs_session_key(), set())

def _merge_bool_params_from_ini(self, tool: str) -> None:
"""Load tool.ini (XML) and merge type=bool parameter paths into session_state."""
ini_path = Path(self.ini_dir, f"{tool}.ini")
if not ini_path.exists():
return
try:
sk = self.bool_pairs_session_key()
if sk not in st.session_state:
st.session_state[sk] = set()
for short in bool_param_paths_from_param_xml_ini(ini_path, tool):
st.session_state[sk].add((tool, short))
except RuntimeError:
# No Streamlit session (e.g. plain `python` import)
pass

def create_ini(self, tool: str) -> bool:
"""
Create an ini file for a TOPP tool if it doesn't exist.
Expand All @@ -41,11 +108,14 @@ def create_ini(self, tool: str) -> bool:
"""
ini_path = Path(self.ini_dir, tool + ".ini")
if ini_path.exists():
self._merge_bool_params_from_ini(tool)
return True
try:
subprocess.call([tool, "-write_ini", str(ini_path)])
except FileNotFoundError:
return False
if ini_path.exists():
self._merge_bool_params_from_ini(tool)
return ini_path.exists()

def save_parameters(self) -> None:
Expand Down
Loading
Loading