diff --git a/app/Console/Commands/BackfillUserActivityStats.php b/app/Console/Commands/BackfillUserActivityStats.php new file mode 100644 index 000000000..b2270a67e --- /dev/null +++ b/app/Console/Commands/BackfillUserActivityStats.php @@ -0,0 +1,90 @@ +option('days'); + $force = $this->option('force'); + + $this->info("Backfilling user activity stats for the last {$days} days..."); + + $startDate = Carbon::now()->subDays($days - 1)->startOfDay(); + $progressBar = $this->output->createProgressBar($days); + + $statsCollected = 0; + $statsSkipped = 0; + + for ($i = $days - 1; $i >= 0; $i--) { + $date = Carbon::now()->subDays($i)->format('Y-m-d'); + + // Check if stats already exist for this date + if (! $force && UserActivityStat::where('stat_date', $date)->exists()) { + $statsSkipped++; + $progressBar->advance(); + + continue; + } + + // Count downloads for the date + $downloadsCount = UserDownload::query() + ->whereRaw('DATE(timestamp) = ?', [$date]) + ->count(); + + // Count API hits for the date + $apiHitsCount = UserRequest::query() + ->whereRaw('DATE(timestamp) = ?', [$date]) + ->count(); + + // Store or update the stats + UserActivityStat::updateOrCreate( + ['stat_date' => $date], + [ + 'downloads_count' => $downloadsCount, + 'api_hits_count' => $apiHitsCount, + ] + ); + + $statsCollected++; + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info('Backfill complete!'); + $this->info("Stats collected: {$statsCollected}"); + if ($statsSkipped > 0) { + $this->info("Stats skipped (already existed): {$statsSkipped}"); + } + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/CaptchaStatus.php b/app/Console/Commands/CaptchaStatus.php new file mode 100644 index 000000000..d2c27d72e --- /dev/null +++ b/app/Console/Commands/CaptchaStatus.php @@ -0,0 +1,87 @@ +info('CAPTCHA Configuration Status'); + $this->info('================================'); + $this->newLine(); + + $provider = config('captcha.provider', 'recaptcha'); + $this->line("Active Provider: {$provider}"); + $this->newLine(); + + // Check reCAPTCHA + $this->info('Google reCAPTCHA:'); + $recaptchaEnabled = config('captcha.recaptcha.enabled'); + $recaptchaSitekey = config('captcha.recaptcha.sitekey'); + $recaptchaSecret = config('captcha.recaptcha.secret'); + + $this->line(' Enabled: '.($recaptchaEnabled ? 'Yes' : 'No')); + $this->line(' Site Key: '.(! empty($recaptchaSitekey) ? 'Configured' : 'Missing')); + $this->line(' Secret: '.(! empty($recaptchaSecret) ? 'Configured' : 'Missing')); + $this->newLine(); + + // Check Turnstile + $this->info('Cloudflare Turnstile:'); + $turnstileEnabled = config('captcha.turnstile.enabled'); + $turnstileSitekey = config('captcha.turnstile.sitekey'); + $turnstileSecret = config('captcha.turnstile.secret'); + + $this->line(' Enabled: '.($turnstileEnabled ? 'Yes' : 'No')); + $this->line(' Site Key: '.(! empty($turnstileSitekey) ? 'Configured' : 'Missing')); + $this->line(' Secret: '.(! empty($turnstileSecret) ? 'Configured' : 'Missing')); + $this->newLine(); + + // Validation + $recaptchaReady = $recaptchaEnabled && ! empty($recaptchaSitekey) && ! empty($recaptchaSecret); + $turnstileReady = $turnstileEnabled && ! empty($turnstileSitekey) && ! empty($turnstileSecret); + + if ($recaptchaReady && $turnstileReady) { + $this->error('⚠ WARNING: Both providers are enabled!'); + $this->warn('Only one CAPTCHA provider should be enabled at a time.'); + $this->warn("The system will use: {$provider}"); + $this->newLine(); + } + + if ($provider === 'recaptcha' && $recaptchaReady) { + $this->info('✓ reCAPTCHA is properly configured and active'); + } elseif ($provider === 'turnstile' && $turnstileReady) { + $this->info('✓ Turnstile is properly configured and active'); + } elseif ($provider === 'recaptcha' && ! $recaptchaReady) { + $this->error('✗ reCAPTCHA is selected but not properly configured'); + } elseif ($provider === 'turnstile' && ! $turnstileReady) { + $this->error('✗ Turnstile is selected but not properly configured'); + } else { + $this->warn('⚠ No CAPTCHA provider is active'); + } + + $this->newLine(); + $this->comment('To change providers, update CAPTCHA_PROVIDER in your .env file'); + $this->comment('Then run: php artisan config:clear'); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/CollectStats.php b/app/Console/Commands/CollectStats.php index 5ca733b37..18947a363 100644 --- a/app/Console/Commands/CollectStats.php +++ b/app/Console/Commands/CollectStats.php @@ -7,6 +7,7 @@ use App\Models\GrabStat; use App\Models\ReleaseStat; use App\Models\RoleStat; use App\Models\SignupStat; +use App\Models\UserActivityStat; use Illuminate\Console\Command; class CollectStats extends Command @@ -41,6 +42,8 @@ class CollectStats extends Command $this->info('New users by month collected.'); RoleStat::insertUsersByRole(); $this->info('Users by role collected.'); + UserActivityStat::collectDailyStats(); + $this->info('User activity stats collected.'); $this->info('Site stats collected.'); } } diff --git a/app/Console/Commands/CollectSystemMetrics.php b/app/Console/Commands/CollectSystemMetrics.php new file mode 100644 index 000000000..a59ae8aef --- /dev/null +++ b/app/Console/Commands/CollectSystemMetrics.php @@ -0,0 +1,58 @@ +metricsService = $metricsService; + } + + /** + * Execute the console command. + */ + public function handle(): int + { + try { + if ($this->option('cleanup')) { + $this->info('Cleaning up old metrics...'); + $deleted = $this->metricsService->cleanupOldMetrics(); + $this->info("Deleted {$deleted} old metric records."); + } + + $this->info('Collecting system metrics...'); + $this->metricsService->collectMetrics(); + $this->info('System metrics collected successfully.'); + + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error('Failed to collect system metrics: '.$e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/app/Console/Commands/FindSizeMismatchedReleases.php b/app/Console/Commands/FindSizeMismatchedReleases.php new file mode 100644 index 000000000..10b99708e --- /dev/null +++ b/app/Console/Commands/FindSizeMismatchedReleases.php @@ -0,0 +1,139 @@ +option('threshold'); // Percentage difference threshold + $limit = $this->option('limit'); + $checkSeasonPack = $this->option('season-pack'); + $direction = $this->option('direction'); + $shouldRename = $this->option('rename'); + $nameFixer = new NameFixer; + + $query = Release::query() + ->select([ + 'releases.id', + 'releases.searchname', + 'releases.name', + 'releases.groups_id', + 'releases.categories_id', + DB::raw('releases.size / POW(1024, 3) as release_size'), + DB::raw('SUM(release_files.size) / POW(1024, 3) as files_total_size'), + DB::raw('(releases.size - SUM(release_files.size)) / POW(1024, 3) as size_diff'), + DB::raw('((releases.size - SUM(release_files.size)) / releases.size * 100) as diff_percent'), + ]) + ->join('release_files', 'releases.id', '=', 'release_files.releases_id') + ->where('releases.searchname', 'REGEXP', 'S[0-9]{1,3}E[0-9]{1,3}') + ->groupBy('releases.id'); + + // Apply direction filter + if ($direction === 'bigger') { + $query->having('size_diff', '>', 0) + ->having('diff_percent', '>', $threshold); + } elseif ($direction === 'smaller') { + $query->having('size_diff', '<', 0) + ->having('diff_percent', '<', -$threshold); + } else { + $query->having(DB::raw('ABS(diff_percent)'), '>', $threshold); + } + + // Order by ID if renaming, otherwise by diff_percent + $query->orderBy($shouldRename ? 'releases.id' : 'diff_percent', $shouldRename ? 'asc' : 'desc'); + + if ($limit > 0) { + $query->limit($limit); + } + + $mismatches = $query->get(); + + if ($checkSeasonPack) { + $mismatches = $mismatches->filter(function ($release) use ($nameFixer) { + return $nameFixer->isSeasonPack($release->name); + }); + } + + if ($mismatches->isEmpty()) { + $this->info('No releases found with size mismatches above '.$threshold.'%' + .($checkSeasonPack ? ' that are season packs' : '')); + + return; + } + + if ($shouldRename) { + $this->info("\nAttempting to rename ".$mismatches->count()." releases...\n"); + + foreach ($mismatches as $release) { + $this->attemptRename($release, $nameFixer); + } + + $this->outputReleaseIdsAsCsv($mismatches); + + return; + } + + // Regular table output for non-rename mode + $headers = ['Release ID', 'Searchname', 'Release Size', 'Files Total', 'Difference', 'Diff %']; + $rows = $mismatches->map(function ($release) { + return [ + $release->id, + $release->searchname, + number_format($release->release_size, 2).' GiB', + number_format($release->files_total_size, 2).' GiB', + number_format($release->size_diff, 2).' GiB', + number_format($release->diff_percent, 2).'%', + ]; + }); + + $this->table($headers, $rows); + $this->info("\nFound ".$mismatches->count().' releases with size mismatches above '.$threshold.'%'); + + $this->outputReleaseIdsAsCsv($mismatches); + } + + private function attemptRename(Release $release, NameFixer $nameFixer): ?string + { + if (preg_match(NameFixer::PREDB_REGEX, $this->stripDomainFromString($release->name), $matches)) { + $newName = $matches[1]; + if ($newName) { + $nameFixer->updateRelease( + release: $release, + name: $newName, + method: 'size-mismatch / season pack', + echo: true, + type: '', + nameStatus: 1, + show: '1', + preId: 0 + ); + + return $newName; + } + } + + return null; + } + + private function outputReleaseIdsAsCsv($mismatches): void + { + $releaseIds = $mismatches->pluck('id')->join(','); + $this->line("\nRelease IDs in CSV format:"); + $this->line($releaseIds); + } + + private function stripDomainFromString(string $str): string + { + return preg_replace("/www\.[^\s]+\.[a-z]{2,4}/i", '', $str); + } +} diff --git a/app/Console/Commands/ImportNzbs.php b/app/Console/Commands/ImportNzbs.php index 5074f4df2..2324f6fe5 100644 --- a/app/Console/Commands/ImportNzbs.php +++ b/app/Console/Commands/ImportNzbs.php @@ -18,7 +18,8 @@ class ImportNzbs extends Command {--folder= : Import folder path} {--filename : Use filename true or false} {--delete : Delete files after import} - {--delete-failed : Delete files after failed import}'; + {--delete-failed : Delete files after failed import} + {--source= : Source of the NZB files}'; /** * The console command description. @@ -48,6 +49,11 @@ class ImportNzbs extends Command } else { $deleteFailedNZB = false; } + if ($this->option('source')) { + $source = $this->option('source'); + } else { + $source = 1; + } $importFolder = $this->option('folder'); $folders = File::directories($importFolder); if (empty($folders)) { @@ -56,7 +62,7 @@ class ImportNzbs extends Command $NZBImport = new NZBImport; try { - $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB); + $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source); } catch (FileNotFoundException $e) { $this->error($e->getMessage()); } @@ -67,7 +73,7 @@ class ImportNzbs extends Command $NZBImport = new NZBImport; try { - $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB); + $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source); } catch (FileNotFoundException $e) { $this->error($e->getMessage()); } diff --git a/app/Console/Commands/NntmuxESReindex.php b/app/Console/Commands/NntmuxESReindex.php new file mode 100644 index 000000000..1f6864d13 --- /dev/null +++ b/app/Console/Commands/NntmuxESReindex.php @@ -0,0 +1,44 @@ +/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/create_es_indexes.php'); + passthru('php '.app()->/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/populate_es_indexes.php releases'); + passthru('php '.app()->/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/populate_es_indexes.php predb'); + } +} diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index ea5f83591..54f1c82b8 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -78,6 +78,7 @@ class NntmuxResetDb extends Command 'audio_data', 'release_subtitles', 'video_data', + 'media_infos', 'releases', 'anidb_titles', 'anidb_info', diff --git a/app/Console/Commands/NntmuxResetPostProcessing.php b/app/Console/Commands/NntmuxResetPostProcessing.php index cd630d1db..b0037faf5 100644 --- a/app/Console/Commands/NntmuxResetPostProcessing.php +++ b/app/Console/Commands/NntmuxResetPostProcessing.php @@ -51,6 +51,14 @@ class NntmuxResetPostProcessing extends Command */ public function handle(): void { + + // Allow resetting categories only if environment is local and category is 'misc' + if (app()->environment() !== 'local' && ((isset($this->option('category')['0']) && $this->option('category')[0] !== 'misc') || ! isset($this->option('category')['0']))) { + $this->error('This command can only be run in local environment'); + + return; + } + $raw = (array) $this->option('category'); if (empty($raw)) { $qry = Release::query()->select(['id'])->get(); diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index d36185e78..118cd46d2 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -213,20 +213,16 @@ if (! function_exists('runCmd')) { } if (! function_exists('escapeString')) { - /** - * @return string - */ - function escapeString($string) + + function escapeString($string): string { return DB::connection()->getPdo()->quote($string); } } if (! function_exists('realDuration')) { - /** - * @return string - */ - function realDuration($milliseconds) + + function realDuration($milliseconds): string { $time = round($milliseconds / 1000); @@ -236,45 +232,44 @@ if (! function_exists('realDuration')) { if (! function_exists('is_it_json')) { /** - * @param array|string $isIt - * @return bool + * @throws JsonException */ - function is_it_json($isIt) + function is_it_json($isIt): bool { if (is_array($isIt)) { return false; } - json_decode($isIt, true); + json_decode($isIt, true, 512, JSON_THROW_ON_ERROR); return json_last_error() === JSON_ERROR_NONE; } } -/** - * @throws Exception - */ -function getStreamingZip(array $guids = []) -{ - $nzb = new NZB; - $zipped = ZipStream::create(now()->format('Ymdhis').'.zip'); - foreach ($guids as $guid) { - $nzbPath = $nzb->NZBPath($guid); - - if ($nzbPath) { - $nzbContents = Utility::unzipGzipFile($nzbPath); - - if ($nzbContents) { - $filename = $guid; - $r = Release::query()->where('guid', $guid)->first(); - if ($r !== null) { - $filename = $r->searchname; +if (! function_exists('getStreamingZip')) { + /** + * @throws Exception + */ + function getStreamingZip(array $guids = []): STS\ZipStream\Builder + { + $nzb = new NZB; + $zipped = ZipStream::create(now()->format('Ymdhis').'.zip'); + foreach ($guids as $guid) { + $nzbPath = $nzb->NZBPath($guid); + if ($nzbPath) { + $nzbContents = Utility::unzipGzipFile($nzbPath); + if ($nzbContents) { + $filename = $guid; + $r = Release::query()->where('guid', $guid)->first(); + if ($r) { + $filename = $r['searchname']; + } + $zipped->addRaw($nzbContents, $filename.'.nzb'); } - $zipped->addRaw($nzbContents, $filename.'.nzb'); } } - } - return $zipped; + return $zipped; + } } if (! function_exists('release_flag')) { @@ -282,9 +277,8 @@ if (! function_exists('release_flag')) { /** * @param string $text Text to match against. * @param string $page Type of page. browse or search. - * @return bool|string */ - function release_flag($text, $page) + function release_flag(string $text, string $page): bool|string { $code = $language = ''; @@ -547,3 +541,96 @@ if (! function_exists('csp_nonce')) { return $nonce; } } + +if (! function_exists('userDate')) { + /** + * Format a date/time string according to the authenticated user's timezone + * + * @param string|null $date The date to format + * @param string $format The format string (default: 'M d, Y H:i') + * @return string The formatted date in user's timezone + */ + function userDate(?string $date, string $format = 'M d, Y H:i'): string + { + if (empty($date)) { + return ''; + } + + try { + // Parse the date in the app's timezone (which should be UTC) + // If dates in DB are stored in server timezone, they'll be parsed correctly + $appTimezone = config('app.timezone', 'UTC'); + $carbon = \Illuminate\Support\Carbon::parse($date, $appTimezone); + + // If user is authenticated and has a timezone set, convert to it + if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) { + $carbon->setTimezone(\Illuminate\Support\Facades\Auth::user()->timezone); + } + + return $carbon->format($format); + } catch (\Exception $e) { + return $date; + } + } +} + +if (! function_exists('userDateDiffForHumans')) { + /** + * Format a date/time string as a human-readable diff according to the authenticated user's timezone + * + * @param string|null $date The date to format + * @return string The formatted date diff in user's timezone + */ + function userDateDiffForHumans(?string $date): string + { + if (empty($date)) { + return ''; + } + + try { + // Parse the date in the app's timezone (which should be UTC) + // If dates in DB are stored in server timezone, they'll be parsed correctly + $appTimezone = config('app.timezone', 'UTC'); + $carbon = \Illuminate\Support\Carbon::parse($date, $appTimezone); + + // If user is authenticated and has a timezone set, convert to it + if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) { + $carbon->setTimezone(\Illuminate\Support\Facades\Auth::user()->timezone); + } + + return $carbon->diffForHumans(); + } catch (\Exception $e) { + return $date; + } + } +} + +if (! function_exists('getAvailableTimezones')) { + /** + * Get a list of available timezones grouped by region + * + * @return array Array of timezones grouped by region + */ + function getAvailableTimezones(): array + { + $timezones = []; + $regions = [ + 'Africa' => \DateTimeZone::AFRICA, + 'America' => \DateTimeZone::AMERICA, + 'Antarctica' => \DateTimeZone::ANTARCTICA, + 'Arctic' => \DateTimeZone::ARCTIC, + 'Asia' => \DateTimeZone::ASIA, + 'Atlantic' => \DateTimeZone::ATLANTIC, + 'Australia' => \DateTimeZone::AUSTRALIA, + 'Europe' => \DateTimeZone::EUROPE, + 'Indian' => \DateTimeZone::INDIAN, + 'Pacific' => \DateTimeZone::PACIFIC, + ]; + + foreach ($regions as $name => $region) { + $timezones[$name] = \DateTimeZone::listIdentifiers($region); + } + + return $timezones; + } +} diff --git a/app/Http/Controllers/Admin/AdminPageController.php b/app/Http/Controllers/Admin/AdminPageController.php index b3c65efc3..b732d0b51 100644 --- a/app/Http/Controllers/Admin/AdminPageController.php +++ b/app/Http/Controllers/Admin/AdminPageController.php @@ -3,16 +3,20 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; +use App\Services\SystemMetricsService; use App\Services\UserStatsService; class AdminPageController extends BasePageController { protected UserStatsService $userStatsService; - public function __construct(UserStatsService $userStatsService) + protected SystemMetricsService $systemMetricsService; + + public function __construct(UserStatsService $userStatsService, SystemMetricsService $systemMetricsService) { parent::__construct(); $this->userStatsService = $userStatsService; + $this->systemMetricsService = $systemMetricsService; } /** @@ -25,8 +29,10 @@ class AdminPageController extends BasePageController // Get user statistics $userStats = [ 'users_by_role' => $this->userStatsService->getUsersByRole(), - 'downloads_per_day' => $this->userStatsService->getDownloadsPerDay(7), - 'api_hits_per_day' => $this->userStatsService->getApiHitsPerDay(7), + 'downloads_per_hour' => $this->userStatsService->getDownloadsPerHour(168), // Last 7 days in hours + 'downloads_per_minute' => $this->userStatsService->getDownloadsPerMinute(60), + 'api_hits_per_hour' => $this->userStatsService->getApiHitsPerHour(168), // Last 7 days in hours + 'api_hits_per_minute' => $this->userStatsService->getApiHitsPerMinute(60), 'summary' => $this->userStatsService->getSummaryStats(), 'top_downloaders' => $this->userStatsService->getTopDownloaders(5), ]; @@ -36,6 +42,7 @@ class AdminPageController extends BasePageController 'meta_description' => 'Admin home page', 'userStats' => $userStats, 'stats' => $this->getDefaultStats(), + 'systemMetrics' => $this->getSystemMetrics(), ])); } @@ -76,4 +83,274 @@ class AdminPageController extends BasePageController return 'N/A'; } } + + /** + * Get system metrics (CPU and RAM usage) + */ + protected function getSystemMetrics(): array + { + $cpuUsage = $this->getCpuUsage(); + $ramUsage = $this->getRamUsage(); + $cpuInfo = $this->getCpuInfo(); + $loadAverage = $this->getLoadAverage(); + + // Get historical data from database - both hourly (24h) and daily (30d) + $cpuHistory24h = $this->systemMetricsService->getHourlyMetrics('cpu', 24); + $cpuHistory30d = $this->systemMetricsService->getDailyMetrics('cpu', 30); + $ramHistory24h = $this->systemMetricsService->getHourlyMetrics('ram', 24); + $ramHistory30d = $this->systemMetricsService->getDailyMetrics('ram', 30); + + return [ + 'cpu' => [ + 'current' => $cpuUsage, + 'label' => 'CPU Usage', + 'history_24h' => $cpuHistory24h, + 'history_30d' => $cpuHistory30d, + 'cores' => $cpuInfo['cores'], + 'threads' => $cpuInfo['threads'], + 'model' => $cpuInfo['model'], + 'load_average' => $loadAverage, + ], + 'ram' => [ + 'used' => $ramUsage['used'], + 'total' => $ramUsage['total'], + 'percentage' => $ramUsage['percentage'], + 'label' => 'RAM Usage', + 'history_24h' => $ramHistory24h, + 'history_30d' => $ramHistory30d, + ], + ]; + } + + /** + * Get current CPU usage percentage + */ + protected function getCpuUsage(): float + { + try { + if (PHP_OS_FAMILY === 'Windows') { + // Windows command + $output = shell_exec('wmic cpu get loadpercentage'); + if ($output) { + preg_match('/\d+/', $output, $matches); + + return $matches[0] ?? 0; + } + } else { + // Linux command - get load average and convert to percentage + $load = sys_getloadavg(); + if ($load !== false) { + $cpuCount = $this->getCpuCount(); + + return round(($load[0] / $cpuCount) * 100, 2); + } + } + } catch (\Exception $e) { + \Log::warning('Could not get CPU usage: '.$e->getMessage()); + } + + return 0; + } + + /** + * Get number of CPU cores + */ + protected function getCpuCount(): int + { + try { + if (PHP_OS_FAMILY === 'Windows') { + $output = shell_exec('wmic cpu get NumberOfLogicalProcessors'); + if ($output) { + preg_match('/\d+/', $output, $matches); + + return (int) ($matches[0] ?? 1); + } + } else { + $cpuinfo = file_get_contents('/proc/cpuinfo'); + preg_match_all('/^processor/m', $cpuinfo, $matches); + + return count($matches[0]) ?: 1; + } + } catch (\Exception $e) { + return 1; + } + + return 1; + } + + /** + * Get detailed CPU information (cores, threads, model) + */ + protected function getCpuInfo(): array + { + $info = [ + 'cores' => 0, + 'threads' => 0, + 'model' => 'Unknown', + ]; + + try { + if (PHP_OS_FAMILY === 'Windows') { + // Get number of cores + $coresOutput = shell_exec('wmic cpu get NumberOfCores'); + if ($coresOutput) { + preg_match('/\d+/', $coresOutput, $matches); + $info['cores'] = (int) ($matches[0] ?? 0); + } + + // Get number of logical processors (threads) + $threadsOutput = shell_exec('wmic cpu get NumberOfLogicalProcessors'); + if ($threadsOutput) { + preg_match('/\d+/', $threadsOutput, $matches); + $info['threads'] = (int) ($matches[0] ?? 0); + } + + // Get CPU model + $modelOutput = shell_exec('wmic cpu get Name'); + if ($modelOutput) { + $lines = explode("\n", trim($modelOutput)); + if (isset($lines[1])) { + $info['model'] = trim($lines[1]); + } + } + } else { + // Linux + $cpuinfo = file_get_contents('/proc/cpuinfo'); + + // Get number of physical cores + preg_match_all('/^cpu cores\s*:\s*(\d+)/m', $cpuinfo, $coresMatches); + if (! empty($coresMatches[1])) { + $info['cores'] = (int) $coresMatches[1][0]; + } + + // Get number of logical processors (threads) + preg_match_all('/^processor/m', $cpuinfo, $processorMatches); + $info['threads'] = count($processorMatches[0]) ?: 0; + + // Get CPU model + preg_match('/^model name\s*:\s*(.+)$/m', $cpuinfo, $modelMatches); + if (! empty($modelMatches[1])) { + $info['model'] = trim($modelMatches[1]); + } + + // If cores is 0, try to get from physical id count + if ($info['cores'] === 0) { + preg_match_all('/^physical id\s*:\s*(\d+)/m', $cpuinfo, $physicalMatches); + $uniquePhysical = ! empty($physicalMatches[1]) ? count(array_unique($physicalMatches[1])) : 1; + $info['cores'] = (int) ($info['threads'] / $uniquePhysical); + } + } + } catch (\Exception $e) { + \Log::warning('Could not get CPU info: '.$e->getMessage()); + } + + return $info; + } + + /** + * Get system load average + */ + protected function getLoadAverage(): array + { + $loadAvg = [ + '1min' => 0, + '5min' => 0, + '15min' => 0, + ]; + + try { + if (PHP_OS_FAMILY === 'Windows') { + // Windows doesn't have load average, use CPU queue length instead + $output = shell_exec('wmic path Win32_PerfFormattedData_PerfOS_System get ProcessorQueueLength'); + if ($output) { + preg_match('/\d+/', $output, $matches); + $queueLength = (int) ($matches[0] ?? 0); + // Approximate load average + $loadAvg['1min'] = round($queueLength / 2, 2); + $loadAvg['5min'] = round($queueLength / 2, 2); + $loadAvg['15min'] = round($queueLength / 2, 2); + } + } else { + // Linux has native load average + $load = sys_getloadavg(); + if ($load !== false) { + $loadAvg['1min'] = round($load[0], 2); + $loadAvg['5min'] = round($load[1], 2); + $loadAvg['15min'] = round($load[2], 2); + } + } + } catch (\Exception $e) { + \Log::warning('Could not get load average: '.$e->getMessage()); + } + + return $loadAvg; + } + + /** + * Get RAM usage information + */ + protected function getRamUsage(): array + { + try { + if (PHP_OS_FAMILY === 'Windows') { + // Windows command + $output = shell_exec('wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value'); + if ($output) { + preg_match('/FreePhysicalMemory=(\d+)/', $output, $free); + preg_match('/TotalVisibleMemorySize=(\d+)/', $output, $total); + + if (isset($free[1]) && isset($total[1])) { + $freeKb = (float) $free[1]; + $totalKb = (float) $total[1]; + $usedKb = $totalKb - $freeKb; + + return [ + 'used' => round($usedKb / 1024 / 1024, 2), + 'total' => round($totalKb / 1024 / 1024, 2), + 'percentage' => round(($usedKb / $totalKb) * 100, 2), + ]; + } + } + } else { + // Linux command + $meminfo = file_get_contents('/proc/meminfo'); + preg_match('/MemTotal:\s+(\d+)/', $meminfo, $total); + preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $available); + + if (isset($total[1]) && isset($available[1])) { + $totalKb = (float) $total[1]; + $availableKb = (float) $available[1]; + $usedKb = $totalKb - $availableKb; + + return [ + 'used' => round($usedKb / 1024 / 1024, 2), + 'total' => round($totalKb / 1024 / 1024, 2), + 'percentage' => round(($usedKb / $totalKb) * 100, 2), + ]; + } + } + } catch (\Exception $e) { + \Log::warning('Could not get RAM usage: '.$e->getMessage()); + } + + return [ + 'used' => 0, + 'total' => 0, + 'percentage' => 0, + ]; + } + + /** + * Get minute-to-minute user activity data (API endpoint) + */ + public function getUserActivityMinutes() + { + $downloadsPerMinute = $this->userStatsService->getDownloadsPerMinute(60); + $apiHitsPerMinute = $this->userStatsService->getApiHitsPerMinute(60); + + return response()->json([ + 'downloads' => $downloadsPerMinute, + 'api_hits' => $apiHitsPerMinute, + ]); + } } diff --git a/app/Http/Controllers/Admin/AdminSiteController.php b/app/Http/Controllers/Admin/AdminSiteController.php index 7ab03c0da..725dec81d 100644 --- a/app/Http/Controllers/Admin/AdminSiteController.php +++ b/app/Http/Controllers/Admin/AdminSiteController.php @@ -4,13 +4,11 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\Category; -use App\Models\DownloadStat; use App\Models\GrabStat; use App\Models\ReleaseStat; use App\Models\RoleStat; use App\Models\Settings; use App\Models\SignupStat; -use Blacklight\utility\Utility; use Illuminate\Http\Request; class AdminSiteController extends BasePageController @@ -22,7 +20,6 @@ class AdminSiteController extends BasePageController */ public function edit(Request $request) { - $this->setAdminPrefs(); $meta_title = $title = 'Site Edit'; $error = ''; @@ -35,41 +32,12 @@ class AdminSiteController extends BasePageController if ($request->missing('book_reqids')) { $request->merge(['book_reqids' => []]); } - $ret = Settings::settingsUpdate($request->all()); - if (\is_int($ret)) { - if ($ret === Settings::ERR_BADUNRARPATH) { - $error = 'The unrar path does not point to a valid binary'; - } elseif ($ret === Settings::ERR_BADFFMPEGPATH) { - $error = 'The ffmpeg path does not point to a valid binary'; - } elseif ($ret === Settings::ERR_BADMEDIAINFOPATH) { - $error = 'The mediainfo path does not point to a valid binary'; - } elseif ($ret === Settings::ERR_BADNZBPATH) { - $error = 'The nzb path does not point to a valid directory'; - } elseif ($ret === Settings::ERR_DEEPNOUNRAR) { - $error = 'Deep password check requires a valid path to unrar binary'; - } elseif ($ret === Settings::ERR_BADTMPUNRARPATH) { - $error = 'The temp unrar path is not a valid directory'; - } elseif ($ret === Settings::ERR_BADLAMEPATH) { - $error = 'The lame path is not a valid directory'; - } elseif ($ret === Settings::ERR_SABCOMPLETEPATH) { - $error = 'The sab complete path is not a valid directory'; - } - } + Settings::settingsUpdate($request->all()); - if ($error === '') { - return redirect()->to('admin/site-edit')->with('success', 'Settings updated successfully'); - } + return redirect()->to('admin/site-edit')->with('success', 'Settings updated successfully'); - $site = (object) $request->all(); - break; case 'view': default: - // Load all settings from database into an object - $allSettings = Settings::all(); - $site = new \stdClass; - foreach ($allSettings as $setting) { - $site->{$setting->name} = $setting->value; - } break; } @@ -88,51 +56,78 @@ class AdminSiteController extends BasePageController $book_reqids_ids = array_map(fn ($value) => (int) $value, $book_reqids_ids); // convert from a list to an array as we need to use an array, but the Settings table only saves strings - $books_selected = explode(',', Settings::settingValue('book_reqids')); + $bookReqidsValue = Settings::settingValue('book_reqids') ?? ''; + $books_selected = $bookReqidsValue !== '' ? explode(',', $bookReqidsValue) : []; - // convert from a string array to an int array - $books_selected = array_map(fn ($value) => (int) $value, $books_selected); + // convert from a string array to an int array, filtering out empty values + $books_selected = array_map(fn ($value) => (int) trim($value), array_filter($books_selected)); $compress_headers_warning = ! str_contains(config('settings.nntp_server'), 'astra') ? 'compress_headers_warning' : ''; $this->viewData = array_merge($this->viewData, [ - 'site' => $site, - 'settings' => Settings::toTree(), 'error' => $error, - 'yesno_ids' => [1, 0], - 'yesno_names' => ['Yes', 'No'], - 'passwd_ids' => [1, 0], - 'passwd_names' => ['Deep (requires unrar)', 'None'], - 'langlist_ids' => [0, 2, 3, 1], - 'langlist_names' => ['English', 'Danish', 'French', 'German'], - 'imdblang_ids' => ['en', 'da', 'nl', 'fi', 'fr', 'de', 'it', 'tlh', 'no', 'po', 'ru', 'es', 'sv'], - 'imdblang_names' => ['English', 'Danish', 'Dutch', 'Finnish', 'French', 'German', 'Italian', 'Klingon', 'Norwegian', 'Polish', 'Russian', 'Spanish', 'Swedish'], + 'yesno' => [ + 'ids' => [1, 0], + 'names' => ['Yes', 'No'], + ], + 'passwd' => [ + 'ids' => [1, 0], + 'names' => ['Deep (requires unrar)', 'None'], + ], + 'langlist' => [ + 'ids' => [0, 2, 3, 1], + 'names' => ['English', 'Danish', 'French', 'German'], + ], + 'imdblang' => [ + 'ids' => ['en', 'da', 'nl', 'fi', 'fr', 'de', 'it', 'tlh', 'no', 'po', 'ru', 'es', 'sv'], + 'names' => ['English', 'Danish', 'Dutch', 'Finnish', 'French', 'German', 'Italian', 'Klingon', 'Norwegian', 'Polish', 'Russian', 'Spanish', 'Swedish'], + ], 'newgroupscan_names' => ['Days', 'Posts'], - 'registerstatus_ids' => [Settings::REGISTER_STATUS_OPEN, Settings::REGISTER_STATUS_INVITE, Settings::REGISTER_STATUS_CLOSED], - 'registerstatus_names' => ['Open', 'Invite', 'Closed'], - 'passworded_ids' => [0, 1], - 'passworded_names' => ['Hide passworded', 'Show everything'], - 'lookuplanguage_iso' => ['en', 'de', 'es', 'fr', 'it', 'nl', 'pt', 'sv'], - 'lookuplanguage_names' => ['English', 'Deutsch', 'Español', 'Français', 'Italiano', 'Nederlands', 'Português', 'Svenska'], - 'imdb_urls' => [0, 1], - 'imdburl_names' => ['imdb.com', 'akas.imdb.com'], - 'lookupbooks_ids' => [0, 1, 2], - 'lookupbooks_names' => ['Disabled', 'Lookup All Books', 'Lookup Renamed Books'], - 'lookupgames_ids' => [0, 1, 2], - 'lookupgames_names' => ['Disabled', 'Lookup All Consoles', 'Lookup Renamed Consoles'], - 'lookupmusic_ids' => [0, 1, 2], - 'lookupmusic_names' => ['Disabled', 'Lookup All Music', 'Lookup Renamed Music'], - 'lookupmovies_ids' => [0, 1, 2], - 'lookupmovies_names' => ['Disabled', 'Lookup All Movies', 'Lookup Renamed Movies'], - 'lookuptv_ids' => [0, 1, 2], - 'lookuptv_names' => ['Disabled', 'Lookup All TV', 'Lookup Renamed TV'], - 'lookup_reqids_ids' => [0, 1, 2], - 'lookup_reqids_names' => ['Disabled', 'Lookup Request IDs', 'Lookup Request IDs Threaded'], - 'coversPath' => config('nntmux_settings.covers_path'), - 'book_reqids_ids' => $book_reqids_ids, - 'book_reqids_names' => $book_reqids_names, - 'book_reqids_selected' => $books_selected, - 'themelist' => Utility::getThemesList(), + 'registerstatus' => [ + 'ids' => [Settings::REGISTER_STATUS_OPEN, Settings::REGISTER_STATUS_INVITE, Settings::REGISTER_STATUS_CLOSED], + 'names' => ['Open', 'Invite', 'Closed'], + ], + 'passworded' => [ + 'ids' => [0, 1], + 'names' => ['Hide passworded', 'Show everything'], + ], + 'lookuplanguage' => [ + 'iso' => ['en', 'de', 'es', 'fr', 'it', 'nl', 'pt', 'sv'], + 'names' => ['English', 'Deutsch', 'Español', 'Français', 'Italiano', 'Nederlands', 'Português', 'Svenska'], + ], + 'imdb_urls' => [ + 'ids' => [0, 1], + 'names' => ['imdb.com', 'akas.imdb.com'], + ], + 'lookupbooks' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup All Books', 'Lookup Renamed Books'], + ], + 'lookupgames' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup All Consoles', 'Lookup Renamed Consoles'], + ], + 'lookupmusic' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup All Music', 'Lookup Renamed Music'], + ], + 'lookupmovies' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup All Movies', 'Lookup Renamed Movies'], + ], + 'lookuptv' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup All TV', 'Lookup Renamed TV'], + ], + 'lookup_reqids' => [ + 'ids' => [0, 1, 2], + 'names' => ['Disabled', 'Lookup Request IDs', 'Lookup Request IDs Threaded'], + ], + 'book_reqids' => [ + 'ids' => $book_reqids_ids, + 'names' => $book_reqids_names, + 'selected' => $books_selected, + ], 'compress_headers_warning' => $compress_headers_warning, 'title' => $title, 'meta_title' => $meta_title, @@ -146,19 +141,16 @@ class AdminSiteController extends BasePageController */ public function stats() { - $this->setAdminPrefs(); $meta_title = $title = 'Site Stats'; $topGrabs = GrabStat::getTopGrabbers(); - $topDownloads = DownloadStat::getTopDownloads(); $recent = ReleaseStat::getRecentlyAdded(); $usersByMonth = SignupStat::getUsersByMonth(); $usersByRole = RoleStat::getUsersByRole(); $this->viewData = array_merge($this->viewData, [ 'topgrabs' => $topGrabs, - 'topdownloads' => $topDownloads, 'recent' => $recent, 'usersbymonth' => $usersByMonth, 'usersbyrole' => $usersByRole, diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index a10cf047c..f670e40d0 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; -use App\Jobs\SendAccountChangedEmail; use App\Models\Invitation; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -153,12 +152,18 @@ class AdminUserController extends BasePageController if ($request->input('password') !== null) { User::updatePassword($editedUser->id, $request->input('password')); } - if ($request->input('rolechangedate') !== null) { - User::updateUserRoleChangeDate($editedUser->id, $request->input('rolechangedate')); + // Handle rolechangedate - update if has value, clear if empty + if ($request->has('rolechangedate')) { + $roleChangeDate = $request->input('rolechangedate'); + if (! empty($roleChangeDate)) { + User::updateUserRoleChangeDate($editedUser->id, $roleChangeDate); + } else { + // Clear the rolechangedate if empty string is provided + $editedUser->update(['rolechangedate' => null]); + } } if ($request->input('role') !== null) { $editedUser->refresh(); - SendAccountChangedEmail::dispatch($editedUser)->onQueue('emails'); } } diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index fbb63b0c7..0f5f6d63f 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -12,6 +12,7 @@ use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; +use Blacklight\NZB; use Blacklight\Releases; use Blacklight\utility\Utility; use Illuminate\Contracts\Foundation\Application; @@ -19,6 +20,8 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Redirector; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -66,6 +69,9 @@ class ApiController extends BasePageController case 'info': $function = 'n'; break; + case 'nzbadd': + $function = 'nzbAdd'; + break; default: return Utility::showApiError(202, 'No such function ('.$request->input('t').')'); } @@ -82,12 +88,12 @@ class ApiController extends BasePageController if ($function !== 'c' && $function !== 'r') { if ($request->missing('apikey') || ($request->has('apikey') && empty($request->input('apikey')))) { return Utility::showApiError(200, 'Missing parameter (apikey)'); - } else { - $apiKey = $request->input('apikey'); - $res = User::getByRssToken($apiKey); - if ($res === null) { - return Utility::showApiError(100, 'Incorrect user credentials (wrong API key)'); - } + } + + $apiKey = $request->input('apikey'); + $res = User::getByRssToken($apiKey); + if ($res === null) { + return Utility::showApiError(100, 'Incorrect user credentials (wrong API key)'); } if ($res->hasRole('Disabled')) { @@ -314,6 +320,58 @@ class ApiController extends BasePageController } else { return Utility::showApiError(300, 'Release does not exist.'); } + break; + // + // nzb add request + // curl -X POST -F "file=@./The.File.nzb" "https://www.tabula-rasa.pw/api/V1/api?t=nzbadd&apikey=xxx" + // + case 'nzbAdd': + if (! User::canPost($uid)) { + return response('User does not have permission to post', 403); + } + + if ($request->missing('file')) { + return response('Missing parameter (file is required for adding an NZB)', 400); + } + if ($request->missing('apikey')) { + return response('Missing parameter (apikey is required for adding an NZB)', 400); + } + + if (! $request->hasFile('file')) { + return response('Missing parameter (file is required for adding an NZB)', 400); + } + + UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + + $nzbFile = $request->file('file'); + + // Save the file to the server, get the name without the extension. + if (File::isFile($nzbFile)) { + // We need to check if file is an actual nzb file. + if ($nzbFile->getClientOriginalExtension() !== 'nzb') { + return response('File is not an NZB file', 400); + } + // Check if the file is proper xml nzb file. + if (! Utility::isValidNewznabNzb($nzbFile->getContent())) { + return response('File is not a valid Newznab NZB file', 400); + } + if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) { + @File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true); + } + + if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $nzbFile->getContent())) { + Log::channel('nzb_upload')->info('NZB file uploaded by API: '.$nzbFile->getClientOriginalName()); + + return response('NZB file uploaded successfully', 200); + } + + Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName()); + } else { + Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName()); + + return response('NZB file upload failed', 500); + } + break; // Capabilities request. diff --git a/app/Http/Controllers/Api/RSS.php b/app/Http/Controllers/Api/RSS.php index f1bfa1517..7eef105e1 100644 --- a/app/Http/Controllers/Api/RSS.php +++ b/app/Http/Controllers/Api/RSS.php @@ -98,6 +98,7 @@ class RSS extends ApiController public function getShowsRss(int $limit, int $userID = 0, array $excludedCats = [], int $airDate = -1) { $sql = sprintf( + "SELECT DISTINCT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, v.id, v.title, g.name AS group_name, CONCAT(cp.title, '-', c.title) AS category_name, COALESCE(cp.id,0) AS parentid FROM releases r INNER JOIN user_series us ON us.videos_id = r.videos_id AND us.users_id = %d LEFT JOIN categories c ON c.id = r.categories_id INNER JOIN root_categories cp ON cp.id = c.root_categories_id LEFT JOIN usenet_groups g ON g.id = r.groups_id LEFT OUTER JOIN videos v ON v.id = r.videos_id LEFT OUTER JOIN tv_episodes tve ON tve.id = r.tv_episodes_id WHERE (us.categories IS NULL OR us.categories = '' OR us.categories = 'NULL' OR FIND_IN_SET(r.categories_id, REPLACE(us.categories,'|',',')) > 0)%s%s AND r.categories_id BETWEEN %d AND %d AND r.passwordstatus %s ORDER BY postdate DESC %s", $userID, (\count($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''), diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index f6351b8b9..e6df7e590 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -440,7 +440,6 @@ class XML_Response if ((int) $this->parameters['extended'] === 1) { $this->writeZedAttr('files', $this->release->totalpart); - $this->writeZedAttr('poster', $this->release->fromname); if (($this->release->videos_id > 0 || $this->release->tv_episodes_id > 0) && $this->namespace === 'newznab') { $this->setTvAttr(); } diff --git a/app/Http/Controllers/BasePageController.php b/app/Http/Controllers/BasePageController.php index e87ea6246..aa7daa949 100644 --- a/app/Http/Controllers/BasePageController.php +++ b/app/Http/Controllers/BasePageController.php @@ -12,7 +12,7 @@ use Illuminate\View\View; class BasePageController extends Controller { - public Settings $settings; + public \Illuminate\Support\Collection $settings; public string $title = ''; @@ -48,17 +48,22 @@ class BasePageController extends Controller */ public function __construct() { - $this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'release', 'reset', 'showLinkRequestForm'); + $this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'btcPayCallback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'release', 'reset', 'showLinkRequestForm'); - // Buffer settings/DB connection. - $this->settings = new Settings; + // Load settings as collection + $this->settings = Settings::query()->pluck('value', 'name'); - // Initialize view data + // Initialize view data FIRST with serverroot $this->viewData = [ 'serverroot' => url('/'), - 'site' => $this->settings, ]; + // Then add the converted settings array as 'site' + // Using array assignment instead of constructor assignment to ensure it persists + $this->viewData['site'] = $this->settings->map(function ($value) { + return Settings::convertValue($value); + })->all(); + // Initialize userdata property for controllers that need it $this->middleware(function ($request, $next) { if (Auth::check()) { diff --git a/app/Http/Controllers/BrowseController.php b/app/Http/Controllers/BrowseController.php index 69dc8a00d..a6772d9f9 100644 --- a/app/Http/Controllers/BrowseController.php +++ b/app/Http/Controllers/BrowseController.php @@ -74,6 +74,7 @@ class BrowseController extends BasePageController $results = $this->paginate($rslt ?? [], $rslt[0]->_totalcount ?? 0, config('nntmux.items_per_page'), $page, $request->url(), $request->query()); $covgroup = ''; + $shows = false; if ($category === -1 && $grp === -1) { $catname = 'All'; } elseif ($category !== -1 && $grp === -1) { @@ -92,6 +93,8 @@ class BrowseController extends BasePageController $covgroup = 'music'; } elseif ($cdata->root_categories_id === Category::BOOKS_ROOT) { $covgroup = 'books'; + } elseif ($cdata->root_categories_id === Category::TV_ROOT) { + $shows = true; } } } else { @@ -112,7 +115,7 @@ class BrowseController extends BasePageController } } - $this->viewData = array_merge($this->viewData, [ + $viewData = [ 'parentcat' => ucfirst($parentCategory), 'category' => $category, 'catname' => $catname, @@ -122,7 +125,13 @@ class BrowseController extends BasePageController 'meta_title' => $meta_title, 'meta_keywords' => 'browse,nzb,description,details', 'meta_description' => 'Browse for Nzbs', - ], $orderByUrls); + ]; + + if ($shows) { + $viewData['shows'] = true; + } + + $this->viewData = array_merge($this->viewData, $viewData, $orderByUrls); return view('browse.index', $this->viewData); } diff --git a/app/Http/Controllers/BtcPaymentController.php b/app/Http/Controllers/BtcPaymentController.php index 512617c80..b4389ffb2 100644 --- a/app/Http/Controllers/BtcPaymentController.php +++ b/app/Http/Controllers/BtcPaymentController.php @@ -17,7 +17,7 @@ class BtcPaymentController extends BasePageController { $hashCheck = 'sha256='.hash_hmac('sha256', $request->getContent(), config('nntmux.btcpay_webhook_secret')); if ($hashCheck !== $request->header('btcpay-sig')) { - Log::error('BTCPay webhook hash check failed: '.$request->header('btcpay-sig')); + Log::channel('btc_payment')->error('BTCPay webhook hash check failed: '.$request->header('btcpay-sig')); return response('Not Found', 404); } @@ -28,7 +28,7 @@ class BtcPaymentController extends BasePageController if ($user) { $checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', 'Settled')->first(); if ($checkOrder !== null) { - Log::error('Duplicate BTCPay webhook: '.$payload['webhookId']); + Log::channel('btc_payment')->error('Duplicate BTCPay webhook: '.$payload['webhookId']); return response('OK', 200); } @@ -50,7 +50,7 @@ class BtcPaymentController extends BasePageController return response('OK', 200); } - Log::error('User not found for BTCPay webhook: '.$payload['metadata']['buyerEmail']); + Log::channel('btc_payment')->error('User not found for BTCPay webhook: '.$payload['metadata']['buyerEmail']); return response('Not Found', 404); } @@ -67,12 +67,12 @@ class BtcPaymentController extends BasePageController User::updateUserRole($user->id, $matches['role']); User::updateUserRoleChangeDate($user->id, null, $matches['addYears']); $checkOrder->update(['invoice_status' => 'Settled']); - Log::info('User: '.$user->username.' upgraded to '.$matches['role'].' for BTCPay webhook: '.$checkOrder->webhook_id); + Log::channel('btc_payment')->info('User: '.$user->username.' upgraded to '.$matches['role'].' for BTCPay webhook: '.$checkOrder->webhook_id); return response('OK', 200); } - Log::error('User not found for BTCPay webhook: '.$checkOrder->webhook_id); + Log::channel('btc_payment')->error('User not found for BTCPay webhook: '.$checkOrder->webhook_id); return response('Not Found', 404); } diff --git a/app/Http/Controllers/ContactUsController.php b/app/Http/Controllers/ContactUsController.php index 852df6fc2..06c982562 100644 --- a/app/Http/Controllers/ContactUsController.php +++ b/app/Http/Controllers/ContactUsController.php @@ -12,10 +12,8 @@ class ContactUsController extends BasePageController */ public function contact(ContactContactURequest $request) { - if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) { - $this->validate($request, [ - 'g-recaptcha-response' => 'required|captcha', - ]); + if (\App\Support\CaptchaHelper::isEnabled()) { + $this->validate($request, \App\Support\CaptchaHelper::getValidationRules()); } $msg = ''; @@ -25,8 +23,9 @@ class ContactUsController extends BasePageController $mailTo = config('mail.from.address'); $mailBody = 'Values submitted from contact form: '; + $captchaFieldName = \App\Support\CaptchaHelper::getResponseFieldName(); foreach ($request->all() as $key => $value) { - if ($key !== 'submit' && $key !== '_token' && $key !== 'g-recaptcha-response') { + if ($key !== 'submit' && $key !== '_token' && $key !== 'g-recaptcha-response' && $key !== 'cf-turnstile-response') { $mailBody .= "$key : $value".PHP_EOL; } } diff --git a/app/Http/Controllers/GetNzbController.php b/app/Http/Controllers/GetNzbController.php index 31e448d38..321c3db12 100644 --- a/app/Http/Controllers/GetNzbController.php +++ b/app/Http/Controllers/GetNzbController.php @@ -10,10 +10,13 @@ use Blacklight\NZB; use Blacklight\utility\Utility; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; class GetNzbController extends BasePageController { /** + * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Foundation\Application|\Illuminate\Http\JsonResponse|\Illuminate\Http\Response|\STS\ZipStream\ZipStream|\Symfony\Component\HttpFoundation\StreamedResponse + * * @throws \Exception */ public function getNzb(Request $request, $guid = null) @@ -27,6 +30,7 @@ class GetNzbController extends BasePageController // Page is accessible only by the rss token, or logged in users. if ($request->user()) { $uid = $this->userdata->id; + $userName = $this->userdata->username; $maxDownloads = $this->userdata->role->downloadrequests; $rssToken = $this->userdata->api_token; if ($this->userdata->hasRole('Disabled')) { @@ -43,6 +47,7 @@ class GetNzbController extends BasePageController } $uid = $res['id']; + $userName = $res->username; $rssToken = $res['api_token']; $maxDownloads = $res->role->downloadrequests; if ($res->hasRole('Disabled')) { @@ -82,6 +87,8 @@ class GetNzbController extends BasePageController } } + Log::channel('zipped')->info('User '.$userName.' downloaded zipped files from site with IP: '.$request->ip()); + return $zip; } @@ -127,27 +134,43 @@ class GetNzbController extends BasePageController $headers['X-DNZB-NFO'] = url('/nfo/'.$request->input('id')); } - $headers['X-DNZB-RCode'] = '200'; - $headers['X-DNZB-RText'] = 'OK, NZB content follows.'; + $headers += ['X-DNZB-RCode' => '200', + 'X-DNZB-RText' => 'OK, NZB content follows.', ]; + + // Raising this value may increase performance + $buffer_size = 1000000; + $bytes = '696'; + + // Open our file (in binary mode) + $zd = gzopen($nzbPath, 'rb'); + + $insert = ' + + alt.binaries.test + + + o9AgxKI0_40wnnFGymLkxrDxt@EggKOp4.48R + + '; + + $final = ''; + // Keep repeating until the end of the input file + while (! gzeof($zd)) { + // Read buffer-size bytes + $contents = gzread($zd, $buffer_size); + $tmp = preg_replace('/file poster=\"/i', 'file poster="'.$uid.'-', $contents, 10); + $final .= $tmp; + } + + $final = preg_replace('/<\/nzb>/i', $insert.PHP_EOL.''.PHP_EOL, $final, 1); + + gzclose($zd); // Sanitize file name $cleanName = str_replace([',', ' ', '/', '\\'], '_', $relData['searchname']); - // Stream the file content - return response()->streamDownload(function () use ($nzbPath) { - $bufferSize = 1000000; // 1 MB chunks - $gz = gzopen($nzbPath, 'rb'); - - if (! $gz) { - throw new RuntimeException('Failed to open gzipped file for streaming.'); - } - - while (! gzeof($gz)) { - echo gzread($gz, $bufferSize); - flush(); // Ensure chunks are sent immediately - } - - gzclose($gz); + return response()->streamDownload(function () use ($final) { + echo $final; }, $cleanName.'.nzb', $headers); } } diff --git a/app/Http/Controllers/MovieController.php b/app/Http/Controllers/MovieController.php index 4b5922463..37ca1379b 100644 --- a/app/Http/Controllers/MovieController.php +++ b/app/Http/Controllers/MovieController.php @@ -78,6 +78,7 @@ class MovieController extends BasePageController 'meta_title' => 'Browse Movies', 'meta_keywords' => 'browse,nzb,description,details', 'meta_description' => 'Browse for Movies', + 'movie_layout' => $this->userdata->movie_layout ?? 2, ]); // Return the appropriate view @@ -215,4 +216,92 @@ class MovieController extends BasePageController return response()->json(['message' => 'Invalid movie ID.'], 400); } + + /** + * Show trending movies (top 15 most downloaded in last 48 hours) + * + * @throws \Exception + */ + public function showTrending(Request $request) + { + $movie = new Movie(['Settings' => $this->settings]); + + // Cache key for trending movies (48 hours) + $cacheKey = 'trending_movies_top_15_48h'; + + // Get trending movies from cache or calculate (refresh every hour) + $trendingMovies = \Illuminate\Support\Facades\Cache::remember($cacheKey, 3600, function () { + // Calculate timestamp for 48 hours ago + $fortyEightHoursAgo = \Illuminate\Support\Carbon::now()->subHours(48); + + // Get movies with their download counts from last 48 hours + // Join with user_downloads to get actual download timestamps + $query = \Illuminate\Support\Facades\DB::table('movieinfo as m') + ->join('releases as r', 'm.imdbid', '=', 'r.imdbid') + ->leftJoin('user_downloads as ud', 'r.id', '=', 'ud.releases_id') + ->select([ + 'm.imdbid', + 'm.title', + 'm.year', + 'm.rating', + 'm.plot', + 'm.genre', + 'm.cover', + 'm.tmdbid', + 'm.traktid', + \Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT ud.id) as total_downloads'), + \Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT r.id) as release_count'), + ]) + ->where('m.title', '!=', '') + ->where('m.imdbid', '!=', '0000000') + ->where('ud.timestamp', '>=', $fortyEightHoursAgo) + ->groupBy('m.imdbid', 'm.title', 'm.year', 'm.rating', 'm.plot', 'm.genre', 'm.cover', 'm.tmdbid', 'm.traktid') + ->havingRaw('COUNT(DISTINCT ud.id) > 0') + ->orderByDesc('total_downloads') + ->limit(15) + ->get(); + + // Process the results + return $query->map(function ($item) { + // Add cover image URL using helper function + $coverArray = [ + 'imdbid' => $item->imdbid, + 'tmdbid' => $item->tmdbid, + 'cover' => $item->cover, + ]; + $item->cover = getReleaseCover($coverArray); + + return $item; + }); + }); + + $this->viewData = array_merge($this->viewData, [ + 'trendingMovies' => $trendingMovies, + 'meta_title' => 'Trending Movies - Last 48 Hours', + 'meta_keywords' => 'trending,movies,popular,downloads,recent', + 'meta_description' => 'Browse the most popular and downloaded movies in the last 48 hours', + ]); + + return view('movies.trending', $this->viewData); + } + + /** + * Update user's movie layout preference + */ + public function updateLayout(Request $request) + { + $request->validate([ + 'layout' => 'required|integer|in:1,2', + ]); + + $user = auth()->user(); + if ($user) { + $user->movie_layout = (int) $request->input('layout'); + $user->save(); + + return response()->json(['success' => true, 'layout' => $user->movie_layout]); + } + + return response()->json(['success' => false, 'message' => 'User not authenticated'], 401); + } } diff --git a/app/Http/Controllers/PasswordSecurityController.php b/app/Http/Controllers/PasswordSecurityController.php index ffc5e5d1d..fb58c5968 100644 --- a/app/Http/Controllers/PasswordSecurityController.php +++ b/app/Http/Controllers/PasswordSecurityController.php @@ -74,20 +74,12 @@ class PasswordSecurityController extends Controller $user->passwordSecurity->google2fa_enable = 1; $user->passwordSecurity->save(); - // Check if we should redirect to profile page - if ($request->has('redirect_to_profile')) { - return redirect()->to('profileedit#security')->with('success_2fa', '2FA is Enabled Successfully.'); - } - - return redirect()->to('2fa')->with('success', '2FA is Enabled Successfully.'); + // Always redirect to profile page after enabling 2FA + return redirect()->to('profileedit#security')->with('success_2fa', '2FA is Enabled Successfully.'); } - // Check if we should redirect to profile page on failure as well - if ($request->has('redirect_to_profile')) { - return redirect()->to('profileedit#security')->with('error_2fa', 'Invalid Verification Code, Please try again.'); - } - - return redirect()->to('2fa')->with('error', 'Invalid Verification Code, Please try again.'); + // Always redirect to profile page on failure as well + return redirect()->to('profileedit#security')->with('error_2fa', 'Invalid Verification Code, Please try again.'); } public function cancelSetup(Request $request): RedirectResponse @@ -107,25 +99,20 @@ class PasswordSecurityController extends Controller public function disable2fa(Disable2faPasswordSecurityRequest $request): \Illuminate\Routing\Redirector|RedirectResponse|\Illuminate\Contracts\Foundation\Application { if (! (Hash::check($request->get('current-password'), $request->user()->password))) { - // Password doesn't match - if ($request->has('redirect_to_profile') || $request->has('from_profile')) { - return redirect()->to('profileedit#security')->with('error_2fa', 'Your password does not match with your account password. Please try again.'); - } - - return redirect()->back()->with('error', 'Your password does not match with your account password. Please try again.'); + // Password doesn't match - always redirect to profile page with error + return redirect()->to('profileedit#security')->with('error_2fa', 'Your password does not match with your account password. Please try again.'); } $validatedData = $request->validated(); $user = $request->user(); - $user->passwordSecurity->google2fa_enable = 0; - $user->passwordSecurity->save(); - // Check if this request is from the profile edit page - if ($request->has('redirect_to_profile') || $request->has('from_profile')) { - return redirect()->to('profileedit#security')->with('success_2fa', '2FA is now Disabled.'); + // Delete the password security record entirely to fully disable 2FA + if ($user->passwordSecurity) { + $user->passwordSecurity->delete(); } - return redirect()->to('2fa')->with('success', '2FA is now Disabled.'); + // Always redirect to profile page after disabling 2FA + return redirect()->to('profileedit#security')->with('success_2fa', '2FA is now Disabled.'); } /** @@ -256,17 +243,7 @@ class PasswordSecurityController extends Controller ->withErrors(['msg' => 'User not found. Please login again.']); } - $theme = 'Gentele'; - $meta_title = 'Two Factor Authentication'; - $meta_keywords = 'Two Factor Authentication, 2FA'; - $meta_description = 'Two Factor Authentication Verification'; - - app('smarty.view')->assign(compact('meta_title', 'meta_keywords', 'meta_description', 'user')); - - // Create a response with the rendered content instead of directly outputting - $content = app('smarty.view')->fetch($theme.'/2fa_verify.tpl'); - - return response($content); + return view('auth.2fa_verify', compact('user')); } /** diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 15e5cc382..16df1c77a 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -150,6 +150,15 @@ class ProfileController extends BasePageController } } + // Update timezone preference + if ($request->has('timezone')) { + $timezoneValue = $request->input('timezone'); + $validTimezones = array_merge(['UTC'], ...array_values(getAvailableTimezones())); + if (in_array($timezoneValue, $validTimezones)) { + User::where('id', $userid)->update(['timezone' => $timezoneValue]); + } + } + // Handle Console permission if ($request->has('viewconsole')) { if (! $this->userdata->hasDirectPermission('view console')) { diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 23a16f52a..ac46d234d 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -103,12 +103,37 @@ class SearchController extends BasePageController $searchVars[$searchVarKey] = ($request->has($searchVarKey) ? (string) $request->input($searchVarKey) : ''); } + // Map new form field names to old internal names + if ($request->has('minage')) { + $searchVars['searchadvdaysnew'] = (string) $request->input('minage'); + } + if ($request->has('maxage')) { + $searchVars['searchadvdaysold'] = (string) $request->input('maxage'); + } + if ($request->has('group')) { + $searchVars['searchadvgroups'] = (string) $request->input('group'); + } + if ($request->has('minsize')) { + $searchVars['searchadvsizefrom'] = (string) $request->input('minsize'); + } + if ($request->has('maxsize')) { + $searchVars['searchadvsizeto'] = (string) $request->input('maxsize'); + } + // Map basic search field to advanced search when in advanced mode + if ($request->has('search') && $searchType === 'advanced') { + $searchVars['searchadvr'] = (string) $request->input('search'); + } + // Map basic category field to advanced category when in advanced mode + if ($request->has('t') && $searchType === 'advanced') { + $searchVars['searchadvcat'] = (string) $request->input('t'); + } + $searchVars['selectedgroup'] = $searchVars['searchadvgroups']; $searchVars['selectedcat'] = $searchVars['searchadvcat']; $searchVars['selectedsizefrom'] = $searchVars['searchadvsizefrom']; $searchVars['selectedsizeto'] = $searchVars['searchadvsizeto']; - if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter'])) { + if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter', 'minage', 'maxage', 'group', 'minsize', 'maxsize', 'search'])) { $orderByString = ''; foreach ($searchVars as $searchVarKey => $searchVar) { $orderByString .= "&$searchVarKey=".htmlentities($searchVar, ENT_QUOTES | ENT_HTML5); diff --git a/app/Http/Controllers/SeriesController.php b/app/Http/Controllers/SeriesController.php index 3608cfeb1..d7c681204 100644 --- a/app/Http/Controllers/SeriesController.php +++ b/app/Http/Controllers/SeriesController.php @@ -175,4 +175,61 @@ class SeriesController extends BasePageController return view('series.viewserieslist', $this->viewData); } } + + /** + * Show trending TV shows (top 15 most downloaded in last 48 hours) + * + * @throws \Exception + */ + public function showTrending(Request $request) + { + // Cache key for trending TV shows (48 hours) + $cacheKey = 'trending_tv_top_15_48h'; + + // Get trending TV shows from cache or calculate (refresh every hour) + $trendingShows = \Illuminate\Support\Facades\Cache::remember($cacheKey, 3600, function () { + // Calculate timestamp for 48 hours ago + $fortyEightHoursAgo = \Illuminate\Support\Carbon::now()->subHours(48); + + // Get TV shows with their download counts from last 48 hours + // Join with user_downloads to get actual download timestamps + $query = \Illuminate\Support\Facades\DB::table('videos as v') + ->join('tv_info as ti', 'v.id', '=', 'ti.videos_id') + ->join('releases as r', 'v.id', '=', 'r.videos_id') + ->leftJoin('user_downloads as ud', 'r.id', '=', 'ud.releases_id') + ->select([ + 'v.id', + 'v.title', + 'v.started', + 'v.tvdb', + 'v.tvmaze', + 'v.trakt', + 'v.tmdb', + 'v.countries_id', + 'ti.summary', + 'ti.image', + \Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT ud.id) as total_downloads'), + \Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT r.id) as release_count'), + ]) + ->where('v.type', 0) // 0 = TV + ->where('v.title', '!=', '') + ->where('ud.timestamp', '>=', $fortyEightHoursAgo) + ->groupBy('v.id', 'v.title', 'v.started', 'v.tvdb', 'v.tvmaze', 'v.trakt', 'v.tmdb', 'v.countries_id', 'ti.summary', 'ti.image') + ->havingRaw('COUNT(DISTINCT ud.id) > 0') + ->orderByDesc('total_downloads') + ->limit(15) + ->get(); + + return $query; + }); + + $this->viewData = array_merge($this->viewData, [ + 'trendingShows' => $trendingShows, + 'meta_title' => 'Trending TV Shows - Last 48 Hours', + 'meta_keywords' => 'trending,tv,shows,series,popular,downloads,recent', + 'meta_description' => 'Browse the most popular and downloaded TV shows in the last 48 hours', + ]); + + return view('series.trending', $this->viewData); + } } diff --git a/app/Http/Requests/Auth/LoginLoginRequest.php b/app/Http/Requests/Auth/LoginLoginRequest.php index 6854f9b69..65af78495 100644 --- a/app/Http/Requests/Auth/LoginLoginRequest.php +++ b/app/Http/Requests/Auth/LoginLoginRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Auth; +use App\Support\CaptchaHelper; use Illuminate\Foundation\Http\FormRequest; class LoginLoginRequest extends FormRequest @@ -11,15 +12,6 @@ class LoginLoginRequest extends FormRequest */ public function rules(): array { - if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) { - return [ - 'g-recaptcha-response' => [ - 'required', - 'captcha', - ], - ]; - } else { - return []; - } + return CaptchaHelper::getValidationRules(); } } diff --git a/app/Http/Requests/Auth/RegisterRegisterRequest.php b/app/Http/Requests/Auth/RegisterRegisterRequest.php index 1dace06f0..ee2d2535d 100644 --- a/app/Http/Requests/Auth/RegisterRegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRegisterRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Auth; +use App\Support\CaptchaHelper; use Illuminate\Foundation\Http\FormRequest; class RegisterRegisterRequest extends FormRequest @@ -11,15 +12,6 @@ class RegisterRegisterRequest extends FormRequest */ public function rules(): array { - if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) { - return [ - 'g-recaptcha-response' => [ - 'required', - 'captcha', - ], - ]; - } else { - return []; - } + return CaptchaHelper::getValidationRules(); } } diff --git a/app/Http/Requests/Auth/ShowLinkRequestFormForgotPasswordRequest.php b/app/Http/Requests/Auth/ShowLinkRequestFormForgotPasswordRequest.php index 4aaccdad1..70959f5ba 100644 --- a/app/Http/Requests/Auth/ShowLinkRequestFormForgotPasswordRequest.php +++ b/app/Http/Requests/Auth/ShowLinkRequestFormForgotPasswordRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Auth; +use App\Support\CaptchaHelper; use Illuminate\Foundation\Http\FormRequest; class ShowLinkRequestFormForgotPasswordRequest extends FormRequest @@ -13,6 +14,6 @@ class ShowLinkRequestFormForgotPasswordRequest extends FormRequest */ public function rules() { - return ['g-recaptcha-response' => 'required|captcha']; + return CaptchaHelper::getValidationRules(); } } diff --git a/app/Http/Requests/ContactContactURequest.php b/app/Http/Requests/ContactContactURequest.php index 1333f02bb..678d13004 100644 --- a/app/Http/Requests/ContactContactURequest.php +++ b/app/Http/Requests/ContactContactURequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Support\CaptchaHelper; use Illuminate\Foundation\Http\FormRequest; class ContactContactURequest extends FormRequest @@ -11,9 +12,6 @@ class ContactContactURequest extends FormRequest */ public function rules(): array { - return ['g-recaptcha-response' => [ - 'required', - 'captcha', - ], ]; + return CaptchaHelper::getValidationRules(); } } diff --git a/app/Listeners/UpdateUserLoggedIn.php b/app/Listeners/UpdateUserLoggedIn.php index 64d8de337..b6d03a307 100644 --- a/app/Listeners/UpdateUserLoggedIn.php +++ b/app/Listeners/UpdateUserLoggedIn.php @@ -28,5 +28,13 @@ class UpdateUserLoggedIn 'host' => $event->ip, ] ); + + // Log the user login event + \Log::channel('user_login')->info('User logged in', [ + 'user_id' => $event->user->id, + 'username' => $event->user->username, + 'ip' => $event->ip, + 'time' => now(), + ]); } } diff --git a/app/Models/GrabStat.php b/app/Models/GrabStat.php index 20d89fe6b..5fee9d420 100644 --- a/app/Models/GrabStat.php +++ b/app/Models/GrabStat.php @@ -22,6 +22,6 @@ class GrabStat extends Model public static function getTopGrabbers(): array { - return self::query()->select(['username', 'grabs'])->get()->toArray(); + return self::query()->select(['username', 'grabs'])->orderByDesc('grabs')->limit(10)->get()->toArray(); } } diff --git a/app/Models/MediaInfo.php b/app/Models/MediaInfo.php index bd59c7332..be22aed5b 100644 --- a/app/Models/MediaInfo.php +++ b/app/Models/MediaInfo.php @@ -24,6 +24,12 @@ class MediaInfo extends Model return; } + $mediaUniqueId = $mediainfoArray->get('unique_id'); + // If unique_id is not present or is 0 or 0x0, we don't want to store it, as it's not unique + if ($mediaUniqueId === null || $mediaUniqueId === '0' || $mediaUniqueId === '0x0' || $mediaUniqueId === 0) { + $mediaUniqueId = null; + } + self::insertOrIgnore([ 'releases_id' => $id, 'movie_name' => $mediainfoArray->get('movie_name') ?? null, diff --git a/app/Models/Release.php b/app/Models/Release.php index 59f36196e..f6a6eed16 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -98,11 +98,6 @@ class Release extends Model return $this->belongsTo(TvEpisode::class, 'tv_episodes_id'); } - public function movieInfo(): BelongsTo - { - return $this->belongsTo(MovieInfo::class, 'movieinfo_id'); - } - /** * Insert a single release returning the ID on success or false on failure. * @@ -135,6 +130,7 @@ class Release extends Model 'isrenamed' => $parameters['isrenamed'], 'iscategorized' => 1, 'predb_id' => $parameters['predb_id'], + 'source' => $parameters['source'] ?? null, 'ishashed' => $parameters['ishashed'] ?? 0, ] ); @@ -361,9 +357,9 @@ class Release extends Model $release->parent_category = $release->category->parent->title ?? null; $release->sub_category = $release->category->title ?? null; $release->category_name = $release->parent_category.' > '.$release->sub_category; - $release->category_ids = $release->category->parentid.','.$release->category->id; + $release->category_ids = $release->category ? ($release->category->parentid.','.$release->category->id) : ''; $release->group_names = $release->releaseGroup->map(function ($relGroup) { - return $relGroup->group->name; + return $relGroup->group ? $relGroup->group->name : null; })->implode(','); }); diff --git a/app/Models/ReleaseStat.php b/app/Models/ReleaseStat.php index 42ca9aa58..b75381d5f 100644 --- a/app/Models/ReleaseStat.php +++ b/app/Models/ReleaseStat.php @@ -14,19 +14,34 @@ class ReleaseStat extends Model public static function insertRecentlyAdded(): void { - $categories = Category::query()->with('parent')->where('r.adddate', '>', now()->subWeek())->select([ - 'root_categories_id', DB::raw('COUNT(r.id) as count'), 'title', - ])->join('releases as r', 'r.categories_id', '=', - 'categories.id')->groupBy('title')->orderByDesc('count')->get(); + $categories = Category::query() + ->with('parent') + ->where('r.adddate', '>', now()->subWeek()) + ->select([ + 'categories.id', + 'root_categories_id', + DB::raw('COUNT(r.id) as count'), + 'categories.title', + ]) + ->join('releases as r', 'r.categories_id', '=', 'categories.id') + ->whereNotIn('categories.id', [10, 20]) // Exclude OTHER_MISC and OTHER_HASHED + ->groupBy('categories.id', 'root_categories_id', 'categories.title') + ->orderByDesc('count') + ->get(); foreach ($categories as $category) { + // Build the category display name with root category prefix + $categoryDisplay = $category->parent + ? $category->parent->title.' > '.$category->title + : $category->title; + // Check if we already have the information and if we do just update the count - if (self::query()->where('category', $category->title)->exists()) { - self::query()->where('category', $category->title)->update(['count' => $category->count]); + if (self::query()->where('category', $categoryDisplay)->exists()) { + self::query()->where('category', $categoryDisplay)->update(['count' => $category->count]); continue; } - self::query()->create(['category' => $category->title, 'count' => $category->count]); + self::query()->create(['category' => $categoryDisplay, 'count' => $category->count]); } } diff --git a/app/Models/SignupStat.php b/app/Models/SignupStat.php index fec2e4254..b8d21ce30 100644 --- a/app/Models/SignupStat.php +++ b/app/Models/SignupStat.php @@ -13,14 +13,28 @@ class SignupStat extends Model public static function insertUsersByMonth(): void { - $users = User::query()->whereNotNull('created_at')->where('created_at', '<>', '0000-00-00 00:00:00')->selectRaw("DATE_FORMAT(created_at, '%M %Y') as mth, COUNT(id) as num")->groupBy(['mth'])->orderByDesc('created_at')->get(); + $users = User::query() + ->whereNotNull('created_at') + ->where('created_at', '<>', '0000-00-00 00:00:00') + ->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as sort_date, DATE_FORMAT(created_at, '%M %Y') as mth, COUNT(id) as num") + ->groupBy(['sort_date', 'mth']) + ->orderByDesc('sort_date') + ->get(); + foreach ($users as $user) { - self::updateOrCreate(['month' => $user->mth], ['signups' => $user->num]); + self::updateOrCreate( + ['month' => $user->mth], + ['signups' => $user->num, 'sort_date' => $user->sort_date] + ); } } public static function getUsersByMonth(): array { - return self::query()->select(['month', 'signups'])->get()->toArray(); + return self::query() + ->select(['month', 'signups']) + ->orderByDesc('sort_date') + ->get() + ->toArray(); } } diff --git a/app/Models/SystemMetric.php b/app/Models/SystemMetric.php new file mode 100644 index 000000000..83273e4bd --- /dev/null +++ b/app/Models/SystemMetric.php @@ -0,0 +1,62 @@ + 'float', + 'recorded_at' => 'datetime', + ]; + + /** + * Scope to get CPU metrics + */ + public function scopeCpu($query) + { + return $query->where('metric_type', 'cpu'); + } + + /** + * Scope to get RAM metrics + */ + public function scopeRam($query) + { + return $query->where('metric_type', 'ram'); + } + + /** + * Scope to get metrics for a specific time period + */ + public function scopeForPeriod($query, int $hours) + { + return $query->where('recorded_at', '>=', now()->subHours($hours)); + } + + /** + * Scope to get metrics for a specific date range + */ + public function scopeBetweenDates($query, $startDate, $endDate) + { + return $query->whereBetween('recorded_at', [$startDate, $endDate]); + } + + /** + * Clean up old metrics (older than specified days) + */ + public static function cleanupOldMetrics(int $days = 60): int + { + return static::where('recorded_at', '<', now()->subDays($days))->delete(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 24f05953c..545cb441e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -238,6 +238,14 @@ class User extends Authenticatable return $this->hasMany(ReleaseComment::class, 'users_id'); } + /** + * Get the user's timezone or default to UTC + */ + public function getTimezone(): string + { + return $this->timezone ?? 'UTC'; + } + /** * @throws \Exception */ diff --git a/app/Models/UserActivityStat.php b/app/Models/UserActivityStat.php new file mode 100644 index 000000000..4906eeab2 --- /dev/null +++ b/app/Models/UserActivityStat.php @@ -0,0 +1,134 @@ + 'date', + ]; + + /** + * Collect and store user activity stats for a specific date + * This aggregates data from user_downloads and user_requests tables + */ + public static function collectDailyStats(?string $date = null): void + { + $statDate = $date ? Carbon::parse($date)->format('Y-m-d') : Carbon::yesterday()->format('Y-m-d'); + + // Count downloads for the date + $downloadsCount = UserDownload::query() + ->whereRaw('DATE(timestamp) = ?', [$statDate]) + ->count(); + + // Count API hits for the date + $apiHitsCount = UserRequest::query() + ->whereRaw('DATE(timestamp) = ?', [$statDate]) + ->count(); + + // Store or update the stats + self::updateOrCreate( + ['stat_date' => $statDate], + [ + 'downloads_count' => $downloadsCount, + 'api_hits_count' => $apiHitsCount, + ] + ); + } + + /** + * Get download stats for the last N days + */ + public static function getDownloadsPerDay(int $days = 30): array + { + $startDate = Carbon::now()->subDays($days - 1)->format('Y-m-d'); + + $stats = self::query() + ->select('stat_date', 'downloads_count') + ->where('stat_date', '>=', $startDate) + ->orderBy('stat_date', 'asc') + ->get() + ->keyBy('stat_date'); + + // Fill in missing days with zero counts + $result = []; + for ($i = $days - 1; $i >= 0; $i--) { + $date = Carbon::now()->subDays($i)->format('Y-m-d'); + $stat = $stats->get($date); + $result[] = [ + 'date' => Carbon::parse($date)->format('M d'), + 'count' => $stat ? $stat->downloads_count : 0, + ]; + } + + return $result; + } + + /** + * Get API hits stats for the last N days + */ + public static function getApiHitsPerDay(int $days = 30): array + { + $startDate = Carbon::now()->subDays($days - 1)->format('Y-m-d'); + + $stats = self::query() + ->select('stat_date', 'api_hits_count') + ->where('stat_date', '>=', $startDate) + ->orderBy('stat_date', 'asc') + ->get() + ->keyBy('stat_date'); + + // Fill in missing days with zero counts + $result = []; + for ($i = $days - 1; $i >= 0; $i--) { + $date = Carbon::now()->subDays($i)->format('Y-m-d'); + $stat = $stats->get($date); + $result[] = [ + 'date' => Carbon::parse($date)->format('M d'), + 'count' => $stat ? $stat->api_hits_count : 0, + ]; + } + + return $result; + } + + /** + * Get total downloads for the last N days + */ + public static function getTotalDownloads(int $days = 7): int + { + return self::query() + ->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d')) + ->sum('downloads_count'); + } + + /** + * Get total API hits for the last N days + */ + public static function getTotalApiHits(int $days = 7): int + { + return self::query() + ->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d')) + ->sum('api_hits_count'); + } + + /** + * Cleanup old stats (keep last N days) + */ + public static function cleanupOldStats(int $keepDays = 90): int + { + $cutoffDate = Carbon::now()->subDays($keepDays)->format('Y-m-d'); + + return self::query() + ->where('stat_date', '<', $cutoffDate) + ->delete(); + } +} diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index a54cdeb96..a8b36cb92 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\User; +use App\Models\UserActivityStat; use App\Models\UserDownload; use App\Models\UserRequest; use Carbon\Carbon; @@ -31,26 +32,108 @@ class UserStatsService } /** - * Get downloads per day for the last 7 days + * Get downloads per day for the last N days + * Uses aggregated stats from user_activity_stats table for dates older than 2 days + * Uses live data from user_downloads table for recent days */ public function getDownloadsPerDay(int $days = 7): array { $startDate = Carbon::now()->subDays($days - 1)->startOfDay(); + $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); + $result = []; + + // For historical data (older than 2 days), use aggregated stats + if ($days > 2) { + $historicalStartDate = $startDate->format('Y-m-d'); + $historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d'); + + $historicalStats = UserActivityStat::query() + ->select('stat_date', 'downloads_count') + ->where('stat_date', '>=', $historicalStartDate) + ->where('stat_date', '<=', $historicalEndDate) + ->orderBy('stat_date', 'asc') + ->get() + ->keyBy('stat_date'); + + // Add historical data + $currentDate = $startDate->copy(); + while ($currentDate->lt($twoDaysAgo)) { + $dateStr = $currentDate->format('Y-m-d'); + $stat = $historicalStats->get($dateStr); + $result[] = [ + 'date' => $currentDate->format('M d'), + 'count' => $stat ? $stat->downloads_count : 0, + ]; + $currentDate->addDay(); + } + } + + // For recent data (last 2 days), use live data from user_downloads $downloads = UserDownload::query() ->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count')) - ->where('timestamp', '>=', $startDate) + ->where('timestamp', '>=', $twoDaysAgo) ->groupBy(DB::raw('DATE(timestamp)')) ->orderBy('date', 'asc') - ->get(); + ->get() + ->keyBy('date'); - // Fill in missing days with zero counts - $result = []; - for ($i = $days - 1; $i >= 0; $i--) { - $date = Carbon::now()->subDays($i)->format('Y-m-d'); - $found = $downloads->firstWhere('date', $date); + // Add recent data + $currentDate = $twoDaysAgo->copy(); + $now = Carbon::now(); + while ($currentDate->lte($now)) { + $dateStr = $currentDate->format('Y-m-d'); + $found = $downloads->get($dateStr); $result[] = [ - 'date' => Carbon::parse($date)->format('M d'), + 'date' => $currentDate->format('M d'), + 'count' => $found ? $found->count : 0, + ]; + $currentDate->addDay(); + } + + return $result; + } + + /** + * Get downloads per hour for the last N hours + * Uses live data from user_downloads table + */ + public function getDownloadsPerHour(int $hours = 168): array + { + $startTime = Carbon::now()->subHours($hours - 1)->startOfHour(); + + $downloads = UserDownload::query() + ->select( + DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00") as hour'), + DB::raw('COUNT(*) as count') + ) + ->where('timestamp', '>=', $startTime) + ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00")')) + ->orderBy('hour', 'asc') + ->get() + ->keyBy('hour'); + + // Fill in missing hours with zero counts + $result = []; + for ($i = $hours - 1; $i >= 0; $i--) { + $time = Carbon::now()->subHours($i)->startOfHour(); + $hourKey = $time->format('Y-m-d H:00:00'); + $found = $downloads->get($hourKey); + + // Format label based on how recent the hour is + $now = Carbon::now(); + if ($time->isToday()) { + $label = $time->format('H:i'); + } elseif ($time->isYesterday()) { + $label = 'Yesterday '.$time->format('H:i'); + } elseif ($time->diffInDays($now) < 7) { + $label = $time->format('D H:i'); + } else { + $label = $time->format('M d H:i'); + } + + $result[] = [ + 'time' => $label, 'count' => $found ? $found->count : 0, ]; } @@ -59,28 +142,173 @@ class UserStatsService } /** - * Get API hits per day for the last 7 days - * Note: This tracks actual API requests from user_requests table + * Get downloads per minute for the last N minutes + */ + public function getDownloadsPerMinute(int $minutes = 60): array + { + $startTime = Carbon::now()->subMinutes($minutes); + + $downloads = UserDownload::query() + ->select( + DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'), + DB::raw('COUNT(*) as count') + ) + ->where('timestamp', '>=', $startTime) + ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")')) + ->orderBy('minute', 'asc') + ->get(); + + // Fill in missing minutes with zero counts + $result = []; + for ($i = $minutes - 1; $i >= 0; $i--) { + $time = Carbon::now()->subMinutes($i); + $minuteKey = $time->format('Y-m-d H:i:00'); + $found = $downloads->firstWhere('minute', $minuteKey); + $result[] = [ + 'time' => $time->format('H:i'), + 'count' => $found ? $found->count : 0, + ]; + } + + return $result; + } + + /** + * Get API hits per day for the last N days + * Uses aggregated stats from user_activity_stats table for dates older than 2 days + * Uses live data from user_requests table for recent days */ public function getApiHitsPerDay(int $days = 7): array { $startDate = Carbon::now()->subDays($days - 1)->startOfDay(); + $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); + + $result = []; + + // For historical data (older than 2 days), use aggregated stats + if ($days > 2) { + $historicalStartDate = $startDate->format('Y-m-d'); + $historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d'); + + $historicalStats = UserActivityStat::query() + ->select('stat_date', 'api_hits_count') + ->where('stat_date', '>=', $historicalStartDate) + ->where('stat_date', '<=', $historicalEndDate) + ->orderBy('stat_date', 'asc') + ->get() + ->keyBy('stat_date'); + + // Add historical data + $currentDate = $startDate->copy(); + while ($currentDate->lt($twoDaysAgo)) { + $dateStr = $currentDate->format('Y-m-d'); + $stat = $historicalStats->get($dateStr); + $result[] = [ + 'date' => $currentDate->format('M d'), + 'count' => $stat ? $stat->api_hits_count : 0, + ]; + $currentDate->addDay(); + } + } + + // For recent data (last 2 days), use live data from user_requests + $apiHits = UserRequest::query() + ->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count')) + ->where('timestamp', '>=', $twoDaysAgo) + ->groupBy(DB::raw('DATE(timestamp)')) + ->orderBy('date', 'asc') + ->get() + ->keyBy('date'); + + // Add recent data + $currentDate = $twoDaysAgo->copy(); + $now = Carbon::now(); + while ($currentDate->lte($now)) { + $dateStr = $currentDate->format('Y-m-d'); + $found = $apiHits->get($dateStr); + $result[] = [ + 'date' => $currentDate->format('M d'), + 'count' => $found ? $found->count : 0, + ]; + $currentDate->addDay(); + } + + return $result; + } + + /** + * Get API hits per hour for the last N hours + * Uses live data from user_requests table + */ + public function getApiHitsPerHour(int $hours = 168): array + { + $startTime = Carbon::now()->subHours($hours - 1)->startOfHour(); + + $apiHits = UserRequest::query() + ->select( + DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00") as hour'), + DB::raw('COUNT(*) as count') + ) + ->where('timestamp', '>=', $startTime) + ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00")')) + ->orderBy('hour', 'asc') + ->get() + ->keyBy('hour'); + + // Fill in missing hours with zero counts + $result = []; + for ($i = $hours - 1; $i >= 0; $i--) { + $time = Carbon::now()->subHours($i)->startOfHour(); + $hourKey = $time->format('Y-m-d H:00:00'); + $found = $apiHits->get($hourKey); + + // Format label based on how recent the hour is + $now = Carbon::now(); + if ($time->isToday()) { + $label = $time->format('H:i'); + } elseif ($time->isYesterday()) { + $label = 'Yesterday '.$time->format('H:i'); + } elseif ($time->diffInDays($now) < 7) { + $label = $time->format('D H:i'); + } else { + $label = $time->format('M d H:i'); + } + + $result[] = [ + 'time' => $label, + 'count' => $found ? $found->count : 0, + ]; + } + + return $result; + } + + /** + * Get API hits per minute for the last N minutes + */ + public function getApiHitsPerMinute(int $minutes = 60): array + { + $startTime = Carbon::now()->subMinutes($minutes); // Track actual API requests from user_requests table $apiHits = UserRequest::query() - ->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count')) - ->where('timestamp', '>=', $startDate) - ->groupBy(DB::raw('DATE(timestamp)')) - ->orderBy('date', 'asc') + ->select( + DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'), + DB::raw('COUNT(*) as count') + ) + ->where('timestamp', '>=', $startTime) + ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")')) + ->orderBy('minute', 'asc') ->get(); - // Fill in missing days with zero counts + // Fill in missing minutes with zero counts $result = []; - for ($i = $days - 1; $i >= 0; $i--) { - $date = Carbon::now()->subDays($i)->format('Y-m-d'); - $found = $apiHits->firstWhere('date', $date); + for ($i = $minutes - 1; $i >= 0; $i--) { + $time = Carbon::now()->subMinutes($i); + $minuteKey = $time->format('Y-m-d H:i:00'); + $found = $apiHits->firstWhere('minute', $minuteKey); $result[] = [ - 'date' => Carbon::parse($date)->format('M d'), + 'time' => $time->format('H:i'), 'count' => $found ? $found->count : 0, ]; } @@ -90,17 +318,39 @@ class UserStatsService /** * Get summary statistics + * Uses aggregated stats for weekly totals where possible */ public function getSummaryStats(): array { $today = Carbon::now()->startOfDay(); + $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); + $sevenDaysAgo = Carbon::now()->subDays(7)->startOfDay(); + + // For weekly stats, combine aggregated historical data + live recent data + $historicalDownloads = UserActivityStat::query() + ->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d')) + ->where('stat_date', '<', $twoDaysAgo->format('Y-m-d')) + ->sum('downloads_count'); + + $recentDownloads = UserDownload::query() + ->where('timestamp', '>=', $twoDaysAgo) + ->count(); + + $historicalApiHits = UserActivityStat::query() + ->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d')) + ->where('stat_date', '<', $twoDaysAgo->format('Y-m-d')) + ->sum('api_hits_count'); + + $recentApiHits = UserRequest::query() + ->where('timestamp', '>=', $twoDaysAgo) + ->count(); return [ 'total_users' => User::whereNull('deleted_at')->count(), 'downloads_today' => UserDownload::where('timestamp', '>=', $today)->count(), - 'downloads_week' => UserDownload::where('timestamp', '>=', Carbon::now()->subDays(7))->count(), + 'downloads_week' => $historicalDownloads + $recentDownloads, 'api_hits_today' => UserRequest::query()->where('timestamp', '>=', $today)->count(), - 'api_hits_week' => UserRequest::query()->where('timestamp', '>=', Carbon::now()->subDays(7))->count(), + 'api_hits_week' => $historicalApiHits + $recentApiHits, ]; } diff --git a/app/Transformers/ApiTransformer.php b/app/Transformers/ApiTransformer.php index 0ee22f8b0..259a13718 100644 --- a/app/Transformers/ApiTransformer.php +++ b/app/Transformers/ApiTransformer.php @@ -31,7 +31,6 @@ class ApiTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'imdbid' => $releases->imdbid !== null && $releases->imdbid !== 0 ? $releases->imdbid : $this->null(), 'tmdbid' => $releases->tmdbid !== null && $releases->tmdbid !== 0 ? $releases->tmdbid : $this->null(), 'traktid' => $releases->traktid !== null && $releases->traktid !== 0 ? $releases->traktid : $this->null(), @@ -39,7 +38,6 @@ class ApiTransformer extends TransformerAbstract 'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(), 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } @@ -53,7 +51,6 @@ class ApiTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'episode_title' => $releases->title ?? $this->null(), 'season' => $releases->series ?? $this->null(), 'episode' => $releases->episode ?? $this->null(), @@ -68,7 +65,6 @@ class ApiTransformer extends TransformerAbstract 'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(), 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } @@ -81,12 +77,10 @@ class ApiTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'grabs' => $releases->grabs !== 0 ? $releases->grabs : $this->null(), 'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(), 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } } diff --git a/app/Transformers/DetailsTransformer.php b/app/Transformers/DetailsTransformer.php index a5db2f26f..a9b347aa9 100644 --- a/app/Transformers/DetailsTransformer.php +++ b/app/Transformers/DetailsTransformer.php @@ -34,13 +34,11 @@ class DetailsTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'imdbid' => $releases->imdbid, 'grabs' => $releases->grabs, 'comments' => $releases->comments, 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } @@ -54,7 +52,6 @@ class DetailsTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'tvairdate' => $releases->firstaired, 'tvdbid' => $releases->tvdb, 'traktid' => $releases->trakt, @@ -66,7 +63,6 @@ class DetailsTransformer extends TransformerAbstract 'comments' => $releases->comments, 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } @@ -79,12 +75,10 @@ class DetailsTransformer extends TransformerAbstract 'added' => Carbon::parse($releases->adddate)->toRssString(), 'size' => $releases->size, 'files' => $releases->totalpart, - 'poster' => $releases->fromname, 'grabs' => $releases->grabs, 'comments' => $releases->comments, 'password' => $releases->passwordstatus, 'usenetdate' => Carbon::parse($releases->postdate)->toRssString(), - 'group' => $releases->group_name, ]; } } diff --git a/app/View/Composers/GlobalDataComposer.php b/app/View/Composers/GlobalDataComposer.php index acc5f6a69..c2bbc1102 100644 --- a/app/View/Composers/GlobalDataComposer.php +++ b/app/View/Composers/GlobalDataComposer.php @@ -16,11 +16,15 @@ class GlobalDataComposer */ public function compose(View $view): void { - $settings = new Settings; + // Load settings as array with type conversions (empty strings -> null, numeric strings -> numbers) + $siteArray = Settings::query() + ->pluck('value', 'name') + ->map(fn ($value) => Settings::convertValue($value)) + ->all(); $viewData = [ 'serverroot' => url('/'), - 'site' => $settings, + 'site' => $siteArray, // Now it's a proper array, not a Settings model 'theme' => 'Gentele', ]; diff --git a/bootstrap/app.php b/bootstrap/app.php index e6fca86e8..b2939e120 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -47,6 +47,8 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web([ \Illuminate\Session\Middleware\AuthenticateSession::class, \App\Http\Middleware\TrustedDevice2FAMiddleware::class, // Add our new trusted device middleware + \App\Http\Middleware\ContentSecurityPolicy::class, // Add CSP middleware for security + \App\Http\Middleware\SetUserTimezone::class, // Set user timezone ]); $middleware->throttleApi('60,1'); @@ -62,5 +64,5 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions) { - // + \Sentry\Laravel\Integration::handles($exceptions); })->create(); diff --git a/composer.lock b/composer.lock index fb52aba22..7f5dcfea5 100644 --- a/composer.lock +++ b/composer.lock @@ -1364,26 +1364,26 @@ }, { "name": "doctrine/sql-formatter", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "ergebnis/phpunit-slow-test-detector": "^2.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" }, "bin": [ "bin/sql-formatter" @@ -1413,9 +1413,9 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" }, - "time": "2025-01-24T11:45:48+00:00" + "time": "2025-10-26T09:35:14+00:00" }, { "name": "dragonmantank/cron-expression", @@ -3053,16 +3053,16 @@ }, { "name": "laravel/framework", - "version": "v12.35.0", + "version": "v12.35.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef" + "reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", - "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", + "url": "https://api.github.com/repos/laravel/framework/zipball/d6d6e3cb68238e2fb25b440f222442adef5a8a15", + "reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15", "shasum": "" }, "require": { @@ -3268,7 +3268,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-10-21T15:15:41+00:00" + "time": "2025-10-23T15:25:03+00:00" }, { "name": "laravel/horizon", @@ -8714,16 +8714,16 @@ }, { "name": "sentry/sentry", - "version": "4.17.0", + "version": "4.17.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "62927369a572efc27ddbd89e466e17788329224b" + "reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/62927369a572efc27ddbd89e466e17788329224b", - "reference": "62927369a572efc27ddbd89e466e17788329224b", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/5c696b8de57e841a2bf3b6f6eecfd99acfdda80c", + "reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c", "shasum": "" }, "require": { @@ -8786,7 +8786,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.17.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.17.1" }, "funding": [ { @@ -8798,7 +8798,7 @@ "type": "custom" } ], - "time": "2025-10-20T12:57:02+00:00" + "time": "2025-10-23T15:19:24+00:00" }, { "name": "sentry/sentry-laravel", @@ -14667,16 +14667,16 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "efb636a08dfddfa2a3f4527b1dd970a898a075a4" + "reference": "abc336cbf06f53d90ab74cecfd319379fc55d408" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/efb636a08dfddfa2a3f4527b1dd970a898a075a4", - "reference": "efb636a08dfddfa2a3f4527b1dd970a898a075a4", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/abc336cbf06f53d90ab74cecfd319379fc55d408", + "reference": "abc336cbf06f53d90ab74cecfd319379fc55d408", "shasum": "" }, "require": { @@ -14696,9 +14696,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.1.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.1.1" }, - "time": "2025-10-12T21:51:39+00:00" + "time": "2025-10-23T13:53:44+00:00" }, { "name": "ergebnis/composer-normalize", @@ -15298,16 +15298,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.89.0", + "version": "v3.89.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "4dd6768cb7558440d27d18f54909eee417317ce9" + "reference": "f34967da2866ace090a2b447de1f357356474573" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4dd6768cb7558440d27d18f54909eee417317ce9", - "reference": "4dd6768cb7558440d27d18f54909eee417317ce9", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/f34967da2866ace090a2b447de1f357356474573", + "reference": "f34967da2866ace090a2b447de1f357356474573", "shasum": "" }, "require": { @@ -15389,7 +15389,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.1" }, "funding": [ { @@ -15397,7 +15397,7 @@ "type": "github" } ], - "time": "2025-10-18T19:30:16+00:00" + "time": "2025-10-24T12:05:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -17296,16 +17296,16 @@ }, { "name": "rector/rector", - "version": "2.2.4", + "version": "2.2.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "904f12f23858ef54ec5782b05cb2979b703cb185" + "reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/904f12f23858ef54ec5782b05cb2979b703cb185", - "reference": "904f12f23858ef54ec5782b05cb2979b703cb185", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/fb9418af7777dfb1c87a536dc58398b5b07c74b9", + "reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9", "shasum": "" }, "require": { @@ -17344,7 +17344,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.4" + "source": "https://github.com/rectorphp/rector/tree/2.2.5" }, "funding": [ { @@ -17352,7 +17352,7 @@ "type": "github" } ], - "time": "2025-10-22T07:50:23+00:00" + "time": "2025-10-23T11:22:37+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/app.php b/config/app.php index 988c13f53..c9e465391 100644 --- a/config/app.php +++ b/config/app.php @@ -4,6 +4,22 @@ use Illuminate\Support\Facades\Facade; return [ + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + | IMPORTANT: This should always be UTC to ensure consistent date storage. + | User-specific timezones are handled at the display layer. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'aliases' => Facade::defaultAliases()->merge([ 'RedisManager' => Illuminate\Support\Facades\Redis::class, 'UserVerification' => Jrean\UserVerification\Facades\UserVerification::class, diff --git a/config/filesystems.php b/config/filesystems.php index bf2c99cd4..b564035a9 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -2,6 +2,76 @@ return [ - 'cloud' => env('FILESYSTEM_CLOUD', 's3'), + /* + |-------------------------------------------------------------------------- + | Default Filesystem Disk + |-------------------------------------------------------------------------- + | + | Here you may specify the default filesystem disk that should be used + | by the framework. The "local" disk, as well as a variety of cloud + | based disks are available to your application for file storage. + | + */ + + 'default' => env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], ]; diff --git a/config/logging.php b/config/logging.php index f74a08107..8b2138f31 100644 --- a/config/logging.php +++ b/config/logging.php @@ -28,6 +28,120 @@ return [ 'permission' => 0775, 'locking' => false, ], + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => 'Laravel Log', + 'emoji' => ':boom:', + 'level' => 'critical', + ], + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => 'debug', + 'handler' => SyslogUdpHandler::class, + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + ], + ], + 'stderr' => [ + 'driver' => 'monolog', + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + ], + 'syslog' => [ + 'driver' => 'syslog', + 'level' => 'debug', + ], + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => 'debug', + ], + 'zipped' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/zipped.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'scrapers' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scrapers.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'failed_login' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/failed_login.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'crc_oso' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/crc_oso.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'btc_payment' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/btc_payment.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'nzb_upload' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/nzb_upload.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'filename_rename' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/filename_rename.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + 'nzb_import' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/nzb_import.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], + + 'user_login' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/user_login.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], 'flare' => [ 'driver' => 'flare', diff --git a/config/nntmux.php b/config/nntmux.php index 4dc9b1f26..7d1fbfd66 100644 --- a/config/nntmux.php +++ b/config/nntmux.php @@ -14,6 +14,7 @@ return [ 'admin_username' => env('ADMIN_USER', 'admin'), 'admin_password' => env('ADMIN_PASS', 'admin'), 'admin_email' => env('ADMIN_EMAIL', 'admin@example.com'), + 'crc_token' => env('CRC_TOKEN', null), 'multiprocessing_max_child_time' => env('NN_MULTIPROCESSING_MAX_CHILD_TIME', 1800), 'stream_fork_output' => env('STREAM_FORK_OUTPUT', false), 'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false), @@ -22,4 +23,6 @@ return [ 'btcpay_webhook_secret' => env('BTCPAY_SECRET'), 'tmp_unrar_path' => env('TEMP_UNRAR_PATH', storage_path('tmp/unrar/')), 'tmp_unzip_path' => env('TEMP_UNZIP_PATH', storage_path('tmp/unzip/')), + 'nzb_import_folder' => env('NZB_IMPORT_FOLDER'), + 'nzb_upload_folder' => env('NZB_UPLOAD_FOLDER'), ]; diff --git a/database/schema/mariadb-schema.sql b/database/schema/mariadb-schema.sql index 08123830d..b32320c6f 100644 --- a/database/schema/mariadb-schema.sql +++ b/database/schema/mariadb-schema.sql @@ -1218,7 +1218,6 @@ CREATE TABLE `users` ( `updated_at` timestamp NULL DEFAULT NULL, `verified` tinyint(1) NOT NULL DEFAULT 0, `verification_token` varchar(255) DEFAULT NULL, - `bad_user` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `ux_users_api_token` (`api_token`), KEY `ix_user_roles` (`roles_id`) @@ -1403,7 +1402,6 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (86,'2023_12_08_191 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (87,'2024_01_06_173518_create_payments_table',1); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (88,'2019_08_14_123627_create_poster_renames_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (89,'2019_08_15_145634_add_source_to_releases_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (90,'2019_10_03_112445_add_bad_user_to_users_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (91,'2024_01_11_203725_create_predb_crcs_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (92,'2024_01_12_193533_alter_filedate_column_predb_crcs_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (93,'2024_01_12_194256_add_back_timestamps_column_to__predb_crcs_table',2); diff --git a/resources/css/csp-safe.css b/resources/css/csp-safe.css index 9231d3117..8aa3f98b6 100644 --- a/resources/css/csp-safe.css +++ b/resources/css/csp-safe.css @@ -363,6 +363,38 @@ html.dark #tmuxForm select.select-other { width: 30px; } +/* Checkbox visibility improvements for dark mode */ +input[type="checkbox"].form-checkbox, +input[type="checkbox"].cart-checkbox { + border-width: 2px; +} + +@media (prefers-color-scheme: dark) { + input[type="checkbox"].form-checkbox, + input[type="checkbox"].cart-checkbox { + background-color: #374151; + border-color: #6b7280; + } + + input[type="checkbox"].form-checkbox:checked, + input[type="checkbox"].cart-checkbox:checked { + background-color: #3b82f6; + border-color: #3b82f6; + } +} + +html.dark input[type="checkbox"].form-checkbox, +html.dark input[type="checkbox"].cart-checkbox { + background-color: #374151; + border-color: #6b7280; +} + +html.dark input[type="checkbox"].form-checkbox:checked, +html.dark input[type="checkbox"].cart-checkbox:checked { + background-color: #3b82f6; + border-color: #3b82f6; +} + /* Admin Groups Management - Fade Out Animation */ .fade-out { transition: opacity 0.3s ease; @@ -471,6 +503,16 @@ table tbody tr:last-child { text-decoration: underline; } +/* Preview Modal Styles */ +#previewModal { + display: none; + z-index: 9999 !important; +} + +#previewModal:not(.hidden) { + display: flex; +} + .prose a:hover { color: #1d4ed8; } @@ -535,6 +577,146 @@ table tbody tr:last-child { margin-bottom: 16px; } +.confirmation-modal-icon { + width: 48px; + height: 48px; + background-color: #fee2e2; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16px; +} + +@media (prefers-color-scheme: dark) { + .confirmation-modal-icon { + background-color: #7f1d1d; + } +} + +html.dark .confirmation-modal-icon { + background-color: #7f1d1d; +} + +.confirmation-modal-icon i { + color: #ef4444; + font-size: 24px; +} + +@media (prefers-color-scheme: dark) { + .confirmation-modal-icon i { + color: #fca5a5; + } +} + +html.dark .confirmation-modal-icon i { + color: #fca5a5; +} + +.confirmation-modal-title { + font-size: 20px; + font-weight: 600; + color: #1f2937; +} + +@media (prefers-color-scheme: dark) { + .confirmation-modal-title { + color: #f3f4f6; + } +} + +html.dark .confirmation-modal-title { + color: #f3f4f6; +} + +.confirmation-modal-body { + color: #6b7280; + margin-bottom: 24px; + line-height: 1.6; +} + +@media (prefers-color-scheme: dark) { + .confirmation-modal-body { + color: #d1d5db; + } +} + +html.dark .confirmation-modal-body { + color: #d1d5db; +} + +.confirmation-modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.confirmation-modal-footer button { + padding: 10px 20px; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + font-size: 14px; +} + +.btn-cancel { + background-color: #f3f4f6; + color: #374151; +} + +.btn-cancel:hover { + background-color: #e5e7eb; +} + +@media (prefers-color-scheme: dark) { + .btn-cancel { + background-color: #374151; + color: #d1d5db; + } + + .btn-cancel:hover { + background-color: #4b5563; + } +} + +html.dark .btn-cancel { + background-color: #374151; + color: #d1d5db; +} + +html.dark .btn-cancel:hover { + background-color: #4b5563; +} + +.btn-confirm { + background-color: #ef4444; + color: white; +} + +.btn-confirm:hover { + background-color: #dc2626; +} + +@media (prefers-color-scheme: dark) { + .btn-confirm { + background-color: #dc2626; + } + + .btn-confirm:hover { + background-color: #b91c1c; + } +} + +html.dark .btn-confirm { + background-color: #dc2626; +} + +html.dark .btn-confirm:hover { + background-color: #b91c1c; +} + @keyframes fadeIn { from { opacity: 0; @@ -544,6 +726,15 @@ table tbody tr:last-child { } } +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + @keyframes slideUp { from { transform: translateY(20px); diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 3124e7bc5..d774c5404 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -34,6 +34,7 @@ document.addEventListener('DOMContentLoaded', function() { initProfileEdit(); initDetailsPageImageModal(); initAddToCart(); + initMoviesLayoutToggle(); }); // Event delegation for dynamically added elements @@ -986,6 +987,201 @@ function initMediainfoAndFilelist() { // Cart and Multi-select functionality function initCartFunctionality() { + // Global showConfirmation function for cart page + window.showConfirmation = function(message, onConfirm) { + const modal = document.createElement('div'); + modal.className = 'confirmation-modal'; + modal.innerHTML = ` +
+
+
+ +
+

Confirm Deletion

+
+
+ ${message} +
+ +
+ `; + + document.body.appendChild(modal); + + const cancelBtn = modal.querySelector('.btn-cancel'); + const confirmBtn = modal.querySelector('.btn-confirm'); + + function closeModal() { + modal.style.animation = 'fadeOut 0.2s ease-out'; + setTimeout(() => modal.remove(), 200); + } + + cancelBtn.addEventListener('click', closeModal); + modal.addEventListener('click', function(e) { + if (e.target === modal) { + closeModal(); + } + }); + + confirmBtn.addEventListener('click', function() { + closeModal(); + onConfirm(); + }); + + // Focus on cancel button by default + setTimeout(() => cancelBtn.focus(), 100); + + // ESC key to close + const escHandler = function(e) { + if (e.key === 'Escape') { + closeModal(); + document.removeEventListener('keydown', escHandler); + } + }; + document.addEventListener('keydown', escHandler); + }; + + // Cart page specific functionality + const checkAll = document.getElementById('check-all'); + const checkboxes = document.querySelectorAll('.cart-checkbox'); + + if (checkAll && checkboxes.length > 0) { + // Function to update the check-all checkbox state + function updateCheckAllState() { + const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length; + checkAll.checked = checkedCount === checkboxes.length; + checkAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; + } + + // Handle check-all checkbox change + checkAll.addEventListener('change', function() { + const isChecked = this.checked; + checkboxes.forEach(checkbox => { + checkbox.checked = isChecked; + }); + }); + + // Handle individual checkbox changes + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', updateCheckAllState); + }); + + // Initialize the check-all state on page load + updateCheckAllState(); + + // Download selected + document.querySelectorAll('.nzb_multi_operations_download_cart').forEach(btn => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + + const selected = Array.from(checkboxes) + .filter(cb => cb.checked) + .map(cb => cb.value); + + if (selected.length === 0) { + if (typeof showToast === 'function') { + showToast('Please select at least one item', 'error'); + } else { + alert('Please select at least one item'); + } + return; + } + + // Download all selected NZBs + selected.forEach(guid => { + window.open('/getnzb?id=' + guid, '_blank'); + }); + + if (typeof showToast === 'function') { + showToast('Downloading ' + selected.length + ' item(s)', 'success'); + } + }); + }); + + // Delete selected + document.querySelectorAll('.nzb_multi_operations_cartdelete').forEach(btn => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + + const selected = Array.from(checkboxes) + .filter(cb => cb.checked) + .map(cb => cb.value); + + if (selected.length === 0) { + if (typeof showToast === 'function') { + showToast('Please select at least one item', 'error'); + } else { + alert('Please select at least one item'); + } + return; + } + + showConfirmation( + `Are you sure you want to delete ${selected.length} item(s) from your cart?`, + function() { + // Delete via AJAX + fetch('/cart/delete/' + selected.join(','), { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Content-Type': 'application/json', + } + }) + .then(response => { + if (response.ok) { + if (typeof showToast === 'function') { + showToast('Items deleted successfully', 'success'); + } + setTimeout(() => window.location.reload(), 1000); + } else { + if (typeof showToast === 'function') { + showToast('Failed to delete items', 'error'); + } else { + alert('Failed to delete items'); + } + } + }) + .catch(error => { + console.error('Error:', error); + if (typeof showToast === 'function') { + showToast('Failed to delete items', 'error'); + } else { + alert('Failed to delete items'); + } + }); + } + ); + }); + }); + + // Individual delete confirmation + document.querySelectorAll('.cart-delete-link').forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + const releaseName = this.getAttribute('data-release-name'); + const deleteUrl = this.getAttribute('data-delete-url'); + + showConfirmation( + `Are you sure you want to remove ${releaseName} from your cart?`, + function() { + if (typeof showToast === 'function') { + showToast('Removing item from cart...', 'info'); + } + + setTimeout(() => { + window.location.href = deleteUrl; + }, 500); + } + ); + }); + }); + } + // Select all checkbox functionality const selectAllCheckbox = document.getElementById('chkSelectAll'); if (selectAllCheckbox) { @@ -1766,8 +1962,8 @@ function escapeHtml(text) { // TinyMCE Initialization for Admin Content Pages function initTinyMCE() { - // Only initialize if TinyMCE editor element exists - if (!document.getElementById('body')) { + // Only initialize if TinyMCE editor element exists (check for #body or .tinymce-editor) + if (!document.getElementById('body') && !document.querySelector('.tinymce-editor')) { return; } @@ -1782,6 +1978,7 @@ function initTinyMCE() { // Get API key from textarea data attribute or create meta tag const bodyTextarea = document.getElementById('body'); + const tinymceEditors = document.querySelectorAll('.tinymce-editor'); let apiKey = 'no-api-key'; // Try to get from existing meta tag first @@ -1794,6 +1991,14 @@ function initTinyMCE() { apiKey = window.NNTmuxConfig.tinymceApiKey; } else if (bodyTextarea && bodyTextarea.dataset.tinymceApiKey) { apiKey = bodyTextarea.dataset.tinymceApiKey; + } else if (tinymceEditors.length > 0) { + // Check if any tinymce-editor has the API key + for (let i = 0; i < tinymceEditors.length; i++) { + if (tinymceEditors[i].dataset.tinymceApiKey) { + apiKey = tinymceEditors[i].dataset.tinymceApiKey; + break; + } + } } // Create and append meta tag @@ -1838,7 +2043,7 @@ function initTinyMCE() { const apiKey = apiKeyMeta ? apiKeyMeta.content : 'no-api-key'; return { - selector: '#body', + selector: '#body, .tinymce-editor', height: 500, menubar: true, skin: darkMode ? 'oxide-dark' : 'oxide', @@ -1918,18 +2123,25 @@ function initTinyMCE() { function doInitTinyMCE() { // Initialize TinyMCE tinymce.init(getTinyMCEConfig()).then(function(editors) { - if (editors && editors[0]) { - console.log('TinyMCE initialized successfully'); + if (editors && editors.length > 0) { + console.log('TinyMCE initialized successfully for ' + editors.length + ' editor(s)'); - // Add form submission handler to sync content - const textarea = document.getElementById('body'); - if (textarea && textarea.form) { - textarea.form.addEventListener('submit', function(e) { - // Sync all TinyMCE editors before form submission - tinymce.triggerSave(); - console.log('TinyMCE content synced to textarea before form submission'); - }); - } + // Add form submission handler to sync content for all editors + editors.forEach(function(editor) { + const textarea = document.getElementById(editor.id); + if (textarea && textarea.form) { + // Remove any existing listeners to avoid duplicates + const form = textarea.form; + if (!form.hasAttribute('data-tinymce-handler')) { + form.addEventListener('submit', function(e) { + // Sync all TinyMCE editors before form submission + tinymce.triggerSave(); + console.log('TinyMCE content synced to textareas before form submission'); + }); + form.setAttribute('data-tinymce-handler', 'true'); + } + } + }); } }).catch(function(error) { console.error('TinyMCE initialization failed:', error); @@ -1939,17 +2151,27 @@ function initTinyMCE() { const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.attributeName === 'class') { - const editor = tinymce.get('body'); - if (editor) { - // Save current content - const content = editor.getContent(); - // Remove the editor - tinymce.remove('#body'); + // Get all active TinyMCE editors + const allEditors = tinymce.editors; + if (allEditors && allEditors.length > 0) { + // Save content from all editors + const editorContents = {}; + allEditors.forEach(function(editor) { + editorContents[editor.id] = editor.getContent(); + }); + + // Remove all editors + tinymce.remove(); + // Reinitialize with new theme tinymce.init(getTinyMCEConfig()).then(function(editors) { - // Restore content - if (editors && editors[0]) { - editors[0].setContent(content); + // Restore content to each editor + if (editors && editors.length > 0) { + editors.forEach(function(editor) { + if (editorContents[editor.id]) { + editor.setContent(editorContents[editor.id]); + } + }); } }); } @@ -1972,14 +2194,16 @@ function initTinyMCE() { }) .catch(function(error) { console.error('Failed to load TinyMCE:', error); - // Show error message to user - const bodyTextarea = document.getElementById('body'); - if (bodyTextarea && bodyTextarea.parentElement) { - const errorDiv = document.createElement('div'); - errorDiv.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded'; - errorDiv.innerHTML = 'TinyMCE editor failed to load. Please refresh the page or check your internet connection.'; - bodyTextarea.parentElement.insertBefore(errorDiv, bodyTextarea.nextSibling); - } + // Show error message to user for all TinyMCE textareas + const textareas = document.querySelectorAll('#body, .tinymce-editor'); + textareas.forEach(function(textarea) { + if (textarea && textarea.parentElement) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded'; + errorDiv.innerHTML = 'TinyMCE editor failed to load. Please refresh the page or check your internet connection.'; + textarea.parentElement.insertBefore(errorDiv, textarea.nextSibling); + } + }); }); } @@ -2308,17 +2532,19 @@ function initAdminDashboardCharts() { cpu24h: null, ram24h: null, cpu30d: null, - ram30d: null + ram30d: null, + downloadsMinute: null, + apiHitsMinute: null }; - // Downloads Chart + // Downloads Chart (Hourly) const downloadsCtx = document.getElementById('downloadsChart'); if (downloadsCtx) { const downloadsData = JSON.parse(downloadsCtx.getAttribute('data-chart-data') || '[]'); new Chart(downloadsCtx, { type: 'bar', data: { - labels: downloadsData.map(d => d.date), + labels: downloadsData.map(d => d.time || d.date), datasets: [{ label: 'Downloads', data: downloadsData.map(d => d.count), @@ -2364,7 +2590,10 @@ function initAdminDashboardCharts() { }, x: { ticks: { - color: textColor + color: textColor, + maxRotation: 45, + minRotation: 45, + maxTicksLimit: 24 }, grid: { display: false @@ -2375,14 +2604,14 @@ function initAdminDashboardCharts() { }); } - // API Hits Chart + // API Hits Chart (Hourly) const apiHitsCtx = document.getElementById('apiHitsChart'); if (apiHitsCtx) { const apiHitsData = JSON.parse(apiHitsCtx.getAttribute('data-chart-data') || '[]'); new Chart(apiHitsCtx, { type: 'line', data: { - labels: apiHitsData.map(d => d.date), + labels: apiHitsData.map(d => d.time || d.date), datasets: [{ label: 'API Hits', data: apiHitsData.map(d => d.count), @@ -2434,7 +2663,158 @@ function initAdminDashboardCharts() { }, x: { ticks: { - color: textColor + color: textColor, + maxRotation: 45, + minRotation: 45, + maxTicksLimit: 24 + }, + grid: { + color: gridColor, + drawBorder: false + } + } + } + } + }); + } + + // Downloads Per Minute Chart + const downloadsMinuteCtx = document.getElementById('downloadsMinuteChart'); + if (downloadsMinuteCtx) { + const downloadsMinuteData = JSON.parse(downloadsMinuteCtx.getAttribute('data-chart-data') || '[]'); + window.adminCharts.downloadsMinute = new Chart(downloadsMinuteCtx, { + type: 'line', + data: { + labels: downloadsMinuteData.map(d => d.time), + datasets: [{ + label: 'Downloads', + data: downloadsMinuteData.map(d => d.count), + backgroundColor: 'rgba(34, 197, 94, 0.1)', + borderColor: 'rgba(34, 197, 94, 1)', + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 2, + pointHoverRadius: 4, + pointBackgroundColor: 'rgba(34, 197, 94, 1)', + pointBorderColor: '#ffffff', + pointBorderWidth: 1, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: isDarkMode ? '#1f2937' : '#ffffff', + titleColor: textColor, + bodyColor: textColor, + borderColor: gridColor, + borderWidth: 1, + padding: 12, + displayColors: false, + callbacks: { + label: function(context) { + return 'Downloads: ' + context.parsed.y.toLocaleString(); + } + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: textColor, + precision: 0 + }, + grid: { + color: gridColor, + drawBorder: false + } + }, + x: { + ticks: { + color: textColor, + maxRotation: 45, + minRotation: 45, + maxTicksLimit: 12 + }, + grid: { + color: gridColor, + drawBorder: false + } + } + } + } + }); + } + + // API Hits Per Minute Chart + const apiHitsMinuteCtx = document.getElementById('apiHitsMinuteChart'); + if (apiHitsMinuteCtx) { + const apiHitsMinuteData = JSON.parse(apiHitsMinuteCtx.getAttribute('data-chart-data') || '[]'); + window.adminCharts.apiHitsMinute = new Chart(apiHitsMinuteCtx, { + type: 'line', + data: { + labels: apiHitsMinuteData.map(d => d.time), + datasets: [{ + label: 'API Hits', + data: apiHitsMinuteData.map(d => d.count), + backgroundColor: 'rgba(147, 51, 234, 0.1)', + borderColor: 'rgba(147, 51, 234, 1)', + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 2, + pointHoverRadius: 4, + pointBackgroundColor: 'rgba(147, 51, 234, 1)', + pointBorderColor: '#ffffff', + pointBorderWidth: 1, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: isDarkMode ? '#1f2937' : '#ffffff', + titleColor: textColor, + bodyColor: textColor, + borderColor: gridColor, + borderWidth: 1, + padding: 12, + displayColors: false, + callbacks: { + label: function(context) { + return 'API Hits: ' + context.parsed.y.toLocaleString(); + } + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: textColor, + precision: 0 + }, + grid: { + color: gridColor, + drawBorder: false + } + }, + x: { + ticks: { + color: textColor, + maxRotation: 45, + minRotation: 45, + maxTicksLimit: 12 }, grid: { color: gridColor, @@ -2850,6 +3230,30 @@ function startSystemMetricsAutoRefresh() { }); } + // Update user activity minute charts + function updateUserActivityMinutes() { + fetch('/admin/api/user-activity/minutes') + .then(response => response.json()) + .then(data => { + // Update Downloads Per Minute chart + if (window.adminCharts && window.adminCharts.downloadsMinute) { + window.adminCharts.downloadsMinute.data.labels = data.downloads.map(d => d.time); + window.adminCharts.downloadsMinute.data.datasets[0].data = data.downloads.map(d => d.count); + window.adminCharts.downloadsMinute.update('none'); + } + + // Update API Hits Per Minute chart + if (window.adminCharts && window.adminCharts.apiHitsMinute) { + window.adminCharts.apiHitsMinute.data.labels = data.api_hits.map(d => d.time); + window.adminCharts.apiHitsMinute.data.datasets[0].data = data.api_hits.map(d => d.count); + window.adminCharts.apiHitsMinute.update('none'); + } + }) + .catch(error => { + console.error('Error fetching user activity minutes:', error); + }); + } + // Initial update updateCurrentMetrics(); @@ -2858,6 +3262,11 @@ function startSystemMetricsAutoRefresh() { // Update historical charts every 5 minutes (to reduce load) setInterval(updateHistoricalCharts, 300000); + + // Update user activity minute charts every 60 seconds (1 minute) + if (window.adminCharts && (window.adminCharts.downloadsMinute || window.adminCharts.apiHitsMinute)) { + setInterval(updateUserActivityMinutes, 60000); + } } // Admin Groups Management @@ -3829,3 +4238,128 @@ window.confirmBulkAction = function(event) { return true; }; +// Movies Layout Toggle Functionality +function initMoviesLayoutToggle() { + const layoutToggle = document.getElementById('layoutToggle'); + const layoutToggleText = document.getElementById('layoutToggleText'); + const moviesGrid = document.getElementById('moviesGrid'); + + if (!layoutToggle || !layoutToggleText || !moviesGrid) { + return; // Not on movies page + } + + // Get saved layout preference from data attribute (from database) + // 1 = 1-column, 2 = 2-columns + let currentLayout = parseInt(moviesGrid.dataset.userLayout) || 2; + + // Apply the saved layout on page load + applyLayout(currentLayout); + + // Toggle layout on button click + layoutToggle.addEventListener('click', function() { + if (currentLayout === 2) { + currentLayout = 1; + } else { + currentLayout = 2; + } + + // Save preference to database via AJAX + fetch('/movies/update-layout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ + layout: currentLayout + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Apply layout after successful save + applyLayout(currentLayout); + } + }) + .catch(error => { + console.error('Error saving layout preference:', error); + // Still apply layout even if save failed + applyLayout(currentLayout); + }); + }); + + function applyLayout(layout) { + // Get all movie poster images and placeholder divs + const posterImages = moviesGrid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700'); + + // Get all release card containers + const releaseCardContainers = moviesGrid.querySelectorAll('.release-card-container'); + + if (layout === 1) { + // 1-column layout: larger images (w-48 h-72, original size) + moviesGrid.classList.remove('lg:grid-cols-2'); + moviesGrid.classList.add('grid-cols-1'); + layoutToggleText.textContent = '1 Column'; + layoutToggle.querySelector('i').className = 'fas fa-th-list mr-2'; + + // Update image sizes to larger + posterImages.forEach(el => { + el.classList.remove('w-32', 'h-48'); + el.classList.add('w-48', 'h-72'); + }); + + // Update release card layout for 1-column view - side by side on all screens + releaseCardContainers.forEach(container => { + // Change container to flex-row with items on sides + container.classList.remove('space-y-2'); + container.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3'); + + // Make info wrapper take available space + const infoWrapper = container.querySelector('.release-info-wrapper'); + if (infoWrapper) { + infoWrapper.classList.add('flex-1', 'min-w-0'); + } + + // Make actions wrapper stay on the right in a horizontal row + const actionsWrapper = container.querySelector('.release-actions'); + if (actionsWrapper) { + actionsWrapper.classList.remove('flex-wrap'); + actionsWrapper.classList.add('flex-shrink-0', 'flex-row', 'items-center'); + } + }); + } else { + // 2-column layout: smaller images (w-32 h-48) + moviesGrid.classList.remove('grid-cols-1'); + moviesGrid.classList.add('lg:grid-cols-2'); + layoutToggleText.textContent = '2 Columns'; + layoutToggle.querySelector('i').className = 'fas fa-th-large mr-2'; + + // Update image sizes to smaller + posterImages.forEach(el => { + el.classList.remove('w-48', 'h-72'); + el.classList.add('w-32', 'h-48'); + }); + + // Update release card layout for 2-column view - stacked vertically + releaseCardContainers.forEach(container => { + // Change back to stacked layout + container.classList.add('space-y-2'); + container.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3'); + + // Remove special styling from info wrapper + const infoWrapper = container.querySelector('.release-info-wrapper'); + if (infoWrapper) { + infoWrapper.classList.remove('flex-1', 'min-w-0'); + } + + // Restore normal flex-wrap for actions wrapper + const actionsWrapper = container.querySelector('.release-actions'); + if (actionsWrapper) { + actionsWrapper.classList.add('flex-wrap', 'items-center'); + actionsWrapper.classList.remove('flex-shrink-0', 'flex-row'); + } + }); + } + } +} + diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 6108ce9ce..c3bbcbbee 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -216,10 +216,10 @@

- Downloads (Last 7 Days) + Downloads (Last 7 Days - Hourly)

- +
@@ -227,10 +227,32 @@

- API Hits (Last 7 Days) + API Hits (Last 7 Days - Hourly)

- + +
+
+ + +
+

+ + Downloads (Last 60 Minutes) +

+
+ +
+
+ + +
+

+ + API Hits (Last 60 Minutes) +

+
+
diff --git a/resources/views/admin/movies/edit.blade.php b/resources/views/admin/movies/edit.blade.php index 5c1434291..ccf08192c 100644 --- a/resources/views/admin/movies/edit.blade.php +++ b/resources/views/admin/movies/edit.blade.php @@ -115,7 +115,7 @@ value="{{ $movie['imdbid'] ?? $movie->imdbid ?? '' }}" readonly class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 dark:text-gray-300"> - View on IMDB @@ -320,7 +320,7 @@ @foreach($movielist as $movie) - + {{ $movie->imdbid }} diff --git a/resources/views/admin/movies/index.blade.php b/resources/views/admin/movies/index.blade.php index f54e68e60..1a31919b3 100644 --- a/resources/views/admin/movies/index.blade.php +++ b/resources/views/admin/movies/index.blade.php @@ -86,7 +86,7 @@ @foreach($movielist as $movie) - + {{ $movie->imdbid }} diff --git a/resources/views/admin/releases/failed.blade.php b/resources/views/admin/releases/failed.blade.php index 4e587df01..660e0a9c3 100644 --- a/resources/views/admin/releases/failed.blade.php +++ b/resources/views/admin/releases/failed.blade.php @@ -112,7 +112,7 @@
- {{ \Carbon\Carbon::parse($release->adddate)->format('Y-m-d H:i') }} + {{ userDate($release->adddate, 'Y-m-d H:i') }}
@@ -120,7 +120,7 @@
- {{ \Carbon\Carbon::parse($release->postdate)->format('Y-m-d H:i') }} + {{ userDate($release->postdate, 'Y-m-d H:i') }}
diff --git a/resources/views/admin/releases/index.blade.php b/resources/views/admin/releases/index.blade.php index 8efe41576..e710464fa 100644 --- a/resources/views/admin/releases/index.blade.php +++ b/resources/views/admin/releases/index.blade.php @@ -70,10 +70,10 @@ {{ $release->totalpart ?? 0 }} - {{ \Carbon\Carbon::parse($release->adddate)->format('Y-m-d H:i') }} + {{ userDate($release->adddate, 'Y-m-d H:i') }} - {{ \Carbon\Carbon::parse($release->postdate)->format('Y-m-d H:i') }} + {{ userDate($release->postdate, 'Y-m-d H:i') }} {{ $release->grabs ?? 0 }} diff --git a/resources/views/admin/site/edit.blade.php b/resources/views/admin/site/edit.blade.php index eca8d20bb..d41240f62 100644 --- a/resources/views/admin/site/edit.blade.php +++ b/resources/views/admin/site/edit.blade.php @@ -106,9 +106,10 @@ - -

Text displayed in the terms and conditions page.

+ +

Text displayed in the terms and conditions page. Use the rich text editor to format your content.

@@ -521,24 +522,6 @@ - -
-

Path Settings

-
-
- - -

Path where NZB files are stored

-
-
- - -

Path where cover images are stored

-
-
-
diff --git a/resources/views/admin/site/stats.blade.php b/resources/views/admin/site/stats.blade.php index e85402fae..1f07d6221 100644 --- a/resources/views/admin/site/stats.blade.php +++ b/resources/views/admin/site/stats.blade.php @@ -51,42 +51,6 @@
@endif - - @if(!empty($topdownloads) && count($topdownloads) > 0) -
-
-
-
- -
-

Top Downloads

-
-
-
-
- @foreach($topdownloads as $index => $download) -
-
-
- {{ $index + 1 }} -
-
-

- {{ $download['searchname'] }} -

-
-
-
- - {{ number_format($download['grabs']) }} - -
-
- @endforeach -
-
-
- @endif @if(!empty($recent) && count($recent) > 0) @@ -182,7 +146,7 @@ @endif - @if(empty($topgrabs) && empty($topdownloads) && empty($recent) && empty($usersbymonth) && empty($usersbyrole)) + @if(empty($topgrabs) && empty($recent) && empty($usersbymonth) && empty($usersbyrole))
diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index 5653424b8..d780471c5 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -111,6 +111,7 @@ Host Country Verified + Bad User Created Actions diff --git a/resources/views/browse/index.blade.php b/resources/views/browse/index.blade.php index 6b6b3f5db..261ce5aae 100644 --- a/resources/views/browse/index.blade.php +++ b/resources/views/browse/index.blade.php @@ -48,6 +48,8 @@
Series List | + Trending TV + | Manage My Shows | RSS Feed @@ -194,7 +196,7 @@ @endif @if(!empty($result->postdate)) - Posted: {{ \Carbon\Carbon::parse($result->postdate)->format('M d, Y H:i') }} + Posted: {{ userDate($result->postdate, 'M d, Y H:i') }} @endif @if(!empty($result->fromname)) @@ -212,7 +214,7 @@ - {{ \Carbon\Carbon::parse($result->adddate)->diffForHumans() }} + {{ userDateDiffForHumans($result->adddate) }} {{ $result->size_formatted ?? number_format($result->size / 1073741824, 2) . ' GB' }} diff --git a/resources/views/cart/index.blade.php b/resources/views/cart/index.blade.php index 1580ec20e..d76d30e79 100644 --- a/resources/views/cart/index.blade.php +++ b/resources/views/cart/index.blade.php @@ -139,424 +139,3 @@
@endsection - -@push('styles') - -@endpush - -@push('scripts') - -@endpush - diff --git a/resources/views/console/index.blade.php b/resources/views/console/index.blade.php index 6295a83ef..e1b839226 100644 --- a/resources/views/console/index.blade.php +++ b/resources/views/console/index.blade.php @@ -184,7 +184,7 @@
- Added {{ \Carbon\Carbon::parse($result->adddate)->diffForHumans() }} + Added {{ userDateDiffForHumans($result->adddate) }}
@@ -194,8 +194,8 @@ @endif @if(!empty($result->postdate)) - - Posted: {{ \Carbon\Carbon::parse($result->postdate)->format('M d, Y H:i') }} + + Posted: {{ userDate($result->postdate, 'M d, Y H:i') }} @endif @if(!empty($result->fromname)) diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index 9f7ab89dc..0b3456750 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -229,7 +229,7 @@
TVDB
- + View on TVDB
@@ -880,7 +880,7 @@
Added
-
{{ \Carbon\Carbon::parse($release->adddate)->format('M d, Y H:i') }}
+
{{ userDate($release->adddate, 'M d, Y H:i') }}
Group
@@ -888,7 +888,7 @@
Posted
-
{{ \Carbon\Carbon::parse($release->postdate)->format('M d, Y H:i') }}
+
{{ userDate($release->postdate, 'M d, Y H:i') }}
@if(!empty($release->fromname))
@@ -908,7 +908,7 @@
IMDB
- + View on IMDB
diff --git a/resources/views/layouts/main.blade.php b/resources/views/layouts/main.blade.php index 9eeffe156..d7fa910ce 100644 --- a/resources/views/layouts/main.blade.php +++ b/resources/views/layouts/main.blade.php @@ -54,7 +54,7 @@