- New SportsMatch model/controller and sports UI components/modal - Move share-modal to a reusable x-share-modal/x-share-button component - Add VideoSharedWithUser notification and share-to-members flow - Device/user-agent tracking on views, downloads, share accesses - ProfileVisit model + migration; subscription source tracking - Email thumbnail support; remove stale TODO files
231 lines
8.5 KiB
PHP
231 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Playlist;
|
|
use App\Models\User;
|
|
use App\Models\Video;
|
|
use App\Models\VideoSlide;
|
|
use App\Services\NasSyncService;
|
|
use Illuminate\Http\Response;
|
|
|
|
/**
|
|
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
|
|
* NAS fallback: if the file is missing locally it is fetched from the NAS and
|
|
* cached in the standard public storage directory before being served.
|
|
*
|
|
* All routes are public (no auth) so images render in emails and for guests.
|
|
*/
|
|
class MediaController extends Controller
|
|
{
|
|
public function thumbnail(string $filename, NasSyncService $nas): Response
|
|
{
|
|
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
|
|
if (str_starts_with($filename, 'users/')) {
|
|
$local = storage_path('app/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
@mkdir(dirname($local), 0755, true);
|
|
|
|
// The thumbnail field stores the local relative path.
|
|
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
|
|
$video = Video::where('thumbnail', $filename)->first();
|
|
if ($video) {
|
|
$dir = $nas->resolveVideoDir($video);
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
|
|
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
|
}
|
|
}
|
|
|
|
// Might be a slide
|
|
if (! file_exists($local)) {
|
|
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
|
if ($slide && $slide->video) {
|
|
$dir = $nas->resolveVideoDir($slide->video);
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
|
|
}
|
|
}
|
|
|
|
// Might be a playlist thumbnail
|
|
if (! file_exists($local)) {
|
|
$nas->ensureLocalAsset($local, $filename);
|
|
}
|
|
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
// Legacy flat filename format
|
|
$local = storage_path('app/public/thumbnails/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
// Find the video that owns this thumbnail and pull from NAS
|
|
$video = Video::where('thumbnail', $filename)->first();
|
|
|
|
if ($video) {
|
|
$dir = $nas->resolveVideoDir($video);
|
|
$nasPath = "{$dir}/thumb.webp";
|
|
$nas->ensureLocalAsset($local, $nasPath);
|
|
}
|
|
|
|
// Not found via video — might be a slide
|
|
if (! file_exists($local)) {
|
|
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
|
if ($slide && $slide->video) {
|
|
$dir = $nas->resolveVideoDir($slide->video);
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
|
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
|
|
$nas->ensureLocalAsset($local, $nasPath);
|
|
}
|
|
}
|
|
|
|
if (! file_exists($local)) {
|
|
abort(404);
|
|
}
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
public function avatar(string $filename, NasSyncService $nas): Response
|
|
{
|
|
// New format: "users/{slug}/profile/avatar.{ext}"
|
|
if (str_starts_with($filename, 'users/')) {
|
|
$local = storage_path('app/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
@mkdir(dirname($local), 0755, true);
|
|
$user = User::where('avatar', $filename)->first();
|
|
if ($user) {
|
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
|
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
|
|
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
|
}
|
|
}
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
// Legacy flat format
|
|
$local = storage_path('app/public/avatars/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
$user = User::where('avatar', $filename)->first();
|
|
if ($user) {
|
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
|
$nasPath = "{$dir}/avatar.webp";
|
|
$nas->ensureLocalAsset($local, $nasPath);
|
|
}
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
public function banner(string $filename, NasSyncService $nas): Response
|
|
{
|
|
// New format: "users/{slug}/profile/cover.{ext}"
|
|
if (str_starts_with($filename, 'users/')) {
|
|
$local = storage_path('app/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
@mkdir(dirname($local), 0755, true);
|
|
$user = User::where('banner', $filename)->first();
|
|
if ($user) {
|
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
|
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
|
|
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
|
}
|
|
}
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
// Legacy flat format
|
|
$local = storage_path('app/public/banners/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
$user = User::where('banner', $filename)->first();
|
|
if ($user) {
|
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
|
$nasPath = "{$dir}/cover.webp";
|
|
$nas->ensureLocalAsset($local, $nasPath);
|
|
}
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
public function postImage(string $filename, NasSyncService $nas): Response
|
|
{
|
|
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
|
|
if (str_starts_with($filename, 'users/')) {
|
|
$local = storage_path('app/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
@mkdir(dirname($local), 0755, true);
|
|
// NAS path is identical to the relative path stored in DB
|
|
$nas->ensureLocalAsset($local, $filename);
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
// Legacy flat format
|
|
$local = storage_path('app/public/post_images/' . $filename);
|
|
if (! file_exists($local)) abort(404);
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
public function sportsImage(string $filename, NasSyncService $nas): Response
|
|
{
|
|
// Format: "users/{slug}/sports/{matchId}/{key}.{ext}"
|
|
if (str_starts_with($filename, 'users/')) {
|
|
$local = storage_path('app/' . $filename);
|
|
|
|
if (! file_exists($local)) {
|
|
@mkdir(dirname($local), 0755, true);
|
|
// NAS path is identical to the relative path stored in DB
|
|
$nas->ensureLocalAsset($local, $filename);
|
|
if (! file_exists($local)) abort(404);
|
|
}
|
|
|
|
return $this->fileResponse($local);
|
|
}
|
|
|
|
abort(404);
|
|
}
|
|
|
|
// ── Helper ────────────────────────────────────────────────────────────────
|
|
|
|
private function fileResponse(string $path): Response
|
|
{
|
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
$mime = match ($ext) {
|
|
'webp' => 'image/webp',
|
|
'png' => 'image/png',
|
|
'gif' => 'image/gif',
|
|
default => 'image/jpeg',
|
|
};
|
|
|
|
return response(file_get_contents($path), 200, [
|
|
'Content-Type' => $mime,
|
|
'Cache-Control' => 'public, max-age=86400',
|
|
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
|
|
]);
|
|
}
|
|
}
|