Files
ServerKit/backend/app/api/wordpress_sites.py
T
Juan DenisandClaude Opus 4.8 4393a47512 feat(wordpress): finish Phase-A glue — create hardening, real URLs, offsite snapshots, push-to-deploy, API keys
Completes roadmap #2, #3, #11, #13, #14.

#2 Canonical Docker create now finalizes the install. create_site waits for the
container to be ready, runs `wp core install` (creates a real admin user) and
applies container-valid hardening via the Docker-aware wp_cli bridge
(DISALLOW_FILE_EDIT, XMLRPC_REQUEST off, shuffle-salts) — NOT host-filesystem
ops, which don't apply to the named-volume model. The generated admin password
is returned and surfaced once in the create page (so the new admin is usable).
Best-effort: site creation still succeeds if the container is slow to finalize.

#3 _enrich_site_data derives the site URL from the primary domain (https when
ssl_enabled, else http), falling back to http://localhost:<port> only when no
domain is attached. Fixes Open Site / Open WP Admin / the SSL panel.

#11 Offsite snapshots + retention. New DatabaseSyncService.upload_snapshot_offsite
(best-effort S3/B2 upload, no-op unless a remote provider + auto_upload are
configured) is called after every snapshot is created (manual + pre-promotion).
New prune_expired_snapshots backfills expires_at (default 30d, tagged snapshots
kept) and deletes expired file+row; an hourly 'snapshot-retention' scheduler
thread runs it. No migration (expires_at/status columns already exist).

#13 Push-to-deploy for WordPress. WebhookService._handle_push_event now fans out
to every WordPressSite whose connected repo+branch matches the push and has
auto_deploy=true, calling GitWordPressService.deploy_from_commit. Repo URLs are
matched scheme/.git/creds-agnostically via a new _normalize_repo_url helper.
Best-effort; never breaks the generic-app webhook path.

#14 WordPress REST is now API-key reachable. All 12 wordpress_sites.py routes
switched from bare @jwt_required() to @auth_required() (accepts API key or JWT),
reading the user via get_current_user().id instead of get_jwt_identity()
(which returns None under an API key). Ownership filters unchanged.

Verified: all backend files compile; the app boots and the retention scheduler,
route swaps, and helpers load; _normalize_repo_url maps ssh/https/creds forms
identically; frontend builds; WordPress.jsx lints clean. No DB migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:39:57 -04:00

425 lines
13 KiB
Python

"""
WordPress Sites API — environment sync, database snapshots, and Git integration.
ROUTING NOTE: the WordPress "hub" surface (list/create/get/delete sites,
environment CRUD, plugins, themes, core update) is owned by ``wordpress_bp``
(``app/api/wordpress.py``) — the Docker-stack model the UI actually creates
against. This blueprint owns ONLY the routes that do not overlap with it:
environment sync, database snapshots, clone-db, and Git integration. Both
blueprints mount at ``/api/v1/wordpress``; their route paths are kept disjoint
so neither shadows the other (a duplicate rule would be silently unreachable
because Flask resolves the first-registered match).
"""
from flask import Blueprint, request, jsonify
from app.middleware.rbac import auth_required, get_current_user
import json
from app import db
from app.models.application import Application
from app.models.wordpress_site import WordPressSite, DatabaseSnapshot
from app.services.wordpress_env_service import WordPressEnvService
from app.services.db_sync_service import DatabaseSyncService
from app.services.git_wordpress_service import GitWordPressService
wordpress_sites_bp = Blueprint('wordpress_sites', __name__)
# =============================================================================
# Environment Sync
# =============================================================================
@wordpress_sites_bp.route('/sites/<int:site_id>/sync', methods=['POST'])
@auth_required()
def sync_environment(site_id):
"""Sync an environment from its production source."""
user = get_current_user()
user_id = user.id
data = request.get_json() or {}
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
if site.is_production:
return jsonify({'error': 'Cannot sync a production site'}), 400
result = WordPressEnvService.sync_environment(site_id, options=data)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
# =============================================================================
# Database Operations
# =============================================================================
@wordpress_sites_bp.route('/sites/<int:site_id>/snapshots', methods=['GET'])
@auth_required()
def list_snapshots(site_id):
"""List database snapshots for a site."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
snapshots = DatabaseSnapshot.query.filter_by(site_id=site_id).order_by(
DatabaseSnapshot.created_at.desc()
).all()
return jsonify({
'snapshots': [s.to_dict() for s in snapshots],
'total': len(snapshots)
})
@wordpress_sites_bp.route('/sites/<int:site_id>/snapshots', methods=['POST'])
@auth_required()
def create_snapshot(site_id):
"""Create a database snapshot."""
user = get_current_user()
user_id = user.id
data = request.get_json() or {}
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=data.get('name', f"{site.application.name}_{site.id}"),
tag=data.get('tag'),
commit_sha=site.last_deploy_commit,
host=site.db_host,
user=site.db_user,
password=WordPressEnvService._get_db_password(site),
exclude_tables=data.get('exclude_tables', [])
)
if result['success']:
# Save to database
snapshot = DatabaseSnapshot(
site_id=site_id,
name=result['snapshot']['name'],
tag=data.get('tag'),
file_path=result['snapshot']['file_path'],
size_bytes=result['snapshot']['size_bytes'],
compressed=result['snapshot']['compressed'],
commit_sha=site.last_deploy_commit,
tables_included=json.dumps(result['snapshot'].get('tables', [])),
row_count=result['snapshot'].get('row_count', 0),
status='completed'
)
db.session.add(snapshot)
db.session.commit()
# Best-effort offsite upload (no-op unless remote storage + auto_upload enabled)
DatabaseSyncService.upload_snapshot_offsite(snapshot.file_path)
return jsonify({
'success': True,
'snapshot': snapshot.to_dict()
}), 201
else:
return jsonify(result), 500
@wordpress_sites_bp.route('/sites/<int:site_id>/snapshots/<int:snapshot_id>/restore', methods=['POST'])
@auth_required()
def restore_snapshot(site_id, snapshot_id):
"""Restore a database snapshot."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
snapshot = DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site_id).first()
if not snapshot:
return jsonify({'error': 'Snapshot not found'}), 404
# Create a backup before restoring
backup_result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=f"pre_restore_{snapshot_id}",
tag='pre-restore',
host=site.db_host,
user=site.db_user,
password=WordPressEnvService._get_db_password(site)
)
# Restore the snapshot
result = DatabaseSyncService.restore_snapshot(
file_path=snapshot.file_path,
target_db=site.db_name,
host=site.db_host,
user=site.db_user,
password=WordPressEnvService._get_db_password(site),
create_db=False
)
if result['success']:
return jsonify({
'success': True,
'message': 'Snapshot restored',
'backup_path': backup_result.get('snapshot', {}).get('file_path') if backup_result.get('success') else None
})
else:
return jsonify(result), 500
@wordpress_sites_bp.route('/sites/<int:site_id>/snapshots/<int:snapshot_id>', methods=['DELETE'])
@auth_required()
def delete_snapshot(site_id, snapshot_id):
"""Delete a database snapshot."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
snapshot = DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site_id).first()
if not snapshot:
return jsonify({'error': 'Snapshot not found'}), 404
# Delete file
DatabaseSyncService.delete_snapshot(snapshot.file_path)
# Delete record
db.session.delete(snapshot)
db.session.commit()
return jsonify({'success': True, 'message': 'Snapshot deleted'})
@wordpress_sites_bp.route('/sites/<int:site_id>/clone-db', methods=['POST'])
@auth_required()
def clone_database(site_id):
"""Clone the database to another environment."""
user = get_current_user()
user_id = user.id
data = request.get_json()
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
target_site_id = data.get('target_site_id')
if not target_site_id:
return jsonify({'error': 'target_site_id is required'}), 400
target_site = WordPressSite.query.join(Application).filter(
WordPressSite.id == target_site_id,
Application.user_id == user_id
).first()
if not target_site:
return jsonify({'error': 'Target site not found'}), 404
result = DatabaseSyncService.clone_database(
source_db=site.db_name,
target_db=target_site.db_name,
source_host=site.db_host,
target_host=target_site.db_host,
source_user=site.db_user,
target_user=target_site.db_user,
source_password=WordPressEnvService._get_db_password(site),
target_password=WordPressEnvService._get_db_password(target_site),
options=data.get('options', {})
)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
# =============================================================================
# Git Integration
# =============================================================================
@wordpress_sites_bp.route('/sites/<int:site_id>/git', methods=['GET'])
@auth_required()
def get_git_status(site_id):
"""Get Git integration status for a site."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
result = GitWordPressService.get_git_status(site_id)
return jsonify(result)
@wordpress_sites_bp.route('/sites/<int:site_id>/git', methods=['POST'])
@auth_required()
def connect_repo(site_id):
"""Connect a Git repository to a site."""
user = get_current_user()
user_id = user.id
data = request.get_json()
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
repo_url = data.get('repo_url')
if not repo_url:
return jsonify({'error': 'repo_url is required'}), 400
result = GitWordPressService.connect_repo(
site_id=site_id,
repo_url=repo_url,
branch=data.get('branch', 'main'),
paths=data.get('paths'),
auto_deploy=data.get('auto_deploy', False)
)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
@wordpress_sites_bp.route('/sites/<int:site_id>/git', methods=['DELETE'])
@auth_required()
def disconnect_repo(site_id):
"""Disconnect Git repository from a site."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
result = GitWordPressService.disconnect_repo(site_id)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
@wordpress_sites_bp.route('/sites/<int:site_id>/git/commits', methods=['GET'])
@auth_required()
def get_commits(site_id):
"""Get recent commits from the connected repository."""
user = get_current_user()
user_id = user.id
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
limit = request.args.get('limit', 20, type=int)
result = GitWordPressService.get_recent_commits(site_id, limit=limit)
return jsonify(result)
@wordpress_sites_bp.route('/sites/<int:site_id>/git/deploy', methods=['POST'])
@auth_required()
def deploy_commit(site_id):
"""Deploy a specific commit or branch."""
user = get_current_user()
user_id = user.id
data = request.get_json() or {}
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
result = GitWordPressService.deploy_from_commit(
site_id=site_id,
commit_sha=data.get('commit_sha'),
branch=data.get('branch'),
create_snapshot=data.get('create_snapshot', True)
)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
@wordpress_sites_bp.route('/sites/<int:site_id>/git/dev-from-commit', methods=['POST'])
@auth_required()
def create_dev_from_commit(site_id):
"""Create a development environment for a specific commit."""
user = get_current_user()
user_id = user.id
data = request.get_json()
site = WordPressSite.query.join(Application).filter(
WordPressSite.id == site_id,
Application.user_id == user_id
).first()
if not site:
return jsonify({'error': 'Site not found'}), 404
commit_sha = data.get('commit_sha')
if not commit_sha:
return jsonify({'error': 'commit_sha is required'}), 400
result = GitWordPressService.create_dev_for_commit(
production_site_id=site_id,
commit_sha=commit_sha,
config=data.get('config', {}),
user_id=user_id
)
if result['success']:
return jsonify(result), 201
else:
return jsonify(result), 500