Add import/export and update system
Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
This commit is contained in:
@@ -6,17 +6,21 @@ $pageIcon = 'fas fa-layer-group';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Main Form -->
|
||||
<!-- Main Container -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-layer-group text-gray-400 mr-2 text-sm"></i>
|
||||
Bulk Add Domains
|
||||
</h2>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 bg-gray-50">
|
||||
<button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white transition-colors">
|
||||
<i class="fas fa-keyboard mr-2"></i>Paste Domains
|
||||
</button>
|
||||
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-colors">
|
||||
<i class="fas fa-file-upload mr-2"></i>Import from File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
|
||||
<!-- Tab 1: Paste Domains (existing) -->
|
||||
<div id="panel-paste" class="p-6">
|
||||
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Domains Textarea -->
|
||||
@@ -44,10 +48,8 @@ ob_start();
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<!-- Tag Display Area -->
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
|
||||
<!-- Tag Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<input type="text"
|
||||
@@ -60,17 +62,15 @@ ob_start();
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store tags for form submission -->
|
||||
<input type="hidden" id="tags" name="tags" value="">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.
|
||||
All imported domains will be tagged with these tags.
|
||||
</p>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 mb-1.5">Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
@@ -116,11 +116,88 @@ ob_start();
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Import from File -->
|
||||
<div id="panel-import" class="hidden p-6">
|
||||
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Select File *
|
||||
</label>
|
||||
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="domainFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="domainDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1.5">or</p>
|
||||
<span class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-3 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="domainDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="domainFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="domainFileSize"></p>
|
||||
<button type="button" id="domainFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expected Format Info -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-gray-900 mb-2"><i class="fas fa-info-circle text-blue-500 mr-1.5"></i>Expected File Format</p>
|
||||
<p class="text-xs text-gray-600 mb-2">CSV columns or JSON fields:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-blue-200 font-semibold text-blue-800">domain_name *</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">tags</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notes</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notification_group</code>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Only <code class="bg-white px-1 rounded">domain_name</code> is required. Tags should be comma-separated. Notification group is matched by name.</p>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Notification Group -->
|
||||
<div>
|
||||
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Default Notification Group
|
||||
<span class="text-gray-400 font-normal">(for domains without a group in the file)</span>
|
||||
</label>
|
||||
<select id="import_notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit" id="domainImportBtn"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-file-import mr-2"></i>
|
||||
Import Domains
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- end card -->
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- How it works -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -131,14 +208,13 @@ ob_start();
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
Paste multiple domain names, one per line. The system will fetch WHOIS information
|
||||
Paste domain names or upload a CSV/JSON file. The system will fetch WHOIS information
|
||||
for each domain automatically. This may take a few moments depending on how many domains you're adding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important notes -->
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -169,6 +245,23 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
|
||||
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
|
||||
|
||||
const pasteTab = document.getElementById('tab-paste');
|
||||
const importTab = document.getElementById('tab-import');
|
||||
|
||||
[pasteTab, importTab].forEach(btn => {
|
||||
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'border-transparent', 'text-gray-500');
|
||||
});
|
||||
const active = tab === 'paste' ? pasteTab : importTab;
|
||||
const inactive = tab === 'paste' ? importTab : pasteTab;
|
||||
active.classList.add('border-primary', 'text-primary', 'bg-white');
|
||||
inactive.classList.add('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
let tags = [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
@@ -254,6 +347,84 @@ function addTagFromInput() {
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
|
||||
// --- Domain Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('domainDropzone');
|
||||
const fileInput = document.getElementById('domainFileInput');
|
||||
const content = document.getElementById('domainDropzoneContent');
|
||||
const fileInfo = document.getElementById('domainDropzoneFile');
|
||||
const fileName = document.getElementById('domainFileName');
|
||||
const fileSize = document.getElementById('domainFileSize');
|
||||
const removeBtn = document.getElementById('domainFileRemove');
|
||||
const form = document.getElementById('domainImportForm');
|
||||
const submitBtn = document.getElementById('domainImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Importing & Fetching WHOIS...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
Reference in New Issue
Block a user