Skip to content

Commit 8e2a34d

Browse files
authored
Merge pull request #324 from OpenMS/claude/freeze-workshop-demo-feature-ZzPh9
Add admin functionality to save workspaces as demo templates
2 parents d36bf02 + 56d8b46 commit 8e2a34d

File tree

4 files changed

+234
-0
lines changed

4 files changed

+234
-0
lines changed

.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: 74 additions & 0 deletions
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
@@ -624,6 +630,74 @@ def change_workspace():
624630
time.sleep(1)
625631
st.rerun()
626632

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+
627701
# All pages have settings, workflow indicator and logo
628702
with st.expander("⚙️ **Settings**"):
629703
img_formats = ["svg", "png", "jpeg", "webp"]

0 commit comments

Comments
 (0)