Compare commits

...

6 Commits

Author SHA1 Message Date
2a22a3e027 upd: DefaultController (#25)
add database transfer capability
2026-03-26 15:14:15 -04:00
c948b1e39d upd: csrf
add csrf protection for forms
2026-03-26 15:12:56 -04:00
840058873a upd: update permissions on files after copy 2026-03-26 15:12:12 -04:00
d12b94b4b1 upd: symfony/config 2026-03-26 08:26:06 -04:00
26b9e6fe97 upd: .gitignore
ignore .continue folder
2026-03-26 08:22:32 -04:00
3aab29cd03 add: migration 2026-03-26 08:22:01 -04:00
11 changed files with 721 additions and 19 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@
###< symfony/asset-mapper ###
/references/
composer.lock
composer.lock
.continue

View File

@@ -71,6 +71,9 @@ RUN mkdir /var/www/html/var/cache
RUN mkdir /var/www/html/var/log
RUN chown -R 33:33 /var/www/html /data
RUN chmod -R 755 /var/www/html /data
RUN find /var/www/html -type d -exec chmod 755 '{}' \;
RUN find /var/www/html -type f -exec chmod 644 '{}' \;
RUN chmod 755 /data
RUN chmod 644 /data/*
EXPOSE 80

11
config/packages/csrf.yaml Normal file
View File

@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@@ -1,29 +1,49 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
transfer:
url: '%env(default::XFER_DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
# report_fields_where_declared: true
# validate_xml_mapping: true
# auto_mapping: true
default_entity_manager: default
entity_managers:
default:
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
transfer:
connection: transfer
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: true
auto_mapping: false
when@test:
doctrine:
@@ -50,3 +70,6 @@ when@prod:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system
parameters:
env(default_url): ''

View File

@@ -22,3 +22,6 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Service\DatabaseTransferService:
arguments:
$projectDir: '%kernel.project_dir%'

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260217014215 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__messenger_messages AS SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM messenger_messages');
$this->addSql('DROP TABLE messenger_messages');
$this->addSql('CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, available_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
)');
$this->addSql('INSERT INTO messenger_messages (id, body, headers, queue_name, created_at, available_at, delivered_at) SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM __temp__messenger_messages');
$this->addSql('DROP TABLE __temp__messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__messenger_messages AS SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM messenger_messages');
$this->addSql('DROP TABLE messenger_messages');
$this->addSql('CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, available_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
)');
$this->addSql('INSERT INTO messenger_messages (id, body, headers, queue_name, created_at, available_at, delivered_at) SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM __temp__messenger_messages');
$this->addSql('DROP TABLE __temp__messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
}
}

View File

@@ -7,6 +7,7 @@ use App\Entity\User;
use App\Entity\Speaker;
use App\Entity\Series;
use App\Entity\SharedNote;
use App\Service\DatabaseTransferService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -40,6 +41,7 @@ class DefaultController extends AbstractController
'last4Notes' => $last4Notes,
'reverseNoteSort' => $openNotes,
'isAdmin' => $this->isGranted('ROLE_ADMIN'),
'xferDB' => DatabaseTransferService::isTransferEnabled(),
'meta' => $meta,
'speakers' => $speakers,
'series' => $series,
@@ -89,7 +91,7 @@ class DefaultController extends AbstractController
]);
}
#[Route('/reference-editor', name: 'app_reference_editor')]
#[Route('/reference-editor', 'app_reference_editor')]
public function referenceEditor(EntityManagerInterface $emi): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -97,10 +99,63 @@ class DefaultController extends AbstractController
return $this->render('editors/reference-editor.html.twig');
}
#[Route('/template-editor', name: 'app_template_editor')]
#[Route('/template-editor', 'app_template_editor')]
public function templateEditor(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
return $this->render('editors/template-editor.html.twig');
}
#[Route('/xfer-database', name: 'app_admin_transfer_db')]
public function transfer(DatabaseTransferService $service, Request $request): Response
{
$step = $request->query->get('step', 'init');
$session = $request->getSession();
if ($step === 'init') {
$service->createSchema();
$session->remove('transfer_logs');
return $this->redirectToRoute('app_admin_transfer_db', ['step' => 0]);
}
if ($step === 'summary') {
// Finalize
$service->finalizeEnvSwap();
return $this->render('default/transfer_summary.html.twig', [
'logs' => $session->get('transfer_logs', [])
]);
}
$classes = $service->getEntityClasses();
$totalClasses = count($classes);
if (isset($classes[$step])) {
$class = explode("\\", $classes[$step]);
$className = end($class);
$func = "transfer{$className}Table";
if (method_exists($service, $func)) {
$skippedCount = $service->{$func}();
if ($skippedCount > 0) {
$logs = $session->get('transfer_logs', []);
$logs[] = "Skipped $skippedCount orphaned records in the $className table.";
$session->set('transfer_logs', $logs);
}
$progress = round((($step+1) / $totalClasses) * 100);
$nextStep = ($step + 1 < $totalClasses) ? ($step + 1) : 'summary';
return $this->render('default/transfer_progress.html.twig', [
'current' => $className,
'progress' => $progress,
'next_step' => $nextStep,
]);
}
}
// Step 7: Logout and redirect
return $this->redirectToRoute('app_logout');
}
}

View File

@@ -0,0 +1,501 @@
<?php
namespace App\Service;
use App\Entity\Bible;
use App\Entity\Note;
use App\Entity\Reference;
use App\Entity\Series;
use App\Entity\Speaker;
use App\Entity\Template;
use App\Entity\User;
use Doctrine\ORM\Tools\SchemaTool;
use Doctrine\Persistence\ManagerRegistry;
use PDO;
use PDOException;
use Symfony\Component\Uid\Uuid;
class DatabaseTransferService
{
private PDO $srcDB;
private PDO $destDB;
public function __construct(
private string $projectDir,
private ManagerRegistry $doctrine
) {
// connect the source database
switch (explode(":", $_ENV['DATABASE_URL'])[0]) {
case 'pdo-sqlite';
list($driverName, $dbFile) = explode(":/", $_ENV['DATABASE_URL']);
$this->srcDB = new PDO('sqlite:'.$dbFile);
break;
case 'pdo-mysql';
case 'pdo-pgsql';
$dsn = $this->convertDBURLString($_ENV['DATABASE_URL']);
$this->srcDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']);
}
// connect the destination database
switch(explode(':', $_ENV['XFER_DATABASE_URL'])[0]) {
case 'pdo-sqlite':
list($driverName, $dbFile) = explode(":/", $_ENV['XFER_DATABASE']);
$this->destDB = new PDO('sqlite:'.$dbFile);
break;
case 'pdo-mysql':
case 'pdo-pgsql':
$dsn = $this->convertDBURLString($_ENV['XFER_DATABASE_URL']);
$this->destDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']);
}
}
public function createSchema(): void
{
$destEm = $this->doctrine->getManager('transfer');
$metadata = $destEm->getMetadataFactory()->getAllMetadata();
$tool = new SchemaTool($destEm);
$tool->dropSchema($metadata);
$tool->createSchema($metadata);
}
public function getEntityClasses(): array
{
return [
User::class,
Bible::class,
Reference::class,
Template::class,
Series::class,
Speaker::class,
Note::class
];
}
public function transferUserTable(): int
{
$sql = "SELECT * FROM user";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO user (id, email, roles, password, name, meta_data, home_church_rss) ".
"VALUES ".
"(:id, :email, :roles, :password, :name, :meta_data, :home_church)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'email' => $row['email'],
'roles' => $row['roles'],
'password' => $row['password'],
'name' => $row['name'],
'meta_data' => $row['meta_data'],
'home_church' => $row['home_church_rss'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferBibleTable(): int
{
$sql = "SELECT * FROM bible";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO bible (id, book, chapter, verse, content, book_index, label) ".
"VALUES ".
"(:id, :book, :chapter, :verse, :content, :book_index, :label)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$uuid = Uuid::fromString($row['id']);
$params = [
'id' => $uuid->toBinary(),
'book' => $row['book'],
'chapter' => $row['chapter'],
'verse' => $row['verse'],
'content' => $row['content'],
'book_index' => $row['book_index'],
'label' => $row['label'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferReferenceTable(): int
{
$sql = "SELECT * FROM reference";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO reference (id, type, name, label, ndx, content) ".
"VALUES ".
"(:id, :type, :name, :label, :ndx, :content)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'type' => $row['type'],
'name' => $row['name'],
'label' => $row['label'],
'ndx' => $row['ndx'],
'content' => $row['content'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferTemplateTable(): int
{
$sql = "SELECT * FROM template";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO template (id, user_id, name, content) ".
"VALUES ".
"(:id, :user_id, :name, :content)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'user_id' => $row['user_id'],
'name' => $row['name'],
'content' => $row['content'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferSpeakerTable(): int
{
$sql = "SELECT * FROM speaker";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO speaker (id, name, user_id) ".
"VALUES ".
"(:id, :name, :user_id)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'name' => $row['name'],
'user_id' => $row['user_id'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferSeriesTable(): int
{
$sql = "SELECT * FROM series";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO series (id, name, user_id, template_id) ".
"VALUES ".
"(:id, :name, :user_id, :template_id)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'name' => $row['name'],
'user_id' => $row['user_id'],
'template_id' => $row['template_id'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function transferNoteTable(): int
{
$sql = "SELECT * FROM note";
$stmt = $this->srcDB->prepare($sql);
$stmt->execute();
$insQuery = "INSERT INTO note (id, title, date, passage, refs, text, speaker_id, series_id, user_id, recording) ".
"VALUES ".
"(:id, :title, :date, :passage, :refs, :text, :speaker_id, :series_id, :user_id, :recording)";
$destStmt = $this->destDB->prepare($insQuery);
$skippedCount = 0;
$this->destDB->beginTransaction();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$params = [
'id' => $row['id'],
'speaker_id' => $row['speaker_id'],
'series_id' => $row['series_id'],
'user_id' => $row['user_id'],
'title' => $row['title'],
'date' => $row['date'],
'passage' => $row['passage'],
'refs' => $row['refs'],
'text' => $row['text'],
'recording' => $row['recording'],
];
$this->destDB->exec("SAVEPOINT row_insert");
try {
$destStmt->execute($params);
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
} catch (PDOException $e) {
$skipCodes = [
'23000',
'23503',
'23505',
];
if (in_array($e->getCode(), $skipCodes, true)) {
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
$skippedCount++; // 4. Increment the counter
continue;
}
$this->destDB->rollBack();
print_r($params);
die("Critical Error: ".$e->getMessage());
}
}
$this->destDB->commit();
return $skippedCount;
}
public function finalizeEnvSwap(): void
{
$envPath = $this->projectDir . '/.env';
if (!file_exists($envPath)) {
throw new \Exception(".env file not found at $envPath");
}
$lines = file($envPath);
$newLines = [];
$xferUrlValue = null;
// 1. First pass: Find the value of XFER_DATABASE_URL
foreach ($lines as $line) {
if (preg_match('/^XFER_DATABASE_URL=(.*)/', trim($line), $matches)) {
$xferUrlValue = $matches[1];
break;
}
}
if (!$xferUrlValue) {
throw new \Exception("XFER_DATABASE_URL not found in .env file.");
}
// 2. Second pass: Perform the swap
foreach ($lines as $line) {
$trimmed = trim($line);
// Prepend #OLD_ to the existing DATABASE_URL
if (preg_match('/^DATABASE_URL=/', $trimmed)) {
$newLines[] = "# OLD_" . $line;
continue;
}
// Rename XFER_DATABASE_URL to DATABASE_URL
if (preg_match('/^XFER_DATABASE_URL=/', $trimmed)) {
$newLines[] = "DATABASE_URL=" . $xferUrlValue . PHP_EOL;
continue;
}
$newLines[] = $line;
}
file_put_contents($envPath, implode('', $newLines));
}
public static function isTransferEnabled(): bool
{
return (isset($_ENV['XFER_DATABASE_URL']) && !empty($_ENV['XFER_DATABASE_URL']));
}
private function convertDBURLString(string $DBURL): array
{
$dsn = preg_split("/[:|\/|\@]+/", $DBURL);
if (count($dsn) > 6) {
die('You cannot have special characters in your password for the database entry during this process. Change the database user password to just use alpha-numeric characters, then run again. You can change it back to more secure after this transfer is complete');
}
$res = [
'dsn' => substr($dsn[0], 4).':'.
'host='.$dsn[3].';'.
'port='.$dsn[4].';'.
'dbname='.$dsn[5],
'username' => $dsn[1],
'password' => $dsn[2]
];
return $res;
}
}

View File

@@ -25,6 +25,9 @@
<li><a href="#" onclick="saveNote()">Save Note</a></li>
{% if isAdmin is defined and isAdmin %}
<li><a href='/reference-editor'>Reference Editor</a></li>
{% if xferDB is defined and xferDB %}
<li><a href='/xfer-database'>Transfer Database</a></li>
{% endif %}
{% endif %}
<li><a href='#' onclick="openRef()">Open Reference</a></li>
<li><a href='/template-editor'>Template Editor</a></li>

View File

@@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="container mt-5 text-center">
<h2>Transferring Database...</h2>
<p>Processing: <strong>{{ current }}</strong></p>
<div class="progress mb-3" style="height: 30px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: {{ progress }}%;">
{{ progress }}%
</div>
</div>
<p>Please do not close this window.</p>
</div>
<script>
// Automatically move to the next step after a short delay
setTimeout(() => {
window.location.href = "{{ path('app_admin_transfer_db', {step: next_step}) }}";
}, 500);
</script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="container mt-5 text-center">
<h2 class="text-success">Database Transfer Complete!</h2>
<p>Your data has been successfully moved to the new database, and the environment variables have been updated.</p>
{% if logs|length > 0 %}
<div class="alert alert-warning text-start mx-auto" style="max-width: 600px;">
<h5 class="alert-heading">Data Clean-up Notice</h5>
<p>During the transfer, the following orphaned records were safely ignored to maintain database integrity:</p>
<ul>
{% for log in logs %}
<li>{{ log }}</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="alert alert-success mx-auto" style="max-width: 600px;">
Perfect transfer! No orphaned records were found.
</div>
{% endif %}
<div class="mt-4">
<p class="text-muted">You must log in again to establish a connection with the new database.</p>
<a href="{{ path('app_logout') }}" class="btn btn-primary btn-lg">Acknowledge & Relogin</a>
</div>
</div>
{% endblock %}