- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
136 lines
3.9 KiB
PHP
136 lines
3.9 KiB
PHP
<?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 static 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 (!self::isAllowedExtension($name)) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$dir = self::tmpDir();
|
|
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
|
return ['ok' => false, 'code' => 'unexpected_error'];
|
|
}
|
|
|
|
self::cleanupExpired();
|
|
|
|
try {
|
|
$target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv';
|
|
} catch (\Throwable $exception) {
|
|
$target = $dir . '/import_' . str_replace('.', '', uniqid('', true)) . '.csv';
|
|
}
|
|
$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 static function delete(string $path): void
|
|
{
|
|
$path = trim($path);
|
|
if ($path === '') {
|
|
return;
|
|
}
|
|
if (!self::isInTmpDir($path)) {
|
|
return;
|
|
}
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
public static function cleanupExpired(): void
|
|
{
|
|
$dir = self::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 static function storageBase(): string
|
|
{
|
|
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
|
return rtrim((string) APP_STORAGE_PATH, '/');
|
|
}
|
|
return rtrim(dirname(__DIR__, 3) . '/storage', '/');
|
|
}
|
|
|
|
public static function tmpDir(): string
|
|
{
|
|
return self::storageBase() . self::TMP_SUBDIR;
|
|
}
|
|
|
|
private static function isAllowedExtension(string $name): bool
|
|
{
|
|
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
return in_array($extension, ['csv', 'txt'], true);
|
|
}
|
|
|
|
private static function isInTmpDir(string $path): bool
|
|
{
|
|
$tmpDir = self::tmpDir();
|
|
$realTmpDir = realpath($tmpDir);
|
|
$realPath = realpath($path);
|
|
if ($realTmpDir === false || $realPath === false) {
|
|
return false;
|
|
}
|
|
return str_starts_with($realPath, $realTmpDir . DIRECTORY_SEPARATOR);
|
|
}
|
|
}
|