Normalize emails, add panel_origin & domain-aware URLs

Make email handling case-insensitive (queries + model normalization) to prevent duplicate accounts. Add SiteDomainService.panel_origin() helper and use it across services to build public URLs (Gitea, WordPress, Roundcube) and Django ALLOWED_HOSTS. Add optional custom-domain support when creating WordPress sites and propagate domain to Roundcube install. Improve environment domain fallback to prefer panel/base domains over .localhost. Small Dockerfile healthcheck tweak (use 127.0.0.1). Update install/update/build scripts to always (re)create virtualenv locally for release installs. Remove unused frontend CreateSiteModal and expose domain field in WordPress page.
This commit is contained in:
Juan Denis committed 2026-06-22 14:37:44 -04:00
1 parent e192250ef1
commit 652d903ae7
20 files changed
+519 -200

No files matched your search

+5 -3
View File
@@ -49,7 +49,7 @@ def create_user():
if role not in User.VALID_ROLES:
return jsonify({'error': f'Invalid role. Must be one of: {", ".join(User.VALID_ROLES)}'}), 400
if User.query.filter_by(email=email).first():
if User.query.filter(func.lower(User.email) == func.lower(email)).first():
return jsonify({'error': 'Email already registered'}), 409
if User.query.filter_by(username=username).first():
@@ -110,8 +110,10 @@ def update_user(user_id):
changes = {}
# Update email
if 'email' in data and data['email'] != user.email:
existing = User.query.filter_by(email=data['email']).first()
if 'email' in data and data['email'].lower() != user.email.lower():
existing = User.query.filter(
func.lower(User.email) == func.lower(data['email'])
).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Email already registered'}), 409
changes['email'] = {'old': user.email, 'new': data['email']}
+7 -4
View File
@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from flask import Blueprint, request, jsonify
from sqlalchemy import func
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
@@ -81,7 +82,7 @@ def register():
if not all([email, username, password]):
return jsonify({'error': 'Missing required fields'}), 400
if User.query.filter_by(email=email).first():
if User.query.filter(func.lower(User.email) == func.lower(email)).first():
return jsonify({'error': 'This email or username is unavailable'}), 409
if User.query.filter_by(username=username).first():
@@ -203,9 +204,9 @@ def login():
if not all([login_id, password]):
return jsonify({'error': 'Missing email/username or password'}), 400
# Try to find user by email or username
# Try to find user by email (case-insensitive) or username
user = User.query.filter(
(User.email == login_id) | (User.username == login_id)
(func.lower(User.email) == func.lower(login_id)) | (User.username == login_id)
).first()
# Check if account is locked
@@ -310,7 +311,9 @@ def update_current_user():
user.username = data['username']
if 'email' in data:
existing = User.query.filter_by(email=data['email']).first()
existing = User.query.filter(
func.lower(User.email) == func.lower(data['email'])
).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Email already registered'}), 409
user.email = data['email']
+1
View File
@@ -400,6 +400,7 @@ def webmail_install():
result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
domain=(data.get('domain') or '').strip() or None,
)
return jsonify(result), 200 if result.get('success') else 400
+2
View File
@@ -56,6 +56,7 @@ def create_site():
php_version = data.get('phpVersion') or None
enable_page_cache = bool(data.get('enablePageCache'))
enable_object_cache = bool(data.get('enableObjectCache'))
domain = (data.get('domain') or '').strip()
if not name:
return jsonify({'error': 'Site name is required'}), 400
@@ -69,6 +70,7 @@ def create_site():
php_version=php_version,
enable_page_cache=enable_page_cache,
enable_object_cache=enable_object_cache,
domain=domain or None,
)
if result.get('success'):
+6
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from sqlalchemy.orm import validates
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
import json
@@ -26,6 +27,11 @@ class User(db.Model):
last_login_at = db.Column(db.DateTime, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
@validates('email')
def _normalize_email(self, key, email):
"""Store emails in lowercase for consistent matching."""
return email.lower() if isinstance(email, str) else email
# Account lockout fields
failed_login_count = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
@@ -76,7 +76,16 @@ class EnvironmentDomainService:
return production_domain
if not production_domain:
# Fallback for sites without a domain
# Fallback for sites without a domain: use the managed-sites base domain
# or the panel domain instead of a useless .localhost address.
from app.services.site_domain_service import SiteDomainService
base = SiteDomainService.base_domain() or SiteDomainService.panel_origin()
if base:
from urllib.parse import urlparse
base_host = urlparse(base).hostname or base
slug = cls.slugify(branch_name) if branch_name else env_type
return f'{env_type}-{slug}.{base_host}'
# Last resort only when absolutely no domain is configured
slug = cls.slugify(branch_name) if branch_name else env_type
return f'{env_type}-{slug}.localhost'
+13 -2
View File
@@ -621,13 +621,24 @@ class GitService:
# Load config for ports
config = cls._load_gitea_config()
# Prefer the panel's canonical origin so the link works through a domain
# and Cloudflare; fall back to the local port only when no domain is set.
from app.services.site_domain_service import SiteDomainService
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
public_url = f"{panel_origin}/gitea"
elif app.port:
public_url = f"http://localhost:{app.port}"
else:
public_url = None
return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'ssh_port': config.get('ssh_port'),
'url_path': '/gitea', # Slug-based URL path
'url': f"http://localhost:{app.port}" if app.port else None, # Legacy port-based URL
'url_path': '/gitea',
'url': public_url,
'app_id': app.id,
'version': config.get('version', '1.21')
}
+16 -1
View File
@@ -315,10 +315,25 @@ SECRET_KEY={secrets.token_hex(32)}
if result.returncode != 0:
return {'success': False, 'error': result.stderr}
# Build ALLOWED_HOSTS: local addresses plus the panel domain and its
# subdomains so Django accepts requests behind nginx on a real domain.
from app.services.site_domain_service import SiteDomainService
allowed_hosts = ['localhost', '127.0.0.1']
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
allowed_hosts.append(panel_host)
# Permit subdomains of the panel domain (e.g. app.serverkit.example.com)
if '.' in panel_host:
allowed_hosts.append('.' + panel_host)
allowed_hosts_str = ','.join(allowed_hosts)
# Create .env file
env_content = f'''DEBUG=False
SECRET_KEY={secrets.token_hex(32)}
ALLOWED_HOSTS=localhost,127.0.0.1
ALLOWED_HOSTS={allowed_hosts_str}
'''
with open(os.path.join(app_path, '.env'), 'w') as f:
f.write(env_content)
+33 -3
View File
@@ -39,7 +39,8 @@ class RoundcubeService:
@classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal') -> Dict:
smtp_host: str = 'host.docker.internal',
domain: str = None) -> Dict:
"""Install Roundcube via Docker container."""
if not is_command_available('docker'):
return {'success': False, 'error': 'Docker is not installed'}
@@ -77,12 +78,41 @@ class RoundcubeService:
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to start container'}
return {
# Build a public URL: prefer a supplied domain, then auto-generate
# webmail.<panel_domain>, and only fall back to localhost:port.
from app.services.site_domain_service import SiteDomainService
public_url = None
warning = None
if domain:
public_url = f'http://{domain}'
proxy_res = cls.configure_nginx_proxy(domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
else:
panel_origin = SiteDomainService.panel_origin()
panel_host = None
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
webmail_domain = f'webmail.{panel_host}'
public_url = f'http://{webmail_domain}'
proxy_res = cls.configure_nginx_proxy(webmail_domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
if not public_url:
public_url = f'http://localhost:{cls.HOST_PORT}'
result = {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': f'http://localhost:{cls.HOST_PORT}',
'url': public_url,
}
if warning:
result['warning'] = warning
return result
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -39,6 +39,30 @@ class SiteDomainService:
"""Public IP that wildcard/custom A-records should point at (Phase 3)."""
return SystemSettings.get('server_public_ip') or current_app.config.get('SERVER_PUBLIC_IP') or None
@classmethod
def panel_origin(cls):
"""Canonical public origin of the ServerKit panel, or None when no
canonical domain is configured.
Uses the persisted canonical_domain / canonical_https_enabled settings.
Falls back to PUBLIC_URL / SERVERKIT_PUBLIC_URL env vars, then to the
sites base domain. Returns None if nothing usable is configured.
"""
domain = SystemSettings.get('canonical_domain')
if domain:
https = bool(SystemSettings.get('canonical_https_enabled', False))
return f'https://{domain}' if https else f'http://{domain}'
url = current_app.config.get('PUBLIC_URL') or current_app.config.get('SERVERKIT_PUBLIC_URL')
if url:
return url.rstrip('/')
base = cls.base_domain()
if base:
return f'https://{base}' if cls.https_enabled() else f'http://{base}'
return None
@classmethod
def https_enabled(cls):
"""True once the wildcard certificate for the base domain is set up, so
+3 -2
View File
@@ -10,6 +10,7 @@ from flask import current_app, session
import base64
import requests as http_requests
from sqlalchemy import func
from app import db
from app.models import User, AuditLog
from app.models.oauth_identity import OAuthIdentity
@@ -336,8 +337,8 @@ def find_or_create_user(provider, profile):
db.session.commit()
return user, False
# 2. Check existing user by email
user = User.query.filter_by(email=email).first() if email else None
# 2. Check existing user by email (case-insensitive)
user = User.query.filter(func.lower(User.email) == func.lower(email)).first() if email else None
if user:
if not user.is_active:
raise ValueError('Account is deactivated')
+36 -5
View File
@@ -1515,12 +1515,23 @@ RewriteRule ^wp-content/uploads/.*\\.php$ - [F]
running = cls._is_wordpress_running()
config = cls._load_wp_config()
# Prefer the panel's canonical origin so the link works through a domain
# and Cloudflare; fall back to the local port only when no domain is set.
from app.services.site_domain_service import SiteDomainService
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
public_url = f"{panel_origin}/wordpress"
elif app.port:
public_url = f"http://localhost:{app.port}"
else:
public_url = None
return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'url_path': '/wordpress',
'url': f"http://localhost:{app.port}" if app.port else None,
'url': public_url,
'app_id': app.id,
'version': config.get('version', '6.4')
}
@@ -1896,7 +1907,7 @@ RewriteRule ^wp-content/uploads/.*\\.php$ - [F]
@classmethod
def create_site(cls, name: str, admin_email: str, user_id: int, admin_user: str = 'admin',
php_version: str = None, enable_page_cache: bool = False,
enable_object_cache: bool = False) -> Dict:
enable_object_cache: bool = False, domain: str = None) -> Dict:
"""Create a new WordPress site via Docker.
One-click orchestration: provision the Docker stack on a chosen PHP version,
@@ -2047,6 +2058,21 @@ RewriteRule ^wp-content/uploads/.*\\.php$ - [F]
except Exception:
pass
# If the caller supplied a custom domain, attach it and migrate the
# WordPress site URL away from localhost:<port> / base subdomain.
if domain:
attach_res = cls.attach_custom_domain(
app, domain, migrate=True, issue_ssl=False
)
if attach_res.get('success'):
site_url = attach_res.get('url') or site_url
site_host = attach_res.get('domain') or site_host
if attach_res.get('warning'):
wp_warning = (wp_warning + ' ' + attach_res['warning']) if wp_warning else attach_res['warning']
else:
attach_err = attach_res.get('error') or 'Custom domain could not be attached'
wp_warning = (wp_warning + ' ' + attach_err) if wp_warning else attach_err
result = {
'success': True,
'message': 'WordPress site created successfully',
@@ -2134,13 +2160,18 @@ RewriteRule ^wp-content/uploads/.*\\.php$ - [F]
@classmethod
def _canonical_site_url(cls, app) -> str:
"""The URL WordPress serves under — its primary domain if one exists,
else the legacy localhost:<port> address. Used to build correct
search-replace pairs when cloning."""
else the panel's canonical origin, else the legacy localhost:<port>
address. Used to build correct search-replace pairs when cloning."""
from app.models.domain import Domain
from app.services.site_domain_service import SiteDomainService
d = (Domain.query.filter_by(application_id=app.id, is_primary=True).first()
or Domain.query.filter_by(application_id=app.id).first())
if d:
return f'http://{d.name}'
scheme = 'https' if d.ssl_enabled else 'http'
return f'{scheme}://{d.name}'
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
return panel_origin
if app.port:
return f'http://localhost:{app.port}'
return None
+2 -2
View File
@@ -30,9 +30,9 @@ RUN chown -R nginx:nginx /usr/share/nginx/html && \
# Expose port
EXPOSE 80
# Health check
# Health check (use 127.0.0.1; the container only listens on IPv4)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
@@ -1,155 +0,0 @@
import React, { useState } from 'react';
import Modal from '../Modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
const CreateSiteModal = ({ onClose, onCreate }) => {
const [formData, setFormData] = useState({
name: '',
domain: '',
adminUser: 'admin',
adminEmail: '',
dbName: '',
createDatabase: true
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
function handleChange(e) {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
}
function generateDbName(siteName) {
return `wp_${siteName.toLowerCase().replace(/[^a-z0-9]/g, '_').substring(0, 20)}`;
}
function handleNameChange(e) {
const name = e.target.value;
setFormData(prev => ({
...prev,
name,
dbName: prev.dbName || generateDbName(name)
}));
}
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
await onCreate({
name: formData.name,
domain: formData.domain,
admin_user: formData.adminUser,
admin_email: formData.adminEmail,
db_name: formData.dbName,
create_database: formData.createDatabase
});
onClose();
} catch (err) {
setError(err.message || 'Failed to create WordPress site');
} finally {
setLoading(false);
}
}
return (
<Modal open={true} onClose={onClose} title="Create WordPress Site">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<Label>Site Name *</Label>
<Input
type="text"
name="name"
value={formData.name}
onChange={handleNameChange}
placeholder="My WordPress Site"
required
/>
</div>
<div className="form-group">
<Label>Domain</Label>
<Input
type="text"
name="domain"
value={formData.domain}
onChange={handleChange}
placeholder="example.com"
/>
<span className="form-hint">Leave empty to use a local development URL</span>
</div>
<div className="form-row">
<div className="form-group">
<Label>Admin Username *</Label>
<Input
type="text"
name="adminUser"
value={formData.adminUser}
onChange={handleChange}
placeholder="admin"
required
/>
</div>
<div className="form-group">
<Label>Admin Email *</Label>
<Input
type="email"
name="adminEmail"
value={formData.adminEmail}
onChange={handleChange}
placeholder="admin@example.com"
required
/>
</div>
</div>
<div className="form-group">
<Label>Database Name</Label>
<Input
type="text"
name="dbName"
value={formData.dbName}
onChange={handleChange}
placeholder="wp_mysite"
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<Checkbox
name="createDatabase"
checked={formData.createDatabase}
onCheckedChange={(checked) =>
setFormData(prev => ({ ...prev, createDatabase: checked }))
}
/>
<span>Create database automatically</span>
</label>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Site'}
</Button>
</div>
</form>
</Modal>
);
};
export default CreateSiteModal;
@@ -1,5 +1,4 @@
export { default as WordPressSiteCard } from './WordPressSiteCard';
export { default as CreateSiteModal } from './CreateSiteModal';
export { default as EnvironmentCard } from './EnvironmentCard';
export { default as SnapshotTable } from './SnapshotTable';
export { default as GitConnectForm } from './GitConnectForm';
+14 -2
View File
@@ -23,7 +23,7 @@ function WordPress() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [createForm, setCreateForm] = useState({
name: '', adminEmail: '', phpVersion: '', enablePageCache: false, enableObjectCache: false,
name: '', domain: '', adminEmail: '', phpVersion: '', enablePageCache: false, enableObjectCache: false,
});
const [createdCreds, setCreatedCreds] = useState(null);
const [showImportModal, setShowImportModal] = useState(false);
@@ -74,7 +74,7 @@ function WordPress() {
}
toast.success('WordPress site created successfully');
setShowCreateModal(false);
setCreateForm({ name: '', adminEmail: '', phpVersion: '', enablePageCache: false, enableObjectCache: false });
setCreateForm({ name: '', domain: '', adminEmail: '', phpVersion: '', enablePageCache: false, enableObjectCache: false });
await loadSites();
} else {
toast.error(result.error || 'Failed to create site');
@@ -526,6 +526,18 @@ function WordPress() {
<span className="form-hint">Used as the Docker project name. Letters, numbers, and hyphens only.</span>
</div>
<div className="form-group">
<Label>Custom Domain</Label>
<Input
type="text"
value={createForm.domain}
onChange={e => setCreateForm({ ...createForm, domain: e.target.value })}
placeholder="example.com"
disabled={createLoading}
/>
<span className="form-hint">Optional. If set, the site will be created and migrated to this domain.</span>
</div>
<div className="form-group">
<Label>Admin Email</Label>
<Input
+5 -5
View File
@@ -955,11 +955,11 @@ main() {
build_frontend
else
make_directories
# The CLI needs a venv even on release installs.
if [ ! -d "$VENV_DIR" ]; then
provision_python
build_virtualenv
fi
# Always build the virtualenv locally. Release tarballs no longer ship a
# pre-built venv because absolute shebangs/paths in a relocated venv
# break on the target machine (e.g. 203/EXEC from systemd).
provision_python
build_virtualenv
fi
sync_templates
+294
View File
@@ -0,0 +1,294 @@
import os
def replace_in_file(path, old, new):
if not os.path.exists(path):
print(f'SKIP (not found): {path}')
return False
with open(path, 'r') as f:
content = f.read()
if old not in content:
print(f'PATTERN NOT FOUND: {path}')
return False
content = content.replace(old, new)
with open(path, 'w') as f:
f.write(content)
print(f'Patched: {path}')
return True
# 1. site_domain_service.py - add panel_origin helper
replace_in_file(
'/opt/serverkit/backend/app/services/site_domain_service.py',
""" @classmethod
def server_ip(cls):
\"\"\"Public IP that wildcard/custom A-records should point at (Phase 3).\"\"\"
return SystemSettings.get('server_public_ip') or current_app.config.get('SERVER_PUBLIC_IP') or None
@classmethod
def https_enabled(cls):""",
""" @classmethod
def server_ip(cls):
\"\"\"Public IP that wildcard/custom A-records should point at (Phase 3).\"\"\"
return SystemSettings.get('server_public_ip') or current_app.config.get('SERVER_PUBLIC_IP') or None
@classmethod
def panel_origin(cls):
\"\"\"Canonical public origin of the ServerKit panel, or None when no
canonical domain is configured.
Uses the persisted canonical_domain / canonical_https_enabled settings.
Falls back to PUBLIC_URL / SERVERKIT_PUBLIC_URL env vars, then to the
sites base domain. Returns None if nothing usable is configured.
\"\"\"
domain = SystemSettings.get('canonical_domain')
if domain:
https = bool(SystemSettings.get('canonical_https_enabled', False))
return f'https://{domain}' if https else f'http://{domain}'
url = current_app.config.get('PUBLIC_URL') or current_app.config.get('SERVERKIT_PUBLIC_URL')
if url:
return url.rstrip('/')
base = cls.base_domain()
if base:
return f'https://{base}' if cls.https_enabled() else f'http://{base}'
return None
@classmethod
def https_enabled(cls):"""
)
# 2. git_service.py - Gitea URL
replace_in_file(
'/opt/serverkit/backend/app/services/git_service.py',
""" return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'ssh_port': config.get('ssh_port'),
'url_path': '/gitea', # Slug-based URL path
'url': f\"http://localhost:{app.port}\" if app.port else None, # Legacy port-based URL
'app_id': app.id,
'version': config.get('version', '1.21')
}""",
""" # Prefer the panel's canonical origin so the link works through a domain
# and Cloudflare; fall back to the local port only when no domain is set.
from app.services.site_domain_service import SiteDomainService
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
public_url = f\"{panel_origin}/gitea\"
elif app.port:
public_url = f\"http://localhost:{app.port}\"
else:
public_url = None
return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'ssh_port': config.get('ssh_port'),
'url_path': '/gitea',
'url': public_url,
'app_id': app.id,
'version': config.get('version', '1.21')
}"""
)
# 3. wordpress_service.py - standalone WordPress status URL
replace_in_file(
'/opt/serverkit/backend/app/services/wordpress_service.py',
""" return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'url_path': '/wordpress',
'url': f\"http://localhost:{app.port}\" if app.port else None,
'app_id': app.id,
'version': config.get('version', '6.4')
}""",
""" # Prefer the panel's canonical origin so the link works through a domain
# and Cloudflare; fall back to the local port only when no domain is set.
from app.services.site_domain_service import SiteDomainService
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
public_url = f\"{panel_origin}/wordpress\"
elif app.port:
public_url = f\"http://localhost:{app.port}\"
else:
public_url = None
return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'url_path': '/wordpress',
'url': public_url,
'app_id': app.id,
'version': config.get('version', '6.4')
}"""
)
# 4. wordpress_service.py - _canonical_site_url fallback
replace_in_file(
'/opt/serverkit/backend/app/services/wordpress_service.py',
""" @classmethod
def _canonical_site_url(cls, app) -> str:
\"\"\"The URL WordPress serves under — its primary domain if one exists,
else the legacy localhost:<port> address. Used to build correct
search-replace pairs when cloning.\"\"\"
from app.models.domain import Domain
d = (Domain.query.filter_by(application_id=app.id, is_primary=True).first()
or Domain.query.filter_by(application_id=app.id).first())
if d:
return f'http://{d.name}'
if app.port:
return f'http://localhost:{app.port}'
return None""",
""" @classmethod
def _canonical_site_url(cls, app) -> str:
\"\"\"The URL WordPress serves under — its primary domain if one exists,
else the panel's canonical origin, else the legacy localhost:<port>
address. Used to build correct search-replace pairs when cloning.\"\"\"
from app.models.domain import Domain
from app.services.site_domain_service import SiteDomainService
d = (Domain.query.filter_by(application_id=app.id, is_primary=True).first()
or Domain.query.filter_by(application_id=app.id).first())
if d:
scheme = 'https' if d.ssl_enabled else 'http'
return f'{scheme}://{d.name}'
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
return panel_origin
if app.port:
return f'http://localhost:{app.port}'
return None"""
)
# 5. roundcube_service.py - install signature + URL
replace_in_file(
'/opt/serverkit/backend/app/services/roundcube_service.py',
""" @classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal') -> Dict:""",
""" @classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal',
domain: str = None) -> Dict:"""
)
replace_in_file(
'/opt/serverkit/backend/app/services/roundcube_service.py',
""" return {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': f'http://localhost:{cls.HOST_PORT}',
}""",
""" # Build a public URL: prefer a supplied domain, then auto-generate
# webmail.<panel_domain>, and only fall back to localhost:port.
from app.services.site_domain_service import SiteDomainService
public_url = None
warning = None
if domain:
public_url = f'http://{domain}'
proxy_res = cls.configure_nginx_proxy(domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
else:
panel_origin = SiteDomainService.panel_origin()
panel_host = None
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
webmail_domain = f'webmail.{panel_host}'
public_url = f'http://{webmail_domain}'
proxy_res = cls.configure_nginx_proxy(webmail_domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
if not public_url:
public_url = f'http://localhost:{cls.HOST_PORT}'
result = {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': public_url,
}
if warning:
result['warning'] = warning
return result"""
)
# 6. email.py - pass domain to roundcube install
replace_in_file(
'/opt/serverkit/backend/app/api/email.py',
""" result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
)""",
""" result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
domain=(data.get('domain') or '').strip() or None,
)"""
)
# 7. python_service.py - Django ALLOWED_HOSTS
replace_in_file(
'/opt/serverkit/backend/app/services/python_service.py',
""" # Create .env file
env_content = f'''DEBUG=False
SECRET_KEY={secrets.token_hex(32)}
ALLOWED_HOSTS=localhost,127.0.0.1
'''
with open(os.path.join(app_path, '.env'), 'w') as f:
f.write(env_content)""",
""" # Build ALLOWED_HOSTS: local addresses plus the panel domain and its
# subdomains so Django accepts requests behind nginx on a real domain.
from app.services.site_domain_service import SiteDomainService
allowed_hosts = ['localhost', '127.0.0.1']
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
allowed_hosts.append(panel_host)
# Permit subdomains of the panel domain (e.g. app.serverkit.example.com)
if '.' in panel_host:
allowed_hosts.append('.' + panel_host)
allowed_hosts_str = ','.join(allowed_hosts)
# Create .env file
env_content = f'''DEBUG=False
SECRET_KEY={secrets.token_hex(32)}
ALLOWED_HOSTS={allowed_hosts_str}
'''
with open(os.path.join(app_path, '.env'), 'w') as f:
f.write(env_content)"""
)
# 8. environment_domain_service.py - fallback
replace_in_file(
'/opt/serverkit/backend/app/services/environment_domain_service.py',
""" if not production_domain:
# Fallback for sites without a domain
slug = cls.slugify(branch_name) if branch_name else env_type
return f'{env_type}-{slug}.localhost'""",
""" if not production_domain:
# Fallback for sites without a domain: use the managed-sites base domain
# or the panel domain instead of a useless .localhost address.
from app.services.site_domain_service import SiteDomainService
base = SiteDomainService.base_domain() or SiteDomainService.panel_origin()
if base:
from urllib.parse import urlparse
base_host = urlparse(base).hostname or base
slug = cls.slugify(branch_name) if branch_name else env_type
return f'{env_type}-{slug}.{base_host}'
# Last resort only when absolutely no domain is configured
slug = cls.slugify(branch_name) if branch_name else env_type
return f'{env_type}-{slug}.localhost'"""
)
print('All backend patches applied.')
+5 -14
View File
@@ -2,8 +2,10 @@
#
# ServerKit release builder.
#
# Produces a portable tarball that installs without compiling on the target:
# the full /opt/serverkit tree with a pre-built venv and a built frontend.
# Produces a portable tarball that installs without compiling the frontend on
# the target: the full /opt/serverkit tree with a built frontend. The Python
# virtualenv is created locally by install.sh/update.sh so absolute paths in
# the venv always match the target machine.
#
# bash scripts/build-release.sh
# VERSION=v1.7.0 bash scripts/build-release.sh
@@ -54,8 +56,7 @@ step "Building release ${RELEASE_TAG} for ${DL_ARCH}"
# ---------------------------------------------------------------------------
# Toolchain check
# ---------------------------------------------------------------------------
command -v python3 &>/dev/null || halt "python3 is required."
command -v node &>/dev/null || halt "Node.js is required."
command -v node &>/dev/null || halt "Node.js is required."
# ---------------------------------------------------------------------------
# Stage a clean copy of the repository
@@ -71,16 +72,6 @@ rsync -a --exclude=.git --exclude=node_modules --exclude=venv --exclude=__pycach
--exclude=/backups --exclude=/backend/instance/backups --exclude=/backend/dev-data/backups \
"$REPO_ROOT/" "$BUILD_DIR/"
# ---------------------------------------------------------------------------
# Bake the Python virtual environment
# ---------------------------------------------------------------------------
step "Creating the Python virtual environment..."
python3 -m venv "$BUILD_DIR/venv"
source "$BUILD_DIR/venv/bin/activate"
pip install --upgrade pip --quiet
pip install -r "$BUILD_DIR/backend/requirements.txt" --quiet
pip install gunicorn gevent gevent-websocket --quiet
# ---------------------------------------------------------------------------
# Build the frontend bundle
# ---------------------------------------------------------------------------
+43
View File
@@ -115,6 +115,45 @@ backfill_encryption_key() {
}
backfill_encryption_key
# ---------------------------------------------------------------------------
# Python virtual environment (release tarballs no longer ship a venv)
# ---------------------------------------------------------------------------
locate_python() {
local c v
for c in python3.12 python3.11 python3; do
if command -v "$c" &>/dev/null; then
v=$("$c" -c 'import sys;print(".".join(map(str,sys.version_info[:2])))' 2>/dev/null || true)
if printf '%s\n%s' "3.11" "$v" | sort -C -V && \
printf '%s\n%s' "$v" "3.12" | sort -C -V; then
printf '%s' "$c"
return 0
fi
fi
done
return 1
}
rebuild_virtualenv() {
phase "Python Environment"
local py_bin
py_bin=$(locate_python) || halt "ServerKit requires Python 3.11 or 3.12."
good "Using $py_bin"
step "Recreating the virtual environment locally..."
rm -rf "$VENV_DIR"
"$py_bin" -m venv "$VENV_DIR"
# shellcheck source=/dev/null
source "$VENV_DIR/bin/activate"
step "Installing Python dependencies..."
pip install --upgrade pip --quiet
pip install -r "$INSTALL_DIR/backend/requirements.txt" --quiet
pip install gunicorn gevent gevent-websocket --quiet
good "Python environment rebuilt."
}
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
@@ -217,6 +256,10 @@ if [ "$INSTALL_FROM_RELEASE" = "1" ]; then
mv "$STAGE/serverkit" "$INSTALL_DIR"
rm -rf "$STAGE"
good "Release deployed."
# Release tarballs no longer include a pre-built venv (the absolute paths
# break on the target). Rebuild it locally from requirements.txt.
rebuild_virtualenv
else
phase "Updating Source"
cd "$INSTALL_DIR"