Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds cover_url and banner_url to API , fixes blades #4388

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions app/Helpers/UrlHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace App\Helpers;

class UrlHelper
{
/**
* Array of trusted URLs or domains.
*
* @var array
*/
private static $trustedUrls = [
'imdb.com', 'imdb.org', 'imdb.co', 'imdb.idv', 'imdbshow.com', 'media-imdb.com',
'archive.org',
'coverartarchive.org',
'dvdempire.com', 'adultdvdempire.com', 'adultempire.com',
'cduniverse.com',

];

/**
* Array of trusted IP ranges.
*
* @var array
*/
private static $trustedIpRanges = [
'192.168.1.0/24',
'10.0.0.0/8',
'52.94.224.0/20', // imdb (AWS host most of the public web, not adding all possible ranges)
'207.241.224.0/20','207.241.224.0/24','207.241.237.0/24','208.70.24.0/21', // archive.org
'142.132.128.0/17', // coverartarchive
'199.182.184.0/22', '204.14.177.0/24', // dvdempire
];

/**
* Check if a URL is trusted for external host
*
* @param string $url The URL to check
* @return bool
*/
public static function isTrustedExternalHost($url)
{
$host = parse_url($url, PHP_URL_HOST);

// Check if the host matches any safe URL or domain
if (in_array($host, self::$trustedUrls)) {
return true;
}

// Convert the host to IP if possible, then check IP ranges
if (filter_var($host, FILTER_VALIDATE_IP)) {
$ip = $host;
} else {
$ip = gethostbyname($host);
}

if ($ip !== $host) { // if DNS lookup was successful
foreach (self::$trustedIpRanges as $range) {
if (self::ipInRange($ip, $range)) {
return true;
}
}
}

return false;
}

/**
* Check if an IP is within a given range.
*
* @param string $ip IP address to check
* @param string $range IP range in CIDR format
* @return bool
*/
private static function ipInRange($ip, $range)
{
[$subnet, $bits] = explode('/', $range);
$ip = ip2long($ip);
$subnet = ip2long($subnet);
$mask = -1 << (32 - $bits);
$subnet &= $mask; # nb: in case the supplied subnet wasn't correctly aligned
return ($ip & $mask) == $subnet;
}
}
118 changes: 118 additions & 0 deletions app/Http/Controllers/API/TorrentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use App\Helpers\Bencode;
use App\Helpers\TorrentHelper;
use App\Helpers\TorrentTools;
use App\Helpers\UrlHelper;
use App\Http\Resources\TorrentResource;
use App\Http\Resources\TorrentsResource;
use App\Models\Category;
Expand Down Expand Up @@ -97,6 +98,17 @@ public function index(): TorrentsResource
return new TorrentsResource($torrents);
}

// handle cover_url or banner_url
private function handleImage($url, $type, $torrentId)
{
if (UrlHelper::isTrustedExternalHost($url)) {
return $url; // Directly store the URL if it's in Trusted External Hosts
} else {
return $this->downloadAndStoreImage($url, $type, $torrentId);
}
}


/**
* Store a newly created resource in storage.
*/
Expand Down Expand Up @@ -172,6 +184,24 @@ public function store(Request $request): \Illuminate\Http\JsonResponse
$torrent->refundable = $user->group->is_modo || $user->group->is_internal ? ($request->input('refundable') ?? false) : false;
$du_until = $request->input('du_until');

// Check and set if it's in safe external hosts
if ($request->has('cover_url')) {
$coverUrl = $request->input('cover_url');
if (UrlHelper::isTrustedExternalHost($coverUrl)) {
$torrent->cover_url = $coverUrl;
}
// Note: We don't set it here if not safe external host because we need the torrent ID at save
}

// Check and set banner_url if it's safe external hosts
if ($request->has('banner_url')) {
$bannerUrl = $request->input('banner_url');
if (UrlHelper::isTrustedExternalHost($bannerUrl)) {
$torrent->banner_url = $bannerUrl;
}
// Note: We don't set it here if not safe external host because we need the torrent ID at save
}

if (($user->group->is_modo || $user->group->is_internal) && isset($du_until)) {
$torrent->du_until = Carbon::now()->addDays($request->integer('du_until'));
}
Expand All @@ -193,6 +223,8 @@ public function store(Request $request): \Illuminate\Http\JsonResponse

// Validation
$v = validator($torrent->toArray(), [
'cover_url' => ['nullable', 'url', 'regex:/\.(avif|bmp|eps|heic|heif|jpe?g|png|svg|tiff?|webp)$/i'],
'banner_url' => ['nullable', 'url', 'regex:/\.(avif|bmp|eps|heic|heif|jpe?g|png|svg|tiff?|webp)$/i'],
'name' => [
'required',
Rule::unique('torrents')->whereNull('deleted_at'),
Expand Down Expand Up @@ -320,6 +352,15 @@ public function store(Request $request): \Illuminate\Http\JsonResponse
// Save The Torrent
$torrent->save();

// Set cover and/or banner _ if not safe2host externally
if ($request->has('cover_url') && !$torrent->cover_url) {
$torrent->cover_url = $this->handleImage($request->input('cover_url'), 'cover', $torrent->id);
}

if ($request->has('banner_url') && !$torrent->banner_url) {
$torrent->banner_url = $this->handleImage($request->input('banner_url'), 'banner', $torrent->id);
}

// Populate the status/seeders/leechers/times_completed fields for the external tracker
$torrent->refresh();

Expand Down Expand Up @@ -448,6 +489,83 @@ public function store(Request $request): \Illuminate\Http\JsonResponse
return $this->sendResponse(route('torrent.download.rsskey', ['id' => $torrent->id, 'rsskey' => auth('api')->user()->rsskey]), 'Torrent uploaded successfully.');
}

/**
* Download image from URL and store it locally, returning the URL to stored image.
* If size cannot be checked beforehand, temporarily download, check size (discard if oversized) else compress then rename.
*
* @param string $url
* @param string $type ('cover' or 'banner')
* @param int $torrentId
* @return string|null
*/
private function downloadAndStoreImage($url, $type, int $torrentId)
{
try {
$headers = get_headers($url, true);
$contentLength = isset($headers['Content-Length']) ? (int)$headers['Content-Length'] : null;

$prefix = ($type === 'cover') ? 'torrent-cover' : 'torrent-banner';
$tempName = "{$prefix}_{$torrentId}_temp";
$finalName = "{$prefix}_{$torrentId}.webp";
$tempPath = public_path('/files/img/') . $tempName;
$finalPath = public_path('/files/img/') . $finalName;

// Skip if over 12MB -- should be easily configurable. Note: 10MB PNG can be crunched to 200-400Kb in WebP
if ($contentLength !== null && $contentLength > 12 * 1024 * 1024) {
\Log::warning("Image from URL {$url} exceeds the 12MB size limit. Skipping download.");
return null;
}

$contents = file_get_contents($url);
if ($contents === false) {
throw new \Exception("Failed to fetch image from URL");
}

if (!file_put_contents($tempPath, $contents)) {
throw new \Exception("Failed to save temporary image");
}

$imageInfo = getimagesize($tempPath);
$mimeType = $imageInfo['mime'];
if ($mimeType === 'image/webp') { // No need to convert if already webP
rename($tempPath, $finalPath);
return asset('files/img/' . $finalName);
}

// Convert and compress based on size
$actualSize = $contentLength ?? filesize($tempPath);
$image = new \Imagick($tempPath);

// Preserve Alpha Channels (Transparency)
if ($image->getImageAlphaChannel() !== \Imagick::ALPHACHANNEL_ACTIVATE) {
$image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_ACTIVATE);
}

// Strip all metadata
$image->stripImage();

// Ensure alpha channel remains active after stripping
$image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_ACTIVATE);

// Convert to WebP with appropriate quality
$quality = $actualSize <= 2 * 1024 * 1024 ? 100 : 80;
$image->setImageFormat('webp');
$image->setImageCompressionQuality($quality);
$image->setOption('webp:lossless', 'false');
$image->writeImage($finalPath);
unlink($tempPath); // Remove the temporary file

return asset('files/img/' . $finalName);
} catch (\Exception $e) {
\Log::error("Failed to download and store image: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine());
if (isset($tempPath) && file_exists($tempPath)) {
unlink($tempPath);
}
return null;
}
}


/**
* Display the specified resource.
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Models/Torrent.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
*
* @property string $info_hash
* @property int $id
* @property string|null $cover_url
* @property string|null $banner_url
* @property string $name
* @property string $description
* @property string|null $mediainfo
Expand Down Expand Up @@ -140,6 +142,8 @@ protected function casts(): array
public const string SEARCHABLE = <<<'SQL'
torrents.id,
torrents.name,
torrents.cover_url,
torrents.banner_url,
torrents.description,
torrents.mediainfo,
torrents.bdinfo,
Expand Down Expand Up @@ -832,6 +836,8 @@ public function toSearchableArray(): array
$missingRequiredAttributes = array_diff([
'id',
'name',
'cover_url',
'banner_url',
'description',
'mediainfo',
'bdinfo',
Expand Down Expand Up @@ -904,6 +910,8 @@ public function toSearchableArray(): array
return [
'id' => $torrent->id,
'name' => $torrent->name,
'cover_url' => $torrent->cover_url,
'banner_url' => $torrent->banner_url,
'description' => $torrent->description,
'mediainfo' => $torrent->mediainfo,
'bdinfo' => $torrent->bdinfo,
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"symfony/dom-crawler": "^6.4.12",
"theodorejb/polycast": "dev-master",
"voku/anti-xss": "^4.1.42",
"vstelmakh/url-highlight": "^3.1.1"
"vstelmakh/url-highlight": "^3.1.1",
"ext-imagick": "*"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.14.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddCoverAndBannerUrlToTorrentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('torrents', function (Blueprint $table) {
$table->string('cover_url', 255)->nullable()->after('folder');
$table->string('banner_url', 255)->nullable()->after('cover_url');
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('torrents', function (Blueprint $table) {
$table->dropColumn(['cover_url', 'banner_url']);
});
}
}
1 change: 1 addition & 0 deletions database/schema/mysql-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2835,3 +2835,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (324,'2024_10_29_18
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (325,'2024_11_01_013426_add_soft_deletes_to_donation_packages_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (326,'2024_11_13_044550_create_unregistered_info_hashes_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (327,'2024_11_26_170256_add_is_torrent_modo_to_groups_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (328, '2024_12_16_183945_add_cover_and_banner_url_to_torrents_table.php', 1);
47 changes: 32 additions & 15 deletions resources/views/components/torrent/card.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,40 @@ class="torrent-card__similar-link"
<figure class="torrent-card__figure">
<img
class="torrent-card__image"
@switch(true)
@switch(true)
@case($torrent->category->movie_meta || $torrent->category->tv_meta)
src="{{ isset($meta->poster) ? tmdb_image('poster_mid', $meta->poster) : 'https://via.placeholder.com/160x240' }}"

@break
@case($torrent->category->game_meta && isset($torrent->meta) && $meta->cover->image_id && $meta->name)
src="https://images.igdb.com/igdb/image/upload/t_cover_big/{{ $torrent->meta->cover->image_id }}.jpg"

@break
@case($torrent->category->music_meta)
src="https://via.placeholder.com/160x240"

@break
@case($torrent->category->no_meta && file_exists(public_path() . '/files/img/torrent-cover_' . $torrent->id . '.jpg'))
src="{{ url('files/img/torrent-cover_' . $torrent->id . '.jpg') }}"

@break

@break
@case($torrent->category->game_meta && isset($torrent->meta) && $meta->cover->image_id && $meta->name)
src="https://images.igdb.com/igdb/image/upload/t_cover_big/{{ $torrent->meta->cover->image_id }}.jpg"

@break
@case($torrent->category->music_meta) {{--Could combine music_meta||no_meta , left seperate to ease special handling if needed--}}
@php
$coverPath = 'files/img/torrent-cover_' . $torrent->id;
$coverFile = collect(glob(public_path($coverPath . '.*')))->first();
$imageSrc = $coverFile !== null
? asset(str_replace(public_path(), '', $coverFile))
: ($torrent->cover_url && filter_var($torrent->cover_url, FILTER_VALIDATE_URL)
? $torrent->cover_url
: 'https://via.placeholder.com/160x240');
@endphp
src="{{ $imageSrc }}"

@break
@case($torrent->category->no_meta)
@php
$coverPath = 'files/img/torrent-cover_' . $torrent->id;
$coverFile = collect(glob(public_path($coverPath . '.*')))->first();
$imageSrc = $coverFile !== null
? asset(str_replace(public_path(), '', $coverFile))
: ($torrent->cover_url && filter_var($torrent->cover_url, FILTER_VALIDATE_URL)
? $torrent->cover_url
: 'https://via.placeholder.com/160x240');
@endphp
src="{{ $imageSrc }}"
@break
@endswitch

alt="{{ __('torrent.similar') }}"
Expand Down
Loading