feat: WordPress migration support — upload existing site files

- Add two deployment modes for WordPress: Fresh Install vs Migrate Existing Site
- Fresh Install: vanilla WordPress from official image (existing behavior)
- Migrate: upload ZIP with wp-content/ (themes, plugins, uploads), wp-config.php, .htaccess
- Custom entrypoint merges staged wp-content into PVC on first container run
- Add init container for fresh WordPress builds (empty source context for Kaniko)
- Increase upload limit to 200MB for WordPress sites
- Add PHP upload limits (64MB) and memory config in WordPress Dockerfile
- Update deploy page review step to show WordPress mode info
- All UI in English
This commit is contained in:
keyhan
2026-04-07 23:12:25 +03:30
parent c2c6a32ae8
commit 5979b48a61
4 changed files with 220 additions and 36 deletions
@@ -51,7 +51,7 @@ export class ApplicationsController {
@ApiOperation({ summary: 'Upload application code (zip file)' }) @ApiOperation({ summary: 'Upload application code (zip file)' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB limits: { fileSize: 200 * 1024 * 1024 }, // 200MB (WordPress sites can be large)
})) }))
async uploadCode( async uploadCode(
@Param('id') id: string, @Param('id') id: string,
+51 -14
View File
@@ -205,12 +205,23 @@ export class BuildService {
{ name: 'workspace', mountPath: '/workspace' }, { name: 'workspace', mountPath: '/workspace' },
]; ];
// If no uploaded code and no git, mount dockerfile directly // If no uploaded code and no git, we need to prepare the workspace
if (!hasUploadedCode && !hasGitUrl) { if (!hasUploadedCode && !hasGitUrl) {
kanikoVolumeMounts.push({ // For runtimes that don't need source (e.g. fresh WordPress),
name: 'dockerfile', // add an init container that creates empty source dir + copies Dockerfile
mountPath: '/workspace/Dockerfile', initContainers.push({
subPath: 'Dockerfile', name: 'prepare-workspace',
image: 'alpine:3.19',
command: ['sh', '-c', `
mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Prepared empty workspace for fresh install" &&
ls -la /workspace-out/
`],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' },
],
}); });
} }
@@ -391,6 +402,8 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
private wordpressDockerfile(app: Application): string { private wordpressDockerfile(app: Application): string {
const wpVersion = app.runtimeVersion || '6.7'; const wpVersion = app.runtimeVersion || '6.7';
const phpVersion = app.phpVersion || '8.3'; const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath;
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
# Install additional PHP extensions commonly needed by WordPress # Install additional PHP extensions commonly needed by WordPress
@@ -399,27 +412,51 @@ RUN docker-php-ext-install opcache
# Enable Apache mod_rewrite for pretty permalinks # Enable Apache mod_rewrite for pretty permalinks
RUN a2enmod rewrite RUN a2enmod rewrite
# Copy user's custom themes, plugins, and uploads if provided # Increase PHP upload limits for WordPress media
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
${hasUploadedCode ? `# Copy user's custom WordPress files
COPY . /tmp/user-content COPY . /tmp/user-content
# Merge user content into the WordPress installation # Merge user content into a staging area for wp-content
# - wp-content/themes, wp-content/plugins, wp-content/uploads # The actual wp-content is on a PVC, so we stage it and copy at runtime
# - Also support full WordPress roots (with wp-config.php, etc.) RUN mkdir -p /usr/src/wordpress-user && \\
RUN if [ -d /tmp/user-content/wp-content ]; then \\ if [ -d /tmp/user-content/wp-content ]; then \\
cp -a /tmp/user-content/wp-content/. /var/www/html/wp-content/; \\ echo ">>> Staging user wp-content (themes, plugins, uploads)..." && \\
cp -a /tmp/user-content/wp-content /usr/src/wordpress-user/wp-content; \\
fi && \\ fi && \\
if [ -f /tmp/user-content/wp-config.php ]; then \\ if [ -f /tmp/user-content/wp-config.php ]; then \\
echo ">>> Copying custom wp-config.php" && \\
cp /tmp/user-content/wp-config.php /var/www/html/wp-config.php; \\ cp /tmp/user-content/wp-config.php /var/www/html/wp-config.php; \\
fi && \\ fi && \\
# Copy any loose PHP files (custom root files) if [ -f /tmp/user-content/.htaccess ]; then \\
echo ">>> Copying .htaccess" && \\
cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\
fi && \\
find /tmp/user-content -maxdepth 1 -name "*.php" ! -name "wp-config.php" -exec cp {} /var/www/html/ \\\\; 2>/dev/null || true && \\ find /tmp/user-content -maxdepth 1 -name "*.php" ! -name "wp-config.php" -exec cp {} /var/www/html/ \\\\; 2>/dev/null || true && \\
rm -rf /tmp/user-content rm -rf /tmp/user-content && \\
echo ">>> WordPress user content staged"
# Custom entrypoint: merge staged wp-content into PVC on first run, then run WP
RUN { \\
echo '#!/bin/bash'; \\
echo 'set -e'; \\
echo 'if [ -d /usr/src/wordpress-user/wp-content ] && [ ! -f /var/www/html/wp-content/.user-content-merged ]; then'; \\
echo ' echo ">>> First run: merging user wp-content into PVC..."'; \\
echo ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\
echo ' touch /var/www/html/wp-content/.user-content-merged'; \\
echo ' chown -R www-data:www-data /var/www/html/wp-content'; \\
echo ' echo ">>> User wp-content merged successfully"'; \\
echo 'fi'; \\
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
} > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh
` : `# Fresh install — no user content to merge
`}
# Set proper ownership # Set proper ownership
RUN chown -R www-data:www-data /var/www/html RUN chown -R www-data:www-data /var/www/html
EXPOSE 80 EXPOSE 80
CMD ["apache2-foreground"] CMD [${hasUploadedCode ? '"cloudhost-entrypoint.sh"' : '"apache2-foreground"'}]
`; `;
} }
+161 -14
View File
@@ -48,6 +48,10 @@ export default function DeployPage() {
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null); const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
const dbDumpInputRef = useRef<HTMLInputElement>(null); const dbDumpInputRef = useRef<HTMLInputElement>(null);
const [dbUploadProgress, setDbUploadProgress] = useState(0); const [dbUploadProgress, setDbUploadProgress] = useState(0);
const [wpMode, setWpMode] = useState<'fresh' | 'migrate'>('fresh');
const [wpContentFile, setWpContentFile] = useState<File | null>(null);
const [isWpDragging, setIsWpDragging] = useState(false);
const wpFileInputRef = useRef<HTMLInputElement>(null);
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly'); const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet'); const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
const [isPaid, setIsPaid] = useState(false); const [isPaid, setIsPaid] = useState(false);
@@ -99,10 +103,11 @@ export default function DeployPage() {
const res = await api.post('/applications', payload); const res = await api.post('/applications', payload);
const appId = res.data.id; const appId = res.data.id;
// Upload source // Upload source (regular apps or WordPress migrate)
if (sourceMethod === 'upload' && zipFile) { const fileToUpload = form.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', zipFile); formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, { await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); }, onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
@@ -159,10 +164,11 @@ export default function DeployPage() {
const res = await api.post('/applications', payload); const res = await api.post('/applications', payload);
const appId = res.data.id; const appId = res.data.id;
// Upload source // Upload source (regular apps or WordPress migrate)
if (sourceMethod === 'upload' && zipFile) { const fileToUpload = form.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', zipFile); formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, { await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); }, onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
@@ -201,9 +207,11 @@ export default function DeployPage() {
const appId = res.data.id; const appId = res.data.id;
// Upload zip file if selected // Upload zip file if selected
if (sourceMethod === 'upload' && zipFile) { // Upload source (regular apps or WordPress migrate)
const fileToUpload = data.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', zipFile); formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, { await api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { onUploadProgress: (e) => {
@@ -294,11 +302,35 @@ export default function DeployPage() {
setIsDragging(false); setIsDragging(false);
}, []); }, []);
const handleWpFileSelect = useCallback((file: File) => {
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
if (!hasValidExt) {
toast.error('Only .zip or .tar.gz files are allowed');
return;
}
if (file.size > 200 * 1024 * 1024) {
toast.error('WordPress files must be less than 200MB');
return;
}
setWpContentFile(file);
toast.success(`WordPress files selected: ${file.name}`);
}, []);
const handleWpDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsWpDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleWpFileSelect(file);
}, [handleWpFileSelect]);
const canNext = () => { const canNext = () => {
if (step === 0) { if (step === 0) {
if (form.name.length < 2) return false; if (form.name.length < 2) return false;
// WordPress doesn't require source code // WordPress: migrate mode requires wp-content file
if (form.runtime !== 'wordpress') { if (form.runtime === 'wordpress') {
if (wpMode === 'migrate' && !wpContentFile) return false;
} else {
if (sourceMethod === 'upload' && !zipFile) return false; if (sourceMethod === 'upload' && !zipFile) return false;
if (sourceMethod === 'git' && !form.gitUrl) return false; if (sourceMethod === 'git' && !form.gitUrl) return false;
} }
@@ -387,6 +419,8 @@ export default function DeployPage() {
else if (opt.value === 'laravel') { updates.port = 8000; updates.runtimeVersion = ''; updates.phpVersion = '8.3'; } else if (opt.value === 'laravel') { updates.port = 8000; updates.runtimeVersion = ''; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; } else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
setForm({ ...form, ...updates }); setForm({ ...form, ...updates });
// Reset WordPress-specific state when switching types
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
}} }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${ className={`p-4 rounded-xl border-2 text-left transition-colors ${
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300' form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
@@ -534,19 +568,130 @@ export default function DeployPage() {
</div> </div>
)} )}
{/* WordPress info — shown when WordPress is selected */} {/* WordPress deployment mode — shown when WordPress is selected */}
{form.runtime === 'wordpress' && ( {form.runtime === 'wordpress' && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Deployment Mode</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { setWpMode('fresh'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'fresh' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<Rocket className="w-5 h-5 text-blue-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Fresh Install</p>
<p className="text-xs text-gray-500 mt-1">
Start with a clean WordPress. Install themes & plugins via the admin panel.
</p>
</button>
<button
type="button"
onClick={() => setWpMode('migrate')}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'migrate' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<Upload className="w-5 h-5 text-emerald-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Migrate Existing Site</p>
<p className="text-xs text-gray-500 mt-1">
Upload your WordPress files (wp-content, themes, plugins) and optionally a DB dump.
</p>
</button>
</div>
{wpMode === 'fresh' && (
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl"> <div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Hexagon className="w-5 h-5 text-blue-600" /> <Hexagon className="w-5 h-5 text-blue-600" />
<h3 className="text-sm font-semibold text-gray-800">WordPress (Official Image)</h3> <h3 className="text-sm font-semibold text-gray-800">WordPress (Official Image)</h3>
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
وردپرس از ایمیج رسمی Docker استفاده میکند و نیازی به آپلود سورسکد ندارد. A fresh WordPress installation will be deployed using the official Docker image.
قالبها و افزونهها از طریق پنل مدیریت وردپرس نصب میشوند. You can install themes and plugins via the WordPress admin panel after deployment.
</p> </p>
</div> </div>
)} )}
{wpMode === 'migrate' && (
<div className="space-y-3">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-xs text-gray-600">
<strong>Upload a ZIP</strong> containing your WordPress files. Supported structures:
</p>
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
<li><code className="bg-amber-100 px-1 rounded">wp-content/</code> themes, plugins, uploads</li>
<li><code className="bg-amber-100 px-1 rounded">wp-config.php</code> custom configuration (optional)</li>
<li>Any custom <code className="bg-amber-100 px-1 rounded">.php</code> files at root level</li>
</ul>
<p className="text-xs text-gray-500 mt-1.5">
You can also upload a SQL database dump in the next step to restore your data.
</p>
</div>
{wpContentFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-green-800">{wpContentFile.name}</p>
<p className="text-xs text-green-600">
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setWpContentFile(null);
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleWpDrop}
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
onClick={() => wpFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isWpDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<div className="space-y-2">
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">
Drag & drop your WordPress ZIP here
</p>
<p className="text-xs text-gray-500">
ZIP with <strong>wp-content/</strong> folder Max 200MB
</p>
</div>
</div>
)}
<input
ref={wpFileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleWpFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
</div>
)}
</div> </div>
)} )}
@@ -1179,7 +1324,9 @@ export default function DeployPage() {
<span className="text-sm text-gray-500">Source</span> <span className="text-sm text-gray-500">Source</span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{form.runtime === 'wordpress' {form.runtime === 'wordpress'
? 'WordPress (Official Image)' ? wpMode === 'migrate' && wpContentFile
? `Migrate: ${wpContentFile.name} (${(wpContentFile.size / (1024 * 1024)).toFixed(1)} MB)`
: 'WordPress (Fresh Install)'
: sourceMethod === 'upload' : sourceMethod === 'upload'
? zipFile ? zipFile
? zipFile.name ? zipFile.name
File diff suppressed because one or more lines are too long