Skip to content

Commit 020cbf7

Browse files
committed
Merge remote-tracking branch 'openms/main'
2 parents 7f5d3a3 + e1df1a7 commit 020cbf7

5 files changed

Lines changed: 242 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ python*
1111
**/__pycache__/
1212
gdpr_consent/node_modules/
1313
*~
14+
.streamlit/secrets.toml

.streamlit/secrets.toml.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Streamlit Secrets Configuration
2+
# Copy this file to secrets.toml and fill in your values.
3+
# IMPORTANT: Never commit secrets.toml to version control!
4+
5+
[admin]
6+
# Password required to save workspaces as demo workspaces (online mode only)
7+
# Set a strong, unique password here
8+
password = "your-secure-admin-password-here"

src/common/admin.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""
2+
Admin utilities for the Streamlit template.
3+
4+
Provides functionality for admin-only operations like saving workspaces as demos.
5+
"""
6+
7+
import hmac
8+
import shutil
9+
from pathlib import Path
10+
11+
import streamlit as st
12+
13+
14+
def is_admin_configured() -> bool:
15+
"""
16+
Check if admin password is configured in Streamlit secrets.
17+
18+
Returns:
19+
bool: True if admin password is configured, False otherwise.
20+
"""
21+
try:
22+
return bool(st.secrets.get("admin", {}).get("password"))
23+
except (FileNotFoundError, KeyError):
24+
return False
25+
26+
27+
def verify_admin_password(password: str) -> bool:
28+
"""
29+
Verify the provided password against the configured admin password.
30+
31+
Uses constant-time comparison to prevent timing attacks.
32+
33+
Args:
34+
password: The password to verify.
35+
36+
Returns:
37+
bool: True if password matches, False otherwise.
38+
"""
39+
if not is_admin_configured():
40+
return False
41+
42+
try:
43+
stored_password = st.secrets["admin"]["password"]
44+
# Use constant-time comparison for security
45+
return hmac.compare_digest(password, stored_password)
46+
except (FileNotFoundError, KeyError):
47+
return False
48+
49+
50+
def get_demo_target_dir() -> Path:
51+
"""
52+
Get the directory where demo workspaces are stored.
53+
54+
Returns:
55+
Path: The demo workspaces directory.
56+
"""
57+
return Path("example-data/workspaces")
58+
59+
60+
def demo_exists(demo_name: str) -> bool:
61+
"""
62+
Check if a demo workspace with the given name already exists.
63+
64+
Args:
65+
demo_name: Name of the demo to check.
66+
67+
Returns:
68+
bool: True if demo exists, False otherwise.
69+
"""
70+
target_dir = get_demo_target_dir()
71+
demo_path = target_dir / demo_name
72+
return demo_path.exists()
73+
74+
75+
def _remove_directory_with_symlinks(path: Path) -> None:
76+
"""
77+
Remove a directory that may contain symlinks.
78+
79+
Handles symlinks properly by removing them without following.
80+
81+
Args:
82+
path: Path to the directory to remove.
83+
"""
84+
if not path.exists():
85+
return
86+
87+
for item in path.rglob("*"):
88+
if item.is_symlink():
89+
item.unlink()
90+
91+
# Now remove the rest normally
92+
if path.exists():
93+
shutil.rmtree(path)
94+
95+
96+
def save_workspace_as_demo(workspace_path: Path, demo_name: str) -> tuple[bool, str]:
97+
"""
98+
Save the current workspace as a demo workspace.
99+
100+
Copies all files from the workspace to the demo directory, following symlinks
101+
to copy actual file contents rather than symlink references.
102+
103+
Args:
104+
workspace_path: Path to the source workspace.
105+
demo_name: Name for the new demo workspace.
106+
107+
Returns:
108+
tuple[bool, str]: (success, message) tuple indicating result.
109+
"""
110+
# Deferred import to avoid circular dependency with common.py
111+
from src.common.common import is_safe_workspace_name
112+
113+
# Validate demo name
114+
if not demo_name:
115+
return False, "Demo name cannot be empty."
116+
117+
if not is_safe_workspace_name(demo_name):
118+
return False, "Invalid demo name. Avoid path separators and special characters."
119+
120+
# Validate source workspace exists
121+
if not workspace_path.exists():
122+
return False, "Source workspace does not exist."
123+
124+
# Get target directory
125+
target_dir = get_demo_target_dir()
126+
demo_path = target_dir / demo_name
127+
128+
try:
129+
# Ensure parent directory exists
130+
target_dir.mkdir(parents=True, exist_ok=True)
131+
132+
# Remove existing demo if it exists (handles symlinks properly)
133+
if demo_path.exists():
134+
_remove_directory_with_symlinks(demo_path)
135+
136+
# Copy workspace to demo directory, following symlinks to get actual files
137+
shutil.copytree(
138+
workspace_path,
139+
demo_path,
140+
symlinks=False, # Follow symlinks, copy actual files
141+
dirs_exist_ok=False
142+
)
143+
144+
return True, f"Workspace saved as demo '{demo_name}' successfully."
145+
146+
except PermissionError:
147+
return False, "Permission denied. Cannot write to demo directory."
148+
except OSError as e:
149+
return False, f"Failed to save demo: {str(e)}"
150+
except Exception as e:
151+
return False, f"Unexpected error: {str(e)}"

src/common/common.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
TK_AVAILABLE = False
2222

2323
from src.common.captcha_ import captcha_control
24+
from src.common.admin import (
25+
is_admin_configured,
26+
verify_admin_password,
27+
demo_exists,
28+
save_workspace_as_demo,
29+
)
2430

2531
# Detect system platform
2632
OS_PLATFORM = sys.platform
@@ -122,6 +128,8 @@ def _symlink_tree(source: Path, target: Path) -> None:
122128
123129
Creates real directories but symlinks individual files, allowing users to
124130
add new files to workspace directories without affecting the original.
131+
params.json and .ini files are copied instead of symlinked so they can be
132+
modified independently.
125133
126134
Args:
127135
source: Source directory path.
@@ -132,6 +140,9 @@ def _symlink_tree(source: Path, target: Path) -> None:
132140
target_item = target / item.name
133141
if item.is_dir():
134142
_symlink_tree(item, target_item)
143+
elif item.name == "params.json" or item.suffix == ".ini":
144+
# Copy config files so they can be modified independently
145+
shutil.copy2(item, target_item)
135146
else:
136147
# Create symlink to the source file
137148
target_item.symlink_to(item.resolve())
@@ -610,14 +621,83 @@ def change_workspace():
610621
else:
611622
if target.exists():
612623
target.unlink()
613-
if OS_PLATFORM == "linux":
624+
# Copy config files so they can be modified independently
625+
if OS_PLATFORM == "linux" and item.name != "params.json" and item.suffix != ".ini":
614626
target.symlink_to(item.resolve())
615627
else:
616628
shutil.copy2(item, target)
617629
st.success(f"Demo data '{selected_demo}' loaded!")
618630
time.sleep(1)
619631
st.rerun()
620632

633+
# Save as Demo section (online mode only)
634+
with st.expander("💾 **Save as Demo**"):
635+
st.caption("Save current workspace as a demo for others to use")
636+
637+
demo_name_input = st.text_input(
638+
"Demo name",
639+
key="save-demo-name",
640+
placeholder="e.g., workshop-2024",
641+
help="Name for the demo workspace (no spaces or special characters)"
642+
)
643+
644+
# Check if demo already exists
645+
demo_name_clean = demo_name_input.strip() if demo_name_input else ""
646+
existing_demo = demo_exists(demo_name_clean) if demo_name_clean else False
647+
648+
if existing_demo:
649+
st.warning(f"Demo '{demo_name_clean}' already exists and will be overwritten.")
650+
confirm_overwrite = st.checkbox(
651+
"Confirm overwrite",
652+
key="confirm-demo-overwrite"
653+
)
654+
else:
655+
confirm_overwrite = True # No confirmation needed for new demos
656+
657+
if st.button("Save as Demo", key="save-demo-btn", disabled=not demo_name_clean):
658+
if not is_admin_configured():
659+
st.error(
660+
"Admin not configured. Create `.streamlit/secrets.toml` with "
661+
"an `[admin]` section containing `password = \"your-password\"`"
662+
)
663+
elif existing_demo and not confirm_overwrite:
664+
st.error("Please confirm overwrite to continue.")
665+
else:
666+
# Show password dialog
667+
st.session_state["show_admin_password_dialog"] = True
668+
669+
# Password dialog (shown after clicking Save as Demo)
670+
if st.session_state.get("show_admin_password_dialog", False):
671+
admin_password = st.text_input(
672+
"Admin password",
673+
type="password",
674+
key="admin-password-input",
675+
help="Enter the admin password to save this workspace as a demo"
676+
)
677+
678+
col1, col2 = st.columns(2)
679+
with col1:
680+
if st.button("Confirm", key="confirm-save-demo"):
681+
if verify_admin_password(admin_password):
682+
success, message = save_workspace_as_demo(
683+
st.session_state.workspace,
684+
demo_name_clean
685+
)
686+
if success:
687+
st.success(message)
688+
st.session_state["show_admin_password_dialog"] = False
689+
time.sleep(1)
690+
st.rerun()
691+
else:
692+
st.error(message)
693+
else:
694+
st.error("Invalid admin password.")
695+
696+
with col2:
697+
if st.button("Cancel", key="cancel-save-demo"):
698+
st.session_state["show_admin_password_dialog"] = False
699+
st.rerun()
700+
621701
# All pages have settings, workflow indicator and logo
622702
with st.expander("⚙️ **Settings**"):
623703
img_formats = ["svg", "png", "jpeg", "webp"]

src/workflow/CommandExecutor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def read_stderr():
153153
try:
154154
for line in iter(process.stderr.readline, ''):
155155
if line:
156-
self.logger.log(f"STDERR: {line.rstrip()}", 2)
156+
self.logger.log(f"STDERR: {line.rstrip()}", 0)
157157
if process.poll() is not None:
158158
break
159159
except Exception as e:

0 commit comments

Comments
 (0)