Compare commits

85 Commits

Author SHA1 Message Date
w33b 60f6c639e5 chore(deps): add Laravel Boost development tooling and configuration
Add laravel/boost as a dev dependency, enable MCP server and skills via boost.json, and include AGENTS.md guidelines. Update .gitignore to exclude .env.testing, /.kilo, and /.cursor.
2026-08-09 13:12:41 +02:00
w33b df6246e3c9 feat(playlist): enhance public playlist index with hero stats and loading optimizations
Add a redesigned hero section to the public playlist page displaying
community-generated totals for playlists and episodes, plus new string
translations (English, German, French). Eager-load user, limited episode
previews, and first gallery image to reduce N+1 queries. Cache aggregate
statistics for 10 minutes to improve performance on high-traffic pages.
2026-08-09 10:36:03 +02:00
w33b 338c190ae8 feat(nav): redesign navigation bar and add Browse/Community links
Enhance the navigation with a modern floating glass design, new Browse and Community dropdown links, and improved theme/blur toggles that now handle multiple instances. Update dropdown components with rounded styling and support for wider widths, refine nav-link active states, and add navigation feature tests.
2026-08-09 10:13:54 +02:00
w33b d08e280ce0 refactor(episode): migrate episode upload to Livewire with real-time CDN verification
Replace the server-rendered episode upload form with a Livewire component,
introducing an interactive admin experience that validates the new episode's
stream path against all configured mirrors before saving. The controller-based
`store` method and its route are removed, and episode creation now flows
through the `AdminEpisodeForm` component with frontend upload handling and
dynamic quality checks.

Key changes:
- Add `AdminEpisodeForm` Livewire component with file uploads, tag handling,
  and debounced stream/base-url validation.
- Extend `CdnPathValidator::validateStream` to accept an episode number so the
  manifest path can point to the specific episode being added.
- Remove the `store` method from `EpisodeController` and its POST route.
- Delete the legacy `createEpisode` method from `EpisodeService`.
- Replace the Blade upload modal with the Livewire view, including progress
  indicators and validation badges.
- Add feature tests for the Livewire component and update unit tests for the
  CDN validator's episode-specific manifest check.
2026-08-08 14:29:05 +02:00
w33b 30a6b080eb feat(admin): rebuild release upload form with Livewire and CDN validation
Replace the server-rendered release upload form with a Livewire component,
introducing an interactive admin release form that supports dynamic episode
management, real‑time CDN path validation, and direct download file checks.

Key changes:
- New `AdminReleaseForm` Livewire component with dynamic episode fields,
  Tagify integration, and upload handling via Livewire's file uploads.
- Added `CdnPathValidator` service to verify download and stream paths across
  all configured mirrors, caching results and capturing file sizes.
- Extended `EpisodeService` and `GalleryService` to support array‑based data
  and temporary uploaded files from Livewire.
- Persist validated download sizes and store the validation timestamp on the
  `downloads` table via a new migration; also add a unique constraint on
  `hentais.slug` to prevent duplicates.
- Remove the old POST `/admin/release/upload` route and controller logic,
  along with the legacy `upload.js` script.
- Add comprehensive feature and unit tests covering the new component and the
  CDN validation service.
2026-08-08 13:18:57 +02:00
w33b 2076517ebd fix(discord): only send notifications in production
Prevent Discord release notifications from being sent in non-production
environments, avoiding accidental notifications during development and
testing. Added unit tests to verify the behavior.
2026-08-08 12:51:22 +02:00
w33b 71d679cc5e Update tests 2026-08-04 21:00:01 +02:00
w33b 9dcf13a62e Update playlist sidebar to use livewire 2026-08-04 18:33:50 +02:00
w33b 7f9573fe0f Update playlist design 2026-08-04 15:29:39 +02:00
w33b 7553b9f895 Update stats page design 2026-07-25 14:18:50 +02:00
w33b 5af1c3c447 Update profile pages design 2026-07-25 13:54:21 +02:00
w33b 2b1a967065 Update nav live search design 2026-07-25 12:38:00 +02:00
w33b ee1c17b903 Remove unused function 2026-07-19 20:31:43 +02:00
w33b a5bf2ac245 Add video engagement tracking and heatmap 2026-07-19 20:13:55 +02:00
w33b 4f81164b44 Only track was watched if more than 10s is played #6 2026-07-19 16:10:24 +02:00
w33b 859a35847a Improve admin user & comment moderation page 2026-07-19 15:10:20 +02:00
w33b f35d1a119e Add ability to edit the playlist name and visibility #5 2026-07-19 13:22:28 +02:00
w33b a04d58c60f Remove external subscription system 2026-07-18 14:03:03 +02:00
w33b bcd15d8569 Discord socialite: catch InvalidStateException 2026-06-15 21:14:35 +02:00
w33b 133a069890 Fix attempt to read property on null on playlist page 2026-06-15 20:51:07 +02:00
w33b b32465a7d0 Fix social preview & SEO optimization 2026-06-14 14:07:10 +02:00
w33b f8022b3f18 Use CSS classes to hide n-th elements instead of JS 2026-05-27 17:12:13 +02:00
w33b 57d1ec34c3 Show two covers side by side on mobile 2026-05-26 16:14:29 +02:00
w33b 81639aaabf Use laravel task scheduler 2026-05-26 15:17:57 +02:00
w33b 5dc1bff60c Add an indicator for v2 releases in the download popup #7 2026-05-26 14:36:21 +02:00
w33b 2f3f0edc30 Add ability to edit hentai title & Update design of edit modal 2026-05-26 14:18:48 +02:00
w33b a71b2976af Add ability for mods to edit episode & Refactor code 2026-05-26 13:45:13 +02:00
w33b 2c016274ab Update packages 2026-05-26 12:06:54 +02:00
w33b 5ba0a55316 Update series page design & Refactor 2026-05-26 12:01:36 +02:00
w33b a6fe34a0d1 Reduce queries on home page by eager loading into cache 2026-05-24 22:15:16 +02:00
w33b bb53e06c69 Smaller style fix 2026-05-24 21:39:46 +02:00
w33b 5cae5dc658 Fix more responsiveness issues 2026-05-24 19:39:20 +02:00
w33b 356d07365f Update episode card design 2026-05-24 19:38:50 +02:00
w33b 3574d20fae Update hentai info design & Remove livewire view count 2026-05-24 15:52:22 +02:00
w33b 9fc9e8ed10 Update comments design 2026-05-24 14:49:46 +02:00
w33b f5c706b587 Fix smaller design issues 2026-05-24 12:14:19 +02:00
w33b cbea71d9ae Make home more responsive depending on screen width 2026-05-24 12:06:44 +02:00
w33b 64a621173c Fix hover to preview once and for all 2026-05-23 14:48:25 +02:00
w33b 839779b82e Update cover/thumbnail design 2026-05-23 14:43:35 +02:00
w33b 112cf9433e Make background darker 2026-05-23 14:00:11 +02:00
w33b 26a6500fca Remove unused modals 2026-05-23 11:46:30 +02:00
w33b d8cf70e747 Update more modal designs 2026-05-23 11:46:20 +02:00
w33b 0d4545c2ab Update language selector modal design 2026-05-23 11:00:18 +02:00
w33b 900103e1c2 Fix setting locale for guest users 2026-05-23 10:59:30 +02:00
w33b d4c90976f8 Fix janky loading icon & weird offset of modal 2026-05-23 10:29:07 +02:00
w33b 72263127df Update filter modal designs & Refactor code 2026-05-23 00:01:02 +02:00
w33b 6d3de59929 Update search filter design 2026-05-22 23:20:00 +02:00
w33b ddb1bc2d14 Stream page sidebar fix lightmode 2026-05-22 23:13:00 +02:00
w33b 5f3874a233 Stream page sidebar make padding consistent 2026-05-22 23:10:41 +02:00
w33b ba3650899e Refactor home template 2026-05-22 23:07:19 +02:00
w33b 904604fcfb Update hover color of tabs on home page 2026-05-22 22:44:38 +02:00
w33b b7b34b503c Update categories design 2026-05-22 22:43:57 +02:00
w33b 4928733383 Refactor by using app layout 2026-05-22 22:34:03 +02:00
w33b 6340302ac6 Update Cover & Thumbnail Design & Refactor 2026-05-22 22:16:31 +02:00
w33b c1829ba7bd Fix offset of thumbnail as guest 2026-05-22 20:59:45 +02:00
w33b 0b155bbb80 Update new comments design 2026-05-22 20:34:06 +02:00
w33b 9f959efa14 Update footer design 2026-05-22 20:31:38 +02:00
w33b 38e3346dc3 Update stats design 2026-05-22 19:41:07 +02:00
w33b 09c08f3fea Add comment restore button 2026-05-06 21:15:15 +02:00
w33b 75f631c3e6 Add MogLog System 2026-05-06 21:08:51 +02:00
w33b fdf26604f3 Add ability to delete comments by moderators 2026-05-06 19:02:50 +02:00
w33b 59cb39ca77 Fix remove role function 2026-05-06 16:27:42 +02:00
w33b 62647be75c Fix active status not showing immediatly (livewire) 2026-05-05 16:02:00 +02:00
w33b de6efb877c Add job to sync subscription keys 2026-05-04 20:22:51 +02:00
w33b 2151d69791 Add external subscription system 2026-05-04 19:12:21 +02:00
w33b 05d4ef1bdb Add Passkey Support & Pint 2026-04-21 15:56:46 +02:00
w33b 8ae9eaaadb Replace captcha package with own implementation 2026-04-20 21:36:02 +02:00
w33b 361b511c3e Update npm packages 2026-04-18 19:17:30 +02:00
w33b 1bc505057f Fix adding theme switch event listener on login page 2026-04-18 19:15:43 +02:00
w33b a78b1c41ac Remove unused code & Move files to correct folder 2026-04-18 18:42:09 +02:00
w33b 2480c5b309 Redo user comments list 2026-04-18 16:28:50 +02:00
w33b 4fc11d7329 Redo notification list 2026-04-18 15:47:06 +02:00
w33b f3e5100d5d Pint 2026-04-18 14:18:52 +02:00
w33b 5b4d3d435e Display the correct file extension on download button 2026-03-06 22:06:20 +01:00
w33b 564f816fb9 Add matrix nsfw rooms 2026-03-04 14:34:16 +01:00
w33b 3709e378c3 Fix Admin Download Buttons 2026-03-03 21:17:43 +01:00
w33b 4a45dae593 Update Readme 2026-03-01 16:07:09 +01:00
w33b 2b0448d517 Add Disclaimer and Guide for Matrix 2026-03-01 16:06:58 +01:00
w33b 3bb6af73c3 Add matrix 2026-02-18 12:34:10 +01:00
w33b 57cf153560 Update Livewire to fix CorruptComponentPayloadException 2026-02-15 14:52:16 +01:00
w33b e45fd4b148 Update axios 2026-02-15 13:02:08 +01:00
w33b d479369770 Merge branch 'laravel-12' 2026-01-18 18:54:25 +01:00
w33b b8ba17b33f Fix patreon role check (again again) 2026-01-16 15:18:29 +00:00
w33b 5a8dd12cb8 Fix patreon role check (again)
Life without typecast is hard
2026-01-16 15:11:28 +00:00
w33b 3a77c4320d Fix patreon role check
We compare the roles using a strict type check, the json contents from the discord api are strings and not integers.
2026-01-16 14:42:39 +00:00
321 changed files with 15553 additions and 6308 deletions
+3
View File
@@ -11,6 +11,7 @@
.env .env
.env.backup .env.backup
.env.production .env.production
.env.testing
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
@@ -21,3 +22,5 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
.directory .directory
/.kilo
/.cursor
+209
View File
@@ -0,0 +1,209 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/scout (SCOUT) - v10
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2
- laravel/breeze (BREEZE) - v2
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v3
## Skills Activation
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
## Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
## Searching Documentation (IMPORTANT)
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
### Search Syntax
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is perfectly fine and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
## Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
# Livewire
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
</laravel-boost-guidelines>
+7 -7
View File
@@ -2,13 +2,13 @@
## hstream Website ## hstream Website
### Install ### Install (Ubuntu)
```bash ```bash
# 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.3 php8.3-xml php8.3-mysql php8.3-gd php8.3-zip php8.3-curl php8.3-mbstring apt install php8.4 php8.4-xml php8.4-mysql php8.4-gd php8.4-zip php8.4-curl php8.4-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,7 +22,7 @@ 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.3-fpm apt install php8.4-fpm
# Install MariaDB # Install MariaDB
apt install mariadb-server apt install mariadb-server
@@ -54,7 +54,7 @@ nano /etc/supervisor/conf.d/laravel-queue.conf :
[program:laravel-queue] [program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d process_name=%(program_name)s_%(process_num)02d
command=php /var/www/hstream/artisan queue:work --queue=default --sleep=3 --tries=3 --max-time=3600 command=php84 /var/www/hstream/artisan queue:work --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true autostart=true
autorestart=true autorestart=true
stopasgroup=true stopasgroup=true
@@ -83,9 +83,9 @@ zip -r hstream_2023_11_30.zip hstream/
### Update ### Update
```bash ```bash
php artisan down php84 artisan down
git pull git pull
npm run build npm run build
php artisan view:clear && php artisan optimize:clear && php artisan cache:clear && service php8.4-fpm restart php84 artisan view:clear && php84 artisan optimize:clear && php84 artisan cache:clear && service php8.4-fpm restart
php artisan up php84 artisan up
``` ```
+2 -3
View File
@@ -3,11 +3,10 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\PopularDaily; use App\Models\PopularDaily;
use App\Models\PopularWeekly;
use App\Models\PopularMonthly; use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class AutoStats extends Command class AutoStats extends Command
{ {
+1 -2
View File
@@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Spatie\Sitemap\Sitemap; use Spatie\Sitemap\Sitemap;
@@ -17,7 +16,7 @@ class GenerateSitemap extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sitemap:generate'; protected $signature = 'app:generate-sitemap';
/** /**
* The console command description. * The console command description.
+2 -3
View File
@@ -2,9 +2,8 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Downloads;
use App\Jobs\GetFileSizeFromCDN; use App\Jobs\GetFileSizeFromCDN;
use App\Models\Downloads;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class GetFileSize extends Command class GetFileSize extends Command
@@ -28,7 +27,7 @@ class GetFileSize extends Command
*/ */
public function handle() public function handle()
{ {
foreach(Downloads::whereNull('size')->get() as $download) { foreach (Downloads::whereNull('size')->get() as $download) {
GetFileSizeFromCDN::dispatch($download->id); GetFileSizeFromCDN::dispatch($download->id);
} }
+1 -2
View File
@@ -4,9 +4,8 @@ namespace App\Console\Commands;
use App\Models\User; use App\Models\User;
use App\Models\UserDownload; use App\Models\UserDownload;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class ResetUserDownloads extends Command class ResetUserDownloads extends Command
{ {
+115 -18
View File
@@ -3,23 +3,24 @@
namespace App\Helpers; namespace App\Helpers;
use App\Models\Comment; use App\Models\Comment;
use App\Models\Downloads;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\PopularDaily;
use App\Models\PopularMonthly; use App\Models\PopularMonthly;
use App\Models\PopularWeekly; use App\Models\PopularWeekly;
use App\Models\PopularDaily; use App\Models\User;
use Conner\Tagging\Model\Tag; use Conner\Tagging\Model\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class CacheHelper class CacheHelper
{ {
public static function getRecentlyReleased(bool $guest) public static function getRecentlyReleased(bool $guest)
{ {
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
return Cache::remember("recently_released_".$guestString, now()->addMinutes(60), function () use ($guest) {
return Cache::remember('recently_released_'.$guestString, now()->addMinutes(60), function () use ($guest) {
return Episode::with('gallery') return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota'])) ->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('release_date', 'desc') ->orderBy('release_date', 'desc')
@@ -31,7 +32,8 @@ class CacheHelper
public static function getRecentlyUploaded(bool $guest) public static function getRecentlyUploaded(bool $guest)
{ {
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
return Cache::remember("recently_uploaded".$guestString, now()->addMinutes(5), function () use ($guest) {
return Cache::remember('recently_uploaded'.$guestString, now()->addMinutes(5), function () use ($guest) {
return Episode::with('gallery') return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota'])) ->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
@@ -42,32 +44,127 @@ class CacheHelper
public static function getTotalViewCount() public static function getTotalViewCount()
{ {
return Cache::remember("total_view_count", now()->addMinutes(60), function () { return Cache::remember('total_view_count', now()->addMinutes(60), function () {
return Episode::sum('view_count'); return Episode::sum('view_count');
}); });
} }
public static function getTotalEpisodeCount() public static function getTotalEpisodeCount()
{ {
return Cache::remember("total_episode_count", now()->addMinutes(60), function () { return Cache::remember('total_episode_count', now()->addMinutes(60), function () {
return Episode::count(); return Episode::count();
}); });
} }
public static function getTotalHentaiCount() public static function getTotalHentaiCount()
{ {
return Cache::remember("total_hentai_count", now()->addMinutes(60), function () { return Cache::remember('total_hentai_count', now()->addMinutes(60), function () {
return Hentai::count(); return Hentai::count();
}); });
} }
public static function getTotalUserCount()
{
return Cache::remember('total_user_count', now()->addMinutes(60), function () {
return User::count();
});
}
public static function getTotalCommentCount()
{
return Cache::remember('total_comment_count', now()->addMinutes(60), function () {
return Comment::count();
});
}
public static function getTotalLikeCount()
{
return Cache::remember('total_like_count', now()->addMinutes(60), function () {
return DB::table('markable_likes')->count();
});
}
public static function getTotalDownloadCount()
{
return Cache::remember('total_download_count', now()->addMinutes(60), function () {
return Downloads::sum('count');
});
}
public static function get4kEpisodeCount()
{
return Cache::remember('episodes_4k_count', now()->addMinutes(60), function () {
return Episode::where('interpolated', true)->count();
});
}
public static function getUHD48FpsEpisodeCount()
{
return Cache::remember('episodes_uhd48_count', now()->addMinutes(60), function () {
return Episode::where('interpolated_uhd', true)->count();
});
}
public static function getTodayViewCount()
{
return Cache::remember('today_view_count', now()->addMinutes(30), function () {
return PopularDaily::whereDate('created_at', today())->count();
});
}
public static function getWeeklyViewCount()
{
return Cache::remember('weekly_view_count', now()->addMinutes(60), function () {
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(7))->count();
});
}
public static function getPreviousWeeklyViewCount()
{
return Cache::remember('prev_weekly_view_count', now()->addMinutes(60), function () {
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(14))
->whereDate('created_at', '<', today()->subDays(7))
->count();
});
}
public static function getAverageViewsPerEpisode()
{
return Cache::remember('avg_views_per_episode', now()->addMinutes(60), function () {
$totalEpisodes = Episode::count();
if ($totalEpisodes === 0) {
return 0;
}
return round(Episode::sum('view_count') / $totalEpisodes);
});
}
public static function getNewEpisodesThisWeek()
{
return Cache::remember('new_episodes_this_week', now()->addMinutes(60), function () {
return Episode::whereDate('created_at', '>=', today()->subDays(7))->count();
});
}
public static function getTopTags()
{
return Cache::remember('top_tags_stats', now()->addMinutes(120), function () {
return Tag::where('count', '>', 0)
->orderBy('count', 'desc')
->limit(10)
->get();
});
}
public static function getPopularAllTime(bool $guest) public static function getPopularAllTime(bool $guest)
{ {
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
return Cache::remember("top_hentai_alltime".$guestString, now()->addMinutes(360), function () use ($guest) {
return Cache::remember('top_hentai_alltime'.$guestString, now()->addMinutes(360), function () use ($guest) {
return Episode::with('gallery') return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota'])) ->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('view_count','desc') ->orderBy('view_count', 'desc')
->limit(16) ->limit(16)
->get(); ->get();
}); });
@@ -75,7 +172,7 @@ class CacheHelper
public static function getPopularMonthly() public static function getPopularMonthly()
{ {
return Cache::remember("top_hentai_monthly", now()->addMinutes(360), function () { return Cache::remember('top_hentai_monthly', now()->addMinutes(360), function () {
return PopularMonthly::groupBy('episode_id') return PopularMonthly::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total')) ->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery') ->with('episode.gallery')
@@ -87,7 +184,7 @@ class CacheHelper
public static function getPopularWeekly() public static function getPopularWeekly()
{ {
return Cache::remember("top_hentai_weekly", now()->addMinutes(360), function () { return Cache::remember('top_hentai_weekly', now()->addMinutes(360), function () {
return PopularWeekly::groupBy('episode_id') return PopularWeekly::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total')) ->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery') ->with('episode.gallery')
@@ -100,7 +197,7 @@ class CacheHelper
public static function getPopularDaily() public static function getPopularDaily()
{ {
return Cache::remember("top_hentai_daily", now()->addMinutes(30), function () { return Cache::remember('top_hentai_daily', now()->addMinutes(30), function () {
return PopularDaily::groupBy('episode_id') return PopularDaily::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total')) ->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery') ->with('episode.gallery')
@@ -112,22 +209,22 @@ class CacheHelper
public static function getMostLikes() public static function getMostLikes()
{ {
return Cache::remember("top_likes", now()->addMinutes(30), function () { return Cache::remember('top_likes', now()->addMinutes(30), function () {
return DB::table('markable_likes')->groupBy('markable_id')->select('markable_id', DB::raw('count(*) as total'))->orderBy('total', 'desc')->limit(16)->get(); return DB::table('markable_likes')->groupBy('markable_id')->select('markable_id', DB::raw('count(*) as total'))->orderBy('total', 'desc')->limit(16)->get();
}); });
} }
public static function getAllTags() public static function getAllTags()
{ {
return Cache::remember("all_tags", now()->addMinutes(10080), function () { return Cache::remember('all_tags', now()->addMinutes(10080), function () {
return Tag::where('count', '>', 0)->orderBy('slug', 'ASC')->get(); return Tag::where('count', '>', 0)->orderBy('slug', 'ASC')->get();
}); });
} }
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 Comment::latest()->take(10)->get(); return Comment::with('user')->latest()->take(10)->get();
}); });
} }
} }
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Conner\Tagging\Model\Tag;
class FilterCategories
{
public static function getFilterCategories()
{
$taglist = Cache::remember(
'searchtags',
300,
fn () => Tag::where('count', '>', 0)
->orderBy('slug')
->get(),
);
$appearances = [
'Loli',
'Shota',
'Milf',
'Futanari',
'Big Boobs',
'Small Boobs',
'Dark Skin',
'Cosplay',
'Elf',
'Maid',
'Nekomimi',
'Nurse',
'School Girl',
'Succubus',
'Teacher',
'Trap',
'Pregnant',
'Glasses',
'Swim Suit',
'Ugly Bastard',
'Monster',
];
$types = [
'3D',
'4K',
'48Fps',
'4K 48Fps',
'Censored',
'Uncensored',
'Comedy',
'Fantasy',
'Horror',
'Vanilla',
'Ntr',
'Pov',
'Filmed',
'X-Ray',
];
$actions = [
'Anal',
'Bdsm',
'Facial',
'Blow Job',
'Boob Job',
'Foot Job',
'Hand Job',
'Rimjob',
'Inflation',
'Masturbation',
'Public Sex',
'Rape',
'Reverse Rape',
'Threesome',
'Orgy',
'Gangbang',
];
$excluded = [...$appearances, ...$types, ...$actions];
$categories = [
'Genres' => $taglist
->reject(fn ($tag) => in_array($tag->name, $excluded))
->pluck('name')
->toArray(),
'Actions' => $actions,
'Appearance' => collect($appearances)
->reject(function ($tag) {
return Auth::guest() && in_array($tag, ['Loli', 'Shota']);
})
->toArray(),
'Types' => $types,
];
return $categories;
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ class GitHelper
{ {
public static function shortCommit() public static function shortCommit()
{ {
return Cache::remember("git_commit", now()->addMinutes(60), function () { return Cache::remember('git_commit', now()->addMinutes(60), function () {
try { try {
return trim(exec('git rev-parse --short HEAD')); return trim(exec('git rev-parse --short HEAD'));
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -4,14 +4,16 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Alert; use App\Models\Alert;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View;
class AlertController extends Controller class AlertController extends Controller
{ {
/** /**
* Display alert index page * Display alert index page
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('admin.alert.index'); return view('admin.alert.index');
} }
@@ -19,7 +21,7 @@ class AlertController extends Controller
/** /**
* Create Alert. * Create Alert.
*/ */
public function store(Request $request): \Illuminate\Http\RedirectResponse public function store(Request $request): RedirectResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'message' => 'required|string|max:255', 'message' => 'required|string|max:255',
@@ -39,7 +41,7 @@ class AlertController extends Controller
/** /**
* Delete Alert. * Delete Alert.
*/ */
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse public function delete(int $alert_id): RedirectResponse
{ {
Alert::where('id', $alert_id)->delete(); Alert::where('id', $alert_id)->delete();
@@ -3,13 +3,14 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\View\View;
class CommentsController extends Controller class CommentsController extends Controller
{ {
/** /**
* Display Comments Page. * Display Comments Page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('admin.comments.index'); return view('admin.comments.index');
} }
@@ -4,25 +4,27 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Contact; use App\Models\Contact;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ContactController extends Controller class ContactController extends Controller
{ {
/** /**
* Display Contact Page. * Display Contact Page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
$contacts = Contact::orderBy('created_at', 'DESC')->get(); $contacts = Contact::orderBy('created_at', 'DESC')->get();
return view('admin.contact.index', [ return view('admin.contact.index', [
'contacts' => $contacts 'contacts' => $contacts,
]); ]);
} }
/** /**
* Delete Contact. * Delete Contact.
*/ */
public function delete(int $contact_id): \Illuminate\Http\RedirectResponse public function delete(int $contact_id): RedirectResponse
{ {
Contact::where('id', $contact_id)->delete(); Contact::where('id', $contact_id)->delete();
@@ -2,19 +2,22 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Episode;
use App\Jobs\DiscordReleaseNotification; use App\Jobs\DiscordReleaseNotification;
use App\Models\Episode;
use App\Services\DownloadService; use App\Services\DownloadService;
use App\Services\EpisodeService; use App\Services\EpisodeService;
use App\Services\GalleryService; use App\Services\GalleryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class EpisodeController extends Controller class EpisodeController extends Controller
{ {
protected EpisodeService $episodeService; protected EpisodeService $episodeService;
protected GalleryService $galleryService; protected GalleryService $galleryService;
protected DownloadService $downloadService; protected DownloadService $downloadService;
public function __construct( public function __construct(
@@ -28,39 +31,22 @@ class EpisodeController extends Controller
} }
/** /**
* Add Episode to existing series * Edit Episode
*/ */
public function store(Request $request): \Illuminate\Http\RedirectResponse public function update(Request $request): RedirectResponse
{ {
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail(); $episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
// Create Episode if ($request->user()->hasRole(UserRole::MODERATOR)) {
$episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode); $this->episodeService->updateEpisodeModerator($request, $episode->id);
$this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1);
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($referenceEpisode->title." - ".$episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episode->slug, 'release');
}
cache()->flush(); cache()->flush();
return to_route('hentai.index', [ return to_route('hentai.index', [
'title' => $episode->slug 'title' => $episode->slug,
]); ]);
} }
/**
* Edit Episode
*/
public function update(Request $request): \Illuminate\Http\RedirectResponse
{
$episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value); $studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
$oldinterpolated = $episode->interpolated; $oldinterpolated = $episode->interpolated;
@@ -87,7 +73,7 @@ class EpisodeController extends Controller
cache()->flush(); cache()->flush();
return to_route('hentai.index', [ return to_route('hentai.index', [
'title' => $episode->slug 'title' => $episode->slug,
]); ]);
} }
} }
@@ -3,86 +3,15 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Hentai; use Illuminate\View\View;
use App\Jobs\DiscordReleaseNotification;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Http\Request;
class ReleaseController extends Controller class ReleaseController extends Controller
{ {
protected EpisodeService $episodeService;
protected GalleryService $galleryService;
protected DownloadService $downloadService;
public function __construct(
EpisodeService $episodeService,
GalleryService $galleryService,
DownloadService $downloadService
) {
$this->episodeService = $episodeService;
$this->galleryService = $galleryService;
$this->downloadService = $downloadService;
}
/** /**
* Display release page * Display release page
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('admin.release.create'); return view('admin.release.create');
} }
/**
* Upload New Hentai with One or Multipe Episodes
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
// Create new Hentai or find existing one
$slug = $this->episodeService->generateSlug($request->input('title'));
$hentai = Hentai::where('slug', $slug)->first();
// If hentai exists and was created today, return to home
if ($hentai?->created_at->isToday()) {
return to_route('home.index');
}
// If hentai does not exist, create a new instance
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $request->input('description1')]
);
// Studio
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
// Create Episode(s)
$releasedEpisodes = [];
for ($i = 1; $i <= $request->input('episodes'); $i++) {
$episode = $this->episodeService->createEpisode($request, $hentai, $i, $studio);
$this->episodeService->createOrUpdateCover($request, $episode, $slug, $i);
$this->downloadService->createOrUpdateDownloads($request, $episode, $i);
$this->galleryService->createOrUpdateGallery($request, $hentai, $episode, $i);
$releasedEpisodes[] = $episode->slug;
}
if ($request->has('censored')) {
DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored');
} else {
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
}
}
cache()->flush();
return to_route('home.index');
}
} }
@@ -3,22 +3,22 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SiteBackground; use App\Models\SiteBackground;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Intervention\Image\Laravel\Facades\Image; use Illuminate\View\View;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
class SiteBackgroundController extends Controller class SiteBackgroundController extends Controller
{ {
/** /**
* Display admin index page * Display admin index page
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('admin.background.index', [ return view('admin.background.index', [
'images' => SiteBackground::all(), 'images' => SiteBackground::all(),
@@ -28,7 +28,7 @@ class SiteBackgroundController extends Controller
/** /**
* Create new site backgrounds * Create new site backgrounds
*/ */
public function create(Request $request): \Illuminate\Http\RedirectResponse public function create(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'images' => 'required', 'images' => 'required',
@@ -36,7 +36,7 @@ class SiteBackgroundController extends Controller
'date_end' => 'required', 'date_end' => 'required',
]); ]);
foreach($request->file('images') as $file) { foreach ($request->file('images') as $file) {
// Initiating a database transaction in case something goes wrong. // Initiating a database transaction in case something goes wrong.
DB::beginTransaction(); DB::beginTransaction();
@@ -44,24 +44,25 @@ class SiteBackgroundController extends Controller
$bg = SiteBackground::create(array_merge( $bg = SiteBackground::create(array_merge(
$request->only(['date_start', 'date_end']), $request->only(['date_start', 'date_end']),
[ [
'default' => (bool) $request->input('default', false) 'default' => (bool) $request->input('default', false),
] ]
)); ));
$resolutions = [1440, 1080, 720, 640]; $resolutions = [1440, 1080, 720, 640];
foreach($resolutions as $resolution) { foreach ($resolutions as $resolution) {
// /images/background/1-2560p.webp // /images/background/1-2560p.webp
$targetPath = "/images/background/{$bg->id}-{$resolution}p.webp"; $targetPath = "/images/background/{$bg->id}-{$resolution}p.webp";
Image::read($file->getRealPath()) Image::read($file->getRealPath())
->scaleDown(height: $resolution) ->scaleDown(height: $resolution)
->encode(new WebpEncoder()) ->encode(new WebpEncoder)
->save(public_path($targetPath)); ->save(public_path($targetPath));
} }
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
Log::error($e->getMessage()); Log::error($e->getMessage());
return redirect()->back(); return redirect()->back();
} }
@@ -74,7 +75,7 @@ class SiteBackgroundController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function update(Request $request): \Illuminate\Http\RedirectResponse public function update(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'id' => 'required|exists:site_backgrounds,id', 'id' => 'required|exists:site_backgrounds,id',
@@ -85,7 +86,7 @@ class SiteBackgroundController extends Controller
SiteBackground::where('id', $request->input('id'))->update(array_merge( SiteBackground::where('id', $request->input('id'))->update(array_merge(
$request->only(['date_start', 'date_end']), $request->only(['date_start', 'date_end']),
[ [
'default' => (bool) $request->input('default', false) 'default' => (bool) $request->input('default', false),
] ]
)); ));
@@ -97,7 +98,7 @@ class SiteBackgroundController extends Controller
/** /**
* Delete backround * Delete backround
*/ */
public function delete(Request $request): \Illuminate\Http\RedirectResponse public function delete(Request $request): RedirectResponse
{ {
$id = $request->input('id'); $id = $request->input('id');
@@ -109,13 +110,14 @@ class SiteBackgroundController extends Controller
$resolutions = [1440, 1080, 720, 640]; $resolutions = [1440, 1080, 720, 640];
try { try {
foreach($resolutions as $resolution) { foreach ($resolutions as $resolution) {
$targetPath = "/images/background/{$id}-{$resolution}p.webp"; $targetPath = "/images/background/{$id}-{$resolution}p.webp";
File::delete(public_path($targetPath)); File::delete(public_path($targetPath));
} }
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
Log::error($e->getMessage()); Log::error($e->getMessage());
return redirect()->back(); return redirect()->back();
} }
@@ -2,10 +2,11 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Episode; use App\Models\Episode;
use App\Models\EpisodeSubtitle; use App\Models\EpisodeSubtitle;
use App\Models\Subtitle; use App\Models\Subtitle;
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SubtitleController extends Controller class SubtitleController extends Controller
@@ -13,7 +14,7 @@ class SubtitleController extends Controller
/** /**
* Add new Subtitle. * Add new Subtitle.
*/ */
public function store(Request $request): \Illuminate\Http\RedirectResponse public function store(Request $request): RedirectResponse
{ {
$subtitle = Subtitle::create([ $subtitle = Subtitle::create([
'name' => $request->name, 'name' => $request->name,
@@ -32,12 +33,12 @@ class SubtitleController extends Controller
/** /**
* Update Episode Subtitles. * Update Episode Subtitles.
*/ */
public function update(Request $request): \Illuminate\Http\RedirectResponse public function update(Request $request): RedirectResponse
{ {
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail(); $episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
// Clear everything // Clear everything
foreach($episode->subtitles as $sub) { foreach ($episode->subtitles as $sub) {
$sub->delete(); $sub->delete();
} }
@@ -3,16 +3,17 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View;
class UserController extends Controller class UserController extends Controller
{ {
/** /**
* Display Users Page. * Display Users Page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('admin.users.index'); return view('admin.users.index');
} }
@@ -27,7 +28,6 @@ class UserController extends Controller
'action' => 'required', 'action' => 'required',
]); ]);
$user = User::findOrFail($validated['id']); $user = User::findOrFail($validated['id']);
switch ($validated['action']) { switch ($validated['action']) {
@@ -40,7 +40,7 @@ class UserController extends Controller
alert()->success('Unbanned', 'User has been unbanned.'); alert()->success('Unbanned', 'User has been unbanned.');
break; break;
default: default:
alert()->error('Error','Invalid action provided'); alert()->error('Error', 'Invalid action provided');
} }
return redirect()->back(); return redirect()->back();
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Helpers\CacheHelper; use App\Helpers\CacheHelper;
use App\Http\Controllers\Controller;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Studios; use App\Models\Studios;
use App\Models\Subtitle; use App\Models\Subtitle;
use App\Http\Controllers\Controller;
class AdminApiController extends Controller class AdminApiController extends Controller
{ {
@@ -2,13 +2,11 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Downloads; use App\Models\Downloads;
use App\Models\Episode; use App\Models\Episode;
use App\Rules\ValidCaptcha;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use GrantHolle\Altcha\Rules\ValidAltcha;
class DownloadApiController extends Controller class DownloadApiController extends Controller
{ {
@@ -19,7 +17,7 @@ class DownloadApiController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'episode_id' => ['required'], 'episode_id' => ['required'],
'captcha' => ['required', new ValidAltcha], 'captcha' => ['required', new ValidCaptcha],
]); ]);
$episode = Episode::where('id', $request->input('episode_id')) $episode = Episode::where('id', $request->input('episode_id'))
@@ -2,12 +2,11 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\PopularMonthly; use App\Models\PopularMonthly;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Http\Controllers\Controller;
class HentaiApiController extends Controller class HentaiApiController extends Controller
{ {
@@ -3,10 +3,15 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Episode; use App\Models\Episode;
use App\Models\VideoEngagement;
use App\Models\Watched;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
class StreamApiController extends Controller class StreamApiController extends Controller
{ {
@@ -22,7 +27,7 @@ class StreamApiController extends Controller
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail(); $episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
$subtitles = $episode->subtitles $subtitles = $episode->subtitles
->mapWithKeys(fn($sub) => [$sub->subtitle->slug => $sub->subtitle->name]) ->mapWithKeys(fn ($sub) => [$sub->subtitle->slug => $sub->subtitle->name])
->toArray(); ->toArray();
return response()->json([ return response()->json([
@@ -33,7 +38,106 @@ class StreamApiController extends Controller
'stream_url' => $episode->dmca_takedown ? 'stuff/dmca' : $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,
], 200); ], 200);
} }
/**
* Track that the authenticated user has watched the episode.
* Called client-side after 10 seconds of playback.
*/
public function trackWatched(Request $request): JsonResponse
{
$validated = $request->validate([
'episode_id' => 'required|integer|exists:episodes,id',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['watched' => false], 401);
}
$episodeId = $request->input('episode_id');
// 1-hour cooldown to prevent duplicate entries
$time = Carbon::now()->subHour(1);
$alreadyWatched = Watched::where('user_id', $user->id)
->where('episode_id', $episodeId)
->where('created_at', '>=', $time)
->exists();
if (! $alreadyWatched) {
Watched::create(['user_id' => $user->id, 'episode_id' => $episodeId]);
cache()->forget('user'.$user->id.'watched'.$episodeId);
}
return response()->json(['watched' => true], 200);
}
/**
* Track engagement segments that the authenticated user has watched.
* Called periodically client-side while the video is playing.
* Segments are 10-second chunks; segment 0 (0-10s) is excluded.
*/
public function trackEngagement(Request $request): JsonResponse
{
$validated = $request->validate([
'episode_id' => 'required|integer|exists:episodes,id',
'segments' => 'required|array',
'segments.*' => 'integer|min:1',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['tracked' => 0], 401);
}
$rateLimitKey = 'engagement:'.$user->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
return response()->json([
'tracked' => 0,
'message' => 'Rate limit exceeded. Try again in '.$seconds.' seconds.',
], 429);
}
RateLimiter::hit($rateLimitKey, 60);
$episodeId = $request->input('episode_id');
$segments = $request->input('segments');
$tracked = 0;
foreach ($segments as $segment) {
// upsert: insert if not exists, otherwise ignore (unique constraint prevents dupes)
VideoEngagement::firstOrCreate([
'episode_id' => $episodeId,
'user_id' => $user->id,
'segment' => $segment,
]);
$tracked++;
}
return response()->json(['tracked' => $tracked], 200);
}
/**
* Get engagement heatmap data for an episode.
* Returns watch counts per segment, aggregated across all users.
*/
public function getEngagement(int $episodeId): JsonResponse
{
$episode = Episode::findOrFail($episodeId);
$data = cache()->remember(
"engagement:{$episodeId}",
600, // 10-minute cache
fn () => VideoEngagement::where('episode_id', $episodeId)
->select('segment', DB::raw('count(*) as watch_count'))
->groupBy('segment')
->pluck('watch_count', 'segment')
->toArray()
);
return response()->json($data, 200);
}
} }
@@ -4,9 +4,8 @@ namespace App\Http\Controllers\Api;
use App\Helpers\CacheHelper; use App\Helpers\CacheHelper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Conner\Tagging\Model\Tag; use Conner\Tagging\Model\Tag;
use Illuminate\Http\Request;
class UserApiController extends Controller class UserApiController extends Controller
{ {
@@ -33,11 +32,10 @@ class UserApiController extends Controller
} }
} }
return response()->json([ return response()->json([
'message' => 'success', 'message' => 'success',
'tags' => $tagWhiteList, 'tags' => $tagWhiteList,
'usertags' => $tagBlackList 'usertags' => $tagBlackList,
], 200); ], 200);
} }
} }
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
use AltchaOrg\Altcha\Altcha;
use AltchaOrg\Altcha\CreateChallengeOptions;
use App\Http\Controllers\Controller;
class CaptchaController extends Controller
{
public function create(): array
{
$pbkdf2 = new Pbkdf2;
$altcha = new Altcha(
hmacSignatureSecret: config('captcha.hmac_key'),
);
// Create challenge
$challenge = $altcha->createChallenge(new CreateChallengeOptions(
algorithm: $pbkdf2,
cost: 5000,
counter: random_int(5000, 10000),
expiresAt: time() + 600,
));
return get_object_vars($challenge);
}
}
@@ -3,15 +3,14 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
class DiscordAuthController extends Controller class DiscordAuthController extends Controller
{ {
@@ -28,11 +27,16 @@ class DiscordAuthController extends Controller
*/ */
public function callback(): RedirectResponse public function callback(): RedirectResponse
{ {
try {
$discordUser = Socialite::driver('discord')->user(); $discordUser = Socialite::driver('discord')->user();
} catch (InvalidStateException $e) {
return redirect()->route('login')
->with('error', 'Your login session expired. Please try signing in again.');
}
$user = User::where('discord_id', $discordUser->id)->first(); $user = User::where('discord_id', $discordUser->id)->first();
if (!$user) { if (! $user) {
// link by email if it already exists // link by email if it already exists
$user = User::where('email', $discordUser->email)->first(); $user = User::where('email', $discordUser->email)->first();
@@ -77,7 +81,7 @@ class DiscordAuthController extends Controller
private function checkDiscordRoles(User $user): void private function checkDiscordRoles(User $user): void
{ {
// Should not ever happen // Should not ever happen
if (!$user->discord_id) { if (! $user->discord_id) {
return; return;
} }
@@ -90,6 +94,7 @@ class DiscordAuthController extends Controller
// User is not in the guild // User is not in the guild
if ($response->status() === 404) { if ($response->status() === 404) {
$user->removeRole(UserRole::SUPPORTER); $user->removeRole(UserRole::SUPPORTER);
return; return;
} }
@@ -109,11 +114,12 @@ class DiscordAuthController extends Controller
$patreonRoles = config('discord.patreon_roles', []); $patreonRoles = config('discord.patreon_roles', []);
// If intersect of array is empty, then the user doesn't have the role // If intersect of array is empty, then the user doesn't have the role
$hasSupporterRole = !empty(array_intersect($discordRoles, $patreonRoles)); $hasSupporterRole = ! empty(array_intersect($discordRoles, $patreonRoles));
if (!$hasSupporterRole) { if (! $hasSupporterRole) {
// Remove role if not found // Remove role if not found
$user->removeRole(UserRole::SUPPORTER); $user->removeRole(UserRole::SUPPORTER);
return; return;
} }
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class NewPasswordController extends Controller class NewPasswordController extends Controller
@@ -26,7 +27,7 @@ class NewPasswordController extends Controller
/** /**
* Handle an incoming new password request. * Handle an incoming new password request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
@@ -16,8 +16,7 @@ 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 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)) if ($request->user()->discord_id && is_null($request->user()->password)) {
{
$validated = $request->validateWithBag('updatePassword', [ $validated = $request->validateWithBag('updatePassword', [
'password' => ['required', Password::defaults(), 'confirmed'], 'password' => ['required', Password::defaults(), 'confirmed'],
]); ]);
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class PasswordResetLinkController extends Controller class PasswordResetLinkController extends Controller
@@ -21,7 +22,7 @@ class PasswordResetLinkController extends Controller
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
@@ -4,21 +4,21 @@ 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\Rules\ValidCaptcha;
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\Validation\ValidationException;
use GrantHolle\Altcha\Rules\ValidAltcha;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
@@ -26,7 +26,7 @@ class RegisteredUserController extends Controller
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', '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], 'altcha' => ['required', new ValidCaptcha],
]); ]);
$user = User::create([ $user = User::create([
+7 -6
View File
@@ -3,16 +3,17 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Contact; use App\Models\Contact;
use App\Rules\ValidCaptcha;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View;
use GrantHolle\Altcha\Rules\ValidAltcha;
class ContactController extends Controller class ContactController extends Controller
{ {
/** /**
* Display Contact Page. * Display Contact Page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('contact.form'); return view('contact.form');
} }
@@ -20,17 +21,17 @@ class ContactController extends Controller
/** /**
* Store Contact Submission. * Store Contact Submission.
*/ */
public function store(Request $request): \Illuminate\Http\RedirectResponse public function store(Request $request): RedirectResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|max:30', 'name' => 'required|max:30',
'email' => 'required|max:50', 'email' => 'required|max:50',
'message' => 'required|max:1000', 'message' => 'required|max:1000',
'subject' => 'required|max:50', 'subject' => 'required|max:50',
'altcha' => ['required', new ValidAltcha], 'altcha' => ['required', new ValidCaptcha],
]); ]);
$contact = new Contact(); $contact = new Contact;
$contact->name = $request->input('name'); $contact->name = $request->input('name');
$contact->email = $request->input('email'); $contact->email = $request->input('email');
$contact->message = $request->input('message'); $contact->message = $request->input('message');
+26 -14
View File
@@ -2,32 +2,32 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Episode;
use App\Helpers\CacheHelper; use App\Helpers\CacheHelper;
use App\Models\Episode;
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\Cookie; use Illuminate\View\View;
class HomeController extends Controller class HomeController extends Controller
{ {
/** /**
* Display Home Page. * Display Home Page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
$guest = Auth::guest(); $guest = Auth::guest();
$guestString = $guest ? 'guest' : 'authed'; $guestString = $guest ? 'guest' : 'authed';
$mostLikes = \cache()->remember('mostLikes'.$guestString, 300, fn () => $mostLikes = \cache()->remember('mostLikes'.$guestString, 300, fn () => Episode::with('gallery')
Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota'])) ->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->whereIn('id', function($query) { ->whereIn('id', function ($query) {
$mostLikesIds = CacheHelper::getMostLikes()->pluck('markable_id')->toArray(); $mostLikesIds = CacheHelper::getMostLikes()->pluck('markable_id')->toArray();
$query->selectRaw('id') $query->selectRaw('id')
->from('episodes') ->from('episodes')
->whereIn('id', $mostLikesIds) ->whereIn('id', $mostLikesIds)
->orderByRaw("FIELD(id, " . implode(',', $mostLikesIds) . ")"); ->orderByRaw('FIELD(id, '.implode(',', $mostLikesIds).')');
}) })
->get() ->get()
); );
@@ -47,7 +47,7 @@ class HomeController extends Controller
/** /**
* Display Banned Page. * Display Banned Page.
*/ */
public function banned(): \Illuminate\View\View public function banned(): View
{ {
return view('auth.banned'); return view('auth.banned');
} }
@@ -56,7 +56,7 @@ class HomeController extends Controller
* Redirects to a random Hentai episode * Redirects to a random Hentai episode
* Done due to performance reasons * Done due to performance reasons
*/ */
public function random(): \Illuminate\Http\RedirectResponse public function random(): RedirectResponse
{ {
$random = Episode::inRandomOrder() $random = Episode::inRandomOrder()
->limit(1) ->limit(1)
@@ -71,7 +71,7 @@ class HomeController extends Controller
/** /**
* Display Search Page. * Display Search Page.
*/ */
public function search(): \Illuminate\View\View public function search(): View
{ {
return view('search.index'); return view('search.index');
} }
@@ -79,7 +79,7 @@ class HomeController extends Controller
/** /**
* Display Download Search Page. * Display Download Search Page.
*/ */
public function downloadSearch(): \Illuminate\View\View public function downloadSearch(): View
{ {
return view('search.download'); return view('search.download');
} }
@@ -87,7 +87,7 @@ class HomeController extends Controller
/** /**
* Redirect POST Data to GET with Query String. * Redirect POST Data to GET with Query String.
*/ */
public function searchRedirect(Request $request): \Illuminate\Http\RedirectResponse public function searchRedirect(Request $request): RedirectResponse
{ {
return redirect()->route('hentai.search', [ return redirect()->route('hentai.search', [
'search' => $request->input('live-search'), 'search' => $request->input('live-search'),
@@ -97,19 +97,31 @@ class HomeController extends Controller
/** /**
* Display Stats Page. * Display Stats Page.
*/ */
public function stats(): \Illuminate\View\View public function stats(): View
{ {
return view('home.stats', [ return view('home.stats', [
'viewCount' => CacheHelper::getTotalViewCount(), 'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(), 'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'hentaiCount' => CacheHelper::getTotalHentaiCount(), 'hentaiCount' => CacheHelper::getTotalHentaiCount(),
'userCount' => CacheHelper::getTotalUserCount(),
'commentCount' => CacheHelper::getTotalCommentCount(),
'likeCount' => CacheHelper::getTotalLikeCount(),
'downloadCount' => CacheHelper::getTotalDownloadCount(),
'episodes4k' => CacheHelper::get4kEpisodeCount(),
'episodesUHD48' => CacheHelper::getUHD48FpsEpisodeCount(),
'todayViews' => CacheHelper::getTodayViewCount(),
'weeklyViews' => CacheHelper::getWeeklyViewCount(),
'prevWeeklyViews' => CacheHelper::getPreviousWeeklyViewCount(),
'avgViewsPerEpisode' => CacheHelper::getAverageViewsPerEpisode(),
'newEpisodesThisWeek' => CacheHelper::getNewEpisodesThisWeek(),
'topTags' => CacheHelper::getTopTags(),
]); ]);
} }
/** /**
* Manually set website language * Manually set website language
*/ */
public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse public function updateLanguage(Request $request): RedirectResponse
{ {
abort_unless(in_array($request->language, config('app.supported_locales'), true), 404); abort_unless(in_array($request->language, config('app.supported_locales'), true), 404);
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\MatrixRegisterRequest;
use App\Services\MatrixRegistrationService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MatrixController extends Controller
{
/**
* Display the user page.
*/
public function index(Request $request): View
{
$rooms = [
['name' => '🏠 General', 'description' => 'Our main chat.', 'alias' => 'https://matrix.to/#/#general:hstream.moe'],
['name' => '📡 Releases', 'description' => 'Were we @everyone for new releases.', 'alias' => 'https://matrix.to/#/#releases:hstream.moe'],
['name' => '👗 NSFW 2D', 'description' => 'Channel for R18 2D Media.', 'alias' => 'https://matrix.to/#/#nsfw:hstream.moe'],
['name' => '👗 NSFW IRL', 'description' => 'Channel for R18 IRL Media.', 'alias' => 'https://matrix.to/#/#nsfw-irl:hstream.moe'],
];
return view('matrix.index', [
'user' => $request->user(),
'rooms' => $rooms,
]);
}
/**
* Create matrix user
*/
public function store(
MatrixRegisterRequest $request,
MatrixRegistrationService $matrixService
) {
try {
$result = $matrixService->registerUser(
$request->username,
$request->password
);
$user = $request->user();
$user->matrix_id = $result['user_id'];
$user->save();
return redirect()
->back()
->with('success', 'Matrix user created successfully.');
} catch (\Exception $e) {
return back()
->withErrors([
'username' => $e->getMessage(),
])
->withInput();
}
}
}
@@ -2,15 +2,16 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\View\View;
class NotificationController extends Controller class NotificationController extends Controller
{ {
/** /**
* Display the user's notification page. * Display the user's notification page.
*/ */
public function index(Request $request): \Illuminate\View\View public function index(Request $request): View
{ {
return view('profile.notifications', [ return view('profile.notifications', [
'user' => $request->user(), 'user' => $request->user(),
@@ -19,10 +20,17 @@ class NotificationController extends Controller
} }
/** /**
* Delete Notifcation * Delete a notification or clear all.
*/ */
public function delete(Request $request): \Illuminate\Http\RedirectResponse public function delete(Request $request): RedirectResponse
{ {
// Clear all notifications if no specific ID is provided
if (! $request->has('id')) {
$request->user()->notifications()->delete();
return redirect()->back()->with('status', 'notifications-cleared');
}
$request->validate([ $request->validate([
'id' => 'required|exists:notifications,id', 'id' => 'required|exists:notifications,id',
]); ]);
+51 -22
View File
@@ -6,8 +6,10 @@ use App\Models\Episode;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistEpisode; use App\Models\PlaylistEpisode;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use RealRashid\SweetAlert\Facades\Alert; use Illuminate\View\View;
class PlaylistController extends Controller class PlaylistController extends Controller
{ {
@@ -21,7 +23,7 @@ class PlaylistController extends Controller
/** /**
* Display the public playlists page. * Display the public playlists page.
*/ */
public function index(): \Illuminate\View\View public function index(): View
{ {
return view('playlist.index'); return view('playlist.index');
} }
@@ -29,9 +31,9 @@ class PlaylistController extends Controller
/** /**
* Display public playlist. * Display public playlist.
*/ */
public function show($playlist_id): \Illuminate\View\View public function show($playlist_id): View
{ {
if (!is_numeric($playlist_id)) { if (! is_numeric($playlist_id)) {
abort(404); abort(404);
} }
@@ -42,14 +44,13 @@ class PlaylistController extends Controller
]); ]);
} }
/** /**
* Display the user's playlists page. * Display the user's playlists page.
*/ */
public function playlists(Request $request): \Illuminate\View\View public function playlists(Request $request): View
{ {
$title = 'Delete Playlist!'; $title = 'Delete Playlist!';
$text = "Are you sure you want to delete?"; $text = 'Are you sure you want to delete?';
confirmDelete($title, $text); confirmDelete($title, $text);
return view('profile.playlists', [ return view('profile.playlists', [
@@ -61,9 +62,9 @@ class PlaylistController extends Controller
/** /**
* Display user's playlist. * Display user's playlist.
*/ */
public function showPlaylist(Request $request, $playlist_id): \Illuminate\View\View public function showPlaylist(Request $request, $playlist_id): View
{ {
if (!is_numeric($playlist_id)) { if (! is_numeric($playlist_id)) {
abort(404); abort(404);
} }
@@ -79,13 +80,13 @@ class PlaylistController extends Controller
/** /**
* Create user playlist (Form). * Create user playlist (Form).
*/ */
public function createPlaylist(Request $request): \Illuminate\Http\RedirectResponse public function createPlaylist(Request $request): RedirectResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|max:30', 'name' => 'required|max:30',
]); ]);
$playlist = new Playlist(); $playlist = new Playlist;
$playlist->user_id = $request->user()->id; $playlist->user_id = $request->user()->id;
$playlist->name = $request->input('name'); $playlist->name = $request->input('name');
$playlist->is_private = $request->input('visiblity') === 'private'; $playlist->is_private = $request->input('visiblity') === 'private';
@@ -94,12 +95,40 @@ class PlaylistController extends Controller
return to_route('profile.playlists'); return to_route('profile.playlists');
} }
/**
* Update user playlist.
*/
public function updatePlaylist(Request $request, $playlist_id): RedirectResponse
{
if (! is_numeric($playlist_id)) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|max:30',
'is_private' => 'required|boolean',
]);
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)
->where('id', $playlist_id)
->firstOrFail();
$playlist->update([
'name' => $request->input('name'),
'is_private' => $request->input('is_private'),
]);
return back()->with('status', 'playlist-updated');
}
/** /**
* Delete user playlist. * Delete user playlist.
*/ */
public function deletePlaylist(Request $request, $playlist_id): \Illuminate\Http\RedirectResponse public function deletePlaylist(Request $request, $playlist_id): RedirectResponse
{ {
if (!is_numeric($playlist_id)) { if (! is_numeric($playlist_id)) {
abort(404); abort(404);
} }
@@ -117,9 +146,9 @@ class PlaylistController extends Controller
/** /**
* Delete episode from playlist. * Delete episode from playlist.
*/ */
public function deleteEpisodeFromPlaylist(Request $request): \Illuminate\Http\JsonResponse public function deleteEpisodeFromPlaylist(Request $request): JsonResponse
{ {
if (!is_numeric($request->input('playlist')) || !is_numeric($request->input('episode'))) { if (! is_numeric($request->input('playlist')) || ! is_numeric($request->input('episode'))) {
return response()->json([ return response()->json([
'message' => 'not-numeric', 'message' => 'not-numeric',
'user' => $request->user(), 'user' => $request->user(),
@@ -145,13 +174,13 @@ class PlaylistController extends Controller
/** /**
* Add to user playlist (API). * Add to user playlist (API).
*/ */
public function addPlaylistApi(Request $request): \Illuminate\Http\JsonResponse public function addPlaylistApi(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$validated = $request->validate([ $validated = $request->validate([
'playlist' => 'required|max:30', 'playlist' => 'required|max:30',
'episode_id' => 'required' 'episode_id' => 'required',
]); ]);
$playlist = Playlist::where('user_id', $user->id)->where('id', $request->input('playlist'))->firstOrFail(); $playlist = Playlist::where('user_id', $user->id)->where('id', $request->input('playlist'))->firstOrFail();
@@ -161,7 +190,7 @@ class PlaylistController extends Controller
$exists = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->exists(); $exists = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->exists();
if ($exists) { if ($exists) {
return response()->json([ return response()->json([
'message' => 'already-added' 'message' => 'already-added',
], 200); ], 200);
} }
@@ -175,20 +204,20 @@ class PlaylistController extends Controller
]); ]);
return response()->json([ return response()->json([
'message' => 'success' 'message' => 'success',
], 200); ], 200);
} }
/** /**
* Create user playlist (API). * Create user playlist (API).
*/ */
public function createPlaylistApi(Request $request): \Illuminate\Http\JsonResponse public function createPlaylistApi(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|max:30', 'name' => 'required|max:30',
]); ]);
$playlist = new Playlist(); $playlist = new Playlist;
$playlist->user_id = $request->user()->id; $playlist->user_id = $request->user()->id;
$playlist->name = $request->input('name'); $playlist->name = $request->input('name');
$playlist->is_private = $request->input('visiblity') === 'private'; $playlist->is_private = $request->input('visiblity') === 'private';
@@ -196,7 +225,7 @@ class PlaylistController extends Controller
return response()->json([ return response()->json([
'message' => 'success', 'message' => 'success',
'playlist_id' => $playlist->id 'playlist_id' => $playlist->id,
], 200); ], 200);
} }
} }
+11 -12
View File
@@ -2,22 +2,21 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use App\Models\Episode; use App\Models\Episode;
use App\Models\User; use App\Models\User;
use App\Http\Requests\ProfileUpdateRequest; use Conner\Tagging\Model\Tag;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
use Intervention\Image\Laravel\Facades\Image; use Intervention\Image\Laravel\Facades\Image;
use Conner\Tagging\Model\Tag;
class ProfileController extends Controller class ProfileController extends Controller
{ {
/** /**
@@ -118,9 +117,10 @@ class ProfileController extends Controller
$user = $request->user(); $user = $request->user();
$tags = json_decode($request->input('tags')); $tags = json_decode($request->input('tags'));
if (!$tags) { if (! $tags) {
$user->tag_blacklist = null; $user->tag_blacklist = null;
$user->save(); $user->save();
return Redirect::route('profile.settings')->with('status', 'blacklist-updated'); return Redirect::route('profile.settings')->with('status', 'blacklist-updated');
} }
@@ -139,12 +139,12 @@ class ProfileController extends Controller
/** /**
* Delete the user's account. * Delete the user's account.
*/ */
public function destroy(Request $request): \Illuminate\Http\RedirectResponse public function destroy(Request $request): RedirectResponse
{ {
$user = $request->user(); $user = $request->user();
// Verify password if user has password // Verify password if user has password
if (!is_null($user->password)) { if (! is_null($user->password)) {
$request->validateWithBag('userDeletion', [ $request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'], 'password' => ['required', 'current_password'],
]); ]);
@@ -174,11 +174,11 @@ class ProfileController extends Controller
/** /**
* Store custom user avatar. * Store custom user avatar.
*/ */
protected function storeAvatar(\Illuminate\Http\UploadedFile $file, User $user): void protected function storeAvatar(UploadedFile $file, User $user): void
{ {
// Create Folder for Image Upload // Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/avatars")) { if (! Storage::disk('public')->exists('/images/avatars')) {
Storage::disk('public')->makeDirectory("/images/avatars"); Storage::disk('public')->makeDirectory('/images/avatars');
} }
// Delete old avatar if it exists // Delete old avatar if it exists
@@ -196,5 +196,4 @@ class ProfileController extends Controller
$user->avatar = $filename; $user->avatar = $filename;
} }
} }
+7 -27
View File
@@ -2,26 +2,23 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\CacheHelper;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Gallery; use App\Models\Gallery;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistEpisode; use App\Models\PlaylistEpisode;
use App\Models\Watched;
use App\Helpers\CacheHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use hisorange\BrowserDetect\Facade as Browser; use hisorange\BrowserDetect\Facade as Browser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class StreamController extends Controller class StreamController extends Controller
{ {
/** /**
* Display Stream Page. * Display Stream Page.
*/ */
public function index(Request $request, string $title): \Illuminate\View\View public function index(Request $request, string $title): View
{ {
$titleParts = explode('-', $title); $titleParts = explode('-', $title);
if (! is_numeric($titleParts[array_key_last($titleParts)])) { if (! is_numeric($titleParts[array_key_last($titleParts)])) {
@@ -37,7 +34,6 @@ class StreamController extends Controller
]); ]);
} }
$episode = Episode::where('slug', $title)->firstOrFail(); $episode = Episode::where('slug', $title)->firstOrFail();
$gallery = Gallery::where('episode_id', $episode->id)->get(); $gallery = Gallery::where('episode_id', $episode->id)->get();
$moreEpisodes = Episode::with(['gallery', 'studio'])->where('hentai_id', $episode->hentai_id)->whereNot('id', $episode->id)->get(); $moreEpisodes = Episode::with(['gallery', 'studio'])->where('hentai_id', $episode->hentai_id)->whereNot('id', $episode->id)->get();
@@ -54,34 +50,19 @@ class StreamController extends Controller
// Increment Popular Count // Increment Popular Count
$episode->incrementPopularCount(); $episode->incrementPopularCount();
if (!Auth::guest()) {
$user = Auth::user();
// Add to user watched list
$time = Carbon::now()->subHour(1);
$alreadyWatched = Watched::where('user_id', $user->id)->where('episode_id', $episode->id)->where('created_at', '>=', $time)->exists();
if (!$alreadyWatched) {
Watched::create(['user_id' => $user->id, 'episode_id' => $episode->id]);
cache()->forget('user' . $user->id . 'watched' . $episode->id);
}
}
// Mobile Detection // Mobile Detection
$isMobile = Browser::isMobile(); $isMobile = Browser::isMobile();
// Playlist // Playlist
if ($request->has('playlist')) { if ($request->has('playlist')) {
// Get and check if playlist exists // Get and check if playlist exists
$playlist = Playlist::where('id', $request->input('playlist'))->firstOrFail(); $playlist = Playlist::withCount('episodes')->where('id', $request->input('playlist'))->firstOrFail();
// Check if episode is in playlist // Check if episode is in playlist
$inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail(); $inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail();
// Get Playlist Episodes and order them
$playlistEpisodes = $playlist->episodes()->orderBy('position')->get();
// Check if authorized // Check if authorized
if ($playlist->is_private && (Auth::guest() || (!Auth::guest() && Auth::user()->id != $playlist->user_id))) { if ($playlist->is_private && (Auth::guest() || (! Auth::guest() && Auth::user()->id != $playlist->user_id))) {
abort(404); abort(404);
} }
@@ -91,7 +72,6 @@ class StreamController extends Controller
'studioEpisodes' => $studioEpisodes, 'studioEpisodes' => $studioEpisodes,
'gallery' => $gallery, 'gallery' => $gallery,
'playlist' => $playlist, 'playlist' => $playlist,
'playlistEpisodes' => $playlistEpisodes,
'popularWeekly' => CacheHelper::getPopularWeekly(), 'popularWeekly' => CacheHelper::getPopularWeekly(),
'isMobile' => $isMobile, 'isMobile' => $isMobile,
]); ]);
+56 -29
View File
@@ -2,7 +2,34 @@
namespace App\Http; namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\IsAdmin;
use App\Http\Middleware\IsBanned;
use App\Http\Middleware\IsModerator;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\SetLocale;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class Kernel extends HttpKernel class Kernel extends HttpKernel
{ {
@@ -15,12 +42,12 @@ class Kernel extends HttpKernel
*/ */
protected $middleware = [ protected $middleware = [
// \App\Http\Middleware\TrustHosts::class, // \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class, HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class, TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ConvertEmptyStringsToNull::class,
]; ];
/** /**
@@ -30,20 +57,20 @@ class Kernel extends HttpKernel
*/ */
protected $middlewareGroups = [ protected $middlewareGroups = [
'web' => [ 'web' => [
\App\Http\Middleware\EncryptCookies::class, EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
\App\Http\Middleware\IsBanned::class, IsBanned::class,
\App\Http\Middleware\SetLocale::class, SetLocale::class,
], ],
'api' => [ 'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api', ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
], ],
]; ];
@@ -55,18 +82,18 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string> * @var array<string, class-string|string>
*/ */
protected $middlewareAliases = [ protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class, 'auth' => Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'auth.session' => AuthenticateSession::class,
'auth.admin' => \App\Http\Middleware\IsAdmin::class, 'auth.admin' => IsAdmin::class,
'auth.moderator' => \App\Http\Middleware\IsModerator::class, 'auth.moderator' => IsModerator::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'cache.headers' => SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class, 'can' => Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'password.confirm' => RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'precognitive' => HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class, 'signed' => ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'verified' => EnsureEmailIsVerified::class,
]; ];
} }
+8 -9
View File
@@ -1,29 +1,28 @@
<?php namespace app\Http\Middleware; <?php
namespace app\Http\Middleware;
use App\Enums\UserRole; use App\Enums\UserRole;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class IsAdmin { class IsAdmin
{
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if(Auth::check() && Auth::user()->hasRole(UserRole::ADMINISTRATOR)) if (Auth::check() && Auth::user()->hasRole(UserRole::ADMINISTRATOR)) {
{
return $next($request); return $next($request);
} }
session()->flash('error_msg','This resource is restricted to Administrators!'); session()->flash('error_msg', 'This resource is restricted to Administrators!');
return redirect()->route('home.index'); return redirect()->route('home.index');
} }
} }
+7 -9
View File
@@ -1,32 +1,30 @@
<?php namespace app\Http\Middleware; <?php
namespace app\Http\Middleware;
use App\Enums\UserRole; use App\Enums\UserRole;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class IsBanned { class IsBanned
{
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if(Auth::check() && Auth::user()->hasRole(UserRole::BANNED)) if (Auth::check() && Auth::user()->hasRole(UserRole::BANNED)) {
{
Auth::logout(); Auth::logout();
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect()->route('home.banned'); return redirect()->route('home.banned');
} }
return $next($request); return $next($request);
} }
} }
+6 -5
View File
@@ -3,7 +3,6 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Enums\UserRole; use App\Enums\UserRole;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -14,16 +13,18 @@ class IsModerator
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (Auth::check() && Auth::user()->hasRole(UserRole::MODERATOR)) if (Auth::check() && (
{ Auth::user()->hasRole(UserRole::MODERATOR) ||
Auth::user()->hasRole(UserRole::ADMINISTRATOR))) {
return $next($request); return $next($request);
} }
session()->flash('error_msg','This resource is restricted to Administrators!'); session()->flash('error_msg', 'This resource is restricted to Moderators!');
return redirect()->route('home.index'); return redirect()->route('home.index');
} }
} }
@@ -13,7 +13,7 @@ class RedirectIfAuthenticated
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next, string ...$guards): Response public function handle(Request $request, Closure $next, string ...$guards): Response
{ {
+6 -2
View File
@@ -13,19 +13,23 @@ class SetLocale
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
// 1. Logged-in user preference // 1. Logged-in user preference
if (Auth::check() && Auth::user()->locale) { if (Auth::check() && Auth::user()->locale) {
App::setLocale(Auth::user()->locale); App::setLocale(Auth::user()->locale);
return $next($request); return $next($request);
} }
// 2. Session (guest or user override) // 2. Session (guest or user override)
if (session()->has('locale') && in_array($request->language, config('app.supported_locales'), true)) { if ($request->session()->has('locale') &&
in_array(session('locale'), config('app.supported_locales'), true)) {
App::setLocale(session('locale')); App::setLocale(session('locale'));
return $next($request); return $next($request);
} }
+6 -6
View File
@@ -2,15 +2,15 @@
namespace App\Http\Requests\Auth; namespace App\Http\Requests\Auth;
use App\Rules\ValidCaptcha;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; 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
{ {
/** /**
@@ -24,21 +24,21 @@ 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\ValidationRule|array<mixed>|string> * @return array<string, 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], 'altcha' => ['required', new ValidCaptcha],
]; ];
} }
/** /**
* Attempt to authenticate the request's credentials. * Attempt to authenticate the request's credentials.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function authenticate(): void public function authenticate(): void
{ {
@@ -58,7 +58,7 @@ class LoginRequest extends FormRequest
/** /**
* Ensure the login request is not rate limited. * Ensure the login request is not rate limited.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function ensureIsNotRateLimited(): void public function ensureIsNotRateLimited(): void
{ {
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class MatrixRegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$isOldEnough = $this->user()->created_at->lt(now()->subMonth());
$noAccount = ! $this->user()->matrix_id;
return $isOldEnough && $noAccount;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'username' => [
'required',
'string',
'min:3',
'max:32',
'regex:/^[a-z0-9._=-]+$/', // Valid Matrix localpart
],
'password' => [
'required',
'string',
'min:8',
'confirmed',
],
];
}
public function messages(): array
{
return [
'username.regex' => 'Username may only contain lowercase letters, numbers and . _ = -',
];
}
}
+3 -2
View File
@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -11,7 +12,7 @@ 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\ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -21,7 +22,7 @@ class ProfileUpdateRequest extends FormRequest
'nullable', 'nullable',
'image', 'image',
'mimes:jpg,png,jpeg,webp,gif', 'mimes:jpg,png,jpeg,webp,gif',
'max:8192' 'max:8192',
], ],
'email' => [ 'email' => [
'required', 'required',
+15 -12
View File
@@ -7,7 +7,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Spatie\DiscordAlerts\Facades\DiscordAlert; use Spatie\DiscordAlerts\Facades\DiscordAlert;
class DiscordReleaseNotification implements ShouldQueue class DiscordReleaseNotification implements ShouldQueue
@@ -32,26 +31,30 @@ class DiscordReleaseNotification implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
switch($this->messageType) // Discord notifications must only ever be sent in production.
{ if (! app()->isProduction()) {
return;
}
switch ($this->messageType) {
case 'release': case '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; break;
case 'release-censored': case 'release-censored':
# Because Discord TOS // Because Discord TOS
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release: ".$this->slug." - *No link here because of* :pLoli:"); DiscordAlert::message('<@&868457842250764289> (´• ω •`)ノ New **4k** Release: '.$this->slug.' - *No link here because of* :pLoli:');
break; break;
case 'update': case 'update':
# 1080p 48fps added // 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; break;
case 'updateUHD': case 'updateUHD':
# 4k 48fps added // 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; break;
case 'v2': case 'v2':
# v2 re-release // v2 re-release
DiscordAlert::to('rerelease')->message("<@&1425505303075754035> (´• ω •`)ノ **v2 Re-**Release! Check it out here: https://hstream.moe/hentai/".$this->slug); DiscordAlert::to('rerelease')->message('<@&1425505303075754035> (´• ω •`)ノ **v2 Re-**Release! Check it out here: https://hstream.moe/hentai/'.$this->slug);
break; break;
default: default:
break; break;
+7 -6
View File
@@ -7,12 +7,12 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GetFileSizeFromCDN implements ShouldQueue class GetFileSizeFromCDN implements ShouldQueue
{ {
@@ -35,8 +35,9 @@ class GetFileSizeFromCDN implements ShouldQueue
{ {
// Retrieve the download record, return if not found // Retrieve the download record, return if not found
$download = Downloads::find($this->downloadId); $download = Downloads::find($this->downloadId);
if (!$download) { if (! $download) {
Log::error("Download not found for ID: {$this->downloadId}"); Log::error("Download not found for ID: {$this->downloadId}");
return; return;
} }
@@ -52,7 +53,7 @@ class GetFileSizeFromCDN implements ShouldQueue
try { try {
// Send HTTP request to the endpoint // Send HTTP request to the endpoint
$response = Http::get($endpoint . '/getSize/' . $file . '/' . $expire); $response = Http::get($endpoint.'/getSize/'.$file.'/'.$expire);
// Check if response is successful // Check if response is successful
if ($response->successful()) { if ($response->successful()) {
@@ -67,9 +68,9 @@ class GetFileSizeFromCDN implements ShouldQueue
Log::error("Failed to retrieve size for download ID: {$this->downloadId}, HTTP status: {$response->status()}"); Log::error("Failed to retrieve size for download ID: {$this->downloadId}, HTTP status: {$response->status()}");
} }
} catch (RequestException $e) { } catch (RequestException $e) {
Log::error("HTTP request failed for download ID: {$this->downloadId}, error: " . $e->getMessage()); Log::error("HTTP request failed for download ID: {$this->downloadId}, error: ".$e->getMessage());
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("An error occurred for download ID: {$this->downloadId}, error: " . $e->getMessage()); Log::error("An error occurred for download ID: {$this->downloadId}, error: ".$e->getMessage());
} }
} }
} }
+123 -11
View File
@@ -2,46 +2,158 @@
namespace App\Livewire; namespace App\Livewire;
use App\Enums\UserRole;
use App\Models\Comment; use App\Models\Comment;
use App\Models\User;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Illuminate\Support\Facades\DB;
class AdminCommentSearch extends Component class AdminCommentSearch extends Component
{ {
use WithPagination; use WithPagination;
#[Url(history: true)]
public $search = ''; public $search = '';
#[Url(history: true)]
public $userSearch = ''; public $userSearch = '';
public function updatingSearch(): void #[Url(history: true)]
public $sortField = 'created_at';
#[Url(history: true)]
public $sortDirection = 'desc';
#[Url(history: true)]
public $perPage = 20;
public $selected = [];
public $selectPage = false;
protected $queryString = [
'search' => ['except' => ''],
'userSearch' => ['except' => ''],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
'perPage' => ['except' => 20],
];
protected $allowedSortFields = ['id', 'body', 'created_at', 'user_id'];
protected $allowedPerPages = [10, 20, 50, 100];
public function updatedPerPage(): void
{ {
$this->resetPage(); $this->resetPage();
} }
public function updatingUserSearch(): void public function updatedPage(): void
{ {
$this->selectPage = false;
$this->selected = [];
}
public function updatedSelectPage($value): void
{
if ($value) {
$this->selected = $this->comments->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} else {
$this->selected = [];
}
}
public function sortBy(string $field): void
{
if (! in_array($field, $this->allowedSortFields)) {
return;
}
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters(): void
{
$this->search = '';
$this->userSearch = '';
$this->sortField = 'created_at';
$this->sortDirection = 'desc';
$this->perPage = 20;
$this->resetPage(); $this->resetPage();
} }
public function deleteComment($commentId) public function deleteComment(int $commentId): void
{ {
$comment = Comment::where('id', (int) $commentId)->firstOrFail(); $comment = Comment::findOrFail($commentId);
$comment->delete(); $comment->delete();
cache()->flush();
$this->dispatch('notify', type: 'success', message: 'Comment deleted successfully.');
}
public function bulkDelete(): void
{
$count = Comment::whereIn('id', array_map('intval', $this->selected))->delete();
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} comment(s) deleted.");
}
public function banCommentAuthor(int $commentId): void
{
$comment = Comment::findOrFail($commentId);
$user = $comment->user;
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
} else {
$this->dispatch('notify', type: 'error', message: 'User is already banned or not found.');
}
}
public function bulkBanAuthors(): void
{
$count = 0;
$userIds = Comment::whereIn('id', array_map('intval', $this->selected))
->pluck('user_id')
->unique();
foreach ($userIds as $userId) {
$user = User::find($userId);
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
$count++;
}
}
cache()->flush(); cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} comment author(s) banned.");
}
public function getCommentsProperty()
{
return Comment::query()
->with('user')
->when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%{$this->search}%"))
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($q) => $q->where('name', 'LIKE', "%{$this->userSearch}%")))
->orderBy($this->sortField, $this->sortDirection)
->paginate((int) $this->perPage);
} }
public function render() 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', [ return view('livewire.admin-comment-search', [
'comments' => $comments 'comments' => $this->comments,
]); ]);
} }
} }
+284
View File
@@ -0,0 +1,284 @@
<?php
namespace App\Livewire;
use App\Enums\UserRole;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Episode;
use App\Services\CdnPathValidator;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class AdminEpisodeForm extends Component
{
use WithFileUploads;
public int $referenceEpisodeId;
public int $episodeNumber;
public string $title = '';
public string $titleJpn = '';
public string $studio = '';
/** @var string[] */
public array $tags = [];
public string $releasedate = '';
public string $baseurl = '';
public string $description = '';
public ?TemporaryUploadedFile $cover = null;
/** @var TemporaryUploadedFile[] */
public array $gallery = [];
/** @var array{fhd: string, fhdi: string, uhd: string, uhdi: string} */
public array $downloads = ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''];
public bool $censored = false;
/** @var array<string, array{state: string, message: string, mirrors: array}> */
public array $validation = [];
public bool $overrideValidation = false;
public bool $saving = false;
public function mount(int $episodeId): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$reference = Episode::with(['hentai', 'studio'])->findOrFail($episodeId);
$this->referenceEpisodeId = $reference->id;
$this->episodeNumber = $reference->hentai->episodes()->count() + 1;
$this->title = $reference->title;
$this->titleJpn = $reference->title_jpn;
$this->studio = $reference->studio?->name ?? '';
$this->tags = $reference->tags->pluck('name')->all();
$this->releasedate = $reference->release_date
? Carbon::parse($reference->release_date)->format('Y-m-d')
: now()->format('Y-m-d');
$this->baseurl = preg_replace('#/E\d+$#', '', $reference->url) ?? '';
$this->description = $reference->description;
}
public function getHasInvalidValidationProperty(): bool
{
foreach ($this->validation as $status) {
if (($status['state'] ?? '') === 'invalid') {
return true;
}
}
return false;
}
public function updatedBaseurl(): void
{
$this->validateStream();
}
public function updatedDownloads(mixed $value, string $key): void
{
if (preg_match('/^(fhd|fhdi|uhd|uhdi)$/', $key)) {
$this->validateDownload($key);
}
}
public function validateStream(): void
{
$baseurl = trim($this->baseurl);
if ($baseurl === '') {
$this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateStream($baseurl, 1, false, $this->episodeNumber);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function validateDownload(string $quality): void
{
$key = "downloads.{$quality}";
$url = $this->downloads[$quality] ?? '';
if (trim($url) === '') {
$this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function save(): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$this->validate([
'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'],
'description' => 'required|string',
'cover' => 'required|image|max:20480',
'gallery.*' => 'nullable|image|max:20480',
'downloads.fhd' => 'required|string',
'downloads.uhd' => 'required|string',
'downloads.fhdi' => 'nullable|string',
'downloads.uhdi' => 'nullable|string',
]);
$this->saving = true;
try {
// Re-load the reference episode fresh so the episode number reflects
// the current episode count rather than the mount-time value.
$referenceEpisode = Episode::with(['hentai', 'studio'])->findOrFail($this->referenceEpisodeId);
$hentai = $referenceEpisode->hentai;
$this->episodeNumber = $hentai->episodes()->count() + 1;
// Re-validate every non-empty download path + stream against the CDN.
$validator = app(CdnPathValidator::class);
$sizes = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$key = "downloads.{$quality}";
$result = $validator->validateDownload($type, $url, true);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
$sizes[$type] = $result['size'];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError($key, 'Path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
$result = $validator->validateStream($this->baseurl, 1, true, $this->episodeNumber);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError('baseurl', 'Stream path not found on all CDN mirrors.');
$this->saving = false;
return;
}
// Persist.
$episodeService = app(EpisodeService::class);
$studio = $episodeService->getOrCreateStudio(trim($this->studio));
$episodeModel = $episodeService->createEpisodeFromArray([
'title' => $this->title,
'title_jpn' => $this->titleJpn,
'baseurl' => $this->baseurl,
'description' => $this->description,
'releasedate' => $this->releasedate,
'tags' => $this->tags,
'interpolated_uhd' => trim($this->downloads['uhdi'] ?? '') !== '',
], $hentai, $this->episodeNumber, $studio);
if ($this->cover) {
$episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $this->episodeNumber, $this->cover);
}
if (! empty($this->gallery)) {
app(GalleryService::class)->saveGalleryFiles($hentai, $episodeModel, $this->episodeNumber, $this->gallery);
}
$downloads = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$downloads[$type] = [
'url' => trim($url),
'size' => $sizes[$type] ?? null,
];
}
app(DownloadService::class)->createOrUpdateDownloadsFromArray($episodeModel, $downloads);
// Discord notifications + cache flush.
if ($this->censored) {
DiscordReleaseNotification::dispatch($this->title.' - '.$this->episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episodeModel->slug, 'release');
}
cache()->flush();
} catch (\Throwable $e) {
Log::error('Failed to create episode from Livewire form: '.$e->getMessage(), [
'exception' => $e,
'referenceEpisodeId' => $this->referenceEpisodeId,
]);
$this->saving = false;
$this->addError('description', 'Something went wrong while saving. Please try again.');
return;
}
$this->redirectRoute('hentai.index', ['title' => $episodeModel->slug]);
}
private function qualityToType(string $quality): string
{
return match ($quality) {
'fhd' => 'FHD',
'fhdi' => 'FHDi',
'uhd' => 'UHD',
'uhdi' => 'UHDi',
};
}
public function render()
{
return view('livewire.admin-episode-form');
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace App\Livewire;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Hentai;
use App\Services\CdnPathValidator;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class AdminReleaseForm extends Component
{
use WithFileUploads;
public string $title = '';
public string $titleJpn = '';
public string $studio = '';
/** @var string[] */
public array $tags = [];
public string $releasedate = '';
public string $baseurl = '';
public bool $censored = false;
public int $episodeCount = 1;
/** @var array<int, array{description: string, cover: TemporaryUploadedFile|null, gallery: TemporaryUploadedFile[], downloads: array{fhd: string, fhdi: string, uhd: string, uhdi: string}}> */
public array $episodes = [];
/** @var array<string, array{state: string, message: string, mirrors: array}> */
public array $validation = [];
public bool $overrideValidation = false;
public bool $saving = false;
public function mount(): void
{
$this->episodes = [$this->defaultEpisode()];
}
private function defaultEpisode(): array
{
return [
'description' => '',
'cover' => null,
'gallery' => [],
'downloads' => ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''],
];
}
public function addEpisode(): void
{
$this->episodes[] = $this->defaultEpisode();
$this->episodeCount = count($this->episodes);
if (trim($this->baseurl) !== '') {
$this->validateStream();
}
}
public function removeEpisode(int $index): void
{
if (count($this->episodes) <= 1) {
return;
}
unset($this->episodes[$index]);
$this->episodes = array_values($this->episodes);
$this->episodeCount = count($this->episodes);
$this->pruneValidation();
if (trim($this->baseurl) !== '') {
$this->validateStream();
}
}
public function getHasInvalidValidationProperty(): bool
{
foreach ($this->validation as $status) {
if (($status['state'] ?? '') === 'invalid') {
return true;
}
}
return false;
}
public function updatedEpisodes(mixed $value, string $key): void
{
if (preg_match('/^(\d+)\.downloads\.(fhd|fhdi|uhd|uhdi)$/', $key, $matches)) {
$this->validateDownload((int) $matches[1], $matches[2]);
}
}
public function updatedBaseurl(): void
{
$this->validateStream();
}
public function validateDownload(int $episodeIndex, string $quality): void
{
$key = "episodes.{$episodeIndex}.downloads.{$quality}";
$url = $this->episodes[$episodeIndex]['downloads'][$quality] ?? '';
if (trim($url) === '') {
$this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function validateStream(): void
{
$baseurl = trim($this->baseurl);
if ($baseurl === '') {
$this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []];
return;
}
$this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []];
$result = app(CdnPathValidator::class)->validateStream($baseurl, count($this->episodes));
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
}
public function save(): void
{
$this->validate([
'title' => 'required|string|max:255',
'titleJpn' => 'required|string|max:255',
'studio' => 'required|string|max:255',
'tags' => 'required|array|min:1',
'releasedate' => 'required|date',
'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'],
'episodes.*.description' => 'required|string',
'episodes.*.cover' => 'required|image|max:20480',
'episodes.*.gallery.*' => 'nullable|image|max:20480',
'episodes.*.downloads.fhd' => 'required|string',
'episodes.*.downloads.uhd' => 'required|string',
]);
$this->saving = true;
// 1) Re-validate every non-empty download path + stream against the CDN.
$validator = app(CdnPathValidator::class);
$sizes = [];
foreach ($this->episodes as $index => $episode) {
foreach ($episode['downloads'] as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$key = "episodes.{$index}.downloads.{$quality}";
$result = $validator->validateDownload($type, $url, true);
$this->validation[$key] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
$sizes[$index][$type] = $result['size'];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError($key, 'Path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
}
if (trim($this->baseurl) !== '') {
$result = $validator->validateStream($this->baseurl, count($this->episodes), true);
$this->validation['stream'] = [
'state' => $result['valid'] ? 'valid' : 'invalid',
'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors',
'mirrors' => $result['mirrors'],
];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError('baseurl', 'Stream path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
// 2) Duplicate-guard: block re-releasing a title created today.
$episodeService = app(EpisodeService::class);
$slug = $episodeService->generateSlug($this->title);
$existing = Hentai::where('slug', $slug)->first();
if ($existing && $existing->created_at->isToday()) {
$this->addError('title', 'A release with this title was already created today. Refine the title to avoid a duplicate.');
$this->saving = false;
return;
}
try {
// 3) Persist.
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $this->episodes[0]['description']]
);
$studio = $episodeService->getOrCreateStudio(trim($this->studio));
$galleryService = app(GalleryService::class);
$downloadService = app(DownloadService::class);
$releasedEpisodes = [];
foreach ($this->episodes as $index => $episode) {
$episodeNumber = $index + 1;
$episodeModel = $episodeService->createEpisodeFromArray([
'title' => $this->title,
'title_jpn' => $this->titleJpn,
'baseurl' => $this->baseurl,
'description' => $episode['description'],
'releasedate' => $this->releasedate,
'tags' => $this->tags,
'interpolated_uhd' => trim($episode['downloads']['uhdi'] ?? '') !== '',
], $hentai, $episodeNumber, $studio);
if ($episode['cover']) {
$episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $episodeNumber, $episode['cover']);
}
if (! empty($episode['gallery'])) {
$galleryService->saveGalleryFiles($hentai, $episodeModel, $episodeNumber, $episode['gallery']);
}
$downloads = [];
foreach ($episode['downloads'] as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$downloads[$type] = [
'url' => trim($url),
'size' => $sizes[$index][$type] ?? null,
];
}
$downloadService->createOrUpdateDownloadsFromArray($episodeModel, $downloads);
$releasedEpisodes[] = $episodeModel->slug;
}
// 4) Discord notifications + cache flush.
if ($this->censored) {
DiscordReleaseNotification::dispatch($this->title, 'release-censored');
} else {
foreach ($releasedEpisodes as $episodeSlug) {
DiscordReleaseNotification::dispatch($episodeSlug, 'release');
}
}
cache()->flush();
} catch (\Throwable $e) {
Log::error('Failed to create release from Livewire form: '.$e->getMessage(), [
'exception' => $e,
'slug' => $slug,
]);
$this->saving = false;
$this->addError('title', 'Something went wrong while saving. Please try again.');
return;
}
// 5) Redirect.
$this->redirectRoute('home.index');
}
private function qualityToType(string $quality): string
{
return match ($quality) {
'fhd' => 'FHD',
'fhdi' => 'FHDi',
'uhd' => 'UHD',
'uhdi' => 'UHDi',
};
}
private function pruneValidation(): void
{
$count = count($this->episodes);
foreach (array_keys($this->validation) as $key) {
if (preg_match('/^episodes\.(\d+)\./', $key, $matches) && (int) $matches[1] >= $count) {
unset($this->validation[$key]);
}
}
}
public function render()
{
return view('livewire.admin-release-form');
}
}
+222 -16
View File
@@ -5,10 +5,9 @@ namespace App\Livewire;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\Comment; use App\Models\Comment;
use App\Models\User; use App\Models\User;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
class AdminUserSearch extends Component class AdminUserSearch extends Component
{ {
@@ -21,32 +20,239 @@ class AdminUserSearch extends Component
public $discordId = ''; public $discordId = '';
#[Url(history: true)] #[Url(history: true)]
public $patreon = []; public $email = '';
#[Url(history: true)] #[Url(history: true)]
public $banned = []; public $roleFilter = [];
public function deleteUserComments(int $userID) #[Url(history: true)]
public $sortField = 'created_at';
#[Url(history: true)]
public $sortDirection = 'desc';
#[Url(history: true)]
public $perPage = 20;
public $selected = [];
public $selectAll = false;
public $selectPage = false;
// Modal state
public $showUserModal = false;
public $modalUser = null;
public $modalUserComments = [];
protected $queryString = [
'search' => ['except' => ''],
'discordId' => ['except' => ''],
'email' => ['except' => ''],
'roleFilter' => ['except' => []],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
'perPage' => ['except' => 20],
];
protected $allowedSortFields = ['id', 'name', 'email', 'discord_id', 'created_at', 'updated_at'];
protected $allowedPerPages = [10, 20, 50, 100];
public function updatedPerPage(): void
{ {
$user = User::where('id', $userID) $this->resetPage();
->firstOrFail(); }
Comment::where('user_id', $user->id) public function updatedPage(): void
->delete(); {
$this->selectPage = false;
$this->selected = [];
}
public function updatedSelectPage($value): void
{
if ($value) {
$this->selected = $this->users->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} else {
$this->selected = [];
}
}
public function sortBy(string $field): void
{
if (! in_array($field, $this->allowedSortFields)) {
return;
}
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters(): void
{
$this->search = '';
$this->discordId = '';
$this->email = '';
$this->roleFilter = [];
$this->sortField = 'created_at';
$this->sortDirection = 'desc';
$this->perPage = 20;
$this->resetPage();
}
public function viewUser(int $userId): void
{
$this->modalUser = User::find($userId);
if ($this->modalUser) {
$this->modalUserComments = $this->modalUser->comments()
->orderBy('created_at', 'desc')
->limit(10)
->get();
$this->showUserModal = true;
}
}
public function closeModal(): void
{
$this->showUserModal = false;
$this->modalUser = null;
$this->modalUserComments = [];
}
public function banUser(int $userId): void
{
$user = User::findOrFail($userId);
$user->addRole(UserRole::BANNED);
cache()->flush(); cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function unbanUser(int $userId): void
{
$user = User::findOrFail($userId);
$user->removeRole(UserRole::BANNED);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been unbanned.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function grantModerator(int $userId): void
{
$user = User::findOrFail($userId);
$user->addRole(UserRole::MODERATOR);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "{$user->name} has been granted Moderator role.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function revokeModerator(int $userId): void
{
$user = User::findOrFail($userId);
$user->removeRole(UserRole::MODERATOR);
cache()->flush();
$this->dispatch('notify', type: 'success', message: "Moderator role revoked from {$user->name}.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUser->refresh();
}
}
public function deleteUserComments(int $userId): void
{
$user = User::findOrFail($userId);
Comment::where('user_id', $user->id)->delete();
cache()->flush();
$this->dispatch('notify', type: 'success', message: "All comments from {$user->name} have been deleted.");
if ($this->showUserModal && $this->modalUser?->id === $userId) {
$this->modalUserComments = collect();
}
}
public function bulkBan(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$user = User::find($userId);
if ($user && ! $user->hasRole(UserRole::BANNED)) {
$user->addRole(UserRole::BANNED);
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been banned.");
}
public function bulkUnban(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$user = User::find($userId);
if ($user && $user->hasRole(UserRole::BANNED)) {
$user->removeRole(UserRole::BANNED);
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been unbanned.");
}
public function bulkDeleteComments(): void
{
$count = 0;
foreach ($this->selected as $userId) {
$deleted = Comment::where('user_id', (int) $userId)->delete();
if ($deleted > 0) {
$count++;
}
}
cache()->flush();
$this->selected = [];
$this->selectPage = false;
$this->dispatch('notify', type: 'success', message: "Deleted comments from {$count} user(s).");
}
public function getUsersProperty()
{
return User::query()
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
->when($this->email !== '', fn ($query) => $query->where('email', 'like', '%'.$this->email.'%'))
->when(! empty($this->roleFilter), function ($query) {
foreach ($this->roleFilter as $role) {
$query->whereJsonContains('roles', $role);
}
})
->orderBy($this->sortField, $this->sortDirection)
->paginate((int) $this->perPage);
} }
public function render() public function render()
{ {
$users = User::when($this->patreon !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::SUPPORTER->value))
->when($this->banned !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::BANNED->value))
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
->paginate(20);
return view('livewire.admin-user-search', [ return view('livewire.admin-user-search', [
'users' => $users 'users' => $this->users,
]); ]);
} }
} }
+6 -9
View File
@@ -2,12 +2,11 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use App\Models\SiteBackground; use App\Models\SiteBackground;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class BackgroundImages extends Component class BackgroundImages extends Component
{ {
@@ -20,16 +19,14 @@ class BackgroundImages extends Component
{ {
$now = Carbon::now(); $now = Carbon::now();
$images = SiteBackground::when($this->filter === 'active', fn ($query) => $images = SiteBackground::when($this->filter === 'active', fn ($query) => $query->whereDate('date_start', '<=', $now)->whereDate('date_end', '>=', $now)
$query->whereDate('date_start', '<=', $now)->whereDate('date_end', '>=', $now)
) )
->when($this->filter === 'inactive', fn ($query) => ->when($this->filter === 'inactive', fn ($query) => $query->whereDate('date_start', '>', $now)->orWhereDate('date_end', '<', $now)
$query->whereDate('date_start', '>', $now)->orWhereDate('date_end', '<', $now)
) )
->paginate(10); ->paginate(10);
return view('livewire.background-images', [ return view('livewire.background-images', [
'images' => $images 'images' => $images,
]); ]);
} }
} }
+53 -18
View File
@@ -1,20 +1,18 @@
<?php <?php
namespace App\Livewire; namespace App\Livewire;
use App\Models\User; use App\Enums\UserRole;
use App\Models\Episode; use App\Models\Episode;
use App\Models\ModLog;
use App\Models\User;
use App\Notifications\CommentNotification; use App\Notifications\CommentNotification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str;
use Livewire\Component;
use Maize\Markable\Models\Like; use Maize\Markable\Models\Like;
class Comment extends Component class Comment extends Component
@@ -30,31 +28,31 @@ class Comment extends Component
public $liked = false; public $liked = false;
public $replyState = [ public $replyState = [
'body' => '' 'body' => '',
]; ];
public $isEditing = false; public $isEditing = false;
public $editState = [ public $editState = [
'body' => '' 'body' => '',
]; ];
protected $listeners = [ protected $listeners = [
'refresh' => '$refresh' 'refresh' => '$refresh',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
'replyState.body' => 'reply' 'replyState.body' => 'reply',
]; ];
public function updatedIsEditing($isEditing) public function updatedIsEditing(bool $isEditing)
{ {
if (! $isEditing) { if (! $isEditing) {
return; return;
} }
$this->editState = [ $this->editState = [
'body' => $this->comment->body 'body' => $this->comment->body,
]; ];
} }
@@ -71,15 +69,50 @@ class Comment extends Component
{ {
$this->authorize('destroy', $this->comment); $this->authorize('destroy', $this->comment);
$user = Auth::user();
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
// Log to ModLog
ModLog::create([
'moderator' => $user->name,
'data' => "Deleted comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
]);
$this->comment->deleted_by_moderator_id = $user->id;
$this->comment->save();
$this->dispatch('refresh');
return;
}
$this->comment->delete(); $this->comment->delete();
$this->dispatch('refresh'); $this->dispatch('refresh');
} }
public function restoreComment()
{
$this->authorize('restore', $this->comment);
$user = Auth::user();
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
// Log to ModLog
ModLog::create([
'moderator' => $user->name,
'data' => "Restored comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
]);
$this->comment->deleted_by_moderator_id = null;
$this->comment->save();
$this->dispatch('refresh');
}
}
public function postReply() public function postReply()
{ {
if (!($this->comment->depth() < 2)) { if (! ($this->comment->depth() < 2)) {
$this->addError('replyState.body', "Too many sub comments."); $this->addError('replyState.body', 'Too many sub comments.');
return; return;
} }
@@ -91,13 +124,14 @@ class Comment extends Component
$seconds = RateLimiter::availableIn($rateLimitKey); $seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('replyState.body', "Too many comments. Try again in {$seconds} seconds."); $this->addError('replyState.body', "Too many comments. Try again in {$seconds} seconds.");
return; return;
} }
RateLimiter::hit($rateLimitKey, $rateLimitMinutes); RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
$this->validate([ $this->validate([
'replyState.body' => 'required' 'replyState.body' => 'required',
]); ]);
$reply = $this->comment->children()->make($this->replyState); $reply = $this->comment->children()->make($this->replyState);
@@ -123,7 +157,7 @@ class Comment extends Component
} }
$this->replyState = [ $this->replyState = [
'body' => '' 'body' => '',
]; ];
$this->isReplying = false; $this->isReplying = false;
@@ -144,6 +178,7 @@ class Comment extends Component
if ($this->liked) { if ($this->liked) {
$this->liked = false; $this->liked = false;
$this->likeCount--; $this->likeCount--;
return; return;
} }
+8 -8
View File
@@ -2,11 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Illuminate\Support\Facades\RateLimiter;
class Comments extends Component class Comments extends Component
{ {
use WithPagination; use WithPagination;
@@ -14,21 +13,21 @@ class Comments extends Component
public $model; public $model;
public $newCommentState = [ public $newCommentState = [
'body' => '' 'body' => '',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
'newCommentState.body' => 'comment' 'newCommentState.body' => 'comment',
]; ];
protected $listeners = [ protected $listeners = [
'refresh' => '$refresh' 'refresh' => '$refresh',
]; ];
public function postComment() public function postComment()
{ {
$this->validate([ $this->validate([
'newCommentState.body' => 'required' 'newCommentState.body' => 'required',
]); ]);
$user = auth()->user(); $user = auth()->user();
@@ -39,6 +38,7 @@ class Comments extends Component
$seconds = RateLimiter::availableIn($rateLimitKey); $seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('newCommentState.body', "Too many comments. Try again in {$seconds} seconds."); $this->addError('newCommentState.body', "Too many comments. Try again in {$seconds} seconds.");
return; return;
} }
@@ -49,7 +49,7 @@ class Comments extends Component
$comment->save(); $comment->save();
$this->newCommentState = [ $this->newCommentState = [
'body' => '' 'body' => '',
]; ];
$this->resetPage(); $this->resetPage();
@@ -65,7 +65,7 @@ class Comments extends Component
->paginate(50); ->paginate(50);
return view('livewire.comments', [ return view('livewire.comments', [
'comments' => $comments 'comments' => $comments,
]); ]);
} }
} }
+20 -1
View File
@@ -21,10 +21,29 @@ class DownloadButton extends Component
public $background = 'bg-rose-600'; public $background = 'bg-rose-600';
public $fileExtension = 'HEVC';
public $version = '';
public function mount()
{
if (str_contains($this->downloadUrl, 'AV1')) {
$this->fileExtension = 'AV1';
}
if (str_contains($this->downloadUrl, 'v2')) {
$this->version = 'v2';
}
if (str_contains($this->downloadUrl, 'v3')) {
$this->version = 'v3';
}
}
public function clicked($downloadId) public function clicked($downloadId)
{ {
$download = Downloads::find($downloadId); $download = Downloads::find($downloadId);
if (!$download) { if (! $download) {
return; return;
} }
+3 -3
View File
@@ -5,11 +5,9 @@ namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use App\Models\User; use App\Models\User;
use App\Models\UserDownload; use App\Models\UserDownload;
use Livewire\Component;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DownloadsFree extends Component class DownloadsFree extends Component
{ {
@@ -51,6 +49,7 @@ class DownloadsFree extends Component
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->delete(); $alreadyDownloaded->delete();
return; return;
} }
@@ -65,6 +64,7 @@ class DownloadsFree extends Component
if ($user->downloads_left <= 0) { if ($user->downloads_left <= 0) {
// Daily limit reached // Daily limit reached
$this->granted = 3; $this->granted = 3;
return; return;
} }
+12 -8
View File
@@ -2,10 +2,11 @@
namespace App\Livewire; namespace App\Livewire;
use App\Enums\UserRole;
use App\Models\Downloads; use App\Models\Downloads;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
class DownloadsSearch extends Component class DownloadsSearch extends Component
{ {
@@ -25,19 +26,20 @@ class DownloadsSearch extends Component
#[Url(history: true)] #[Url(history: true)]
public $studios = []; public $studios = [];
public $studiosCopy = []; public $studiosCopy = [];
// To toggle individual option selection // To toggle individual option selection
public function toggleOption($option) public function toggleOption($option)
{ {
$this->options[$option] = !$this->options[$option]; $this->options[$option] = ! $this->options[$option];
$this->resetPage(); $this->resetPage();
} }
// To toggle dropdown visibility // To toggle dropdown visibility
public function toggleDropdown() public function toggleDropdown()
{ {
$this->isOpen = !$this->isOpen; $this->isOpen = ! $this->isOpen;
} }
protected $queryString = [ protected $queryString = [
@@ -73,9 +75,9 @@ class DownloadsSearch extends Component
$types[] = 'FHD'; $types[] = 'FHD';
} elseif ($label === 'FHD 48fps') { } elseif ($label === 'FHD 48fps') {
$types[] = 'FHDi'; $types[] = 'FHDi';
} elseif ($label === 'UHD' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) { } elseif ($label === 'UHD' && auth()->user()->hasRole(UserRole::SUPPORTER)) {
$types[] = 'UHD'; $types[] = 'UHD';
} elseif ($label === 'UHD 48fps' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) { } elseif ($label === 'UHD 48fps' && auth()->user()->hasRole(UserRole::SUPPORTER)) {
$types[] = 'UHDi'; $types[] = 'UHDi';
} }
} }
@@ -87,7 +89,7 @@ class DownloadsSearch extends Component
public function clicked($downloadId) public function clicked($downloadId)
{ {
$download = Downloads::find($downloadId); $download = Downloads::find($downloadId);
if (!$download) { if (! $download) {
return; return;
} }
@@ -98,7 +100,7 @@ class DownloadsSearch extends Component
public function mount() public function mount()
{ {
if (!auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) { if (! auth()->user()->hasRole(UserRole::SUPPORTER)) {
return; return;
} }
@@ -144,7 +146,9 @@ class DownloadsSearch extends Component
$downloads = Downloads::when($this->fileSearch != '', fn ($query) => $query->where('url', 'like', '%'.$this->fileSearch.'%')) $downloads = Downloads::when($this->fileSearch != '', fn ($query) => $query->where('url', 'like', '%'.$this->fileSearch.'%'))
->whereIn('type', $this->getSelectedTypes()) ->whereIn('type', $this->getSelectedTypes())
->when($this->studios !== [], fn ($q) => $q->whereHas('episode', fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); }))) ->when($this->studios !== [], fn ($q) => $q->whereHas('episode', fn ($query) => $query->whereHas('studio', function ($query) {
$query->whereIn('slug', $this->studios);
})))
->whereNotNull('size') ->whereNotNull('size')
->orderBy($orderby, $orderdirection) ->orderBy($orderby, $orderdirection)
->paginate(20); ->paginate(20);
+2 -2
View File
@@ -4,10 +4,9 @@ namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use App\Models\User; use App\Models\User;
use Livewire\Component;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Livewire\Component;
use Maize\Markable\Models\Like; use Maize\Markable\Models\Like;
class LikeButton extends Component class LikeButton extends Component
@@ -52,6 +51,7 @@ class LikeButton extends Component
if ($this->liked) { if ($this->liked) {
$this->liked = false; $this->liked = false;
$this->likeCount--; $this->likeCount--;
return; return;
} }
+12 -5
View File
@@ -3,10 +3,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
class LiveSearch extends Component class LiveSearch extends Component
{ {
@@ -20,14 +20,17 @@ class LiveSearch extends Component
#[Url(history: true)] #[Url(history: true)]
public $tags = []; public $tags = [];
public $tagsCopy = []; public $tagsCopy = [];
#[Url(history: true)] #[Url(history: true)]
public $studios = []; public $studios = [];
public $studiosCopy = []; public $studiosCopy = [];
#[Url(history: true)] #[Url(history: true)]
public $blacklist = []; public $blacklist = [];
public $blacklistCopy = []; public $blacklistCopy = [];
#[Url(history: true)] #[Url(history: true)]
@@ -72,7 +75,7 @@ class LiveSearch extends Component
public function mount() public function mount()
{ {
// User blacklist // User blacklist
if (Auth::check() && empty($this->blacklist) && !empty(auth()->user()->tag_blacklist)) { if (Auth::check() && empty($this->blacklist) && ! empty(auth()->user()->tag_blacklist)) {
$this->blacklist = auth()->user()->tag_blacklist; $this->blacklist = auth()->user()->tag_blacklist;
} }
@@ -118,10 +121,14 @@ class LiveSearch extends Component
} }
$user_id = Auth::check() ? auth()->user()->id : 0; $user_id = Auth::check() ? auth()->user()->id : 0;
$episodes = Episode::with('gallery')->when($this->search != '', fn ($query) => $query->where(function($query) { $query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%'); })) $episodes = Episode::with('gallery')->when($this->search != '', fn ($query) => $query->where(function ($query) {
$query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%');
}))
->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags)) ->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags))
->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist)) ->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist))
->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); })) ->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) {
$query->whereIn('slug', $this->studios);
}))
->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) { ->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) {
$query->where('user_id', $user_id); $query->where('user_id', $user_id);
})) }))
+2 -1
View File
@@ -3,8 +3,8 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use Livewire\Component;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class NavLiveSearch extends Component class NavLiveSearch extends Component
{ {
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
if ($this->navSearch != '') { if ($this->navSearch != '') {
$episodes = Episode::search($this->navSearch) $episodes = Episode::search($this->navSearch)
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota'])) ->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->query(fn ($query) => $query->with(['gallery', 'studio']))
->take(7) ->take(7)
->get(); ->get();
} }
+135 -40
View File
@@ -2,16 +2,15 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistEpisode; use App\Models\PlaylistEpisode;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Collection;
class PlaylistOverview extends Component class PlaylistOverview extends Component
{ {
@@ -20,13 +19,16 @@ class PlaylistOverview extends Component
protected PlaylistService $playlistService; protected PlaylistService $playlistService;
#[Url(history: true)] #[Url(history: true)]
public $search; public string $search = '';
public int $pagination = 25; #[Url(history: true)]
public int $perPage = 25;
public Playlist $playlist; public Playlist $playlist;
public Collection $playlistEpisodes; public bool $editingName = false;
public string $editingPlaylistName = '';
public function boot(PlaylistService $playlistService) public function boot(PlaylistService $playlistService)
{ {
@@ -35,35 +37,56 @@ class PlaylistOverview extends Component
public function mount($playlist_id) public function mount($playlist_id)
{ {
$this->playlist = Playlist::with(['episodes.episode'])->findOrFail($playlist_id); $this->playlist = Playlist::withCount('episodes')->with('user')->findOrFail($playlist_id);
// Set position if null $this->sanitizePerPage();
$this->playlist->episodes->each(function ($item, $index) {
if ($item->position === null) {
$item->position = $index + 1;
$item->save();
}
});
$this->refreshEpisodes(); $this->repairNullPositions();
} }
public function refreshEpisodes() public function updatingSearch(): void
{ {
$this->playlistEpisodes = $this->playlist->episodes()->orderBy('position')->with('episode')->get(); $this->resetPage();
}
public function updatingPerPage(): void
{
$this->resetPage();
}
public function getEpisodesProperty(): LengthAwarePaginator
{
$this->sanitizePerPage();
return PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->when($this->search !== '', fn ($query) => $query->whereHas('episode', fn ($episode) => $episode
->where('title', 'like', '%'.$this->search.'%')
->orWhere('title_jpn', 'like', '%'.$this->search.'%')))
->orderBy('position')
->with(['episode.gallery' => fn ($gallery) => $gallery->orderBy('id')->limit(1)])
->paginate($this->perPage);
}
public function getFirstEpisodeProperty(): ?Episode
{
return PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')
->first()?->episode;
} }
public function moveUp($episodeId) public function moveUp($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
$episode = PlaylistEpisode::find($episodeId); $episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id) $above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '<', $episode->position) ->where('position', '<', $episode->position)
->orderBy('position', 'desc') ->orderBy('position', 'desc')
@@ -72,21 +95,20 @@ class PlaylistOverview extends Component
if ($above) { if ($above) {
$this->playlistService->swapPositions($episode, $above); $this->playlistService->swapPositions($episode, $above);
} }
$this->refreshEpisodes();
} }
public function moveDown($episodeId) public function moveDown($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
$episode = PlaylistEpisode::find($episodeId); $episode = PlaylistEpisode::find($episodeId);
if (! $episode) {
return;
}
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id) $below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '>', $episode->position) ->where('position', '>', $episode->position)
->orderBy('position') ->orderBy('position')
@@ -95,29 +117,102 @@ class PlaylistOverview extends Component
if ($below) { if ($below) {
$this->playlistService->swapPositions($episode, $below); $this->playlistService->swapPositions($episode, $below);
} }
$this->refreshEpisodes();
} }
public function remove($episodeId) public function remove($episodeId)
{ {
if (! Auth::check()) { if (! $this->isOwner()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return; return;
} }
PlaylistEpisode::find($episodeId)?->delete(); PlaylistEpisode::find($episodeId)?->delete();
$this->playlistService->reorderPositions($this->playlist); $this->playlistService->reorderPositions($this->playlist);
$this->refreshEpisodes(); $this->playlist->loadCount('episodes');
$lastPage = max(1, (int) ceil(PlaylistEpisode::where('playlist_id', $this->playlist->id)->count() / $this->perPage));
if ($this->getPage() > $lastPage) {
$this->setPage($lastPage);
}
}
public function editName()
{
if (! $this->isOwner()) {
return;
}
$this->editingPlaylistName = $this->playlist->name;
$this->editingName = true;
}
public function cancelEditName()
{
$this->editingName = false;
$this->editingPlaylistName = '';
}
public function updateName()
{
if (! $this->isOwner()) {
return;
}
$this->validate([
'editingPlaylistName' => 'required|max:30',
]);
$this->playlist->update([
'name' => $this->editingPlaylistName,
]);
$this->editingName = false;
$this->editingPlaylistName = '';
}
public function toggleVisibility()
{
if (! $this->isOwner()) {
return;
}
$this->playlist->update([
'is_private' => ! $this->playlist->is_private,
]);
$this->playlist->loadCount('episodes');
} }
public function render() public function render()
{ {
return view('livewire.playlist-overview', [ return view('livewire.playlist-overview', [
'query' => $this->search, 'episodes' => $this->episodes,
'firstEpisode' => $this->firstEpisode,
'isOwner' => $this->isOwner(),
]); ]);
} }
private function isOwner(): bool
{
return Auth::check() && Auth::user()->id === $this->playlist->user_id;
}
private function sanitizePerPage(): void
{
if (! in_array($this->perPage, [25, 50, 100], true)) {
$this->perPage = 25;
}
}
private function repairNullPositions(): void
{
if (! PlaylistEpisode::where('playlist_id', $this->playlist->id)->whereNull('position')->exists()) {
return;
}
PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')->orderBy('id')
->get()
->each(fn ($playlistEpisode, $index) => $playlistEpisode->update(['position' => $index + 1]));
}
} }
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Services\PlaylistService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class PlaylistSidebar extends Component
{
public const PAGE_SIZE = 20;
public const MAX_WINDOW = 60;
protected PlaylistService $playlistService;
public Playlist $playlist;
public Episode $currentEpisode;
public int $currentEpisodeId;
public int $total = 0;
public int $windowStart = 1;
public int $windowEnd = 0;
public bool $isOwner = false;
public bool $collapsible = false;
public ?string $previousEpisodeSlug = null;
public ?string $nextEpisodeSlug = null;
public function boot(PlaylistService $playlistService): void
{
$this->playlistService = $playlistService;
}
public function mount(int $playlistId, int $currentEpisodeId, bool $collapsible = false): void
{
$this->playlist = Playlist::with('user')->withCount('episodes')->findOrFail($playlistId);
$this->currentEpisodeId = $currentEpisodeId;
$this->currentEpisode = Episode::with(['gallery' => fn ($query) => $query->orderBy('id')->limit(1)])
->findOrFail($currentEpisodeId);
$this->total = $this->playlist->episodes_count;
$this->collapsible = $collapsible;
$this->isOwner = Auth::check() && Auth::user()->id === $this->playlist->user_id;
$this->repairNullPositions();
$position = $this->currentPosition() ?? 1;
$this->setWindowAround($position);
$this->resolveAdjacentSlugs($position);
}
public function getEpisodesProperty(): Collection
{
return PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->whereBetween('position', [$this->windowStart, $this->windowEnd])
->orderBy('position')
->with([
'episode.gallery' => fn ($gallery) => $gallery->orderBy('id')->limit(1),
'episode.studio',
])
->get();
}
public function appendChunk(): void
{
if ($this->windowEnd >= $this->total) {
return;
}
$this->windowEnd = min($this->total, $this->windowEnd + self::PAGE_SIZE);
$this->windowStart = max(1, $this->windowEnd - self::MAX_WINDOW + 1);
}
public function prependChunk(): void
{
if ($this->windowStart <= 1) {
return;
}
$this->windowStart = max(1, $this->windowStart - self::PAGE_SIZE);
$this->windowEnd = min($this->total, $this->windowStart + self::MAX_WINDOW - 1);
}
public function remove(int $playlistEpisodeId): void
{
if (! $this->isOwner) {
return;
}
$playlistEpisode = PlaylistEpisode::find($playlistEpisodeId);
if (! $playlistEpisode) {
return;
}
$playlistEpisode->delete();
$this->playlistService->reorderPositions($this->playlist);
$this->playlist->loadCount('episodes');
$this->total = $this->playlist->episodes_count;
$this->windowEnd = min($this->windowEnd, max(1, $this->total));
$this->windowStart = min($this->windowStart, max(1, $this->total));
$position = $this->currentPosition();
if ($position) {
$this->resolveAdjacentSlugs($position);
}
}
public function render(): View
{
$activePlaylistEpisode = $this->activePlaylistEpisode();
return view('livewire.playlist-sidebar', [
'episodes' => $this->episodes,
'currentPosition' => $activePlaylistEpisode?->position ?? 1,
'currentPlaylistEpisodeId' => $activePlaylistEpisode?->id,
'isOwner' => $this->isOwner,
]);
}
private function activePlaylistEpisode(): ?PlaylistEpisode
{
return PlaylistEpisode::where('playlist_id', $this->playlist->id)
->where('episode_id', $this->currentEpisodeId)
->first();
}
private function currentPosition(): ?int
{
return $this->activePlaylistEpisode()?->position;
}
private function setWindowAround(int $position): void
{
$this->windowStart = max(1, $position - self::PAGE_SIZE);
$this->windowEnd = min($this->total, $position + self::PAGE_SIZE);
}
private function resolveAdjacentSlugs(int $position): void
{
$adjacent = PlaylistEpisode::query()
->where('playlist_id', $this->playlist->id)
->whereBetween('position', [$position - 1, $position + 1])
->orderBy('position')
->with('episode')
->get();
$slugsByPosition = $adjacent->keyBy('position');
$this->previousEpisodeSlug = $slugsByPosition->get($position - 1)?->episode->slug;
$this->nextEpisodeSlug = $slugsByPosition->get($position + 1)?->episode->slug;
}
private function repairNullPositions(): void
{
if (! PlaylistEpisode::where('playlist_id', $this->playlist->id)->whereNull('position')->exists()) {
return;
}
PlaylistEpisode::where('playlist_id', $this->playlist->id)
->orderBy('position')->orderBy('id')
->get()
->each(fn ($playlistEpisode, $index) => $playlistEpisode->update(['position' => $index + 1]));
}
}
+23 -4
View File
@@ -3,10 +3,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Playlist; use App\Models\Playlist;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
class Playlists extends Component class Playlists extends Component
{ {
@@ -54,12 +54,31 @@ class Playlists extends Component
$playlists = Playlist::where('is_private', 0) $playlists = Playlist::where('is_private', 0)
->withCount('episodes') ->withCount('episodes')
->having('episodes_count', '>', 1) ->having('episodes_count', '>', 1)
->when($this->search != '', fn($query) => $query->where('name', 'like', '%' . $this->search . '%')) ->when($this->search != '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
->with([
'user',
'episodes' => fn ($query) => $query->orderBy('position')->limit(4),
'episodes.episode.gallery' => fn ($query) => $query->limit(1),
])
->orderBy($orderby, $orderdirection) ->orderBy($orderby, $orderdirection)
->paginate($this->pagination); ->paginate($this->pagination);
$stats = Cache::remember('publicPlaylistStats', 600, function () {
$playlists = Playlist::where('is_private', 0)
->withCount('episodes')
->having('episodes_count', '>', 1)
->get();
return [
'playlists' => $playlists->count(),
'episodes' => $playlists->sum('episodes_count'),
];
});
return view('livewire.playlists', [ return view('livewire.playlists', [
'playlists' => $playlists 'playlists' => $playlists,
'totalPlaylists' => $stats['playlists'],
'totalEpisodes' => $stats['episodes'],
]); ]);
} }
} }
+2 -4
View File
@@ -3,7 +3,6 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Comment; use App\Models\Comment;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -36,14 +35,13 @@ class UserComments extends Component
$orderdirection = 'desc'; $orderdirection = 'desc';
} }
$comments = Comment::where('user_id', $this->model->id) $comments = Comment::where('user_id', $this->model->id)
->when($this->commentSearch != '', fn ($query) => $query->where('body', 'like', '%'.$this->commentSearch.'%')) ->when($this->commentSearch != '', fn ($query) => $query->where('body', 'like', '%'.$this->commentSearch.'%'))
->orderBy($orderby, $orderdirection) ->orderBy($orderby, $orderdirection)
->paginate(10); ->paginate(6);
return view('livewire.user-comments', [ return view('livewire.user-comments', [
'comments' => $comments 'comments' => $comments,
]); ]);
} }
} }
+12 -5
View File
@@ -3,10 +3,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Episode; use App\Models\Episode;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
class UserLikes extends Component class UserLikes extends Component
{ {
@@ -20,14 +20,17 @@ class UserLikes extends Component
#[Url(history: true)] #[Url(history: true)]
public $tags = []; public $tags = [];
public $tagsCopy = []; public $tagsCopy = [];
#[Url(history: true)] #[Url(history: true)]
public $studios = []; public $studios = [];
public $studiosCopy = []; public $studiosCopy = [];
#[Url(history: true)] #[Url(history: true)]
public $blacklist = []; public $blacklist = [];
public $blacklistCopy = []; public $blacklistCopy = [];
#[Url(history: true)] #[Url(history: true)]
@@ -72,7 +75,7 @@ class UserLikes extends Component
public function mount() public function mount()
{ {
// User blacklist // User blacklist
if (Auth::check() && empty($this->blacklist) && !empty(auth()->user()->tag_blacklist)) { if (Auth::check() && empty($this->blacklist) && ! empty(auth()->user()->tag_blacklist)) {
$this->blacklist = auth()->user()->tag_blacklist; $this->blacklist = auth()->user()->tag_blacklist;
} }
@@ -119,10 +122,14 @@ class UserLikes extends Component
$user_id = Auth::check() ? auth()->user()->id : 0; $user_id = Auth::check() ? auth()->user()->id : 0;
$episodes = Episode::whereHasLike(auth()->user()) $episodes = Episode::whereHasLike(auth()->user())
->when($this->search !== '', fn ($query) => $query->where(function($query) { $query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%'); })) ->when($this->search !== '', fn ($query) => $query->where(function ($query) {
$query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%');
}))
->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags)) ->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags))
->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist)) ->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist))
->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); })) ->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) {
$query->whereIn('slug', $this->studios);
}))
->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) { ->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) {
$query->where('user_id', $user_id); $query->where('user_id', $user_id);
})) }))
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use Livewire\Component;
class ViewCount extends Component
{
public $episodeId = 0;
public $viewCount = 0;
public function mount(Episode $episode)
{
$this->episodeId = $episode->id;
$this->viewCount = $episode->view_count;
}
public function update()
{
$this->viewCount = Episode::where('id', $this->episodeId)->firstOrFail()->view_count;
}
public function render()
{
return view('livewire.view-count');
}
}
-1
View File
@@ -3,7 +3,6 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Watched as UserWatched; use App\Models\Watched as UserWatched;
use App\Models\User;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
+14 -7
View File
@@ -4,19 +4,18 @@ namespace App\Models;
use App\Models\Presenters\CommentPresenter; use App\Models\Presenters\CommentPresenter;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Maize\Markable\Markable; use Maize\Markable\Markable;
use Maize\Markable\Models\Like; use Maize\Markable\Models\Like;
class Comment extends Model class Comment extends Model
{ {
use HasFactory, SoftDeletes, Markable; use HasFactory, Markable, SoftDeletes;
protected static $marks = [ protected static $marks = [
Like::class Like::class,
]; ];
/** /**
@@ -25,7 +24,7 @@ class Comment extends Model
* @var string[] * @var string[]
*/ */
protected $fillable = [ protected $fillable = [
'body' 'body',
]; ];
public function presenter() public function presenter()
@@ -71,6 +70,14 @@ class Comment extends Model
*/ */
public function likeCount(): int public function likeCount(): int
{ {
return cache()->remember('commentLikes' . $this->id, 300, fn() => $this->likes->count()); return cache()->remember('commentLikes'.$this->id, 300, fn () => $this->likes->count());
}
/**
* Returns wether or not comment has been removed by moderation
*/
public function isDeletedByModerator(): bool
{
return $this->deleted_by_moderator_id !== null;
} }
} }
+3 -1
View File
@@ -18,6 +18,8 @@ class Downloads extends Model
'episode_id', 'episode_id',
'type', 'type',
'url', 'url',
'size',
'validated_at',
]; ];
/** /**
@@ -39,7 +41,7 @@ class Downloads extends Model
$bytes /= 1024; $bytes /= 1024;
} }
return round($bytes, 2) . ' ' . $units[$i]; return round($bytes, 2).' '.$units[$i];
} }
/** /**
+22 -27
View File
@@ -2,35 +2,28 @@
namespace App\Models; namespace App\Models;
use App\Models\Downloads;
use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use App\Models\PopularDaily;
use Conner\Tagging\Taggable; use Conner\Tagging\Taggable;
use Laravel\Scout\Searchable;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Carbon;
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;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url;
class Episode extends Model implements Sitemapable class Episode extends Model implements Sitemapable
{ {
use Markable, Taggable;
use HasFactory; use HasFactory;
use Markable, Taggable;
use Searchable; use Searchable;
protected static $marks = [ protected static $marks = [
Like::class Like::class,
]; ];
/** /**
@@ -125,7 +118,7 @@ class Episode extends Model implements Sitemapable
*/ */
public function viewCount(): int public function viewCount(): int
{ {
return cache()->remember('episodeViews' . $this->id, 300, fn() => $this->view_count); return cache()->remember('episodeViews'.$this->id, 300, fn () => $this->view_count);
} }
/** /**
@@ -141,7 +134,7 @@ class Episode extends Model implements Sitemapable
$index = floor(log($this->viewCount(), 1000)); $index = floor(log($this->viewCount(), 1000));
$shortNumber = $this->viewCount() / pow(1000, $index); $shortNumber = $this->viewCount() / pow(1000, $index);
return round($shortNumber, 0) . $units[$index - 1]; return round($shortNumber, 0).$units[$index - 1];
} }
/** /**
@@ -149,7 +142,7 @@ class Episode extends Model implements Sitemapable
*/ */
public function likeCount(): int public function likeCount(): int
{ {
return cache()->remember('episodeLikes' . $this->id, 300, fn() => $this->likes->count()); return cache()->remember('episodeLikes'.$this->id, 300, fn () => $this->likes->count());
} }
/** /**
@@ -157,7 +150,7 @@ class Episode extends Model implements Sitemapable
*/ */
public function commentCount(): int public function commentCount(): int
{ {
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() public function comments()
@@ -180,6 +173,7 @@ class Episode extends Model implements Sitemapable
$problematicResults .= $pTag; $problematicResults .= $pTag;
} }
return $problematicResults; return $problematicResults;
} }
@@ -202,17 +196,17 @@ class Episode extends Model implements Sitemapable
*/ */
public function hasAutoTrans(): bool public function hasAutoTrans(): bool
{ {
return cache()->remember('mt' . $this->id, 900, fn() => $this->subtitles()->exists()); return cache()->remember('mt'.$this->id, 900, fn () => $this->subtitles()->exists());
} }
public function is48Fps(): bool public function is48Fps(): bool
{ {
return cache()->remember('48fps' . $this->id, 900, fn() => $this->interpolated); return cache()->remember('48fps'.$this->id, 900, fn () => $this->interpolated);
} }
public function isUHD48Fps(): bool public function isUHD48Fps(): bool
{ {
return cache()->remember('48fpsUHD' . $this->id, 900, fn() => $this->interpolated_uhd); return cache()->remember('48fpsUHD'.$this->id, 900, fn () => $this->interpolated_uhd);
} }
public function getResolution(): string public function getResolution(): string
@@ -226,7 +220,7 @@ class Episode extends Model implements Sitemapable
public function userWatched(int $user_id): bool public function userWatched(int $user_id): bool
{ {
return cache()->remember('user' . $user_id . 'watched' . $this->id, 300, fn() => Watched::where('user_id', $user_id)->where('episode_id', $this->id)->exists()); return cache()->remember('user'.$user_id.'watched'.$this->id, 300, fn () => Watched::where('user_id', $user_id)->where('episode_id', $this->id)->exists());
} }
public function watched(): HasMany public function watched(): HasMany
@@ -234,15 +228,16 @@ class Episode extends Model implements Sitemapable
return $this->hasMany(Watched::class); return $this->hasMany(Watched::class);
} }
public function getDownloadByType(string $type): Downloads | null public function getDownloadByType(string $type): ?Downloads
{ {
$cacheKey = "episode_{$this->id}_download_{$type}"; $cacheKey = "episode_{$this->id}_download_{$type}";
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($type) { return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($type) {
return $this->downloads()->where('type', $type)->first(); return $this->downloads()->where('type', $type)->first();
}); });
} }
public function toSitemapTag(): Url | string | array public function toSitemapTag(): Url|string|array
{ {
return Url::create(route('hentai.index', $this->slug)) return Url::create(route('hentai.index', $this->slug))
->setLastModificationDate(Carbon::create($this->created_at)); ->setLastModificationDate(Carbon::create($this->created_at));
+10
View File
@@ -2,12 +2,22 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Gallery extends Model class Gallery extends Model
{ {
use HasFactory;
public $table = 'gallery'; public $table = 'gallery';
/**
* The attributes that aren't mass assignable.
*
* @var array<int, string>
*/
protected $guarded = [];
/** /**
* Belongs To Episode. * Belongs To Episode.
*/ */
+8 -9
View File
@@ -2,19 +2,18 @@
namespace App\Models; namespace App\Models;
use Conner\Tagging\Taggable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Spatie\Sitemap\Contracts\Sitemapable; use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url; use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Conner\Tagging\Taggable;
class Hentai extends Model implements Sitemapable class Hentai extends Model implements Sitemapable
{ {
use Taggable;
use HasFactory; use HasFactory;
use Taggable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -31,7 +30,7 @@ class Hentai extends Model implements Sitemapable
return $this->hasMany(Episode::class, 'hentai_id'); return $this->hasMany(Episode::class, 'hentai_id');
} }
public function title(): String public function title(): string
{ {
return $this->episodes->first()->title; return $this->episodes->first()->title;
} }
@@ -63,7 +62,7 @@ class Hentai extends Model implements Sitemapable
); );
} }
public function toSitemapTag(): Url | string | array public function toSitemapTag(): Url|string|array
{ {
return Url::create(route('hentai.index', $this->slug)) return Url::create(route('hentai.index', $this->slug))
->setLastModificationDate(Carbon::create($this->created_at)); ->setLastModificationDate(Carbon::create($this->created_at));
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'moderator',
'data',
];
}
+24 -1
View File
@@ -2,11 +2,34 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Playlist extends Model class Playlist extends Model
{ {
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'name',
'is_private',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_private' => 'boolean',
];
/** /**
* Belongs To A User. * Belongs To A User.
*/ */
+4 -1
View File
@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlaylistEpisode extends Model class PlaylistEpisode extends Model
{ {
use HasFactory;
/** /**
* Indicates If The Model Should Be Timestamped. * Indicates If The Model Should Be Timestamped.
* *
+1 -1
View File
@@ -13,7 +13,7 @@ class PopularDaily extends Model
* *
* @var string[] * @var string[]
*/ */
protected $fillable = [ 'episode_id' ]; protected $fillable = ['episode_id'];
/** /**
* Get the Episode. * Get the Episode.
+1 -1
View File
@@ -13,7 +13,7 @@ class PopularMonthly extends Model
* *
* @var string[] * @var string[]
*/ */
protected $fillable = [ 'episode_id' ]; protected $fillable = ['episode_id'];
/** /**
* Get the Episode. * Get the Episode.
+1 -1
View File
@@ -13,7 +13,7 @@ class PopularWeekly extends Model
* *
* @var string[] * @var string[]
*/ */
protected $fillable = [ 'episode_id' ]; protected $fillable = ['episode_id'];
/** /**
* Get the Episode. * Get the Episode.
+2 -2
View File
@@ -16,13 +16,13 @@ class SiteBackground extends Model
protected $fillable = [ protected $fillable = [
'date_start', 'date_start',
'date_end', 'date_end',
'default' 'default',
]; ];
/** /**
* Returns the current IDs of active wallpaper * Returns the current IDs of active wallpaper
*/ */
public function getImages(): ? \Illuminate\Support\Collection public function getImages(): ?Collection
{ {
$now = Carbon::now(); $now = Carbon::now();
+1 -1
View File
@@ -3,8 +3,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Studios extends Model class Studios extends Model
{ {
+16 -14
View File
@@ -2,20 +2,21 @@
namespace App\Models; namespace App\Models;
//use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserRole; use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
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 Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
class User extends Authenticatable class User extends Authenticatable implements HasPasskeys
{ {
use HasFactory, Notifiable; use HasFactory, InteractsWithPasskeys, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -62,7 +63,6 @@ class User extends Authenticatable
'discord_avatar' => 'string', 'discord_avatar' => 'string',
]; ];
/** /**
* Has Many Playlists. * Has Many Playlists.
*/ */
@@ -100,7 +100,7 @@ class User extends Authenticatable
*/ */
public function commentCount(): int public function commentCount(): int
{ {
return cache()->remember('userComments' . $this->id, 300, fn() => $this->comments->count()); return cache()->remember('userComments'.$this->id, 300, fn () => $this->comments->count());
} }
/** /**
@@ -108,13 +108,11 @@ class User extends Authenticatable
*/ */
public function getAvatar(): string public function getAvatar(): string
{ {
if ($this->discord_id && $this->discord_avatar && !$this->avatar) if ($this->discord_id && $this->discord_avatar && ! $this->avatar) {
{
return "https://external-content.duckduckgo.com/iu/?u={$this->discord_avatar}"; return "https://external-content.duckduckgo.com/iu/?u={$this->discord_avatar}";
} }
if ($this->avatar) if ($this->avatar) {
{
return Storage::url($this->avatar); return Storage::url($this->avatar);
} }
@@ -153,11 +151,15 @@ class User extends Authenticatable
*/ */
public function removeRole(UserRole $role): void public function removeRole(UserRole $role): void
{ {
if (!$this->hasRole($role)) { if (! $this->hasRole($role)) {
return; return;
} }
$this->roles = array_diff($this->roles, [$role->value]); $this->roles = collect($this->roles)
->reject(fn ($value) => $value === $role->value)
->values()
->all();
$this->save(); $this->save();
} }
} }
+1 -1
View File
@@ -2,8 +2,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserDownload extends Model class UserDownload extends Model
{ {
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VideoEngagement extends Model
{
public $table = 'video_engagement';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = ['episode_id', 'user_id', 'segment'];
/**
* Get the Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class, 'episode_id');
}
/**
* Get the User.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+1 -1
View File
@@ -2,8 +2,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Watched extends Model class Watched extends Model
{ {
+2 -2
View File
@@ -3,8 +3,6 @@
namespace App\Notifications; namespace App\Notifications;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
class CommentNotification extends Notification class CommentNotification extends Notification
@@ -12,7 +10,9 @@ class CommentNotification extends Notification
use Queueable; use Queueable;
protected $type; protected $type;
protected $message; protected $message;
protected $url; protected $url;
/** /**
+22 -1
View File
@@ -2,8 +2,9 @@
namespace App\Policies; namespace App\Policies;
use App\Models\User; use App\Enums\UserRole;
use App\Models\Comment; use App\Models\Comment;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
class CommentPolicy class CommentPolicy
@@ -17,6 +18,26 @@ class CommentPolicy
public function destroy(User $user, Comment $comment): bool public function destroy(User $user, Comment $comment): bool
{ {
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
$user->hasRole(UserRole::MODERATOR)) {
return true;
}
return $user->id === $comment->user_id; return $user->id === $comment->user_id;
} }
public function restore(User $user, Comment $comment): bool
{
// Comment not deleted
if ($comment->deleted_by_moderator_id === null) {
return false;
}
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
$user->hasRole(UserRole::MODERATOR)) {
return true;
}
return false;
}
} }
+4 -2
View File
@@ -4,6 +4,8 @@ namespace App\Providers;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -20,8 +22,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class); $event->extendSocialite('discord', Provider::class);
}); });
} }
} }
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Rules;
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
use AltchaOrg\Altcha\Altcha;
use AltchaOrg\Altcha\Challenge;
use AltchaOrg\Altcha\ChallengeParameters;
use AltchaOrg\Altcha\Payload;
use AltchaOrg\Altcha\Solution;
use AltchaOrg\Altcha\VerifySolutionOptions;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* Validation rule to verify captcha solution.
*/
class ValidCaptcha implements ValidationRule
{
/**
* Altcha instance.
*/
protected Altcha $altcha;
/**
* Pbkdf2 algorithm instance.
*/
protected Pbkdf2 $pbkdf2;
/**
* Class constructor.
*/
public function __construct()
{
$this->pbkdf2 = new Pbkdf2;
$this->altcha = new Altcha(
hmacSignatureSecret: config('captcha.hmac_key'),
);
}
/**
* Parse payload and return the decoded data as an array.
*/
private function parsePayload(string $value): ?array
{
$decoded = base64_decode($value, true);
if ($decoded === false) {
return null;
}
$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return null;
}
return $payload;
}
/**
* Verify if payload has required fields.
*/
private function verifyFields(array $payload): bool
{
if (! isset($payload['challenge'], $payload['solution'])) {
return false;
}
if (! is_array($payload['challenge']) || ! is_array($payload['solution'])) {
return false;
}
return true;
}
/**
* Create a Challenge object from challenge data.
*/
private function createChallenge(array $challengeData): Challenge
{
return new Challenge(
ChallengeParameters::fromArray($challengeData['parameters'] ?? []),
$challengeData['signature'] ?? null,
);
}
/**
* Create a Solution object from solution data.
*/
private function createSolution(array $solutionData): Solution
{
return new Solution(
counter: (int) ($solutionData['counter'] ?? 0),
derivedKey: (string) ($solutionData['derivedKey'] ?? ''),
);
}
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$payload = $this->parsePayload($value);
if (! $payload) {
$fail('Invalid captcha.');
return;
}
if (! $this->verifyFields($payload)) {
$fail('Invalid captcha.');
return;
}
$challenge = $this->createChallenge($payload['challenge']);
$solution = $this->createSolution($payload['solution']);
$result = $this->altcha->verifySolution(new VerifySolutionOptions(
algorithm: $this->pbkdf2,
payload: new Payload($challenge, $solution),
));
if (! $result->verified) {
$fail('Invalid captcha.');
}
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
class CdnPathValidator
{
public const DOWNLOAD_PREFIX = [
'FHD' => 'hentai-1080p/',
'FHDi' => 'hentai-1080p/',
'UHD' => 'hentai/',
'UHDi' => 'hentai/',
];
private const STREAM_PREFIX = 'hentai-stream/';
private const CACHE_TTL = 300;
public function validateDownload(string $type, string $url, bool $fresh = false): array
{
$path = self::DOWNLOAD_PREFIX[$type].ltrim($url, '/');
$domains = config('hstream.download_domain_4k');
return $this->checkMirrors($domains, [$path], $fresh);
}
public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false, int $episodeNumber = 1): array
{
$domains = config('hstream.download_domain_4k');
// Validate the base directory, plus the episode manifest as a sanity check.
$paths = [self::STREAM_PREFIX.rtrim($baseurl, '/')];
if ($episodeCount >= 1) {
$paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd';
}
return $this->checkMirrors($domains, $paths, $fresh);
}
private function checkMirrors(array $domains, array $paths, bool $fresh = false): array
{
$cacheKey = 'cdncheck:'.sha1(serialize([$domains, $paths]));
if ($fresh) {
Cache::forget($cacheKey);
}
return Cache::remember($cacheKey, self::CACHE_TTL, fn () => $this->runChecks($domains, $paths));
}
private function runChecks(array $domains, array $paths): array
{
$entries = [];
foreach ($domains as $domain) {
foreach ($paths as $path) {
$entries[] = ['domain' => $domain, 'path' => $path];
}
}
try {
$responses = Http::pool(function ($pool) use ($entries) {
foreach ($entries as $entry) {
$pool->timeout(4)
->withHeaders(['Accept' => 'application/json'])
->get($entry['domain'].'/check/'.$this->tokenize($entry['path']).'/'.$this->tokenize(now()->addHours(6)));
}
});
} catch (\Throwable $e) {
$mirrors = [];
foreach ($entries as $entry) {
$mirrors[$entry['domain']][$entry['path']] = false;
}
return [
'valid' => false,
'mirrors' => $mirrors,
'size' => null,
];
}
$mirrors = [];
$allValid = true;
$firstSize = null;
foreach ($entries as $index => $entry) {
$response = $responses[$index];
$ok = $response instanceof Response
&& $response->ok()
&& $response->json('valid') === true;
$mirrors[$entry['domain']][$entry['path']] = $ok;
if (! $ok) {
$allValid = false;
} elseif ($firstSize === null) {
$firstSize = $response->json('size');
}
}
return [
'valid' => $allValid,
'mirrors' => $mirrors,
'size' => $allValid ? $firstSize : null,
];
}
private function tokenize(string $value): string
{
return urlencode(Crypt::encryptString($value));
}
}
+25 -3
View File
@@ -2,11 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Jobs\GetFileSizeFromCDN;
use App\Models\Downloads; use App\Models\Downloads;
use App\Models\Episode; use App\Models\Episode;
use App\Jobs\GetFileSizeFromCDN;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class DownloadService class DownloadService
@@ -35,4 +33,28 @@ class DownloadService
} }
} }
} }
/**
* Persist validated download URLs for an episode. Sizes are written
* directly from the CDN validation response, so no size job is dispatched.
*
* @param array<string, array{url: string, size?: float|int|null}> $downloads
*/
public function createOrUpdateDownloadsFromArray(Episode $episode, array $downloads): void
{
foreach ($downloads as $type => $data) {
if (empty($data['url'])) {
continue;
}
Downloads::updateOrCreate([
'episode_id' => $episode->id,
'type' => $type,
], [
'url' => $data['url'],
'size' => $data['size'] ?? null,
'validated_at' => now(),
]);
}
}
} }
+125 -41
View File
@@ -4,15 +4,16 @@ namespace App\Services;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Hentai; use App\Models\Hentai;
use App\Models\ModLog;
use App\Models\Studios; use App\Models\Studios;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EpisodeService class EpisodeService
{ {
@@ -24,44 +25,73 @@ class EpisodeService
if (is_numeric($lastPart) && $lastPart < 1000) { if (is_numeric($lastPart) && $lastPart < 1000) {
$slugParts[array_key_last($slugParts)] = 's'.$lastPart; $slugParts[array_key_last($slugParts)] = 's'.$lastPart;
return implode('-', $slugParts); return implode('-', $slugParts);
} }
return $slug; return $slug;
} }
public function createEpisode( private function applyTags(Request $request, Episode $episode): void
Request $request,
Hentai $hentai,
int $episodeNumber,
?Studios $studio = null,
?Episode $referenceEpisode = null
): Episode
{ {
$episode = new Episode(); $tags = json_decode($request->input('tags'));
$episode->title = $referenceEpisode->title ?? $request->input('title'); $newtags = [];
$episode->title_search = preg_replace("/[^A-Za-z0-9 ]/", '', $episode->title);
$episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn');
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $referenceEpisode->studio->id ?? $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}");
$episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->is_dvd_aspect = false;
$episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
// Tagging
$tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags'));
foreach ($tags as $t) { foreach ($tags as $t) {
$episode->tag($referenceEpisode ? $t->name : $t->value); $newtags[] = $t->value;
} }
return $episode; $newTagsTemp = $newtags;
$oldTagsTemp = $episode->tagNames();
sort($newTagsTemp);
sort($oldTagsTemp);
if ($newTagsTemp !== $oldTagsTemp) {
ModLog::create([
'moderator' => $request->user()->name,
'data' => sprintf(
'Updated Episode tags from %s to %s',
implode(', ', $oldTagsTemp),
implode(', ', $newTagsTemp),
),
]);
}
$episode->retag($newtags);
}
private function updateTitle(Request $request, Episode $episode): void
{
$updates = [];
if ($episode->title !== $request->input('title')) {
$updates['title'] = $request->input('title');
$updates['title_search'] = preg_replace(
'/[^A-Za-z0-9 ]/',
'',
$request->input('title')
);
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updating Hentai Title from {$episode->title} to {$request->input('title')}",
]);
}
if ($episode->title_jpn !== $request->input('title_jpn')) {
$updates['title_jpn'] = $request->input('title_jpn');
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updating Hentai Title from {$episode->title_jpn} to {$request->input('title_jpn')}",
]);
}
if (! empty($updates)) {
$episode->hentai->episodes()->update($updates);
}
} }
public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode
@@ -77,17 +107,31 @@ class EpisodeService
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true'; $episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
$episode->save(); $episode->save();
// Tagging $this->applyTags($request, $episode);
$tags = json_decode($request->input('tags')); $this->updateTitle($request, $episode);
$newtags = [];
foreach ($tags as $t) {
$newtags[] = $t->value;
}
$episode->retag($newtags);
return $episode; return $episode;
} }
public function updateEpisodeModerator(Request $request, int $episodeId): void
{
$episode = Episode::where('id', $episodeId)->firstOrFail();
$oldDescription = $episode->description;
$episode->description = $request->input('description');
$episode->save();
if ($episode->description !== $oldDescription) {
// Log to ModLog
ModLog::create([
'moderator' => $request->user()->name,
'data' => "Updated Episode description from {$oldDescription} to {$episode->description}",
]);
}
$this->applyTags($request, $episode);
$this->updateTitle($request, $episode);
}
public function getOrCreateStudio(string $studioName): Studios public function getOrCreateStudio(string $studioName): Studios
{ {
return Studios::firstOrCreate( return Studios::firstOrCreate(
@@ -96,6 +140,41 @@ class EpisodeService
); );
} }
/**
* Create an episode from an array of data (used by the Livewire release form).
*
* @param array{title: string, title_jpn: string, baseurl: string, description: string, releasedate: string, tags: string[], interpolated_uhd?: bool} $data
*/
public function createEpisodeFromArray(
array $data,
Hentai $hentai,
int $episodeNumber,
?Studios $studio = null
): Episode {
$episode = new Episode;
$episode->title = $data['title'];
$episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title);
$episode->title_jpn = $data['title_jpn'];
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $data['description'];
$episode->url = rtrim($data['baseurl'], '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->interpolated_uhd = $data['interpolated_uhd'] ?? false;
$episode->is_dvd_aspect = false;
$episode->release_date = Carbon::parse($data['releasedate'])->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
foreach ($data['tags'] as $tag) {
$episode->tag($tag);
}
return $episode;
}
public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void
{ {
@@ -103,15 +182,20 @@ class EpisodeService
return; return;
} }
$this->saveCoverFromFile($episode, $slug, $episodeNumber, $request->file("episodecover{$episodeNumber}"));
}
public function saveCoverFromFile(Episode $episode, string $slug, int $episodeNumber, UploadedFile|TemporaryUploadedFile $file): void
{
// Create Folder for Image Upload // Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) { if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}"); Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
} }
// Encode and save cover image // Encode and save cover image
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath()) Image::read($file->getRealPath())
->cover(268, 394) ->cover(268, 394)
->encode(new WebpEncoder()) ->encode(new WebpEncoder)
->save(Storage::disk('public')->path($episode->cover_url)); ->save(Storage::disk('public')->path($episode->cover_url));
} }
} }
+19 -12
View File
@@ -5,35 +5,42 @@ namespace App\Services;
use App\Models\Episode; use App\Models\Episode;
use App\Models\Gallery; use App\Models\Gallery;
use App\Models\Hentai; use App\Models\Hentai;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class GalleryService class GalleryService
{ {
public function createOrUpdateGallery(Request $request, Hentai $hentai, Episode $episode, int $episodeNumber, bool $override = false): void public function createOrUpdateGallery(Request $request, Hentai $hentai, Episode $episode, int $episodeNumber, bool $override = false): void
{ {
$galleryInputNumber = $override ? 1 : $episodeNumber; $galleryInputNumber = $override ? 1 : $episodeNumber;
if($request->hasFile('episodegallery'.$galleryInputNumber)) { if ($request->hasFile('episodegallery'.$galleryInputNumber)) {
$this->saveGalleryFiles($hentai, $episode, $episodeNumber, $request->file('episodegallery'.$galleryInputNumber));
}
}
/**
* Save a gallery for the given episode from a list of uploaded files.
*
* @param array<int, UploadedFile|TemporaryUploadedFile> $files
*/
public function saveGalleryFiles(Hentai $hentai, Episode $episode, int $episodeNumber, array $files): void
{
$this->deleteOldGallery($episode); $this->deleteOldGallery($episode);
$this->createGalleryFolder($hentai); $this->createGalleryFolder($hentai);
$counter = 0; $counter = 0;
foreach($request->file('episodegallery'.$galleryInputNumber) as $file) { foreach ($files as $file) {
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter); $gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
$this->saveGalleryImage($gallery, $file); $this->saveGalleryImage($gallery, $file);
$counter += 1; $counter += 1;
} }
} }
}
private function createGalleryFolder(Hentai $hentai): void private function createGalleryFolder(Hentai $hentai): void
{ {
@@ -45,7 +52,7 @@ class GalleryService
private function createGallery(Hentai $hentai, Episode $episode, int $episodeNumber, int $counter): Gallery private function createGallery(Hentai $hentai, Episode $episode, int $episodeNumber, int $counter): Gallery
{ {
$gallery = new Gallery(); $gallery = new Gallery;
$gallery->hentai_id = $hentai->id; $gallery->hentai_id = $hentai->id;
$gallery->episode_id = $episode->id; $gallery->episode_id = $episode->id;
$gallery->image_url = "/images/hentai/{$hentai->slug}/gallery-ep-{$episodeNumber}-{$counter}.webp"; $gallery->image_url = "/images/hentai/{$hentai->slug}/gallery-ep-{$episodeNumber}-{$counter}.webp";
@@ -55,16 +62,16 @@ class GalleryService
return $gallery; return $gallery;
} }
private function saveGalleryImage(Gallery $gallery, $sourceImage): void private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void
{ {
Image::read($sourceImage->getRealPath()) Image::read($sourceImage->getRealPath())
->cover(1920, 1080) ->cover(1920, 1080)
->encode(new WebpEncoder()) ->encode(new WebpEncoder)
->save(Storage::disk('public')->path($gallery->image_url)); ->save(Storage::disk('public')->path($gallery->image_url));
Image::read($sourceImage->getRealPath()) Image::read($sourceImage->getRealPath())
->cover(960, 540) ->cover(960, 540)
->encode(new WebpEncoder()) ->encode(new WebpEncoder)
->save(Storage::disk('public')->path($gallery->thumbnail_url)); ->save(Storage::disk('public')->path($gallery->thumbnail_url));
} }
@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class MatrixRegistrationService
{
public function registerUser(string $username, string $password)
{
$server = config('services.matrix.server');
$secret = config('services.matrix.shared_secret');
// Get nonce from Synapse
$nonceResponse = Http::get("$server/_synapse/admin/v1/register");
if (! $nonceResponse->ok()) {
throw new \Exception('Could not fetch nonce from Matrix.');
}
$nonce = $nonceResponse->json()['nonce'];
// Generate MAC
$mac = hash_hmac(
'sha1',
$nonce."\0".
$username."\0".
$password."\0".
'notadmin',
$secret
);
// Send registration request
$response = Http::post("$server/_synapse/admin/v1/register", [
'nonce' => $nonce,
'username' => $username,
'password' => $password,
'admin' => false,
'mac' => $mac,
]);
if ($response->failed()) {
$error = $response->json()['error'] ?? $response->body();
throw new \Exception($error);
}
return $response->json();
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"agents": [
"cursor"
],
"cloud": false,
"guidelines": true,
"mcp": true,
"nightwatch": false,
"sail": false,
"skills": [
"laravel-best-practices",
"scout-development",
"socialite-development",
"livewire-development",
"tailwindcss-development"
]
}
+9 -4
View File
@@ -1,5 +1,10 @@
<?php <?php
use App\Exceptions\Handler;
use App\Http\Kernel;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Application;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Create The Application | Create The Application
@@ -11,7 +16,7 @@
| |
*/ */
$app = new Illuminate\Foundation\Application( $app = new Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
); );
@@ -28,7 +33,7 @@ $app = new Illuminate\Foundation\Application(
$app->singleton( $app->singleton(
Illuminate\Contracts\Http\Kernel::class, Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class Kernel::class
); );
$app->singleton( $app->singleton(
@@ -37,8 +42,8 @@ $app->singleton(
); );
$app->singleton( $app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class, ExceptionHandler::class,
App\Exceptions\Handler::class Handler::class
); );
/* /*
+3 -1
View File
@@ -9,7 +9,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"grantholle/laravel-altcha": "^2.1", "altcha-org/altcha": "^2.0",
"guzzlehttp/guzzle": "^7.8.1", "guzzlehttp/guzzle": "^7.8.1",
"hisorange/browser-detect": "^5.0", "hisorange/browser-detect": "^5.0",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
@@ -28,11 +28,13 @@
"rtconner/laravel-tagging": "^5.0", "rtconner/laravel-tagging": "^5.0",
"socialiteproviders/discord": "^4.2", "socialiteproviders/discord": "^4.2",
"spatie/laravel-discord-alerts": "^1.8", "spatie/laravel-discord-alerts": "^1.8",
"spatie/laravel-passkeys": "^1.7",
"spatie/laravel-sitemap": "^7.3" "spatie/laravel-sitemap": "^7.3"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.16", "barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.24.0", "fakerphp/faker": "^1.24.0",
"laravel/boost": "^2.4",
"laravel/breeze": "^2.3", "laravel/breeze": "^2.3",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
Generated
+2088 -757
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,7 +1,12 @@
<?php <?php
use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Mews\Captcha\Facades\Captcha;
return [ return [
@@ -175,11 +180,11 @@ return [
/* /*
* Application Service Providers... * Application Service Providers...
*/ */
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\AuthServiceProvider::class, AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class, // App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class, EventServiceProvider::class,
App\Providers\RouteServiceProvider::class, RouteServiceProvider::class,
])->toArray(), ])->toArray(),
/* /*
@@ -195,7 +200,7 @@ return [
'aliases' => Facade::defaultAliases()->merge([ 'aliases' => Facade::defaultAliases()->merge([
// 'Example' => App\Facades\Example::class, // 'Example' => App\Facades\Example::class,
'Captcha' => Mews\Captcha\Facades\Captcha::class, 'Captcha' => Captcha::class,
])->toArray(), ])->toArray(),
]; ];
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
use App\Models\User;
return [ return [
/* /*
@@ -62,7 +64,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => App\Models\User::class, 'model' => User::class,
], ],
// 'users' => [ // 'users' => [
+9
View File
@@ -0,0 +1,9 @@
<?php
/**
* Altcha Captcha System
*/
return [
'hmac_key' => env('ALTCHA_HMAC_KEY'),
];

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