Compare commits

..

95 Commits

Author SHA1 Message Date
af739e3c88 Remove old comments repo in composer.json 2026-01-18 18:49:59 +01:00
273ed65a8d Remove laravel sail 2026-01-18 18:44:59 +01:00
ccfd5b996b Replace captcha system 2026-01-18 18:37:08 +01:00
e5ef197ed6 Add user roles system 2026-01-16 23:14:47 +01:00
c0be2e294a Refactor routes 2026-01-16 23:01:34 +01:00
823a284fbc Fix PHP 8.4 deprecation warning 2026-01-11 16:39:14 +01:00
67e601d0c4 Add ability to properly set locale (session/account) 2026-01-11 16:33:13 +01:00
7e4ebd91ad Remove vluzrmos/language-detector package 2026-01-11 16:31:16 +01:00
4dc5dee2b9 Update dependencies 2026-01-11 15:46:33 +01:00
5310908b0c Update login button on mobile 2026-01-11 00:21:05 +01:00
4b05b3db6d Fix cache not flushed after comment delete by admin 2026-01-10 23:24:31 +01:00
df47a926e4 Fix comment mass delete 2026-01-10 23:21:29 +01:00
1e9e95f35f Fix admin comment moderation 2026-01-10 22:35:35 +01:00
2aa76baafd Fix account deletion anonymizing comments 2026-01-10 22:35:21 +01:00
aa50bb1f72 Merge pull request 'Replace Comment System' (#4) from comment-system into main
Reviewed-on: #4
2026-01-10 21:16:55 +00:00
dfedf4058e Fix rate limit and make it more strict (1 message in 5 minutes) 2026-01-10 22:15:50 +01:00
268e3eb4c2 Add Notification for comments 2026-01-10 22:00:09 +01:00
ab61574956 Fix comment depth chain check 2026-01-10 21:59:53 +01:00
81038b6c26 Add id to comment, so it can autoscroll to that notification 2026-01-10 21:59:08 +01:00
e949ba955a Add rate limiter to comment system 2026-01-10 21:04:26 +01:00
819e2fde27 Misc changes 2026-01-10 20:33:35 +01:00
3259e2197b Update design comments home page 2026-01-10 19:45:19 +01:00
b133db0573 Add likes to comments 2026-01-10 19:41:23 +01:00
41c34e6d89 Fix style 2026-01-10 19:15:32 +01:00
db6da608aa Add comments to home page 2026-01-10 19:11:28 +01:00
13b70fdf23 Misc changes 2026-01-10 18:55:53 +01:00
cfd6af59fb Add Profile Comment Search (Livewire) 2026-01-10 18:55:47 +01:00
7810cd53fb Add comments to Hentai 2026-01-10 18:54:48 +01:00
871028930b Migrate existing comments 2026-01-10 16:41:06 +01:00
6ce0255764 Remove ring offset 2026-01-10 15:45:52 +01:00
e136e8e1b6 Refresh on delete 2026-01-10 15:45:41 +01:00
a3b66b483b Add admin and donator badge 2026-01-10 15:34:05 +01:00
4c2a6024d7 Add dark mode 2026-01-10 15:27:37 +01:00
5f575024e2 Add Livewire comment system 2026-01-10 15:02:14 +01:00
67f5d0db8b Remove laravelista/comments 2026-01-10 14:06:00 +01:00
571bf4584c Remove view_count from meilisearch 2026-01-10 12:27:54 +01:00
d7dc96e11c Don't trigger update on view_count increase 2026-01-10 12:27:24 +01:00
58426b6e4e Add studio filter on download page closes #1 2026-01-09 22:51:15 +01:00
53b600daea Fix certain livewire components not working 2026-01-09 22:32:16 +01:00
224cdbcdc5 Save mute state of player - fixes #2 2026-01-09 22:28:53 +01:00
972d3d0aa4 Add zhentube.com to footer 2026-01-09 22:20:02 +01:00
8f7f012c14 Merge pull request 'Replace Auth System' (#3) from auth-redo into main
Reviewed-on: #3
2026-01-09 15:11:36 +00:00
c0b068de58 Misc changes 2026-01-09 13:01:53 +01:00
51c67bb797 Improve Migrations & Fix Discord Avatars 2026-01-09 10:45:41 +01:00
3d78f9e524 Optionally update discord avatar on login 2026-01-08 22:17:00 +01:00
2d28a37463 Don't set password on new account with oauth 2026-01-08 20:03:24 +01:00
ac853920ee Fix delete account function & delete modal 2026-01-08 19:28:44 +01:00
fb3722036a Add ability to set custom avatar 2026-01-08 18:47:31 +01:00
ab4e7c7999 Update Mail Design (Password Reset) 2026-01-08 17:21:03 +01:00
8f99718058 Allow changing email, username and password 2026-01-08 16:14:35 +01:00
2029af334c Fix Signup redirect 2026-01-08 16:13:54 +01:00
b1c48830c4 Remove nickname 2026-01-08 16:13:43 +01:00
e100f3bf23 Remove Public Profile Page (because usernames are not unique) 2026-01-07 20:28:40 +01:00
c13d443696 Add discord patreon check 2026-01-07 19:04:51 +01:00
8e7a56f559 Add discord oauth 2026-01-07 18:17:46 +01:00
30777a6968 Login / Register Design 2026-01-07 17:03:57 +01:00
256af435ad Add Auth System (Breeze) 2026-01-07 16:04:23 +01:00
e972f8db41 Rename column names 2026-01-07 12:54:10 +01:00
98d36d6018 Fix database structure 2026-01-07 12:41:11 +01:00
7eea8285ca Remove old breeze auth controllers 2026-01-07 12:10:49 +01:00
9e8efbbe05 Remove jakyeru/larascord 2026-01-07 12:02:02 +01:00
5461606857 DMCA 2025-12-19 22:13:50 +01:00
9ca2f73714 Admin: Add comments overview for moderation 2025-10-29 15:59:43 +01:00
59d63abd79 Admin: Add ability to delete all coments from user 2025-10-28 16:20:40 +01:00
efb3e4197b Use cached values for view and like count in nav search 2025-10-26 21:25:50 +01:00
735dd693ca [Expiremental] Add ability to disable blur effects 2025-10-24 23:15:52 +02:00
36f0126a21 Only display last 28 days on stats page (excluding current day) 2025-10-23 21:13:07 +02:00
50d8704560 Fix mobile home page offset 2025-10-23 19:33:30 +02:00
7e382ffe1d Redesign nav search 2025-10-23 15:39:07 +02:00
6a25fd2700 Use meilisearch in nav search 2025-10-23 15:30:32 +02:00
71bcf277f6 Add type icons to downloads search page 2025-10-15 19:33:37 +02:00
6c44d83e6b Add type filter to download search page 2025-10-15 18:50:49 +02:00
444feac1e0 Use redirect for random button to save database queries 2025-10-09 12:55:24 +02:00
c034c94db5 Add padding to comments on home page 2025-10-09 12:38:54 +02:00
ca52584da9 Cache random hentai row for 5 minutes 2025-10-09 12:38:17 +02:00
dca4924e9a Add v2 re-release discord webhook 2025-10-08 20:01:06 +02:00
9ad7c7afc2 Fix incorrect cache key 2025-10-08 19:50:55 +02:00
6c8d34b030 Fix vite build 2025-10-08 19:38:54 +02:00
35a0d61437 Add censored discord notification 2025-10-08 18:32:08 +02:00
725a441d9e Add discord notification for v2 releases 2025-10-08 17:41:10 +02:00
96a9019eba Temp fix call on null for removed downloads (dmca) 2025-10-08 17:24:14 +02:00
e2497867f0 Display login message 2025-10-08 17:05:31 +02:00
a6c47cf2e6 Remove unused Controller 2025-10-08 16:53:51 +02:00
fffa320c08 Add monthly views chart 2025-10-08 16:52:40 +02:00
2880547f3e Fix mobile offset episode thumbnail / cover 2025-10-08 13:00:16 +02:00
b5dbf24e91 Update node packages 2025-10-08 12:42:35 +02:00
43cf5db6c9 Update composer packages 2025-10-08 12:04:12 +02:00
9e0042b09f Add stats and current git commit to footer 2025-10-08 11:44:40 +02:00
e717b2bd07 Temp fix call on null for removed downloads (dmca) 2025-10-07 23:46:17 +02:00
bbbc4ac329 Add domain 2025-10-07 22:38:20 +02:00
55739a21b5 Add hentai list api endoint 2025-10-02 21:26:09 +02:00
bc193c7141 Remove torrents system 2025-09-22 13:34:52 +02:00
c0a22e875c Fix duplicate download entries and add constraint 2025-09-22 00:18:22 +02:00
4697073e9f Replace old download search with new patreon file search 2025-09-21 23:49:58 +02:00
9be33db14f Improve patreon filebrowser 2025-09-21 23:10:53 +02:00
193 changed files with 9109 additions and 5498 deletions

View File

@@ -57,3 +57,8 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
SCOUT_QUEUE=true
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey

View File

@@ -8,7 +8,7 @@
# Install PHP # Install PHP
sudo add-apt-repository ppa:ondrej/php sudo add-apt-repository ppa:ondrej/php
apt update && apt upgrade apt update && apt upgrade
apt install php8.4 php8.4-xml php8.4-mysql php8.4-gd php8.4-zip php8.4-curl php8.4-mbstring apt install php8.3 php8.3-xml php8.3-mysql php8.3-gd php8.3-zip php8.3-curl php8.3-mbstring
# Install NodeJS # Install NodeJS
curl -sL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh curl -sL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
@@ -22,12 +22,16 @@ mv composer.phar composer
# Install NGINX (skip for local dev) # Install NGINX (skip for local dev)
apt install nginx apt install nginx
apt install php8.4-fpm apt install php8.3-fpm
# Install MariaDB # Install MariaDB
apt install mariadb-server apt install mariadb-server
sudo mysql_secure_installation sudo mysql_secure_installation
# Install Meilisearch
echo "deb [trusted=yes] https://apt.fury.io/meilisearch/ /" | sudo tee /etc/apt/sources.list.d/fury.list
sudo apt update && sudo apt install meilisearch
# Clone Repo # Clone Repo
cd /var/www cd /var/www
git clone https://gitea.hstream.moe/w33b/hstream.git git clone https://gitea.hstream.moe/w33b/hstream.git

View File

@@ -30,9 +30,9 @@ class AutoStats extends Command
*/ */
public function handle() public function handle()
{ {
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->forceDelete(); PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->delete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->forceDelete(); PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->delete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->forceDelete(); PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->delete();
$this->comment('Automated Purge Stats Complete'); $this->comment('Automated Purge Stats Complete');
} }

View File

@@ -35,6 +35,6 @@ class ResetUserDownloads extends Command
// Clear old downloads which have expired // Clear old downloads which have expired
UserDownload::where('created_at', '<=', Carbon::now()->subHour(6)) UserDownload::where('created_at', '<=', Carbon::now()->subHour(6))
->forceDelete(); ->delete();
} }
} }

11
app/Enums/UserRole.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum UserRole: string
{
case ADMINISTRATOR = 'admin';
case MODERATOR = 'moderator';
case SUPPORTER = 'supporter';
case BANNED = 'banned';
}

View File

@@ -2,6 +2,7 @@
namespace App\Helpers; namespace App\Helpers;
use App\Models\Comment;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\PopularMonthly; use App\Models\PopularMonthly;
@@ -60,13 +61,6 @@ class CacheHelper
}); });
} }
public static function getTotalMonthlyViews()
{
return Cache::remember("total_monthly_view_count", now()->addMinutes(60), function () {
return PopularMonthly::count();
});
}
public static function getPopularAllTime(bool $guest) public static function getPopularAllTime(bool $guest)
{ {
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
@@ -133,7 +127,7 @@ class CacheHelper
public static function getLatestComments() public static function getLatestComments()
{ {
return Cache::remember("latest_comments", now()->addMinutes(60), function () { return Cache::remember("latest_comments", now()->addMinutes(60), function () {
return DB::table('comments')->latest()->take(10)->get(); return Comment::latest()->take(10)->get();
}); });
} }
} }

19
app/Helpers/GitHelper.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Cache;
class GitHelper
{
public static function shortCommit()
{
return Cache::remember("git_commit", now()->addMinutes(60), function () {
try {
return trim(exec('git rev-parse --short HEAD'));
} catch (\Exception $e) {
return 'unknown';
}
});
}
}

View File

@@ -41,7 +41,7 @@ class AlertController extends Controller
*/ */
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse public function delete(int $alert_id): \Illuminate\Http\RedirectResponse
{ {
Alert::where('id', $alert_id)->forceDelete(); Alert::where('id', $alert_id)->delete();
cache()->forget('alerts'); cache()->forget('alerts');

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
class CommentsController extends Controller
{
/**
* Display Comments Page.
*/
public function index(): \Illuminate\View\View
{
return view('admin.comments.index');
}
}

View File

@@ -42,7 +42,11 @@ class EpisodeController extends Controller
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true); $this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert // Discord Alert
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($referenceEpisode->title." - ".$episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episode->slug, 'release'); DiscordReleaseNotification::dispatch($episode->slug, 'release');
}
cache()->flush(); cache()->flush();
@@ -76,6 +80,10 @@ class EpisodeController extends Controller
DiscordReleaseNotification::dispatch($episode->slug, 'updateUHD'); DiscordReleaseNotification::dispatch($episode->slug, 'updateUHD');
} }
if ($request->has('v2')) {
DiscordReleaseNotification::dispatch($episode->slug, 'v2');
}
cache()->flush(); cache()->flush();
return to_route('hentai.index', [ return to_route('hentai.index', [

View File

@@ -72,10 +72,14 @@ class ReleaseController extends Controller
$releasedEpisodes[] = $episode->slug; $releasedEpisodes[] = $episode->slug;
} }
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored');
} else {
foreach ($releasedEpisodes as $slug) { foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert // Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release'); DiscordReleaseNotification::dispatch($slug, 'release');
} }
}
cache()->flush(); cache()->flush();

View File

@@ -105,7 +105,7 @@ class SiteBackgroundController extends Controller
DB::beginTransaction(); DB::beginTransaction();
$bg = SiteBackground::where('id', $id)->firstOrFail(); $bg = SiteBackground::where('id', $id)->firstOrFail();
$bg->forceDelete(); $bg->delete();
$resolutions = [1440, 1080, 720, 640]; $resolutions = [1440, 1080, 720, 640];
try { try {

View File

@@ -38,7 +38,7 @@ class SubtitleController extends Controller
// Clear everything // Clear everything
foreach($episode->subtitles as $sub) { foreach($episode->subtitles as $sub) {
$sub->forceDelete(); $sub->delete();
} }
if (! $request->input('subtitles')) { if (! $request->input('subtitles')) {

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Torrents;
use Illuminate\Http\Request;
class TorrentController extends Controller
{
/**
* Display Add Torrent Page.
*/
public function index(int $hentai_id): \Illuminate\View\View
{
return view('admin.add-torrent', [
'hentai_id' => $hentai_id
]);
}
/**
* Add Torrent.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'hentai_id' => 'required|exists:hentais,id',
'torrent_url' => 'required|string|max:256',
'torrent_episodes' => 'required|string|max:8',
]);
Torrents::create([
'hentai_id' => $request->hentai_id,
'torrent_url' => $request->torrent_url,
'episodes' => $request->torrent_episodes,
]);
return to_route('download.search');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Models\User; use App\Models\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -31,11 +32,11 @@ class UserController extends Controller
switch ($validated['action']) { switch ($validated['action']) {
case 'ban': case 'ban':
$user->update(['is_banned' => 1]); $user->addRole(UserRole::BANNED);
alert()->success('Banned', 'User has been banned.'); alert()->success('Banned', 'User has been banned.');
break; break;
case 'unban': case 'unban':
$user->update(['is_banned' => 0]); $user->removeRole(UserRole::BANNED);
alert()->success('Unbanned', 'User has been unbanned.'); alert()->success('Unbanned', 'User has been unbanned.');
break; break;
default: default:

View File

@@ -8,6 +8,8 @@ use App\Models\Episode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use GrantHolle\Altcha\Rules\ValidAltcha;
class DownloadApiController extends Controller class DownloadApiController extends Controller
{ {
/** /**
@@ -16,11 +18,12 @@ class DownloadApiController extends Controller
public function getDownload(Request $request) public function getDownload(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'episode_id' => 'required', 'episode_id' => ['required'],
'captcha' => 'required|captcha' 'captcha' => ['required', new ValidAltcha],
]); ]);
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail(); $episode = Episode::where('id', $request->input('episode_id'))
->firstOrFail();
// Increase download count, as we assume the user // Increase download count, as we assume the user
// downloads after submitting the captcha // downloads after submitting the captcha

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Hentai;
use App\Models\PopularMonthly;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use App\Http\Controllers\Controller;
class HentaiApiController extends Controller
{
/**
* Get a list of all hentai with it's episodes
*/
public function index()
{
// Cache for 10 minutes
$data = Cache::remember('api_hentai_list', now()->addMinutes(10), function () {
return Hentai::with('episodes')
->orderBy('created_at', 'desc')
->get()
->map(function ($hentai) {
return [
'title' => $hentai->episodes[0]->title,
'title_jpn' => $hentai->episodes[0]->title_jpn,
'slug' => $hentai->slug,
'episodes' => $hentai->episodes->map(function ($ep) {
return [
'episode' => $ep->episode,
'slug' => $ep->slug,
];
}),
];
});
});
return response()->json($data);
}
/**
* Get monthly views by day for stats
*/
public function getMonthlyViews()
{
// Cache for 60 minutes
$data = Cache::remember('api_monthly_views', now()->addMinutes(60), function () {
return PopularMonthly::selectRaw('DATE(created_at) as date, COUNT(*) as count')
->whereDate('created_at', '<', Carbon::today())
->whereDate('created_at', '>=', Carbon::today()->subDays(28))
->groupBy('date')
->orderBy('date', 'asc')
->get();
});
return response()->json($data);
}
}

View File

@@ -30,7 +30,7 @@ class StreamApiController extends Controller
'poster' => $episode->gallery()->first()->image_url, 'poster' => $episode->gallery()->first()->image_url,
'interpolated' => $episode->interpolated, 'interpolated' => $episode->interpolated,
'interpolated_uhd' => $episode->interpolated_uhd, 'interpolated_uhd' => $episode->interpolated_uhd,
'stream_url' => $episode->url, 'stream_url' => $episode->dmca_takedown ? 'stuff/dmca' : $episode->url,
'stream_domains' => config('hstream.stream_domain'), 'stream_domains' => config('hstream.stream_domain'),
'asia_stream_domains' => config('hstream.asia_stream_domain'), 'asia_stream_domains' => config('hstream.asia_stream_domain'),
'extra_subtitles' => $subtitles 'extra_subtitles' => $subtitles

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -29,7 +28,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME); return redirect()->intended(route('home.index', absolute: false));
} }
/** /**

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -36,6 +35,6 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time()); $request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME); return redirect()->intended(route('home.index', absolute: false));
} }
} }

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Enums\UserRole;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Facades\Socialite;
class DiscordAuthController extends Controller
{
/**
* Redirect to Discord
*/
public function redirect(): RedirectResponse
{
return Socialite::driver('discord')->redirect();
}
/**
* Callback received from Discord
*/
public function callback(): RedirectResponse
{
$discordUser = Socialite::driver('discord')->user();
$user = User::where('discord_id', $discordUser->id)->first();
if (!$user) {
// link by email if it already exists
$user = User::where('email', $discordUser->email)->first();
if ($user) {
$user->update([
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
]);
} else {
// Create new user
$user = User::create([
'name' => $discordUser->name,
'email' => $discordUser->email,
'discord_id' => $discordUser->id,
'discord_avatar' => $discordUser->avatar,
'password' => null,
]);
}
}
$this->checkDiscordAvatar($discordUser, $user);
$this->checkDiscordRoles($user);
Auth::login($user, true);
return redirect()->route('home.index');
}
/**
* Check if discord avatar changed
*/
private function checkDiscordAvatar(\Laravel\Socialite\Contracts\User $socialiteUser, User $user): void
{
if ($socialiteUser->avatar != $user->discord_avatar) {
$user->update(['discord_avatar' => $socialiteUser->avatar]);
}
}
/**
* Check Discord Roles if user is Patreon member
*/
private function checkDiscordRoles(User $user): void
{
// Should not ever happen
if (!$user->discord_id) {
return;
}
$guildId = config('discord.guild_id');
$response = Http::withToken(config('discord.discord_bot_token'), 'Bot')
->timeout(5)
->get("https://discord.com/api/v10/guilds/{$guildId}/members/{$user->discord_id}");
// User is not in the guild
if ($response->status() === 404) {
$user->removeRole(UserRole::SUPPORTER);
return;
}
// Something else failed
if ($response->failed()) {
Log::warning('Discord role check failed', [
'user_id' => $user->id,
'discord_id' => $user->discord_id,
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$discordRoles = $response->json('roles', []);
$patreonRoles = config('discord.patreon_roles', []);
// If intersect of array is empty, then the user doesn't have the role
$hasSupporterRole = !empty(array_intersect($discordRoles, $patreonRoles));
if (!$hasSupporterRole) {
// Remove role if not found
$user->removeRole(UserRole::SUPPORTER);
return;
}
$user->addRole(UserRole::SUPPORTER);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -15,7 +14,7 @@ class EmailVerificationNotificationController extends Controller
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
if ($request->user()->hasVerifiedEmail()) { if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME); return redirect()->intended(route('home.index', absolute: false));
} }
$request->user()->sendEmailVerificationNotification(); $request->user()->sendEmailVerificationNotification();

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -16,7 +15,7 @@ class EmailVerificationPromptController extends Controller
public function __invoke(Request $request): RedirectResponse|View public function __invoke(Request $request): RedirectResponse|View
{ {
return $request->user()->hasVerifiedEmail() return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME) ? redirect()->intended(route('home.index', absolute: false))
: view('auth.verify-email'); : view('auth.verify-email');
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -40,7 +41,7 @@ class NewPasswordController extends Controller
// database. Otherwise we will parse the error and return the response. // database. Otherwise we will parse the error and return the response.
$status = Password::reset( $status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'), $request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) { function (User $user) use ($request) {
$user->forceFill([ $user->forceFill([
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'remember_token' => Str::random(60), 'remember_token' => Str::random(60),

View File

@@ -15,6 +15,20 @@ class PasswordController extends Controller
*/ */
public function update(Request $request): RedirectResponse public function update(Request $request): RedirectResponse
{ {
// If user logged in with Discord and has not yet a password, allow to set password
if ($request->user()->discord_id && is_null($request->user()->password))
{
$validated = $request->validateWithBag('updatePassword', [
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
$validated = $request->validateWithBag('updatePassword', [ $validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'], 'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'], 'password' => ['required', Password::defaults(), 'confirmed'],

View File

@@ -4,25 +4,17 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\View\View;
use GrantHolle\Altcha\Rules\ValidAltcha;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
* *
@@ -32,8 +24,9 @@ class RegisteredUserController extends Controller
{ {
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
'altcha' => ['required', new ValidAltcha],
]); ]);
$user = User::create([ $user = User::create([
@@ -46,6 +39,6 @@ class RegisteredUserController extends Controller
Auth::login($user); Auth::login($user);
return redirect(RouteServiceProvider::HOME); return redirect(route('home.index', absolute: false));
} }
} }

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -16,13 +15,13 @@ class VerifyEmailController extends Controller
public function __invoke(EmailVerificationRequest $request): RedirectResponse public function __invoke(EmailVerificationRequest $request): RedirectResponse
{ {
if ($request->user()->hasVerifiedEmail()) { if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1'); return redirect()->intended(route('home.index', absolute: false).'?verified=1');
} }
if ($request->user()->markEmailAsVerified()) { if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user())); event(new Verified($request->user()));
} }
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1'); return redirect()->intended(route('home.index', absolute: false).'?verified=1');
} }
} }

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Models\Contact; use App\Models\Contact;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use GrantHolle\Altcha\Rules\ValidAltcha;
class ContactController extends Controller class ContactController extends Controller
{ {
/** /**
@@ -25,7 +27,7 @@ class ContactController extends Controller
'email' => 'required|max:50', 'email' => 'required|max:50',
'message' => 'required|max:1000', 'message' => 'required|max:1000',
'subject' => 'required|max:50', 'subject' => 'required|max:50',
'captcha' => 'required|captcha', 'altcha' => ['required', new ValidAltcha],
]); ]);
$contact = new Contact(); $contact = new Contact();
@@ -37,9 +39,4 @@ class ContactController extends Controller
return back()->with('status', 'contact-submitted'); return back()->with('status', 'contact-submitted');
} }
public function reloadCaptcha(): \Illuminate\Http\JsonResponse
{
return response()->json(['captcha'=> captcha_img()]);
}
} }

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DownloadsController extends Controller
{
/**
* Display Downloads Page.
*/
public function index(Request $request): \Illuminate\View\View
{
$user = $request->user();
if (!$user->is_patreon) {
abort(404);
}
return view('search.download-patreon');
}
}

View File

@@ -52,6 +52,22 @@ class HomeController extends Controller
return view('auth.banned'); return view('auth.banned');
} }
/**
* Redirects to a random Hentai episode
* Done due to performance reasons
*/
public function random(): \Illuminate\Http\RedirectResponse
{
$random = Episode::inRandomOrder()
->limit(1)
->pluck('slug')
->first();
return redirect()->route('hentai.index', [
'title' => $random,
]);
}
/** /**
* Display Search Page. * Display Search Page.
*/ */
@@ -87,7 +103,6 @@ class HomeController extends Controller
'viewCount' => CacheHelper::getTotalViewCount(), 'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(), 'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'hentaiCount' => CacheHelper::getTotalHentaiCount(), 'hentaiCount' => CacheHelper::getTotalHentaiCount(),
'monthlyCount' => CacheHelper::getTotalMonthlyViews()
]); ]);
} }
@@ -96,11 +111,13 @@ class HomeController extends Controller
*/ */
public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse
{ {
if(! in_array($request->language, config('lang-detector.languages'))) { abort_unless(in_array($request->language, config('app.supported_locales'), true), 404);
return redirect()->back();
}
Cookie::queue(Cookie::forever('locale', $request->language)); session(['locale' => $request->language]);
if (Auth::check()) {
Auth::user()->update(['locale' => $request->language]);
}
return redirect()->back(); return redirect()->back();
} }

View File

@@ -32,7 +32,7 @@ class NotificationController extends Controller
->where('id', $request->input('id')) ->where('id', $request->input('id'))
->firstOrFail(); ->firstOrFail();
$notification->forceDelete(); $notification->delete();
return redirect()->back(); return redirect()->back();
} }

View File

@@ -105,13 +105,11 @@ class PlaylistController extends Controller
$user = $request->user(); $user = $request->user();
$playlist = Playlist::where('user_id', $user->id)->where('id', $playlist_id)->firstOrFail(); $playlist = Playlist::where('user_id', $user->id)
->where('id', $playlist_id)
->firstOrFail();
// Delete Playlist Episodes $playlist->delete();
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
// Delete Playlist
$playlist->forceDelete();
return to_route('profile.playlists'); return to_route('profile.playlists');
} }
@@ -128,8 +126,14 @@ class PlaylistController extends Controller
], 404); ], 404);
} }
$playlist = Playlist::where('user_id', $request->user()->id)->where('id', (int) $request->input('playlist'))->firstOrFail(); $playlist = Playlist::where('user_id', $request->user()->id)
PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', (int) $request->input('episode'))->forceDelete(); ->where('id', (int) $request->input('playlist'))
->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)
->where('episode_id', (int) $request->input('episode'))
->delete();
$this->playlistService->reorderPositions($playlist); $this->playlistService->reorderPositions($playlist);
return response()->json([ return response()->json([

View File

@@ -3,12 +3,18 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Episode; use App\Models\Episode;
use App\Models\User;
use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Laravel\Facades\Image;
use Conner\Tagging\Model\Tag; use Conner\Tagging\Model\Tag;
@@ -17,7 +23,7 @@ class ProfileController extends Controller
/** /**
* Display the user page. * Display the user page.
*/ */
public function index(Request $request): \Illuminate\View\View public function index(Request $request): View
{ {
return view('profile.index', [ return view('profile.index', [
'user' => $request->user(), 'user' => $request->user(),
@@ -27,7 +33,7 @@ class ProfileController extends Controller
/** /**
* Display the user's settings form. * Display the user's settings form.
*/ */
public function settings(Request $request): \Illuminate\View\View public function settings(Request $request): View
{ {
$example = Episode::where('title', 'Succubus Yondara Gibo ga Kita!?')->first(); $example = Episode::where('title', 'Succubus Yondara Gibo ga Kita!?')->first();
@@ -37,10 +43,33 @@ class ProfileController extends Controller
]); ]);
} }
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
// Fill everything except the image
$user->fill($request->safe()->except('image'));
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
if ($request->hasFile('image')) {
$this->storeAvatar($request->file('image'), $user);
}
$user->save();
return Redirect::route('profile.settings')->with('status', 'profile-updated');
}
/** /**
* Display the user's watched page. * Display the user's watched page.
*/ */
public function watched(Request $request): \Illuminate\View\View public function watched(Request $request): View
{ {
return view('profile.watched', [ return view('profile.watched', [
'user' => $request->user(), 'user' => $request->user(),
@@ -50,7 +79,7 @@ class ProfileController extends Controller
/** /**
* Display the user's comments page. * Display the user's comments page.
*/ */
public function comments(Request $request): \Illuminate\View\View public function comments(Request $request): View
{ {
return view('profile.comments', [ return view('profile.comments', [
'user' => $request->user(), 'user' => $request->user(),
@@ -60,7 +89,7 @@ class ProfileController extends Controller
/** /**
* Display the user's likes page. * Display the user's likes page.
*/ */
public function likes(Request $request): \Illuminate\View\View public function likes(Request $request): View
{ {
return view('profile.likes', [ return view('profile.likes', [
'user' => $request->user(), 'user' => $request->user(),
@@ -70,7 +99,7 @@ class ProfileController extends Controller
/** /**
* Update user settings. * Update user settings.
*/ */
public function saveSettings(Request $request): \Illuminate\Http\RedirectResponse public function saveSettings(Request $request): RedirectResponse
{ {
$user = $request->user(); $user = $request->user();
$user->search_design = $request->input('searchDesign') == 'thumbnail'; $user->search_design = $request->input('searchDesign') == 'thumbnail';
@@ -84,7 +113,7 @@ class ProfileController extends Controller
/** /**
* Update user tag blacklist. * Update user tag blacklist.
*/ */
public function saveBlacklist(Request $request): \Illuminate\Http\RedirectResponse public function saveBlacklist(Request $request): RedirectResponse
{ {
$user = $request->user(); $user = $request->user();
$tags = json_decode($request->input('tags')); $tags = json_decode($request->input('tags'));
@@ -112,19 +141,60 @@ class ProfileController extends Controller
*/ */
public function destroy(Request $request): \Illuminate\Http\RedirectResponse public function destroy(Request $request): \Illuminate\Http\RedirectResponse
{ {
$user = $request->user();
// Verify password if user has password
if (!is_null($user->password)) {
$request->validateWithBag('userDeletion', [ $request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'], 'password' => ['required', 'current_password'],
]); ]);
}
$user = $request->user(); // Update comments to deleted user
DB::table('comments')->where('user_id', '=', $user->id)->update(['user_id' => 1]);
// Delete Profile Picture
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
Auth::logout(); Auth::logout();
$user->delete(); $user->delete();
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
cache()->flush();
return Redirect::to('/'); return Redirect::to('/');
} }
/**
* Store custom user avatar.
*/
protected function storeAvatar(\Illuminate\Http\UploadedFile $file, User $user): void
{
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/avatars")) {
Storage::disk('public')->makeDirectory("/images/avatars");
}
// Delete old avatar if it exists
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$filename = "images/avatars/{$user->id}.webp";
$image = Image::read($file->getRealPath())
->cover(128, 128)
->toWebp(quality: 85);
Storage::disk('public')->put($filename, $image);
$user->avatar = $filename;
}
} }

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Watched;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
/**
* Display User Page.
*/
public function index(string $username): \Illuminate\View\View
{
$user = User::where('username', $username)
->select('id', 'username', 'global_name', 'avatar', 'created_at', 'is_patreon')
->firstOrFail();
return view('user.index', [
'user' => $user,
]);
}
/**
* Delete User.
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$user = User::where('id', $request->user()->id)->firstOrFail();
// Delete Playlist
$playlists = Playlist::where('user_id', $user->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $user->id)->update(['commenter_id' => 1]);
$user->forceDelete();
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return redirect('/');
}
}

View File

@@ -37,6 +37,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\IsBanned::class, \App\Http\Middleware\IsBanned::class,
\App\Http\Middleware\SetLocale::class,
], ],
'api' => [ 'api' => [
@@ -58,6 +59,7 @@ class Kernel extends HttpKernel
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'auth.admin' => \App\Http\Middleware\IsAdmin::class, 'auth.admin' => \App\Http\Middleware\IsAdmin::class,
'auth.moderator' => \App\Http\Middleware\IsModerator::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,

View File

@@ -1,28 +1,14 @@
<?php namespace app\Http\Middleware; <?php namespace app\Http\Middleware;
use App\Enums\UserRole;
use Closure; use Closure;
use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class IsAdmin { class IsAdmin {
/**
* The Guard implementation.
*
* @var Guard
*/
protected $auth;
/**
* Create a new filter instance.
*
* @param Guard $auth
* @return void
*/
public function __construct(Guard $auth)
{
$this->auth = $auth;
}
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
@@ -30,15 +16,14 @@ class IsAdmin {
* @param \Closure $next * @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next) public function handle(Request $request, Closure $next): Response
{ {
if( ! $this->auth->user()->is_admin) if(Auth::check() && Auth::user()->hasRole(UserRole::ADMINISTRATOR))
{ {
session()->flash('error_msg','This resource is restricted to Administrators!');
return redirect()->route('home.index');
}
return $next($request); return $next($request);
} }
session()->flash('error_msg','This resource is restricted to Administrators!');
return redirect()->route('home.index');
}
} }

View File

@@ -1,8 +1,11 @@
<?php namespace app\Http\Middleware; <?php namespace app\Http\Middleware;
use App\Enums\UserRole;
use Closure; use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Contracts\Auth\Guard; use Symfony\Component\HttpFoundation\Response;
class IsBanned { class IsBanned {
@@ -13,9 +16,9 @@ class IsBanned {
* @param \Closure $next * @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next) public function handle(Request $request, Closure $next): Response
{ {
if(auth()->check() && auth()->user()->is_banned == 1) if(Auth::check() && Auth::user()->hasRole(UserRole::BANNED))
{ {
Auth::logout(); Auth::logout();
$request->session()->invalidate(); $request->session()->invalidate();

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use App\Enums\UserRole;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class IsModerator
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && Auth::user()->hasRole(UserRole::MODERATOR))
{
return $next($request);
}
session()->flash('error_msg','This resource is restricted to Administrators!');
return redirect()->route('home.index');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 1. Logged-in user preference
if (Auth::check() && Auth::user()->locale) {
App::setLocale(Auth::user()->locale);
return $next($request);
}
// 2. Session (guest or user override)
if (session()->has('locale') && in_array($request->language, config('app.supported_locales'), true)) {
App::setLocale(session('locale'));
return $next($request);
}
// 3. Browser language
$locale = $request->getPreferredLanguage(config('app.supported_locales'));
if ($locale) {
App::setLocale($locale);
}
return $next($request);
}
}

View File

@@ -9,6 +9,8 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use GrantHolle\Altcha\Rules\ValidAltcha;
class LoginRequest extends FormRequest class LoginRequest extends FormRequest
{ {
/** /**
@@ -22,13 +24,14 @@ class LoginRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string> * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
'email' => ['required', 'string', 'email'], 'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
'altcha' => ['required', new ValidAltcha],
]; ];
} }
@@ -80,6 +83,6 @@ class LoginRequest extends FormRequest
*/ */
public function throttleKey(): string public function throttleKey(): string
{ {
return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip()); return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
} }
} }

View File

@@ -11,13 +11,26 @@ class ProfileUpdateRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string> * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
'name' => ['string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)], 'image' => [
'nullable',
'image',
'mimes:jpg,png,jpeg,webp,gif',
'max:8192'
],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
]; ];
} }
} }

View File

@@ -32,19 +32,29 @@ class DiscordReleaseNotification implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
// Wait 2s to avoid Discord API Rate limit switch($this->messageType)
sleep(2); {
case 'release':
if ($this->messageType == 'release') {
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/".$this->slug); DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
} break;
case 'release-censored':
if ($this->messageType == 'update') { # Because Discord TOS
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release: ".$this->slug." - *No link here because of* :pLoli:");
break;
case 'update':
# 1080p 48fps added
DiscordAlert::to('update')->message("<@&1283518462584426598> (´• ω •`)ノ Added **48fps** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug); DiscordAlert::to('update')->message("<@&1283518462584426598> (´• ω •`)ノ Added **48fps** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
} break;
case 'updateUHD':
if ($this->messageType == 'updateUHD') { # 4k 48fps added
DiscordAlert::to('update')->message("<@&1326860920902778963> (´• ω •`)ノ Added **48fps 4k** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug); DiscordAlert::to('update')->message("<@&1326860920902778963> (´• ω •`)ノ Added **48fps 4k** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
case 'v2':
# v2 re-release
DiscordAlert::to('rerelease')->message("<@&1425505303075754035> (´• ω •`)ノ **v2 Re-**Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
break;
default:
break;
} }
} }
} }

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Livewire;
use App\Models\Comment;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Support\Facades\DB;
class AdminCommentSearch extends Component
{
use WithPagination;
public $search = '';
public $userSearch = '';
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingUserSearch(): void
{
$this->resetPage();
}
public function deleteComment($commentId)
{
$comment = Comment::where('id', (int) $commentId)->firstOrFail();
$comment->delete();
cache()->flush();
}
public function render()
{
$comments = Comment::when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%$this->search%"))
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($query) => $query->where('name', 'LIKE', "%{$this->userSearch}%")))
->orderBy('created_at', 'DESC')
->paginate(12);
return view('livewire.admin-comment-search', [
'comments' => $comments
]);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Livewire; namespace App\Livewire;
use App\Enums\UserRole;
use App\Models\Comment;
use App\Models\User; use App\Models\User;
use Livewire\Component; use Livewire\Component;
@@ -16,7 +18,7 @@ class AdminUserSearch extends Component
public $search = ''; public $search = '';
#[Url(history: true)] #[Url(history: true)]
public $filtered = ['true']; public $discordId = '';
#[Url(history: true)] #[Url(history: true)]
public $patreon = []; public $patreon = [];
@@ -24,15 +26,23 @@ class AdminUserSearch extends Component
#[Url(history: true)] #[Url(history: true)]
public $banned = []; public $banned = [];
public function deleteUserComments(int $userID)
{
$user = User::where('id', $userID)
->firstOrFail();
Comment::where('user_id', $user->id)
->delete();
cache()->flush();
}
public function render() public function render()
{ {
$users = User::when($this->filtered !== [], fn ($query) => $query->where('id', '>=', 10000)) $users = User::when($this->patreon !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::SUPPORTER->value))
->when($this->patreon !== [], fn ($query) => $query->where('is_patreon', 1)) ->when($this->banned !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::BANNED->value))
->when($this->banned !== [], fn ($query) => $query->where('is_banned', 1)) ->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->when($this->search !== '', fn ($query) => $query->where(function($query) { ->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
$query->where('username', 'like', '%'.$this->search.'%')
->orWhere('global_name', 'like', '%'.$this->search.'%');
}))
->paginate(20); ->paginate(20);
return view('livewire.admin-user-search', [ return view('livewire.admin-user-search', [

166
app/Livewire/Comment.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
namespace App\Livewire;
use App\Models\User;
use App\Models\Episode;
use App\Notifications\CommentNotification;
use Livewire\Component;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Maize\Markable\Models\Like;
class Comment extends Component
{
use AuthorizesRequests;
public $comment;
public $isReplying = false;
public $likeCount = 0;
public $liked = false;
public $replyState = [
'body' => ''
];
public $isEditing = false;
public $editState = [
'body' => ''
];
protected $listeners = [
'refresh' => '$refresh'
];
protected $validationAttributes = [
'replyState.body' => 'reply'
];
public function updatedIsEditing($isEditing)
{
if (! $isEditing) {
return;
}
$this->editState = [
'body' => $this->comment->body
];
}
public function editComment()
{
$this->authorize('update', $this->comment);
$this->comment->update($this->editState);
$this->isEditing = false;
}
public function deleteComment()
{
$this->authorize('destroy', $this->comment);
$this->comment->delete();
$this->dispatch('refresh');
}
public function postReply()
{
if (!($this->comment->depth() < 2)) {
$this->addError('replyState.body', "Too many sub comments.");
return;
}
$user = auth()->user();
$rateLimitKey = "send-comment:{$user->id}";
$rateLimitMinutes = 60 * 5; // 5 minutes
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('replyState.body', "Too many comments. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
$this->validate([
'replyState.body' => 'required'
]);
$reply = $this->comment->children()->make($this->replyState);
$reply->user()->associate($user);
$reply->commentable()->associate($this->comment->commentable);
$reply->save();
// Notify if Episode and if not the same user
if ($reply->commentable_type == Episode::class && $user->id !== $reply->parent->user->id) {
$episode = Episode::where('id', $reply->commentable_id)
->firstOrFail();
$url = route('hentai.index', ['title' => $episode->slug]);
$reply->parent->user->notify(
new CommentNotification(
"{$user->name} replied to your comment.",
Str::limit($reply->body, 50),
"{$url}#comment-{$reply->id}"
)
);
}
$this->replyState = [
'body' => ''
];
$this->isReplying = false;
$this->dispatch('refresh')->self();
}
public function like()
{
if (! Auth::check()) {
return;
}
Like::toggle($this->comment, User::where('id', Auth::user()->id)->firstOrFail());
Cache::forget('commentLikes'.$this->comment->id);
if ($this->liked) {
$this->liked = false;
$this->likeCount--;
return;
}
$this->liked = true;
$this->likeCount++;
}
public function mount()
{
if (Auth::check()) {
$this->likeCount = $this->comment->likeCount();
$this->liked = Like::has($this->comment, User::where('id', Auth::user()->id)->firstOrFail());
}
}
public function render()
{
return view('livewire.comment');
}
}

71
app/Livewire/Comments.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Support\Facades\RateLimiter;
class Comments extends Component
{
use WithPagination;
public $model;
public $newCommentState = [
'body' => ''
];
protected $validationAttributes = [
'newCommentState.body' => 'comment'
];
protected $listeners = [
'refresh' => '$refresh'
];
public function postComment()
{
$this->validate([
'newCommentState.body' => 'required'
]);
$user = auth()->user();
$rateLimitKey = "send-comment:{$user->id}";
$rateLimitMinutes = 60 * 5; // 5 minutes
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('newCommentState.body', "Too many comments. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
$comment = $this->model->comments()->make($this->newCommentState);
$comment->user()->associate($user);
$comment->save();
$this->newCommentState = [
'body' => ''
];
$this->resetPage();
}
public function render()
{
$comments = $this->model
->comments()
->with('user', 'children.user', 'children.children')
->parent()
->latest()
->paginate(50);
return view('livewire.comments', [
'comments' => $comments
]);
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Hentai;
use Livewire\Component;
use Livewire\WithPagination;
class Downloads extends Component
{
use WithPagination;
public $search;
public $order = 'az';
public $withTorrents;
protected $queryString = [
'search' => ['except' => '', 'as' => 's'],
'withTorrents' => ['withTorrents' => '', 'as' => 'withTorrents'],
'order' => ['except' => '', 'as' => 'order'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$orderby = 'slug';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'slug';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'slug';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$hentai = Hentai::with('episodes')
->when($this->search != '', fn ($query) => $query->whereHas('episodes', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%')))
->when($this->withTorrents != '', fn ($query) => $query->whereHas('torrents'))
->orderBy($orderby, $orderdirection)
->paginate(10);
return view('livewire.downloads', [
'hentai' => $hentai,
'query' => $this->search,
]);
}
}

View File

@@ -10,7 +10,6 @@ use Livewire\Component;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class DownloadsFree extends Component class DownloadsFree extends Component
{ {
@@ -51,7 +50,7 @@ class DownloadsFree extends Component
// Check timestamp // Check timestamp
if (Carbon::parse($alreadyDownloaded->created_at)->addHours(6) <= Carbon::now()) { if (Carbon::parse($alreadyDownloaded->created_at)->addHours(6) <= Carbon::now()) {
// Already expired // Already expired
$alreadyDownloaded->forceDelete(); $alreadyDownloaded->delete();
return; return;
} }

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Downloads;
use Livewire\Component;
use Livewire\WithPagination;
class DownloadsPatreon extends Component
{
use WithPagination;
public $fileSearch;
public $order = 'created_at_desc';
protected $queryString = [
'fileSearch' => ['except' => '', 'as' => 'fS'],
'order' => ['except' => '', 'as' => 'order'],
];
public function updatingFileSearch()
{
$this->resetPage();
}
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'url';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'url';
$orderdirection = 'desc';
break;
case 'created_at_desc':
$orderby = 'created_at';
$orderdirection = 'desc';
break;
case 'created_at_asc':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
case 'size_asc':
$orderby = 'size';
$orderdirection = 'asc';
break;
case 'size_desc':
$orderby = 'size';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$downloads = Downloads::when($this->fileSearch != '', fn ($query) => $query->where('url', 'like', '%'.$this->fileSearch.'%'))
->where('size', '>', 0)
->where(fn ($q) => $q->where('type', '=', 'UHD')->orWhere('type', '=', 'UHDi'))
->orderBy($orderby, $orderdirection)
->paginate(20);
return view('livewire.downloads-patreon', [
'downloads' => $downloads,
'query' => $this->fileSearch,
]);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Livewire;
use App\Models\Downloads;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
class DownloadsSearch extends Component
{
use WithPagination;
#[Url(history: true)]
public $fileSearch;
public $order = 'created_at_desc';
public $options = [
'FHD' => true,
'FHD 48fps' => true,
];
public $isOpen = false;
#[Url(history: true)]
public $studios = [];
public $studiosCopy = [];
// To toggle individual option selection
public function toggleOption($option)
{
$this->options[$option] = !$this->options[$option];
$this->resetPage();
}
// To toggle dropdown visibility
public function toggleDropdown()
{
$this->isOpen = !$this->isOpen;
}
protected $queryString = [
'fileSearch' => ['except' => '', 'as' => 'fS'],
'order' => ['except' => '', 'as' => 'order'],
];
public function updatingFileSearch()
{
$this->resetPage();
}
public function applyFilters(): void
{
$this->studiosCopy = $this->studios;
$this->resetPage();
}
public function revertFilters(): void
{
$this->studios = $this->studiosCopy;
}
// Map the selected options to database types
private function getSelectedTypes()
{
$types = [];
// Map the options to their corresponding database values
foreach ($this->options as $label => $selected) {
if ($selected) {
if ($label === 'FHD') {
$types[] = 'FHD';
} elseif ($label === 'FHD 48fps') {
$types[] = 'FHDi';
} elseif ($label === 'UHD' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
$types[] = 'UHD';
} elseif ($label === 'UHD 48fps' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
$types[] = 'UHDi';
}
}
}
return $types;
}
public function clicked($downloadId)
{
$download = Downloads::find($downloadId);
if (!$download) {
return;
}
$download->count++;
$download->save();
cache()->forget("episode_{$download->episode->id}_download_{$download->type}");
}
public function mount()
{
if (!auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
return;
}
// Add patreon options
$this->options['UHD'] = true;
$this->options['UHD 48fps'] = true;
}
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'url';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'url';
$orderdirection = 'desc';
break;
case 'created_at_desc':
$orderby = 'created_at';
$orderdirection = 'desc';
break;
case 'created_at_asc':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
case 'size_asc':
$orderby = 'size';
$orderdirection = 'asc';
break;
case 'size_desc':
$orderby = 'size';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$downloads = Downloads::when($this->fileSearch != '', fn ($query) => $query->where('url', 'like', '%'.$this->fileSearch.'%'))
->whereIn('type', $this->getSelectedTypes())
->when($this->studios !== [], fn ($q) => $q->whereHas('episode', fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); })))
->whereNotNull('size')
->orderBy($orderby, $orderdirection)
->paginate(20);
return view('livewire.downloads-search', [
'downloads' => $downloads,
'query' => $this->fileSearch,
'studiocount' => is_array($this->studios) ? count($this->studios) : 0,
]);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Gallery;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -18,22 +17,15 @@ class NavLiveSearch extends Component
public function render() public function render()
{ {
$episodes = []; $episodes = [];
$randomimage = null;
if ($this->navSearch != '') { if ($this->navSearch != '') {
$episodes = Episode::with('gallery')->where('title', 'like', '%'.$this->navSearch.'%') $episodes = Episode::search($this->navSearch)
->orWhere('title_jpn', 'like', '%'.$this->navSearch.'%') ->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->when(Auth::guest(), fn ($query) => $query->withoutTags(['loli', 'shota'])) ->take(7)
->take(10)
->get(); ->get();
$randomimage = Gallery::all()
->random(1)
->first();
} }
return view('livewire.nav-live-search', [ return view('livewire.nav-live-search', [
'episodes' => $episodes, 'episodes' => $episodes,
'randomimage' => $randomimage,
'query' => $this->navSearch, 'query' => $this->navSearch,
'hide' => empty($this->navSearch), 'hide' => empty($this->navSearch),
]); ]);

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Livewire;
use App\Models\Comment;
use Livewire\Component;
use Livewire\WithPagination;
class UserComments extends Component
{
use WithPagination;
public $model;
public $commentSearch;
public $order = 'created_at_desc';
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'created_at_desc':
$orderby = 'created_at';
$orderdirection = 'desc';
break;
case 'created_at_asc':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$comments = Comment::where('user_id', $this->model->id)
->when($this->commentSearch != '', fn ($query) => $query->where('body', 'like', '%'.$this->commentSearch.'%'))
->orderBy($orderby, $orderdirection)
->paginate(10);
return view('livewire.user-comments', [
'comments' => $comments
]);
}
}

76
app/Models/Comment.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models;
use App\Models\Presenters\CommentPresenter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
class Comment extends Model
{
use HasFactory, SoftDeletes, Markable;
protected static $marks = [
Like::class
];
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'body'
];
public function presenter()
{
return new CommentPresenter($this);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeParent(Builder $builder)
{
$builder->whereNull('parent_id');
}
public function children()
{
return $this->hasMany(Comment::class, 'parent_id')->oldest();
}
public function commentable()
{
return $this->morphTo();
}
public function parent()
{
return $this->hasOne(Comment::class, 'id', 'parent_id');
}
// Recursevly calculates how deep the nesting is
public function depth(): int
{
return $this->parent
? $this->parent->depth() + 1
: 0;
}
/**
* Get cached like count
*/
public function likeCount(): int
{
return cache()->remember('commentLikes' . $this->id, 300, fn() => $this->likes->count());
}
}

View File

@@ -8,7 +8,7 @@ use App\Models\PopularWeekly;
use App\Models\PopularDaily; use App\Models\PopularDaily;
use Conner\Tagging\Taggable; use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable; use Laravel\Scout\Searchable;
use Maize\Markable\Markable; use Maize\Markable\Markable;
use Maize\Markable\Models\Like; use Maize\Markable\Models\Like;
@@ -17,6 +17,7 @@ use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -24,13 +25,41 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Episode extends Model implements Sitemapable class Episode extends Model implements Sitemapable
{ {
use Commentable, Markable, Taggable; use Markable, Taggable;
use HasFactory; use HasFactory;
use Searchable;
protected static $marks = [ protected static $marks = [
Like::class Like::class
]; ];
/**
* Get the name of the index associated with the model.
*/
public function searchableAs(): string
{
return 'episodes_index';
}
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray()
{
return [
'title' => $this->title,
'title_search' => $this->title_search,
'title_jpn' => $this->title_jpn,
'slug' => $this->slug,
'description' => $this->description,
'tags' => $this->tagNames(),
'release_date' => $this->release_date,
'created_at' => $this->created_at,
];
}
/** /**
* Get the studio for the Hentai. * Get the studio for the Hentai.
*/ */
@@ -74,10 +103,11 @@ class Episode extends Model implements Sitemapable
/** /**
* Increment View Count. * Increment View Count.
*/ */
public function incrementViewCount(): bool public function incrementViewCount(): void
{ {
$this->view_count++; DB::table('episodes')
return $this->save(); ->where('id', $this->id)
->update(['view_count' => $this->view_count + 1]);
} }
/** /**
@@ -130,6 +160,11 @@ class Episode extends Model implements Sitemapable
return cache()->remember('episodeComments' . $this->id, 300, fn() => $this->comments->count()); return cache()->remember('episodeComments' . $this->id, 300, fn() => $this->comments->count());
} }
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function getProblematicTags(): string public function getProblematicTags(): string
{ {
$problematicTags = ['Gore', 'Scat', 'Horror']; $problematicTags = ['Gore', 'Scat', 'Horror'];

View File

@@ -10,11 +10,10 @@ use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Conner\Tagging\Taggable; use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable;
class Hentai extends Model implements Sitemapable class Hentai extends Model implements Sitemapable
{ {
use Commentable, Taggable; use Taggable;
use HasFactory; use HasFactory;
/** /**
@@ -32,16 +31,16 @@ class Hentai extends Model implements Sitemapable
return $this->hasMany(Episode::class, 'hentai_id'); return $this->hasMany(Episode::class, 'hentai_id');
} }
public function torrents()
{
return $this->hasMany(Torrents::class, 'hentai_id');
}
public function title(): String public function title(): String
{ {
return $this->episodes->first()->title; return $this->episodes->first()->title;
} }
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
/** /**
* Has a Gallery. * Has a Gallery.
*/ */

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Presenters;
use App\Models\Comment;
use Illuminate\Support\Str;
class CommentPresenter
{
public $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
public function markdownBody()
{
return Str::of($this->comment->body)->markdown([
'html_input' => 'strip',
]);
}
public function relativeCreatedAt()
{
return $this->comment->created_at->diffForHumans();
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Torrents extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'hentai_id',
'torrent_url',
'episodes',
];
}

View File

@@ -2,20 +2,20 @@
namespace App\Models; namespace App\Models;
//use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Jakyeru\Larascord\Traits\InteractsWithDiscord;
use Laravelista\Comments\Commenter;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class User extends Authenticatable class User extends Authenticatable
{ {
use HasApiTokens, HasFactory, Notifiable, InteractsWithDiscord, Commenter; use HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -23,22 +23,13 @@ class User extends Authenticatable
* @var string[] * @var string[]
*/ */
protected $fillable = [ protected $fillable = [
'id', 'name',
'username',
'global_name',
'discriminator',
'email', 'email',
'avatar', 'password',
'verified',
'banner',
'banner_color',
'accent_color',
'locale', 'locale',
'mfa_enabled', // Discord
'premium_type', 'discord_id',
'public_flags', 'discord_avatar',
'roles',
'is_banned',
]; ];
/** /**
@@ -47,6 +38,7 @@ class User extends Authenticatable
* @var array * @var array
*/ */
protected $hidden = [ protected $hidden = [
'password',
'remember_token', 'remember_token',
]; ];
@@ -56,24 +48,21 @@ class User extends Authenticatable
* @var array * @var array
*/ */
protected $casts = [ protected $casts = [
'id' => 'integer', // Laravel defaults
'username' => 'string', 'email_verified_at' => 'datetime',
'global_name' => 'string', 'password' => 'hashed',
'discriminator' => 'string', // Other
'name' => 'string',
'email' => 'string', 'email' => 'string',
'avatar' => 'string',
'verified' => 'boolean',
'banner' => 'string',
'banner_color' => 'string',
'accent_color' => 'string',
'locale' => 'string', 'locale' => 'string',
'mfa_enabled' => 'boolean', 'roles' => 'array',
'premium_type' => 'integer',
'public_flags' => 'integer',
'roles' => 'json',
'tag_blacklist' => 'array', 'tag_blacklist' => 'array',
// Discord
'discord_id' => 'integer',
'discord_avatar' => 'string',
]; ];
/** /**
* Has Many Playlists. * Has Many Playlists.
*/ */
@@ -101,8 +90,74 @@ class User extends Authenticatable
/** /**
* Has Many Comments. * Has Many Comments.
*/ */
public function comments()
{
return $this->hasMany(Comment::class, 'user_id');
}
/**
* Get Comment Count.
*/
public function commentCount(): int public function commentCount(): int
{ {
return DB::table('comments')->where('commenter_id', $this->id)->count(); return cache()->remember('userComments' . $this->id, 300, fn() => $this->comments->count());
}
/**
* Returns the user avatar image url.
*/
public function getAvatar(): string
{
if ($this->discord_id && $this->discord_avatar && !$this->avatar)
{
return "https://external-content.duckduckgo.com/iu/?u={$this->discord_avatar}";
}
if ($this->avatar)
{
return Storage::url($this->avatar);
}
return asset('images/default-avatar.webp');
}
/**
* Check if user has a specific role
*/
public function hasRole(UserRole $role): bool
{
return in_array($role->value, $this->roles ?? [], true);
}
/**
* Add Role to User
*/
public function addRole(UserRole $role): void
{
if ($this->hasRole($role)) {
return;
}
// Get all current roles
$roles = $this->roles ?? [];
// Add new role
$roles[] = $role->value;
$this->roles = $roles;
$this->save();
}
/**
* Remove Role from User
*/
public function removeRole(UserRole $role): void
{
if (!$this->hasRole($role)) {
return;
}
$this->roles = array_diff($this->roles, [$role->value]);
$this->save();
} }
} }

View File

@@ -1,56 +0,0 @@
<?php
namespace Laravelista\Comments;
use Laravelista\Comments\Comment;
class CommentPolicy
{
/**
* Can user create the comment
*
* @param $user
* @return bool
*/
public function create($user) : bool
{
return true;
}
/**
* Can user delete the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function delete($user, Comment $comment) : bool
{
return ($user->getKey() == $comment->commenter_id) || $user->is_admin;
}
/**
* Can user update the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function update($user, Comment $comment) : bool
{
return $user->getKey() == $comment->commenter_id;
}
/**
* Can user reply to the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function reply($user, Comment $comment) : bool
{
return $user->getKey() != $comment->commenter_id;
}
}

View File

@@ -1,139 +0,0 @@
<?php
namespace Laravelista\Comments;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use App\Notifications\CommentNotification;
use App\Models\User;
use App\Models\Episode;
class CommentService
{
/**
* Handles creating a new comment for given model.
* @return mixed the configured comment-model
*/
public function store(Request $request)
{
// If guest commenting is turned off, authorize this action.
if (Config::get('comments.guest_commenting') == false) {
Gate::authorize('create-comment', Comment::class);
}
// Define guest rules if user is not logged in.
if (!Auth::check()) {
$guest_rules = [
'guest_name' => 'required|string|max:255',
'guest_email' => 'required|string|email|max:255',
];
}
// Merge guest rules, if any, with normal validation rules.
Validator::make($request->all(), array_merge($guest_rules ?? [], [
'commentable_type' => 'required|string',
'commentable_id' => 'required|string|min:1',
'message' => 'required|string'
]))->validate();
$model = $request->commentable_type::findOrFail($request->commentable_id);
$commentClass = Config::get('comments.model');
$comment = new $commentClass;
if (!Auth::check()) {
$comment->guest_name = $request->guest_name;
$comment->guest_email = $request->guest_email;
} else {
$comment->commenter()->associate(Auth::user());
}
$comment->commentable()->associate($model);
$comment->comment = $request->message;
$comment->approved = !Config::get('comments.approval_required');
$comment->save();
return $comment;
}
/**
* Handles updating the message of the comment.
* @return mixed the configured comment-model
*/
public function update(Request $request, Comment $comment)
{
Gate::authorize('edit-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$comment->update([
'comment' => $request->message
]);
return $comment;
}
/**
* Handles deleting a comment.
* @return mixed the configured comment-model
*/
public function destroy(Comment $comment): void
{
Gate::authorize('delete-comment', $comment);
if (Config::get('comments.soft_deletes') == true) {
$comment->delete();
} else {
$comment->forceDelete();
}
}
/**
* Handles creating a reply "comment" to a comment.
* @return mixed the configured comment-model
*/
public function reply(Request $request, Comment $comment)
{
Gate::authorize('reply-to-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$commentClass = Config::get('comments.model');
$reply = new $commentClass;
$reply->commenter()->associate(Auth::user());
$reply->commentable()->associate($comment->commentable);
$reply->parent()->associate($comment);
$reply->comment = $request->message;
$reply->approved = !Config::get('comments.approval_required');
$reply->save();
// Notify
if ($comment->commentable_type == 'App\Models\Episode') {
$episode = Episode::where('id', $comment->commentable_id)->firstOrFail();
$url = '/hentai/' . $episode->slug . '#comment-' . $reply->id;
$user = Auth::user();
$username = $user->global_name ?? $user->username;
$parentCommentUser = User::where('id', $comment->commenter_id)->firstOrFail();
$parentCommentUser->notify(
new CommentNotification(
"{$username} replied to your comment.",
Str::limit($reply->comment, 50),
$url
)
);
}
return $reply;
}
}

View File

@@ -1,155 +0,0 @@
<?php
namespace Jakyeru\Larascord\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Jakyeru\Larascord\Http\Requests\StoreUserRequest;
use Jakyeru\Larascord\Services\DiscordService;
use RealRashid\SweetAlert\Facades\Alert;
class DiscordController extends Controller
{
/**
* Handles the Discord OAuth2 login.
*/
public function handle(StoreUserRequest $request): RedirectResponse | JsonResponse
{
// Making sure the "guilds" scope was added to .env if there are any guilds specified in "larascord.guilds".
if (count(config('larascord.guilds'))) {
if (!in_array('guilds', explode('&', config('larascord.scopes')))) {
return $this->throwError('missing_guilds_scope');
}
}
// Getting the accessToken from the Discord API.
try {
$accessToken = (new DiscordService())->getAccessTokenFromCode($request->get('code'));
} catch (\Exception $e) {
return $this->throwError('invalid_code', $e);
}
// Get the user from the Discord API.
try {
$user = (new DiscordService())->getCurrentUser($accessToken);
$user->setAccessToken($accessToken);
} catch (\Exception $e) {
return $this->throwError('authorization_failed', $e);
}
// Making sure the user has an email if the email scope is set.
if (in_array('email', explode('&', config('larascord.scopes')))) {
if (empty($user->email)) {
return $this->throwError('missing_email');
}
}
if (auth()->check()) {
// Making sure the current logged-in user's ID is matching the ID retrieved from the Discord API.
if (auth()->id() !== (int)$user->id) {
auth()->logout();
return $this->throwError('invalid_user');
}
// Confirming the session in case the user was redirected from the password.confirm middleware.
$request->session()->put('auth.password_confirmed_at', time());
}
// Trying to create or update the user in the database.
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
try {
$user = (new DiscordService())->createOrUpdateUser($user);
$user->accessToken()->updateOrCreate([], $accessToken->toArray());
} catch (\Exception $e) {
DB::rollBack();
return $this->throwError('database_error', $e);
}
// Verifying if the user is soft-deleted.
if (Schema::hasColumn('users', 'deleted_at')) {
if ($user->trashed()) {
DB::rollBack();
return $this->throwError('user_deleted');
}
}
// Patreon check
try {
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) {
DB::rollBack();
return $this->throwError('missing_guilds_members_read_scope');
}
$guildMember = (new DiscordService())->getGuildMember($accessToken, config('discord.guild_id'));
$patreonroles = config('discord.patreon_roles');
$user->is_patreon = false;
if ((new DiscordService())->hasRoleInGuild($guildMember, $patreonroles)) {
$user->is_patreon = true;
}
$user->save();
} catch (\Exception $e) {
// Clearly not a patreon
$user->is_patreon = false;
$user->save();
}
// Committing the database transaction.
DB::commit();
// Authenticating the user if the user is not logged in.
if (!auth()->check()) {
auth()->login($user, config('larascord.remember_me', false));
}
// Redirecting the user to the intended page or to the home page.
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Handles the throwing of an error.
*/
private function throwError(string $message, \Exception $exception = NULL): RedirectResponse | JsonResponse
{
if (app()->hasDebugModeEnabled()) {
return response()->json([
'larascord_message' => config('larascord.error_messages.' . $message),
'message' => $exception?->getMessage(),
'code' => $exception?->getCode()
]);
} else {
if (config('larascord.error_messages.' . $message . '.redirect')) {
Alert::error('Error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
return redirect(config('larascord.error_messages.' . $message . '.redirect'))->with('error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
} else {
return redirect('/')->with('error', config('larascord.error_messages.' . $message, 'An error occurred while trying to log you in.'));
}
}
}
/**
* Handles the deletion of the user.
*/
public function destroy(): RedirectResponse | JsonResponse
{
// Revoking the OAuth2 access token.
try {
(new DiscordService())->revokeAccessToken(auth()->user()->accessToken()->first()->refresh_token);
} catch (\Exception $e) {
return $this->throwError('revoke_token_failed', $e);
}
// Deleting the user from the database.
auth()->user()->delete();
// Showing the success message.
if (config('larascord.success_messages.user_deleted.redirect')) {
return redirect(config('larascord.success_messages.user_deleted.redirect'))->with('success', config('larascord.success_messages.user_deleted.message', 'Your account has been deleted.'));
} else {
return redirect('/')->with('success', config('larascord.success_messages.user_deleted', 'Your account has been deleted.'));
}
}
}

View File

@@ -1,273 +0,0 @@
<?php
namespace Jakyeru\Larascord\Services;
use App\Models\User;
use App\Models\OldUser;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Jakyeru\Larascord\Types\AccessToken;
use Jakyeru\Larascord\Types\GuildMember;
use Illuminate\Support\Facades\DB;
class DiscordService
{
/**
* The Discord OAuth2 token URL.
*/
protected string $tokenURL = "https://discord.com/api/oauth2/token";
/**
* The Discord API base URL.
*/
protected string $baseApi = "https://discord.com/api";
/**
* The required data for the token request.
*/
protected array $tokenData = [
"client_id" => NULL,
"client_secret" => NULL,
"grant_type" => "authorization_code",
"code" => NULL,
"redirect_uri" => NULL,
"scope" => null
];
/**
* UserService constructor.
*/
public function __construct()
{
$this->tokenData['client_id'] = config('larascord.client_id');
$this->tokenData['client_secret'] = config('larascord.client_secret');
$this->tokenData['grant_type'] = config('larascord.grant_type');
$this->tokenData['redirect_uri'] = config('larascord.redirect_uri');
$this->tokenData['scope'] = config('larascord.scopes');
}
/**
* Handles the Discord OAuth2 callback and returns the access token.
*
* @throws RequestException
*/
public function getAccessTokenFromCode(string $code): AccessToken
{
$this->tokenData['code'] = $code;
$response = Http::asForm()->post($this->tokenURL, $this->tokenData);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Get access token from refresh token.
*
* @throws RequestException
*/
public function refreshAccessToken(string $refreshToken): AccessToken
{
$response = Http::asForm()->post($this->tokenURL, [
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Authenticates the user with the access token and returns the user data.
*
* @throws RequestException
*/
public function getCurrentUser(AccessToken $accessToken): \Jakyeru\Larascord\Types\User
{
$response = Http::withToken($accessToken->access_token)->get($this->baseApi . '/users/@me');
$response->throw();
return new \Jakyeru\Larascord\Types\User(json_decode($response->body()));
}
/**
* Get the user's guilds.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserGuilds(AccessToken $accessToken, bool $withCounts = false): array
{
if (!$accessToken->hasScope('guilds')) throw new Exception(config('larascord.error_messages.missing_guilds_scope.message'));
$endpoint = '/users/@me/guilds';
if ($withCounts) {
$endpoint .= '?with_counts=true';
}
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . $endpoint);
$response->throw();
return array_map(function ($guild) {
return new \Jakyeru\Larascord\Types\Guild($guild);
}, json_decode($response->body()));
}
/**
* Get the Guild Member object for a user.
*
* @throws RequestException
* @throws Exception
*/
public function getGuildMember(AccessToken $accessToken, string $guildId): GuildMember
{
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) throw new Exception(config('larascord.error_messages.missing_guilds_members_read_scope.message'));
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/guilds/' . $guildId . '/member');
$response->throw();
return new GuildMember(json_decode($response->body()));
}
/**
* Get the User's connections.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserConnections(AccessToken $accessToken): array
{
if (!$accessToken->hasScope('connections')) throw new Exception('The "connections" scope is required.');
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/connections');
$response->throw();
return array_map(function ($connection) {
return new \Jakyeru\Larascord\Types\Connection($connection);
}, json_decode($response->body()));
}
/**
* Join a guild.
*
* @throws RequestException
* @throws Exception
*/
public function joinGuild(AccessToken $accessToken, User $user, string $guildId, array $options = []): GuildMember
{
if (!config('larascord.access_token')) throw new Exception(config('larascord.error_messages.missing_access_token.message'));
if (!$accessToken->hasScope('guilds.join')) throw new Exception('The "guilds" and "guilds.join" scopes are required.');
$response = Http::withToken(config('larascord.access_token'), 'Bot')->put($this->baseApi . '/guilds/' . $guildId . '/members/' . $user->id, array_merge([
'access_token' => $accessToken->access_token,
], $options));
$response->throw();
if ($response->status() === 204) return throw new Exception('User is already in the guild.');
return new GuildMember(json_decode($response->body()));
}
/**
* Create or update a user in the database.
*
* @throws Exception
*/
public function createOrUpdateUser(\Jakyeru\Larascord\Types\User $user): User
{
if (!$user->getAccessToken()) {
throw new Exception('User access token is missing.');
}
$forgottenUser = User::where('email', '=', $user->email)->where('id', '!=', $user->id)->first();
if ($forgottenUser) {
// This case should never happen (TM) - The discord id changed
// The user probably re-created their discord account with the same email
// Delete Playlist
$playlists = Playlist::where('user_id', $forgottenUser->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $forgottenUser->id)->update(['commenter_id' => 1]);
$forgottenUser->forceDelete();
}
return User::updateOrCreate(
[
'id' => $user->id,
],
$user->toArray(),
);
}
/**
* Verify if the user is in the specified guild(s).
*/
public function isUserInGuilds(array $guilds): bool
{
// Verify if the user is in all the specified guilds if strict mode is enabled.
if (config('larascord.guilds_strict')) {
return empty(array_diff(config('larascord.guilds'), array_column($guilds, 'id')));
}
// Verify if the user is in any of the specified guilds if strict mode is disabled.
return !empty(array_intersect(config('larascord.guilds'), array_column($guilds, 'id')));
}
/**
* Verify if the user has the specified role(s) in the specified guild.
*/
public function hasRoleInGuild(GuildMember $guildMember, array $roles): bool
{
// Verify if the user has any of the specified roles.
return !empty(array_intersect($roles, $guildMember->roles));
}
/**
* Updates the user's roles in the database.
*/
public function updateUserRoles(User $user, GuildMember $guildMember, int $guildId): void
{
// Updating the user's roles in the database.
$updatedRoles = $user->roles;
$updatedRoles[$guildId] = $guildMember->roles;
$user->roles = $updatedRoles;
$user->save();
}
/**
* Revoke the user's access token.
*
* @throws RequestException
*/
public function revokeAccessToken(string $accessToken): object
{
$response = Http::asForm()->post($this->tokenURL . '/revoke', [
'token' => $accessToken,
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
]);
$response->throw();
return json_decode($response->body());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Comment;
use Illuminate\Auth\Access\HandlesAuthorization;
class CommentPolicy
{
use HandlesAuthorization;
public function update(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
public function destroy(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class);
});
} }
} }

View File

@@ -34,8 +34,8 @@ class EpisodeService
Request $request, Request $request,
Hentai $hentai, Hentai $hentai,
int $episodeNumber, int $episodeNumber,
Studios $studio = null, ?Studios $studio = null,
Episode $referenceEpisode = null ?Episode $referenceEpisode = null
): Episode ): Episode
{ {
$episode = new Episode(); $episode = new Episode();
@@ -74,6 +74,7 @@ class EpisodeService
$episode->interpolated = $request->input('interpolated') == 'yes'; $episode->interpolated = $request->input('interpolated') == 'yes';
$episode->interpolated_uhd = $request->input('downloadUHDi1') ? true : false; $episode->interpolated_uhd = $request->input('downloadUHDi1') ? true : false;
$episode->is_dvd_aspect = $request->input('dvd') == 'yes'; $episode->is_dvd_aspect = $request->input('dvd') == 'yes';
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
$episode->save(); $episode->save();
// Tagging // Tagging

View File

@@ -74,7 +74,7 @@ class GalleryService
foreach ($oldGallery as $oldImage) { foreach ($oldGallery as $oldImage) {
Storage::disk('public')->delete($oldImage->image_url); Storage::disk('public')->delete($oldImage->image_url);
Storage::disk('public')->delete($oldImage->thumbnail_url); Storage::disk('public')->delete($oldImage->thumbnail_url);
$oldImage->forceDelete(); $oldImage->delete();
} }
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "laravel/laravel", "name": "w33b/hstream",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "The website of hstream.moe",
"keywords": [ "keywords": [
"laravel", "laravel",
"framework" "framework"
@@ -9,59 +9,46 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"grantholle/laravel-altcha": "^2.1",
"guzzlehttp/guzzle": "^7.8.1", "guzzlehttp/guzzle": "^7.8.1",
"hisorange/browser-detect": "^5.0", "hisorange/browser-detect": "^5.0",
"intervention/image": "^3.9", "http-interop/http-factory-guzzle": "^1.2",
"intervention/image-laravel": "^1.3", "intervention/image": "^3.11",
"jakyeru/larascord": "^6.0", "intervention/image-laravel": "^1.5",
"laravel/framework": "^11.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.2",
"laravel/scout": "^10.20",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10", "laravel/tinker": "^2.10",
"laravelista/comments": "dev-l11-compatibility", "livewire/livewire": "^3.7.0",
"livewire/livewire": "^3.6.4", "maize-tech/laravel-markable": "^2.3.0",
"maize-tech/laravel-markable": "2.2.0", "meilisearch/meilisearch-php": "^1.16",
"mews/captcha": "3.4.4",
"predis/predis": "^2.2", "predis/predis": "^2.2",
"realrashid/sweet-alert": "^7.2", "realrashid/sweet-alert": "^7.2",
"rtconner/laravel-tagging": "^4.1", "rtconner/laravel-tagging": "^5.0",
"spatie/laravel-discord-alerts": "^1.5", "socialiteproviders/discord": "^4.2",
"spatie/laravel-sitemap": "^7.3", "spatie/laravel-discord-alerts": "^1.8",
"vluzrmos/language-detector": "^2.3" "spatie/laravel-sitemap": "^7.3"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "3.14.7", "barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.24.0", "fakerphp/faker": "^1.24.0",
"laravel/breeze": "^2.3",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"laravel/sail": "^1.38",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11.4", "phpunit/phpunit": "^11.4",
"spatie/laravel-ignition": "^2.0" "spatie/laravel-ignition": "^2.0"
}, },
"repositories": [ "repositories": [],
{
"type": "vcs",
"url": "https://github.com/renatokira/comments.git"
}
],
"autoload": { "autoload": {
"exclude-from-classmap": [ "exclude-from-classmap": [],
"vendor/jakyeru/larascord/src/Http/Services/DiscordService.php",
"vendor/jakyeru/larascord/src/Http/Controllers/DiscordController.php",
"vendor/laravelista/comments/src/CommentPolicy.php",
"vendor/laravelista/comments/src/CommentService.php"
],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
"Database\\Factories\\": "database/factories/", "Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/" "Database\\Seeders\\": "database/seeders/"
}, },
"files": [ "files": []
"app/Override/Discord/Services/DiscordService.php",
"app/Override/Discord/DiscordController.php",
"app/Override/Comments/CommentPolicy.php",
"app/Override/Comments/CommentService.php"
]
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {

4701
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,18 @@ return [
'faker_locale' => 'en_US', 'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Supported Locales
|--------------------------------------------------------------------------
|
| This is used to display the supported locales by this app, it also is
| used to verify session data and requests in the SetLocale Middleware
|
*/
'supported_locales' => ['en', 'de', 'fr'],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Encryption Key | Encryption Key

View File

@@ -1,50 +0,0 @@
<?php
return [
'characters' => ['2', '3', '4', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'X', 'Y', 'Z'],
'default' => [
'length' => 5,
'width' => 120,
'height' => 36,
'quality' => 90,
'math' => false,
'expire' => 60,
'encrypt' => false,
],
'math' => [
'length' => 9,
'width' => 120,
'height' => 36,
'quality' => 90,
'math' => true,
],
'flat' => [
'length' => 6,
'width' => 160,
'height' => 46,
'quality' => 90,
'lines' => 6,
'bgImage' => false,
'bgColor' => '#ecf2f4',
'fontColors' => ['#2c3e50', '#c0392b', '#16a085', '#c0392b', '#8e44ad', '#303f9f', '#f57c00', '#795548'],
'contrast' => -5,
],
'mini' => [
'length' => 3,
'width' => 60,
'height' => 32,
],
'inverse' => [
'length' => 5,
'width' => 120,
'height' => 36,
'quality' => 90,
'sensitive' => true,
'angle' => 12,
'sharpen' => 10,
'blur' => 2,
'invert' => true,
'contrast' => -5,
]
];

View File

@@ -7,6 +7,7 @@ return [
'webhook_urls' => [ 'webhook_urls' => [
'default' => env('DISCORD_ALERT_WEBHOOK'), 'default' => env('DISCORD_ALERT_WEBHOOK'),
'update' => env('DISCORD_ALERT_UPDATE_WEBHOOK'), 'update' => env('DISCORD_ALERT_UPDATE_WEBHOOK'),
'rerelease' => env('DISCORD_ALERT_RERELEASE_WEBHOOK'),
], ],
/* /*

View File

@@ -1,10 +1,19 @@
<?php <?php
return [ return [
'invite_link' => 'https://discord.gg/yAqgVKNgG5', 'invite_link' => 'https://discord.gg/yAqgVKNgG5',
'guild_id' => 802233383710228550, 'guild_id' => 802233383710228550,
'patreon_roles' => [841798154999169054, 803329707650187364, 803327903659196416, 803325441942356059, 803322725576736858, 802270568912519198, 802234830384267315], 'patreon_roles' => [
'841798154999169054', // ????
'803329707650187364', // Tier-5
'803327903659196416', // ????
'803325441942356059', // Tier-3
'803322725576736858', // Tier-2
'802270568912519198', // Tier-1
'802234830384267315' // admin
],
'discord_bot_token' => env('DISCORD_BOT_TOKEN'),
]; ];

View File

@@ -6,7 +6,8 @@ return [
'https://imoto-ddl.ane-h.xyz', 'https://imoto-ddl.ane-h.xyz',
'https://chibi-ddl.imoto-h.xyz', 'https://chibi-ddl.imoto-h.xyz',
'https://koneko-ddl.musume-h.xyz', 'https://koneko-ddl.musume-h.xyz',
'https://shinobu-ddl.rorikon-h.xyz' 'https://shinobu-ddl.rorikon-h.xyz',
'https://oppai-ddl.shoujo-h.org',
], ],
// 4k Download Domain // 4k Download Domain
@@ -14,7 +15,8 @@ return [
'https://imoto-ddlp.ane-h.xyz', 'https://imoto-ddlp.ane-h.xyz',
'https://chibi-ddlp.imoto-h.xyz', 'https://chibi-ddlp.imoto-h.xyz',
'https://koneko-ddlp.musume-h.xyz', 'https://koneko-ddlp.musume-h.xyz',
'https://shinobu-ddlp.rorikon-h.xyz' 'https://shinobu-ddlp.rorikon-h.xyz',
'https://oppai-ddlp.shoujo-h.org',
], ],
// Stream Domain // Stream Domain
@@ -22,7 +24,8 @@ return [
'https://imoto-str.ane-h.xyz', 'https://imoto-str.ane-h.xyz',
'https://chibi-str.imoto-h.xyz', 'https://chibi-str.imoto-h.xyz',
'https://koneko-str.musume-h.xyz', 'https://koneko-str.musume-h.xyz',
'https://shinobu-str.rorikon-h.xyz' 'https://shinobu-str.rorikon-h.xyz',
'https://oppai-str.shoujo-h.org',
], ],
// Asia Fallback (HTTP) // Asia Fallback (HTTP)

View File

@@ -1,46 +0,0 @@
<?php
return [
/*
* Indicates whenever should autodetect and apply the language of the request.
*/
'autodetect' => env('LANG_DETECTOR_AUTODETECT', true),
/*
* Default driver to use to detect the request language.
*
* Available: browser, subdomain, uri.
*/
'driver' => env('LANG_DETECTOR_DRIVER', 'browser'),
/*
* Used on subdomain and uri drivers. That indicates which segment should be used
* to verify the language.
*/
'segment' => env('LANG_DETECTOR_SEGMENT', 0),
/*
* Languages available on the application.
*
* You could use parse_langs_to_array to use the string syntax
* or just use the array of languages with its aliases.
*/
'languages' => parse_langs_to_array(
env('LANG_DETECTOR_LANGUAGES', ['en', 'de', 'fr'])
),
/*
* Indicates if should store detected locale on cookies
*/
'cookie' => (bool) env('LANG_DETECTOR_COOKIE', true),
/*
* Indicates if should encrypt cookie
*/
'cookie_encrypt' => (bool) env('LANG_DETECTOR_COOKIE_ENCRYPT', false),
/*
* Cookie name
*/
'cookie_name' => env('LANG_DETECTOR_COOKIE', 'locale'),
];

View File

@@ -1,247 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application ID
|--------------------------------------------------------------------------
|
| This is the ID of your Discord application.
|
*/
'client_id' => env('LARASCORD_CLIENT_ID', null),
/*
|--------------------------------------------------------------------------
| Application Secret
|--------------------------------------------------------------------------
|
| This is the secret of your Discord application.
|
*/
'client_secret' => env('LARASCORD_CLIENT_SECRET', null),
/*
|--------------------------------------------------------------------------
| Application Access Token
|--------------------------------------------------------------------------
|
| This is the access token of your Discord application.
|
*/
'access_token' => env('LARASCORD_ACCESS_TOKEN', null),
/*
|--------------------------------------------------------------------------
| Grant Type
|--------------------------------------------------------------------------
|
| This is the grant type of your Discord application. It must be set to
| "authorization_code".
|
*/
'grant_type' => env('LARASCORD_GRANT_TYPE', 'authorization_code'),
/*
|--------------------------------------------------------------------------
| Redirect URI
|--------------------------------------------------------------------------
|
| This is the URI that Discord will redirect to after the user authorizes
| your application.
|
*/
'redirect_uri' => env('APP_URL', 'http://localhost:8000') . '/' . env('LARASCORD_PREFIX', 'larascord') . '/callback',
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
|
| These are the OAuth2 scopes of your Discord application.
|
*/
'scopes' => env('LARASCORD_SCOPE', 'identify&email&guilds&guilds.members.read'),
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
|
| This is the prefix that Larascord will use for its routes. For example,
| the prefix "larascord" will result in the route
| "https://domain.com/larascord/login".
|
*/
'route_prefix' => env('LARASCORD_PREFIX', 'larascord'),
/*
|--------------------------------------------------------------------------
| OAuth2 Prompt - "none" or "consent"
|--------------------------------------------------------------------------
|
| The prompt controls how the authorization flow handles existing authorizations.
| If a user has previously authorized your application with the requested scopes
| and prompt is set to consent,it will request them to re-approve their
| authorization. If set to none, it will skip the authorization screen
| and redirect them back to your redirect URI without requesting
| their authorization.
|
*/
'prompt' => 'none',
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds
|--------------------------------------------------------------------------
|
| This option restricts access to the application to users who are members
| of specific Discord guilds. Users who are not members of the specified
| guilds will not be able to use the application.
|
*/
'guilds' => [],
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Guilds - Strict Mode
|--------------------------------------------------------------------------
|
| Enabling this option will require the user to be a member of ALL the
| aforementioned guilds. If this option is disabled, the user will
| only need to be a member of at least ONE of the guilds.
|
*/
'guilds_strict' => false,
/*
|--------------------------------------------------------------------------
| Restrict Access to Specific Roles
|--------------------------------------------------------------------------
|
| When this option is enabled, the user will only be able to use the
| application if they have at least one of the specified roles.
|
*/
// WARNING: This feature makes one request to the Discord API for each guild you specify. (Because you need to fetch the roles for each guild)
// At the moment the database is not checked for roles when the user logs in. It will always fetch the roles from the Discord API.
// Currently, the roles are only updated in the database when the user logs in. The roles from the database can be used in a middleware.
// I'm working on a better way to do this, but for now, this will work.
'guild_roles' => [
// 'guild_id' => [
// 'role_id',
// 'role_id',
// ],
],
/*
|--------------------------------------------------------------------------
| Remember Me
|--------------------------------------------------------------------------
|
| Whether or not to remember the user after they log in.
|
*/
'remember_me' => true,
/*
|--------------------------------------------------------------------------
| Error Messages
|--------------------------------------------------------------------------
|
| These are the error messages that will be displayed to the user if there
| is an error.
|
*/
'error_messages' => [
'missing_code' => [
'message' => 'The authorization code is missing.',
'redirect' => '/'
],
'invalid_code' => [
'message' => 'The authorization code is invalid.',
'redirect' => '/'
],
'authorization_failed' => [
'message' => 'The authorization failed.',
'redirect' => '/'
],
'missing_email' => [
'message' => 'Couldn\'t get your e-mail address. Please add an e-mail address to your Discord account!',
'redirect' => '/'
],
'invalid_user' => [
'message' => 'The user ID doesn\'t match the logged-in user.',
'redirect' => '/'
],
'database_error' => [
'message' => 'There was an error with the database. Please try again later.',
'redirect' => '/'
],
'missing_guilds_scope' => [
'message' => 'The "guilds" scope is required.',
'redirect' => '/'
],
'missing_guilds_members_read_scope' => [
'message' => 'The "guilds" and "guilds.members.read" scopes are required.',
'redirect' => '/'
],
'authorization_failed_guilds' => [
'message' => 'Couldn\'t get the servers you\'re in.',
'redirect' => '/'
],
'not_member_guild_only' => [
'message' => 'You are not a member of the required guilds.',
'redirect' => '/'
],
'missing_access_token' => [
'message' => 'The access token is missing.',
'redirect' => '/'
],
'authorization_failed_roles' => [
'message' => 'Couldn\'t get the roles you have.',
'redirect' => '/'
],
'missing_role' => [
'message' => 'You don\'t have the required roles.',
'redirect' => '/'
],
'revoke_token_failed' => [
'message' => 'An error occurred while trying to revoke your access token.',
'redirect' => '/'
],
],
/*
|--------------------------------------------------------------------------
| Success Messages
|--------------------------------------------------------------------------
|
| These are the success messages that will be displayed to the user if there
| is no error.
|
*/
'success_messages' => [
'user_deleted' => [
'message' => 'Your account has been deleted.',
'redirect' => '/'
],
],
];

223
config/scout.php Normal file
View File

@@ -0,0 +1,223 @@
<?php
use App\Models\Episode;
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Episode::class => [
'filterableAttributes' => [
'title',
'title_search',
'title_jpn',
'slug',
'description',
'tags'
],
'sortableAttributes' => [
'created_at',
'release_date',
'title'
],
],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
],
];

View File

@@ -31,4 +31,21 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
/*
|--------------------------------------------------------------------------
| Socialite Providers
|--------------------------------------------------------------------------
*/
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => '/auth/discord/callback',
// optional
'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true),
'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'webp'), // only pick from jpg, png, webp
],
]; ];

View File

@@ -0,0 +1,55 @@
<?php
use App\Models\Downloads;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
# Delete entries with "#" as URL
Downloads::where('url', '#')->delete();
# Remove duplicate entries
$duplicates = DB::table('downloads')
->select('episode_id', 'type', DB::raw('COUNT(*) as count'))
->groupBy('episode_id', 'type')
->having('count', '>', 1)
->get();
foreach ($duplicates as $duplicate) {
// Find all rows for this episode_id and type
$rows = DB::table('downloads')
->where('episode_id', $duplicate->episode_id)
->where('type', $duplicate->type)
->orderBy('count', 'desc') // Order by count to delete the ones with the lower count
->get();
// Delete the rows with lower counts, keeping the one with the highest count
$rows->skip(1)->each(function ($row) {
DB::table('downloads')->where('id', $row->id)->delete();
});
}
Schema::table('downloads', function (Blueprint $table) {
$table->unique(['episode_id', 'type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('downloads', function (Blueprint $table) {
$table->dropUnique(['episode_id', 'type']);
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('torrents');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Not reversible
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('episodes', function (Blueprint $table) {
$table->boolean('dmca_takedown')->default(0)->after('interpolated_uhd');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('episodes', function (Blueprint $table) {
$table->dropColumn('dmca_takedown');
});
}
};

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Remove tables from larascord
Schema::dropIfExists('discord_access_tokens');
Schema::dropIfExists('personal_access_tokens');
// Drop columns from larascord
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('discriminator');
$table->dropColumn('remember_token');
$table->dropColumn('banner');
$table->dropColumn('banner_color');
$table->dropColumn('accent_color');
$table->dropColumn('premium_type');
$table->dropColumn('public_flags');
$table->dropColumn('verified');
$table->dropColumn('mfa_enabled');
$table->dropColumn('global_name');
$table->dropColumn('locale');
});
// Change & Add Columns
Schema::table('users', function (Blueprint $table) {
// Rename
$table->renameColumn('username', 'name');
$table->renameColumn('avatar', 'discord_avatar');
$table->string('avatar')->nullable()->after('email');
// Re-Add Email verification
$table->timestamp('email_verified_at')->nullable()->after('email');
// Re-Add Password Auth
$table->string('password')->nullable()->after('email_verified_at');
$table->rememberToken()->after('password');
});
/**
* --------------------------------------------------------------------
* Fix Discord Profile Pictures
* --------------------------------------------------------------------
* The oauth package by socialite now returns a full url of the avatar.
* Meaning all the old entries have to be fixed.
*/
foreach (User::whereNotNull('discord_avatar')->get() as $user)
{
$isGif = preg_match('/a_.+/m', $user->discord_avatar) === 1;
$extension = $isGif ? 'gif' : 'webp';
$user->discord_avatar = sprintf('https://cdn.discordapp.com/avatars/%s/%s.%s', $user->id, $user->discord_avatar, $extension);
$user->save();
}
}
};

View File

@@ -0,0 +1,207 @@
<?php
use App\Models\Playlist;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create new column discord_id
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('discord_id')->nullable()->after('id');
});
// 2. Migrate Discord Users IDs
DB::table('users')
->where('id', '>', 10000)
->update(['discord_id' => DB::raw('id')]);
// 3. Temporary new auto increment column
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('new_id')->first();
});
// 3.5 Count
DB::statement('
UPDATE users u
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM users
) t ON u.id = t.id
SET u.new_id = t.rn
');
// 4. Drop foreign keys
$this->dropForeignKeys();
// 5. Fix ID's in other tables
$this->updateUserIDsInOtherTables();
// 6. Remove old ID
Schema::table('users', function (Blueprint $table) {
$table->bigInteger('id')->unsigned()->change();
$table->dropPrimary('id');
$table->dropColumn('id');
});
// 7. Rename new_id to id
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('new_id', 'id');
});
// 8. Change new ID to auto increment and set as primary key
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('id')->autoIncrement()->primary()->change();
});
// 9. Remove data that would conflict with constraints
$this->deleteUnreferencedData();
// 9. Recreate foreign key constraints
$this->addForeignKeys();
}
/**
* Drop Foreign Keys referencing the user id
*/
private function dropForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
Schema::table('watched', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
// Our Schema does include a foreign key, for whatever reason it doesn't exist in the first palce
// Schema::table('user_downloads', function (Blueprint $table) {
// $table->dropForeign(['user_id']);
// });
}
/**
* Tables to fix the IDs:
* - comments ['commenter_id']
* - markable_likes ['user_id']
* - notifications ['notifiable_id']
* - playlists ['user_id']
* - user_downloads ['user_id']
* - watched ['user_id']
*/
private function updateUserIDsInOtherTables(): void
{
DB::statement('
UPDATE comments c
JOIN users u ON c.commenter_id = u.id
SET c.commenter_id = u.new_id
');
DB::statement('
UPDATE watched w
JOIN users u ON w.user_id = u.id
SET w.user_id = u.new_id
');
DB::statement('
UPDATE markable_likes ml
JOIN users u ON ml.user_id = u.id
SET ml.user_id = u.new_id
');
DB::statement('
UPDATE notifications n
JOIN users u ON n.notifiable_id = u.id
SET n.notifiable_id = u.new_id
');
DB::statement('
UPDATE playlists p
JOIN users u ON p.user_id = u.id
SET p.user_id = u.new_id
');
DB::statement('
UPDATE user_downloads ud
JOIN users u ON ud.user_id = u.id
SET ud.user_id = u.new_id
');
}
/**
* Due to incorrect handling of user deletes,
* we have unreferenced data
*/
private function deleteUnreferencedData(): void
{
// User Downloads Table
DB::table('user_downloads')
->where('user_id', '>', 1_000_000)
->delete();
// User Playlists Table
$playlists = Playlist::where('user_id', '>', 1_000_000)
->get();
foreach($playlists as $playlist) {
DB::table('playlist_episodes')
->where('playlist_id', '=', $playlist->id)
->delete();
$playlist->delete();
}
}
/**
* Re-Add Foreign Keys to tables which we dropped previously
*/
private function addForeignKeys(): void
{
Schema::table('markable_likes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('watched', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('user_downloads', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::table('playlist_episodes', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('playlist_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade');
});
Schema::table('playlists', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Drop Foreign Keys and Index
Schema::table('comments', function (Blueprint $table) {
$table->dropForeign(['child_id']);
$table->dropIndex(['commenter_id', 'commenter_type']);
});
// Rename and Drop columns
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('commenter_id', 'user_id');
$table->dropColumn('commenter_type');
$table->dropColumn('guest_name');
$table->dropColumn('guest_email');
$table->renameColumn('child_id', 'parent_id');
$table->renameColumn('comment', 'body');
$table->dropColumn('approved');
});
// Add Foreign Keys
Schema::table('comments', function (Blueprint $table) {
// Ensure the column is unsigned
$table->bigInteger('user_id')->unsigned()->change();
// Add the foreign key constraint
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('parent_id')->references('id')->on('comments')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Drop Foreign Keys
Schema::table('comments', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropForeign(['user_id']);
});
// Rename and Re-Add Columns
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('user_id', 'commenter_id');
$table->string('commenter_type')->nullable()->after('commenter_id');
$table->string('guest_name')->nullable()->after('commenter_type');
$table->string('guest_email')->nullable()->after('guest_name');
$table->renameColumn('parent_id', 'child_id');
$table->renameColumn('body', 'comment');
$table->boolean('approved')->default(true)->after('comment');
});
DB::table('comments')->update([
'commenter_type' => 'App\Models\User',
]);
// Re-Add foreign key constraint and index
Schema::table('comments', function (Blueprint $table) {
$table->foreign('child_id')->references('id')->on('comments')->onDelete('cascade');
$table->index(["commenter_id", "commenter_type"]);
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 10)
->nullable()
->after('discord_avatar');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Migrate supporters
DB::table('users')->where('is_patreon', 1)->update([
'roles' => DB::raw("JSON_ARRAY('supporter')")
]);
// Migrate banned
DB::table('users')->where('is_banned', 1)->update([
'roles' => DB::raw("JSON_ARRAY('banned')")
]);
// Migrate admins
DB::table('users')->where('is_admin', 1)->update([
'roles' => DB::raw("JSON_ARRAY('admin')")
]);
// Drop columns
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
$table->dropColumn('is_patreon');
$table->dropColumn('is_banned');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('users')->update(['roles' => null]);
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(0);
$table->boolean('is_patreon')->default(0);
$table->boolean('is_banned')->default(0);
});
}
};

View File

@@ -1,74 +0,0 @@
services:
laravel.test:
build:
context: './vendor/laravel/sail/runtimes/8.3'
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
MYSQL_CLIENT: mariadb-client
image: 'sail-8.3/app'
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:80'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mariadb
- redis
mariadb:
image: 'mariadb:11'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
volumes:
- 'sail-mariadb:/var/lib/mysql'
- './vendor/laravel/sail/database/mariadb/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- sail
healthcheck:
test:
- CMD
- healthcheck.sh
- '--connect'
- '--innodb_initialized'
retries: 3
timeout: 5s
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
networks:
sail:
driver: bridge
volumes:
sail-mariadb:
driver: local
sail-redis:
driver: local

2347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,20 +6,23 @@
"build": "vite build" "build": "vite build"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.2",
"autoprefixer": "^10.4.18", "alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"laravel-vite-plugin": "^1.0.2", "laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.35", "postcss": "^8.4.31",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.1.0",
"vite": "^5.1.6", "vite": "^7.1.6",
"vite-plugin-static-copy": "^1.0.1" "vite-plugin-static-copy": "^3.0.1"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@jellyfin/libass-wasm": "^4.1.1", "@jellyfin/libass-wasm": "^4.1.1",
"@yaireo/tagify": "^4.21.2", "@yaireo/tagify": "^4.21.2",
"dashjs": "^4.7.4", "altcha": "^2.3.0",
"chart.js": "^4.5.0",
"dashjs": "^5.0.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"plyr": "^3.7.8", "plyr": "^3.7.8",
"tw-elements": "^1.1.0", "tw-elements": "^1.1.0",

View File

@@ -123,3 +123,32 @@ input:checked~.dot {
src: url(https://fonts.bunny.net/figtree/files/figtree-latin-ext-600-normal.woff2) format('woff2'), url(https://fonts.bunny.net/figtree/files/figtree-latin-ext-600-normal.woff) format('woff'); src: url(https://fonts.bunny.net/figtree/files/figtree-latin-ext-600-normal.woff2) format('woff2'), url(https://fonts.bunny.net/figtree/files/figtree-latin-ext-600-normal.woff) format('woff');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* Captcha */
:root {
--altcha-border-width: 1px;
--altcha-border-radius: 0.375rem;
--altcha-color-base: #333;
--altcha-color-border: #a0a0a0;
--altcha-color-text: #fff;
--altcha-color-border-focus: currentColor;
--altcha-color-error-text: #f23939;
--altcha-color-footer-bg: #141414;
--altcha-max-width: 260px;
}
.altcha-footer {
border-bottom-left-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
input[type="checkbox"] {
background-color: #ffffff;
border-color: #a0a0a0;
color: rgb(225,29,72);
}
input[type="checkbox"]:checked {
background-color: rgb(225,29,72);
box-shadow: 0 0 0 0px #fff, 0 0 0 calc(2px + 0px) rgba(246, 59, 118, 0.5), 0 0 #0000;
}

View File

@@ -1,8 +1,5 @@
import './bootstrap'; import './bootstrap';
import 'hammerjs';
// import { Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm';
// Alpine.start();
import { import {
Collapse, Collapse,
Carousel, Carousel,
@@ -15,6 +12,13 @@ import {
initTE, initTE,
} from "tw-elements"; } from "tw-elements";
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple }); // Captcha
import 'altcha';
import 'hammerjs'; // import Alpine from 'alpinejs';
// window.Alpine = Alpine;
// Alpine.start();
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });

View File

@@ -1,5 +1,5 @@
// Plyr Player // Plyr Player
import Plyr from 'plyr/dist/plyr.polyfilled.min.js'; import Plyr from 'plyr';
import 'plyr/dist/plyr.css'; import 'plyr/dist/plyr.css';
// Vidstack Player // Vidstack Player
@@ -8,7 +8,7 @@ import 'vidstack/player/styles/default/layouts/video.css';
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player'; import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
// Dash Support // Dash Support
import dashjs from 'dashjs'; import * as dashjs from 'dashjs';
// Subtitle Support // Subtitle Support
import SubtitlesOctopus from '@jellyfin/libass-wasm'; import SubtitlesOctopus from '@jellyfin/libass-wasm';
@@ -28,6 +28,7 @@ var av1Supported = (!!document.createElement('video').canPlayType('video/webm; c
var dashSupported = dashjs.supportsMediaSource(); var dashSupported = dashjs.supportsMediaSource();
var apiResponse = {}; var apiResponse = {};
var volume = 0.5; var volume = 0.5;
var muted = false;
var captions = true; var captions = true;
var lastTime = 0.0; var lastTime = 0.0;
var streamServer = ''; var streamServer = '';
@@ -65,10 +66,16 @@ if (localStorage.hstreamCaptions) {
console.log('Loaded Captions Status from Local Storage: ' + captions); console.log('Loaded Captions Status from Local Storage: ' + captions);
} }
// Load Muted from LocalStorage
if (localStorage.hstreamCaptions) {
muted = (localStorage.getItem('hstreamMuted') == 'true');
console.log('Loaded Muted Status from Local Storage: ' + muted);
}
// Asia Server Fallback // Asia Server Fallback
if (localStorage.hstreamServerFallback) { if (localStorage.hstreamServerFallback) {
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true'); serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
console.log('Loaded Captions Status from Local Storage: ' + captions); console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
} }
// Alert User when AV1 is not supported // Alert User when AV1 is not supported
@@ -224,6 +231,7 @@ function initPlayer() {
}; };
player.volume = volume; player.volume = volume;
player.muted = muted;
//player.captions.languages = ['en']; //player.captions.languages = ['en'];
player.captions.language = 'en'; player.captions.language = 'en';
player.captions.active = captions; player.captions.active = captions;
@@ -306,6 +314,8 @@ function initPlayer() {
player.on('volumechange', () => { player.on('volumechange', () => {
console.log('Saving Audio Volume to Local Storage: ' + player.volume); console.log('Saving Audio Volume to Local Storage: ' + player.volume);
localStorage.setItem('hstreamVolume', player.volume.toString()) localStorage.setItem('hstreamVolume', player.volume.toString())
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
localStorage.setItem('hstreamMuted', player.muted.toString())
}); });
player.on('ended', () => { player.on('ended', () => {

73
resources/js/stats.js Normal file
View File

@@ -0,0 +1,73 @@
import Chart from 'chart.js/auto';
// Theming
if (localStorage.theme !== 'light') {
Chart.defaults.color = "#ADBABD";
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
}
// Get Tags from API
window.axios.get('/v1/monthly-views').then(function (response) {
if (response.status != 200) {
return;
}
const data = {
labels: response.data.map((entry) => { return entry.date }),
datasets: [{
label: 'Views',
fill: false,
backgroundColor: 'rgba(190, 18, 60, 0.3)',
borderColor: 'rgba(190, 18, 60, 1.0)',
cubicInterpolationMode: 'monotone',
data: response.data.map((entry) => { return entry.count }),
}]
}
const config = {
type: 'line',
data: data,
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Views the last 28 days',
font: {
size: 18
}
},
},
interaction: {
intersect: false,
},
scales: {
x: {
display: true,
title: {
display: true
}
},
y: {
display: true,
title: {
display: true,
text: 'Views'
},
suggestedMin: 0,
suggestedMax: 40000
}
}
},
};
const monthlyViewChart = new Chart(
document.getElementById('monthlyChart'),
config
);
}).catch(function (error) {
console.log(error);
});

View File

@@ -20,3 +20,45 @@ if(localStorage.theme) {
// Default Dark Theme // Default Dark Theme
localStorage.theme = 'dark'; localStorage.theme = 'dark';
} }
// Ability to disable blur effects for slower devices
const LOCAL_STORAGE_KEY = 'blur';
const blurCheckbox = document.querySelector("input[type='checkbox']#toggleBlur");
function setCSSFilter(selector, value) {
document.querySelectorAll(selector).forEach(el => {
el.style.backdropFilter = value
});
}
function applyBlur(enabled) {
if (!enabled) {
setCSSFilter('.backdrop-blur, .backdrop-blur-sm, .backdrop-blur-lg', 'none');
return;
}
setCSSFilter('.backdrop-blur-lg', 'blur(16px)');
setCSSFilter('.backdrop-blur', 'blur(8px)');
setCSSFilter('.backdrop-blur-sm', 'blur(4px)');
}
function initBlurToggle() {
const storedValue = localStorage.getItem(LOCAL_STORAGE_KEY);
const enabled = storedValue === null ? true : storedValue === 'true';
// initialize UI and DOM
applyBlur(enabled);
if (blurCheckbox) blurCheckbox.checked = enabled;
// add event listener
if (blurCheckbox) {
blurCheckbox.addEventListener('click', (e) => {
console.log("Received Event");
const isEnabled = e.target.checked;
applyBlur(isEnabled);
localStorage.setItem(LOCAL_STORAGE_KEY, isEnabled ? 'true' : 'false');
});
}
}
initBlurToggle();

View File

@@ -1,32 +0,0 @@
<x-app-layout>
<div class="p-5">
<div class="w-[50%] mx-auto sm:px-6 lg:px-8 text-gray-800 dark:text-gray-200 bg-white dark:bg-neutral-950 rounded-lg">
<div class="relative p-4">
<form method="POST" action="{{ route('admin.add.torrent') }}">
@csrf
<div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Hentai ID:</label>
<input id="hentai_id" name="hentai_id" class="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" value="{{ $hentai_id }}" required>
</div>
<div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Torrent URL:</label>
<input id="torrent_url" name="torrent_url" class="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" required>
</div>
<div class="p-4">
<label class="mb-2 leading-tight text-gray-800 dark:text-gray-200 w-full" for="name">Episodes (e.g. "01-02" or "01"):</label>
<input id="torrent_episodes" name="torrent_episodes" class="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" required>
</div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<button type="submit" class="ml-1 inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
Create
</button>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>

View File

@@ -0,0 +1,5 @@
@extends('admin.layout')
@section('content')
@livewire('admin-comment-search')
@endsection

View File

@@ -6,7 +6,7 @@
<div class="mb-4 rounded-lg bg-success-400 px-6 py-5 text-base text-success-800 mt-5" role="alert"> <div class="mb-4 rounded-lg bg-success-400 px-6 py-5 text-base text-success-800 mt-5" role="alert">
{{ $alert->text }} {{ $alert->text }}
@auth @auth
@if(Auth::user()->is_admin) @if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<form method="POST" action="{{ route('admin.alert.delete', $alert->id) }}" class="float-right hover:text-success-900"> <form method="POST" action="{{ route('admin.alert.delete', $alert->id) }}" class="float-right hover:text-success-900">
@csrf @csrf
@method('delete') @method('delete')
@@ -21,7 +21,7 @@
<div class="mb-4 rounded-lg bg-danger-400 px-6 py-5 text-base text-danger-800 mt-5" role="alert"> <div class="mb-4 rounded-lg bg-danger-400 px-6 py-5 text-base text-danger-800 mt-5" role="alert">
{{ $alert->text }} {{ $alert->text }}
@auth @auth
@if(Auth::user()->is_admin) @if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
<form method="POST" action="{{ route('admin.alert.delete', $alert->id) }}" class="float-right hover:text-danger-900"> <form method="POST" action="{{ route('admin.alert.delete', $alert->id) }}" class="float-right hover:text-danger-900">
@csrf @csrf
@method('delete') @method('delete')

View File

@@ -97,6 +97,20 @@
</div> </div>
<div class="flex flex-wrap flex-shrink-0 justify-end items-center p-4 rounded-b-md"> <div class="flex flex-wrap flex-shrink-0 justify-end items-center p-4 rounded-b-md">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="v2" name="v2" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="v2">
v2 Re-Release Notification
</label>
</div>
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="dmca_takedown" name="dmca_takedown" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="dmca_takedown">
DMCA Takedown
</label>
</div>
<button type="button" class="inline-block px-6 pt-2.5 pb-2 text-xs font-medium leading-normal uppercase rounded transition duration-150 ease-in-out bg-primary-100 text-primary-700 hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light"> <button type="button" class="inline-block px-6 pt-2.5 pb-2 text-xs font-medium leading-normal uppercase rounded transition duration-150 ease-in-out bg-primary-100 text-primary-700 hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel Cancel
</button> </button>

View File

@@ -56,6 +56,13 @@
</div> </div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4"> <div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light"> <button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel Cancel
</button> </button>

View File

@@ -19,6 +19,12 @@
<span class="ms-3">Users</span> <span class="ms-3">Users</span>
</a> </a>
</li> </li>
<li>
<a href="{{ route('admin.comments.index') }}" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-pink-700/40 group @if(Route::is('admin.comments.index')) bg-pink-700/40 @endif">
<i class="fa-solid fa-comment"></i>
<span class="ms-3">Comments</span>
</a>
</li>
<li> <li>
<a href="{{ route('admin.contact.index') }}" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-pink-700/40 group @if(Route::is('admin.contact.index')) bg-pink-700/40 @endif"> <a href="{{ route('admin.contact.index') }}" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-pink-700/40 group @if(Route::is('admin.contact.index')) bg-pink-700/40 @endif">
<i class="fa-solid fa-message"></i> <i class="fa-solid fa-message"></i>

View File

@@ -62,13 +62,12 @@
<div class="p-4 pt-0"> <div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description1">Description 1:</label> <label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description1">Description 1:</label>
<textarea rows="4" cols="50" id="description1" name="description1" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required> <textarea rows="4" cols="50" id="description1" name="description1" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required></textarea>
</textarea>
</div> </div>
<div class="p-4 pt-0"> <div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p 1:</label> <label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p 1:</label>
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" required /> <x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" />
</div> </div>
<div class="p-4 pt-0"> <div class="p-4 pt-0">
@@ -78,7 +77,7 @@
<div class="p-4 pt-0"> <div class="p-4 pt-0">
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k 1:</label> <label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k 1:</label>
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" required /> <x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
</div> </div>
<div class="p-4 pt-0"> <div class="p-4 pt-0">
@@ -90,6 +89,13 @@
</div> </div>
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4"> <div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
<div class="inline-block mr-2">
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
type="checkbox" value="true" id="censored" name="censored" />
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
Censored Notification
</label>
</div>
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light"> <button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
Cancel Cancel
</button> </button>

Some files were not shown because too many files have changed in this diff Show More