mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
e192250ef1
commit
652d903ae7
20 files changed
+519
-200
No files matched your search
@@ -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']}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user