phase a audit

This commit is contained in:
team2
2026-02-22 18:04:53 +01:00
parent b3e9110dd1
commit 3b2e1bc772
10 changed files with 608 additions and 516 deletions

View File

@@ -21,12 +21,10 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
use function function_exists;
#[Route('/admin/documents')]
class DocumentController extends AbstractController
{
#[Route('', name: 'admin_documents')]
public function index(EntityManagerInterface $em): Response
{
@@ -41,7 +39,7 @@ class DocumentController extends AbstractController
->getResult();
return $this->render('admin/document/index.html.twig', [
'documents' => $documents
'documents' => $documents,
]);
}
@@ -54,7 +52,7 @@ class DocumentController extends AbstractController
{
try {
$uuid = Uuid::fromString($id);
} catch (\Exception $e) {
} catch (\Exception) {
throw new NotFoundHttpException();
}
@@ -65,7 +63,7 @@ class DocumentController extends AbstractController
}
return $this->render('admin/document/show.html.twig', [
'document' => $document
'document' => $document,
]);
}
@@ -76,92 +74,72 @@ class DocumentController extends AbstractController
FormatText $formatText,
IngestJobService $jobService,
ParameterBagInterface $params
): Response
{
if ($request->isMethod('POST')) {
/** @var UploadedFile|null $file */
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new \InvalidArgumentException('No valid file uploaded.');
}
$rawTitle = $request->request->get('title');
$title = is_string($rawTitle) && $rawTitle !== ''
? $rawTitle
: $formatText->slugify($file->getClientOriginalName());
if (!$title) {
$this->addFlash('error', 'Titel ist erforderlich.');
return $this->redirectToRoute('admin_document_new');
}
$uploadDir = $params->get('mto.vector.data.upload.path');
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$newFilename = uniqid() . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException $e) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
// Dokument erstellen
$document = $documentService->createDocument(
$title,
$filePath,
$this->getUser()
);
// ---------------------------------------------------------
// AUTO-INTEGRATION: gleicher Flow wie "Version aktivieren"
// ---------------------------------------------------------
$version = $document->getCurrentVersion();
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
$this->getUser(),
$version->getDocument()->getId(),
$version->getId(),
null,
IngestJob::STATUS_QUEUED
);
$projectDir = (string)$this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
$cmd = sprintf(
'%s %s %s %s > /dev/null 2>&1 &',
escapeshellarg($console),
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg((string)$job->getId()),
escapeshellarg('--no-interaction'),
);
if (!function_exists('exec')) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('danger', 'Dokument erstellt, aber Ingest konnte nicht asynchron gestartet werden.');
return $this->redirectToRoute('admin_documents');
}
exec($cmd);
return $this->redirectToRoute('admin_job_show', [
'id' => (string)$job->getId(),
]);
): Response {
if (!$request->isMethod('POST')) {
return $this->render('admin/document/new.html.twig');
}
return $this->render('admin/document/new.html.twig');
/** @var UploadedFile|null $file */
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new \InvalidArgumentException('No valid file uploaded.');
}
$rawTitle = $request->request->get('title');
$title = is_string($rawTitle) && $rawTitle !== ''
? $rawTitle
: $formatText->slugify($file->getClientOriginalName());
if (!$title) {
$this->addFlash('error', 'Titel ist erforderlich.');
return $this->redirectToRoute('admin_document_new');
}
$uploadDir = (string)$params->get('mto.vector.data.upload.path');
$this->ensureDir($uploadDir);
$newFilename = uniqid('', true) . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
$document = $documentService->createDocument(
$title,
$filePath,
$this->getUser()
);
$version = $document->getCurrentVersion();
if (!$version instanceof DocumentVersion) {
$this->addFlash('danger', 'Dokument erstellt, aber es wurde keine aktuelle Version erzeugt.');
return $this->redirectToRoute('admin_documents');
}
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
$this->getUser(),
$version->getDocument()->getId(),
$version->getId(),
null,
IngestJob::STATUS_QUEUED
);
if (!$this->canExec()) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('danger', 'Dokument erstellt, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_documents');
}
$this->startIngestJob((string)$job->getId());
return $this->redirectToRoute('admin_job_show', [
'id' => (string)$job->getId(),
]);
}
#[Route('/{id}/version/new', name: 'admin_document_version_new', requirements: ['id' => '[0-9a-fA-F\-]{36}'])]
@@ -171,52 +149,46 @@ class DocumentController extends AbstractController
EntityManagerInterface $em,
DocumentService $documentService,
ParameterBagInterface $params
): Response
{
): Response {
$document = $em->getRepository(Document::class)->find($id);
if (!$document) {
throw $this->createNotFoundException();
}
if ($request->isMethod('POST')) {
$file = $request->files->get('file');
if (!$file) {
$this->addFlash('error', 'Datei ist erforderlich.');
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
}
$uploadDir = $params->get('mto.vector.data.upload.path');
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$newFilename = uniqid() . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException $e) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
$documentService->addVersion(
$document,
$filePath,
$this->getUser()
);
return $this->redirectToRoute('admin_document_show', ['id' => $id]);
if (!$request->isMethod('POST')) {
return $this->render('admin/document/new_version.html.twig', [
'document' => $document,
]);
}
return $this->render('admin/document/new_version.html.twig', [
'document' => $document
]);
/** @var UploadedFile|null $file */
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
$this->addFlash('error', 'Datei ist erforderlich.');
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
}
$uploadDir = (string)$params->get('mto.vector.data.upload.path');
$this->ensureDir($uploadDir);
$newFilename = uniqid('', true) . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
$documentService->addVersion(
$document,
$filePath,
$this->getUser()
);
return $this->redirectToRoute('admin_document_show', ['id' => $id]);
}
#[Route(
@@ -231,27 +203,18 @@ class DocumentController extends AbstractController
EntityManagerInterface $em,
DocumentService $documentService,
IngestJobService $jobService,
): RedirectResponse
{
if (!$this->isCsrfTokenValid('activate_version_' . $versionId, $request->request->get('_token'))) {
): RedirectResponse {
if (!$this->isCsrfTokenValid('activate_version_' . $versionId, (string)$request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
if (!$version) {
throw $this->createNotFoundException();
}
try {
$documentService->activateVersion($version);
// ---------------------------------------------------------
// Saubere IngestJob-Integration:
// 1) Job als QUEUED anlegen (spezieller Typ für Aktivierung)
// 2) Symfony-Command im Hintergrund starten
// 3) Direkt auf Job-Detailseite redirecten (Loader + Polling)
// ---------------------------------------------------------
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
@@ -262,28 +225,15 @@ class DocumentController extends AbstractController
IngestJob::STATUS_QUEUED
);
// Hintergrundprozess starten (Provider-kompatibel, kein Worker/Daemon)
$projectDir = (string)$this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
$cmd = sprintf(
'%s %s %s %s > /dev/null 2>&1 &',
escapeshellarg($console),
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg((string)$job->getId()),
escapeshellarg('--no-interaction'),
);
// Best effort: wenn exec deaktiviert ist, sauber abbrechen.
if (!function_exists('exec')) {
if (!$this->canExec()) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('danger', 'Aktivierung ok, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
'id' => $version->getDocument()->getId(),
]);
}
exec($cmd);
$this->startIngestJob((string)$job->getId());
$this->addFlash('success', 'Version aktiviert. Ingest-Job wurde erstellt und gestartet.');
@@ -295,7 +245,7 @@ class DocumentController extends AbstractController
}
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
'id' => $version->getDocument()->getId(),
]);
}
@@ -310,19 +260,17 @@ class DocumentController extends AbstractController
Request $request,
EntityManagerInterface $em,
IngestJobService $jobService,
): ?RedirectResponse
{
$dryRun = false;
if (!$this->isCsrfTokenValid('ingest_version_' . $versionId, $request->request->get('_token'))) {
): ?RedirectResponse {
if (!$this->isCsrfTokenValid('ingest_version_' . $versionId, (string)$request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
if (!$version) {
throw $this->createNotFoundException();
}
/** @var IngestJob|null $existing */
$existing = $em->getRepository(IngestJob::class)
->findOneBy(
['documentVersionId' => $version->getId()],
@@ -333,13 +281,6 @@ class DocumentController extends AbstractController
return null;
}
// ---------------------------------------------------------
// Asynchroner Ingest (ohne Messenger):
// 1) Job als QUEUED anlegen
// 2) Symfony-Command im Hintergrund starten
// 3) Direkt auf Job-Detailseite redirecten (Loader + Polling)
// ---------------------------------------------------------
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT,
$this->getUser(),
@@ -349,28 +290,15 @@ class DocumentController extends AbstractController
IngestJob::STATUS_QUEUED
);
// Hintergrundprozess starten (Provider-kompatibel, kein Worker/Daemon)
$projectDir = (string)$this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
$cmd = sprintf(
'%s %s %s %s > /dev/null 2>&1 &',
escapeshellarg($console),
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg((string)$job->getId()),
escapeshellarg('--no-interaction'),
);
// Best effort: wenn exec deaktiviert ist, sauber abbrechen.
if (!function_exists('exec')) {
if (!$this->canExec()) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('error', 'Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
'id' => $version->getDocument()->getId(),
]);
}
exec($cmd);
$this->startIngestJob((string)$job->getId());
return $this->redirectToRoute('admin_job_show', [
'id' => (string)$job->getId(),
@@ -384,17 +312,21 @@ class DocumentController extends AbstractController
)]
public function resetCompleteSystem(ParameterBagInterface $params, Connection $connection): ?RedirectResponse
{
if (!function_exists('exec')) {
if (!$this->canExec()) {
$this->addFlash('danger', 'Der Reset konnte nicht gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_dashboard');
}
@unlink($params->get('mto.knowledge.ndjson'));
@unlink($params->get('mto.knowledge.vector_index'));
@unlink($params->get('mto.knowledge.vector_index_meta'));
@unlink($params->get('mto.knowledge.index_meta'));
@unlink($params->get('mto.runtime.meta'));
exec('rm -rf ' . $params->get('mto.knowledge.upload'));
@unlink((string)$params->get('mto.knowledge.ndjson'));
@unlink((string)$params->get('mto.knowledge.vector_index'));
@unlink((string)$params->get('mto.knowledge.vector_index_meta'));
@unlink((string)$params->get('mto.knowledge.index_meta'));
@unlink((string)$params->get('mto.runtime.meta'));
$uploadDir = (string)$params->get('mto.knowledge.upload');
if ($uploadDir !== '' && is_dir($uploadDir)) {
exec('rm -rf ' . escapeshellarg($uploadDir));
}
$sql = '
SET FOREIGN_KEY_CHECKS = 0;
@@ -425,39 +357,29 @@ class DocumentController extends AbstractController
EntityManagerInterface $em,
IngestJobService $jobService,
LockService $lockService,
DocumentService $documentService
): RedirectResponse
{
if (!$this->isCsrfTokenValid('delete_document_' . $id, $request->request->get('_token'))) {
): RedirectResponse {
if (!$this->isCsrfTokenValid('delete_document_' . $id, (string)$request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
try {
$uuid = Uuid::fromString($id);
} catch (\Exception $e) {
} catch (\Exception) {
throw $this->createNotFoundException();
}
/** @var Document|null $document */
$document = $em->getRepository(Document::class)->find($uuid);
if (!$document) {
throw $this->createNotFoundException();
}
// ---------------------------------------------------------
// 🔒 Delete nur erlauben wenn kein anderer Job läuft
// ---------------------------------------------------------
if (!$lockService->acquire()) {
$this->addFlash('danger', 'Ein Ingest-Job läuft bereits. Löschen derzeit nicht möglich.');
return $this->redirectToRoute('admin_documents');
}
// Nur Test-Lock echter Lock im Orchestrator
$lockService->release();
// ---------------------------------------------------------
// 1) Delete-Job anlegen (QUEUED)
// ---------------------------------------------------------
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT_DELETE,
$this->getUser(),
@@ -467,27 +389,13 @@ class DocumentController extends AbstractController
IngestJob::STATUS_QUEUED
);
// ---------------------------------------------------------
// 2) Hintergrundprozess starten
// ---------------------------------------------------------
$projectDir = (string)$this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
$cmd = sprintf(
'%s %s %s %s > /dev/null 2>&1 &',
escapeshellarg($console),
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg((string)$job->getId()),
escapeshellarg('--no-interaction'),
);
if (!function_exists('exec')) {
if (!$this->canExec()) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('danger', 'Löschen konnte nicht gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_documents');
}
exec($cmd);
$this->startIngestJob((string)$job->getId());
$this->addFlash('success', 'Löschvorgang gestartet. Dokument wird nach Index-Rebuild entfernt.');
@@ -495,4 +403,42 @@ class DocumentController extends AbstractController
'id' => (string)$job->getId(),
]);
}
}
// =========================================================
// Helpers
// =========================================================
private function canExec(): bool
{
return function_exists('exec');
}
private function ensureDir(string $dir): void
{
if ($dir === '') {
throw new \RuntimeException('Upload directory not configured.');
}
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Unable to create upload directory.');
}
}
private function startIngestJob(string $jobId): void
{
$projectDir = (string)$this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
// WICHTIG: --no-interaction ist ein GLOBAL-Flag und muss VOR dem Command stehen!
$cmd = sprintf(
'%s %s %s %s %s > /dev/null 2>&1 &',
escapeshellarg(PHP_BINARY),
escapeshellarg($console),
'--no-interaction',
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg($jobId),
);
exec($cmd);
}
}