Upload beautify
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Upload\Contracts;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
interface UploadDraftServiceInterface
|
||||
{
|
||||
/**
|
||||
* Create a new draft and return identifying info.
|
||||
*
|
||||
* @param array $attributes
|
||||
* @return array ['id' => string, 'path' => string, 'meta' => array]
|
||||
*/
|
||||
public function createDraft(array $attributes = []): array;
|
||||
|
||||
/**
|
||||
* Store the main uploaded file for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param UploadedFile $file
|
||||
* @return array Metadata about stored file (path, size, mime, hash)
|
||||
*/
|
||||
public function storeMainFile(string $draftId, UploadedFile $file): array;
|
||||
|
||||
/**
|
||||
* Store a screenshot/preview image for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param UploadedFile $file
|
||||
* @return array Metadata about stored screenshot
|
||||
*/
|
||||
public function storeScreenshot(string $draftId, UploadedFile $file): array;
|
||||
|
||||
/**
|
||||
* Calculate a content hash for a local file path or storage path.
|
||||
*
|
||||
* @param string $filePath
|
||||
* @return string
|
||||
*/
|
||||
public function calculateHash(string $filePath): string;
|
||||
|
||||
/**
|
||||
* Set an expiration timestamp for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param \Carbon\Carbon|null $expiresAt
|
||||
* @return bool
|
||||
*/
|
||||
public function setExpiration(string $draftId, ?\Carbon\Carbon $expiresAt = null): bool;
|
||||
}
|
||||
79
app/Services/Upload/PreviewService.php
Normal file
79
app/Services/Upload/PreviewService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class PreviewService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable $e) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function generateFromImage(string $uploadId, string $sourcePath): array
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('PreviewService requires Intervention Image.');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
if (! $disk->exists($sourcePath)) {
|
||||
return $this->generatePlaceholder($uploadId);
|
||||
}
|
||||
|
||||
$absolute = $disk->path($sourcePath);
|
||||
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||
|
||||
$preview = $this->manager->read($absolute)->scaleDown(1280, 1280);
|
||||
$thumb = $this->manager->read($absolute)->cover(320, 320);
|
||||
|
||||
$previewEncoded = (string) $preview->encode(new \Intervention\Image\Encoders\WebpEncoder(85));
|
||||
$thumbEncoded = (string) $thumb->encode(new \Intervention\Image\Encoders\WebpEncoder(82));
|
||||
|
||||
$disk->put($previewPath, $previewEncoded);
|
||||
$disk->put($thumbPath, $thumbEncoded);
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'thumb_path' => $thumbPath,
|
||||
];
|
||||
}
|
||||
|
||||
public function generateFromArchive(string $uploadId, ?string $screenshotPath = null): array
|
||||
{
|
||||
if ($screenshotPath !== null && Storage::disk('local')->exists($screenshotPath)) {
|
||||
return $this->generateFromImage($uploadId, $screenshotPath);
|
||||
}
|
||||
|
||||
return $this->generatePlaceholder($uploadId);
|
||||
}
|
||||
|
||||
public function generatePlaceholder(string $uploadId): array
|
||||
{
|
||||
$disk = Storage::disk('local');
|
||||
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||
|
||||
// 1x1 transparent webp
|
||||
$tinyWebp = base64_decode('UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAAAfQ//73v/+BiOh/AAA=');
|
||||
$disk->put($previewPath, $tinyWebp ?: '');
|
||||
$disk->put($thumbPath, $tinyWebp ?: '');
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'thumb_path' => $thumbPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
105
app/Services/Upload/TagAnalysisService.php
Normal file
105
app/Services/Upload/TagAnalysisService.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use App\Services\TagNormalizer;
|
||||
|
||||
final class TagAnalysisService
|
||||
{
|
||||
public function __construct(private readonly TagNormalizer $normalizer)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||
*/
|
||||
public function analyze(string $filename, ?string $previewPath, ?string $categoryContext): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->extractFilenameTags($filename) as $tag) {
|
||||
$results[] = [
|
||||
'tag' => $tag,
|
||||
'confidence' => 0.72,
|
||||
'source' => 'filename',
|
||||
];
|
||||
}
|
||||
|
||||
if ($previewPath !== null && $previewPath !== '') {
|
||||
// Stub AI output for now (real model integration can replace this later)
|
||||
$results[] = [
|
||||
'tag' => 'ai-detected',
|
||||
'confidence' => 0.66,
|
||||
'source' => 'ai',
|
||||
];
|
||||
$results[] = [
|
||||
'tag' => 'visual-content',
|
||||
'confidence' => 0.61,
|
||||
'source' => 'ai',
|
||||
];
|
||||
}
|
||||
|
||||
if ($categoryContext !== null && $categoryContext !== '') {
|
||||
$normalized = $this->normalizer->normalize($categoryContext);
|
||||
if ($normalized !== '') {
|
||||
$results[] = [
|
||||
'tag' => $normalized,
|
||||
'confidence' => 0.60,
|
||||
'source' => 'manual',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->dedupe($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractFilenameTags(string $filename): array
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME) ?: $filename;
|
||||
$parts = preg_split('/[\s._\-]+/', mb_strtolower($base, 'UTF-8')) ?: [];
|
||||
|
||||
$tags = [];
|
||||
foreach ($parts as $part) {
|
||||
$normalized = $this->normalizer->normalize((string) $part);
|
||||
if ($normalized !== '' && mb_strlen($normalized, 'UTF-8') >= 3) {
|
||||
$tags[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{tag:string,confidence:float,source:string}> $rows
|
||||
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||
*/
|
||||
private function dedupe(array $rows): array
|
||||
{
|
||||
$best = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||
if ($tag === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$confidence = (float) ($row['confidence'] ?? 0.0);
|
||||
$source = (string) ($row['source'] ?? 'manual');
|
||||
|
||||
if (! isset($best[$tag]) || $best[$tag]['confidence'] < $confidence) {
|
||||
$best[$tag] = [
|
||||
'tag' => $tag,
|
||||
'confidence' => max(0.0, min(1.0, $confidence)),
|
||||
'source' => in_array($source, ['ai', 'filename', 'manual'], true) ? $source : 'manual',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($best);
|
||||
}
|
||||
}
|
||||
191
app/Services/Upload/UploadDraftService.php
Normal file
191
app/Services/Upload/UploadDraftService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UploadDraftService implements UploadDraftServiceInterface
|
||||
{
|
||||
protected FilesystemManager $filesystem;
|
||||
protected FilesystemContract $disk;
|
||||
protected string $diskName;
|
||||
protected string $basePath = 'tmp/drafts';
|
||||
|
||||
public function __construct(FilesystemManager $filesystem, string $diskName = 'local')
|
||||
{
|
||||
$this->filesystem = $filesystem;
|
||||
$this->diskName = $diskName;
|
||||
$this->disk = $this->filesystem->disk($this->diskName);
|
||||
}
|
||||
|
||||
public function createDraft(array $attributes = []): array
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
$path = trim($this->basePath, '/') . '/' . $id;
|
||||
|
||||
if (! $this->disk->exists($path)) {
|
||||
$this->disk->makeDirectory($path);
|
||||
}
|
||||
|
||||
$meta = array_merge(['id' => $id, 'created_at' => Carbon::now()->toISOString()], $attributes);
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $id,
|
||||
'user_id' => (int) ($attributes['user_id'] ?? 0),
|
||||
'type' => (string) ($attributes['type'] ?? 'image'),
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'pending',
|
||||
'processing_state' => 'pending_scan',
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->writeMeta($id, $meta);
|
||||
|
||||
return ['id' => $id, 'path' => $path, 'meta' => $meta];
|
||||
}
|
||||
|
||||
public function storeMainFile(string $draftId, UploadedFile $file): array
|
||||
{
|
||||
$dir = trim($this->basePath, '/') . '/' . $draftId . '/main';
|
||||
if (! $this->disk->exists($dir)) {
|
||||
$this->disk->makeDirectory($dir);
|
||||
}
|
||||
|
||||
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||
|
||||
$size = $this->safeSize($storedPath, $file);
|
||||
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||
|
||||
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['main_file'] = $info;
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $draftId,
|
||||
'path' => $storedPath,
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'mime' => $mime,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function storeScreenshot(string $draftId, UploadedFile $file): array
|
||||
{
|
||||
$dir = trim($this->basePath, '/') . '/' . $draftId . '/screenshots';
|
||||
if (! $this->disk->exists($dir)) {
|
||||
$this->disk->makeDirectory($dir);
|
||||
}
|
||||
|
||||
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||
|
||||
$size = $this->safeSize($storedPath, $file);
|
||||
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||
|
||||
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['screenshots'][] = $info;
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $draftId,
|
||||
'path' => $storedPath,
|
||||
'type' => 'screenshot',
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'mime' => $mime,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function calculateHash(string $filePath): string
|
||||
{
|
||||
// If path points to a local filesystem file
|
||||
if (is_file($filePath)) {
|
||||
return hash_file('sha256', $filePath);
|
||||
}
|
||||
|
||||
// If path is a storage-relative path
|
||||
if ($this->disk->exists($filePath)) {
|
||||
$contents = $this->disk->get($filePath);
|
||||
return hash('sha256', $contents);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('File not found for hashing: ' . $filePath);
|
||||
}
|
||||
|
||||
public function setExpiration(string $draftId, ?Carbon $expiresAt = null): bool
|
||||
{
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['expires_at'] = $expiresAt?->toISOString();
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('uploads')->where('id', $draftId)->update([
|
||||
'expires_at' => $expiresAt,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function metaPath(string $draftId): string
|
||||
{
|
||||
return trim($this->basePath, '/') . '/' . $draftId . '/meta.json';
|
||||
}
|
||||
|
||||
protected function readMeta(string $draftId): array
|
||||
{
|
||||
$path = $this->metaPath($draftId);
|
||||
if (! $this->disk->exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = $this->disk->get($path);
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function writeMeta(string $draftId, array $meta): void
|
||||
{
|
||||
$path = $this->metaPath($draftId);
|
||||
$this->disk->put($path, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
protected function safeSize(string $storedPath, UploadedFile $file): int
|
||||
{
|
||||
try {
|
||||
return $this->disk->size($storedPath);
|
||||
} catch (\Throwable $e) {
|
||||
return (int) $file->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
protected function safeMimeType(string $storedPath): ?string
|
||||
{
|
||||
try {
|
||||
return $this->disk->mimeType($storedPath);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user