- Remove orphaned PUBLIC_ACCESS rule for deleted /api/test route - Remove JWT login firewall (app is session-based only) - Set APP_SECRET placeholder (real value must be in .env.local) - Remove JWT env vars from .env - Add session regeneration on login (prevent session fixation) - Remove Document.path from API serialization groups (prevent path leak) - Restrict health check details to ROLE_ADMIN (anonymes get status only) - Add path traversal guard in DocumentStorageService - Convert CreateProfileCommand password to interactive hidden prompt - Restrict Profile Get endpoint to ROLE_ADMIN - Change api firewall to stateless: false (matches session-based auth) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
149 lines
5.5 KiB
PHP
149 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use DateTimeImmutable;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpKernel\KernelInterface;
|
|
|
|
use function dirname;
|
|
|
|
class DocumentStorageService
|
|
{
|
|
private readonly string $storageDir;
|
|
|
|
public function __construct(
|
|
private readonly KernelInterface $kernel,
|
|
) {
|
|
$this->storageDir = $this->kernel->getProjectDir().'/var/storage/documents';
|
|
}
|
|
|
|
public function getStorageDir(): string
|
|
{
|
|
return $this->storageDir;
|
|
}
|
|
|
|
public function getAbsolutePath(string $relativePath): string
|
|
{
|
|
$absolutePath = $this->storageDir.'/'.$relativePath;
|
|
$realPath = realpath($absolutePath);
|
|
|
|
if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) {
|
|
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
|
|
}
|
|
|
|
return $absolutePath;
|
|
}
|
|
|
|
/**
|
|
* Store binary content and return the relative path.
|
|
* Path format: {year}/{month}/{documentId}.{ext}.
|
|
*/
|
|
public function store(string $content, string $documentId, string $extension): string
|
|
{
|
|
$now = new DateTimeImmutable();
|
|
$subDir = $now->format('Y').'/'.$now->format('m');
|
|
$relativePath = $subDir.'/'.$documentId.'.'.$extension;
|
|
$absolutePath = $this->storageDir.'/'.$relativePath;
|
|
|
|
$dir = dirname($absolutePath);
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
|
|
throw new RuntimeException(sprintf('Cannot create directory "%s"', $dir));
|
|
}
|
|
}
|
|
|
|
$bytesWritten = file_put_contents($absolutePath, $content);
|
|
if (false === $bytesWritten) {
|
|
throw new RuntimeException(sprintf('Cannot write file "%s"', $absolutePath));
|
|
}
|
|
|
|
return $relativePath;
|
|
}
|
|
|
|
/**
|
|
* Store a file from a given source path (e.g., temp upload).
|
|
*/
|
|
public function storeFromPath(string $sourcePath, string $documentId, string $extension): string
|
|
{
|
|
$content = file_get_contents($sourcePath);
|
|
if (false === $content) {
|
|
throw new RuntimeException(sprintf('Cannot read source file "%s"', $sourcePath));
|
|
}
|
|
|
|
return $this->store($content, $documentId, $extension);
|
|
}
|
|
|
|
public function read(string $relativePath): string
|
|
{
|
|
$absolutePath = $this->getAbsolutePath($relativePath);
|
|
if (!file_exists($absolutePath)) {
|
|
throw new RuntimeException(sprintf('File not found: "%s"', $absolutePath));
|
|
}
|
|
|
|
$content = file_get_contents($absolutePath);
|
|
if (false === $content) {
|
|
throw new RuntimeException(sprintf('Cannot read file "%s"', $absolutePath));
|
|
}
|
|
|
|
return $content;
|
|
}
|
|
|
|
public function delete(string $relativePath): bool
|
|
{
|
|
$absolutePath = $this->getAbsolutePath($relativePath);
|
|
if (!file_exists($absolutePath)) {
|
|
return false;
|
|
}
|
|
|
|
return @unlink($absolutePath);
|
|
}
|
|
|
|
public function exists(string $relativePath): bool
|
|
{
|
|
return file_exists($this->getAbsolutePath($relativePath));
|
|
}
|
|
|
|
public function isBase64DataUri(string $path): bool
|
|
{
|
|
return str_starts_with($path, 'data:');
|
|
}
|
|
|
|
public function extensionFromMimeType(string $mimeType): string
|
|
{
|
|
$map = [
|
|
'application/pdf' => 'pdf',
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
'image/svg+xml' => 'svg',
|
|
'image/bmp' => 'bmp',
|
|
'text/plain' => 'txt',
|
|
'text/csv' => 'csv',
|
|
'application/json' => 'json',
|
|
'application/xml' => 'xml',
|
|
'application/zip' => 'zip',
|
|
'audio/mpeg' => 'mp3',
|
|
'audio/ogg' => 'ogg',
|
|
'video/mp4' => 'mp4',
|
|
'video/webm' => 'webm',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
|
'application/msword' => 'doc',
|
|
'application/vnd.ms-excel' => 'xls',
|
|
];
|
|
|
|
return $map[$mimeType] ?? 'bin';
|
|
}
|
|
|
|
public function extensionFromFilename(string $filename): string
|
|
{
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
|
|
|
return '' !== $ext ? strtolower($ext) : 'bin';
|
|
}
|
|
}
|