refactor: rename lib/ to core/ for clearer core-module separation

Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:20:05 +02:00
parent 7c10fadcb9
commit 0e86925464
371 changed files with 191 additions and 192 deletions

View File

@@ -0,0 +1,139 @@
<?php
namespace MintyPHP\Service\Import;
class ImportTempFileService
{
public const MAX_UPLOAD_BYTES = 10485760; // 10 MB
private const TMP_SUBDIR = '/imports/tmp';
private const MAX_FILE_AGE_SECONDS = 86400;
/**
* @param array<string, mixed> $upload
* @return array{ok:bool, path?:string, code?:string}
*/
public function storeUploadedFile(array $upload): array
{
$tmpName = (string) ($upload['tmp_name'] ?? '');
$name = (string) ($upload['name'] ?? '');
$size = (int) ($upload['size'] ?? 0);
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE || $tmpName === '') {
return ['ok' => false, 'code' => 'upload_missing'];
}
if ($error !== UPLOAD_ERR_OK) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
if ($size <= 0) {
return ['ok' => false, 'code' => 'empty_csv'];
}
if ($size > self::MAX_UPLOAD_BYTES) {
return ['ok' => false, 'code' => 'upload_too_large'];
}
if (!$this->isAllowedExtension($name)) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
$dir = $this->tmpDir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return ['ok' => false, 'code' => 'unexpected_error'];
}
// Lazy cleanup on each upload — removes abandoned files without needing a separate cron job.
$this->cleanupExpired();
try {
$target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv';
} catch (\Throwable $exception) {
$target = $dir . '/import_' . str_replace('.', '', uniqid('', true)) . '.csv';
}
// Real uploads use move_uploaded_file (validates PHP upload context).
// Fallback rename/copy path handles test uploads without a real HTTP context.
$moved = false;
if (is_uploaded_file($tmpName)) {
$moved = @move_uploaded_file($tmpName, $target);
} elseif (is_file($tmpName) && is_readable($tmpName)) {
$moved = @rename($tmpName, $target);
if (!$moved) {
$moved = @copy($tmpName, $target);
}
}
if (!$moved) {
return ['ok' => false, 'code' => 'invalid_file_type'];
}
@chmod($target, 0640);
return ['ok' => true, 'path' => $target];
}
public function delete(string $path): void
{
$path = trim($path);
if ($path === '') {
return;
}
if (!$this->isInTmpDir($path)) {
return;
}
if (is_file($path)) {
@unlink($path);
}
}
public function cleanupExpired(): void
{
$dir = $this->tmpDir();
if (!is_dir($dir)) {
return;
}
$files = glob($dir . '/*');
if (!is_array($files)) {
return;
}
$threshold = time() - self::MAX_FILE_AGE_SECONDS;
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$modifiedAt = @filemtime($file);
if ($modifiedAt !== false && $modifiedAt < $threshold) {
@unlink($file);
}
}
}
public function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim((string) APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 3) . '/storage', '/');
}
public function tmpDir(): string
{
return $this->storageBase() . self::TMP_SUBDIR;
}
private function isAllowedExtension(string $name): bool
{
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return in_array($extension, ['csv', 'txt'], true);
}
// Resolves both paths via realpath to prevent path traversal attacks (e.g. "../../../etc/passwd").
private function isInTmpDir(string $path): bool
{
$tmpDir = $this->tmpDir();
$realTmpDir = realpath($tmpDir);
$realPath = realpath($path);
if ($realTmpDir === false || $realPath === false) {
return false;
}
return str_starts_with($realPath, $realTmpDir . DIRECTORY_SEPARATOR);
}
}