Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ repositories/
__pycache__/
*.pyc
flask_session/

# Runtime lock files
config/*.lock
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ RUN apt-get update && apt-get install -y \
python3-pip \
gcc \
curl \
gosu \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system app \
&& useradd --system --gid app --create-home app
Expand All @@ -43,18 +44,23 @@ RUN playwright install --with-deps chromium && \
COPY . .

# Create necessary directories for data persistence and adjust ownership
RUN mkdir -p static data data/photos repositories && \
RUN mkdir -p static data data/photos config repositories && \
chmod 775 data config repositories && \
chown -R app:app /app

# Make entrypoint executable
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Expose port
EXPOSE ${APP_PORT}

# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${APP_PORT:-5000}/ || exit 1

# Switch to non-root user
USER app
# Entrypoint fixes bind-mount permissions then drops to 'app' user
ENTRYPOINT ["/entrypoint.sh"]

# Run the application using gunicorn
CMD ["gunicorn", "--config", "deploy/gunicorn.conf.py", "simple_org_chart:app"]
20 changes: 20 additions & 0 deletions deploy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/bash
set -e

APP_UID=$(id -u app)
APP_GID=$(id -g app)

# Fix ownership of bind-mounted directories so the 'app' user can write to them.
# Only chown when the top-level directory owner doesn't already match to avoid
# slow recursive traversal of large bind mounts on every container start.
for dir in /app/data /app/config /app/repositories; do
if [ -d "$dir" ]; then
current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "")
if [ -z "$current_owner" ] || [ "$current_owner" != "$APP_UID:$APP_GID" ]; then
chown -R --no-dereference app:app "$dir" 2>/dev/null || true
fi
fi
done

# Drop privileges and exec the main process
exec gosu app "$@"
16 changes: 3 additions & 13 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,6 @@ services:
env_file:
- .env
volumes:
- orgchart_data_dev:/app/data
- orgchart_repos_dev:/app/repositories
labels:
com.docker.compose.project: "docker"
networks:
- orgchart_dev_default

networks:
orgchart_dev_default:

volumes:
orgchart_data_dev:
orgchart_repos_dev:
- ./data:/app/data
- ./config:/app/config
- ./repositories:/app/repositories
16 changes: 3 additions & 13 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,6 @@ services:
env_file:
- .env
volumes:
- orgchart_data:/app/data
- orgchart_repos:/app/repositories
labels:
com.docker.compose.project: "docker"
networks:
- default

networks:
default:

volumes:
orgchart_data:
orgchart_repos:
- ./data:/app/data
- ./config:/app/config
- ./repositories:/app/repositories
85 changes: 85 additions & 0 deletions simple_org_chart/app_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def flock(fd, op):
from simple_org_chart.config import EMPLOYEE_LIST_FILE
from simple_org_chart.auth import login_required, require_auth, sanitize_next_path
from simple_org_chart.email_config import (
DEFAULT_EMAIL_CONFIG,
get_smtp_config,
is_smtp_configured,
load_email_config,
Expand Down Expand Up @@ -1128,6 +1129,7 @@ def reset_all_settings():
os.remove(logo_path)

save_settings(DEFAULT_SETTINGS)
save_email_config(DEFAULT_EMAIL_CONFIG)

threading.Thread(target=restart_scheduler).start()

Expand All @@ -1137,6 +1139,89 @@ def reset_all_settings():
return jsonify({'error': 'Reset failed'}), 500


# ---------------------------------------------------------------------------
# Config export / import
# ---------------------------------------------------------------------------

@app.route('/api/settings/export', methods=['GET'])
@require_auth
def export_settings():
"""Download all configuration as a single flat JSON file."""
settings = load_settings()
email_config = load_email_config()
# Merge everything flat; strip transient runtime fields and non-default keys
settings_public = {k: v for k, v in settings.items() if k in DEFAULT_SETTINGS}
merged = {}
merged.update(settings_public)
merged.update({k: v for k, v in email_config.items() if k != 'lastSent'})
payload = json.dumps(merged, indent=2)
return app.response_class(
payload,
mimetype='application/json',
headers={
'Content-Disposition': 'attachment; filename="simple-org-chart-config.json"',
},
)


@app.route('/api/settings/import', methods=['POST'])
@require_auth
def import_settings():
"""Import configuration from an uploaded JSON file."""
uploaded = request.files.get('file')
if not uploaded:
return jsonify({'error': 'No file uploaded'}), 400

# Enforce file size limit consistent with other upload endpoints
uploaded.seek(0, 2)
file_size = uploaded.tell()
uploaded.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({'error': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024 * 1024)}MB'}), 400

try:
raw = uploaded.read()
incoming = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Config import: invalid JSON – %s", exc)
return jsonify({'error': 'Invalid JSON file'}), 400

if not isinstance(incoming, dict):
return jsonify({'error': 'Expected a JSON object'}), 400

# Split incoming keys into settings vs email config
email_keys = set(DEFAULT_EMAIL_CONFIG.keys()) - {'lastSent'}
settings_data = {}
email_data = {}

for key, value in incoming.items():
if key in DEFAULT_SETTINGS:
settings_data[key] = value
elif key in email_keys:
email_data[key] = value

# Only save settings if at least one valid settings key was provided
settings_ok = True
if settings_data:
settings_ok = save_settings(settings_data)

email_ok = True
if email_data:
email_ok = save_email_config(email_data)

if settings_ok and email_ok:
if 'updateTime' in settings_data or 'autoUpdateEnabled' in settings_data:
threading.Thread(target=restart_scheduler, daemon=True).start()
return jsonify({'success': True, 'settings': load_settings()})

errors = []
if not settings_ok:
errors.append('settings')
if not email_ok:
errors.append('email config')
return jsonify({'error': f'Failed to save: {", ".join(errors)}'}), 500


@app.route('/api/email-config', methods=['GET', 'POST'])
@require_auth
@limiter.limit(RATE_LIMIT_SETTINGS)
Expand Down
10 changes: 7 additions & 3 deletions simple_org_chart/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@

BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
CONFIG_DIR = BASE_DIR / "config"
STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "templates"

SETTINGS_FILE = DATA_DIR / "app_settings.json"
REPO_DIR = BASE_DIR / "repositories"
SETTINGS_FILE = CONFIG_DIR / "app_settings.json"
DATA_FILE = DATA_DIR / "employee_data.json"
MISSING_MANAGER_FILE = DATA_DIR / "missing_manager_records.json"
EMPLOYEE_LIST_FILE = DATA_DIR / "employee_list.json"
Expand All @@ -28,8 +30,8 @@


def ensure_directories() -> None:
"""Ensure that the application's data and static directories exist."""
for target in (DATA_DIR, STATIC_DIR):
"""Ensure that the application's data, config, static, and repo directories exist."""
for target in (DATA_DIR, CONFIG_DIR, STATIC_DIR, REPO_DIR):
try:
target.mkdir(parents=True, exist_ok=True)
except OSError as error:
Expand All @@ -44,8 +46,10 @@ def as_posix_env(mapping: Dict[str, Path]) -> Dict[str, str]:
__all__ = [
"BASE_DIR",
"DATA_DIR",
"CONFIG_DIR",
"STATIC_DIR",
"TEMPLATE_DIR",
"REPO_DIR",
"SETTINGS_FILE",
"DATA_FILE",
"MISSING_MANAGER_FILE",
Expand Down
Loading
Loading