Compare commits

..

10 Commits

Author SHA1 Message Date
ghassan
05db0e128a Add playlist controls: prev/next, shuffle, loop, autoplay toggle
Controls bar added to playlist sidebar header with:
- Prev/Next skip buttons (disabled when at bounds)
- Shuffle toggle (Fisher-Yates order stored in localStorage)
- Loop 3-state: off → loop all → loop one
- Autoplay toggle (default on, persists per playlist in localStorage)
All state is instant — no page reload on toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:16:42 +03:00
ghassan
c160242dbc WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:15:20 +03:00
ghassan
6b3ab5b65e Use NAS as primary storage — direct upload when enabled
When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.

- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
  organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
  deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
  file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
  thumbnailStorageKey(), localPath(), storageKey() helpers for
  format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:17:07 +03:00
ghassan
296d605864 Add nas:free-local command to remove local files already on NAS
Scans all videos that still have a local file, checks NAS for meta.json
(written last in syncVideo, so its presence confirms a complete push),
then removes the local copy if confirmed.

Usage:
  php artisan nas:free-local --dry-run   # preview
  php artisan nas:free-local --force     # delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:59:47 +03:00
ghassan
0b75acec89 Make NAS the primary storage when enabled (not a mirror)
When NAS sync is enabled:
- Audio uploads: pushed to NAS via NasSyncVideoJob, local file deleted immediately after
- Video uploads: processed locally (ffprobe, compress, HLS), then at the end of
  GenerateHlsJob the final compressed file is re-synced to NAS and the local copy removed
- stream() and download(): if local file is missing, pull from NAS into a local
  stream cache (storage/app/nas_cache/videos/) and serve from there with full
  byte-range support — so seeking still works over NAS-sourced files

When NAS is disabled:
- Upload, stream, and download all use local storage exclusively (no change)

HLS segments are intentionally kept local: they are small, generated on-demand,
and serving them via per-segment SMB round-trips would hurt playback performance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:56:55 +03:00
ghassan
d1441b213a Fix progress bar seek pausing playback + add persistent mini-player
- video-player: add userSeeking flag so mid-seek pause events are
  suppressed; force-resume on 'seeked' if video was playing before seek;
  guard player click handler against progCont clicks; e.preventDefault()
  on touchend to stop synthetic click toggling play
- audio-player: apply identical seek fixes (same four changes)
- app layout: add floating mini-player that saves video state to
  sessionStorage when bottom nav is tapped while a video is playing,
  then restores playback on the next page via a floating overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:45:20 +03:00
ghassan
615e7efd7c Redesign NAS file manager to match admin dark theme
- Published package views to resources/views/vendor/nas-file-manager/
- Rewrote file-manager.blade.php using admin CSS variables (--bg, --bg-card,
  --border, --text, --brand, etc.) and Bootstrap Icons instead of Tailwind/SVGs
- Replaced accordion wrapper with flat tab bar matching .adm-* tab pattern
- Dialogs use --bg-card2, --border-light, and .adm-btn classes
- Removed Tailwind CDN and brand color overrides from nas-storage.blade.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:50:41 +03:00
ghassan
8a00bcecac Simplify NAS storage page — let package Connection tab own the UI
Removed the manual connection summary card now that the package widget
has a built-in Connection tab with a live test button and form fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:46:00 +03:00
ghassan
69ae56331a Update p7h/nas-file-manager to latest (adds Connection tab)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:43:54 +03:00
ghassan
0b2e95ea65 Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00
169 changed files with 34597 additions and 7344 deletions

186
.claude/component-usage.md Normal file
View File

@ -0,0 +1,186 @@
# Reusable Select Component Usage
This file tracks every page/partial that uses `<x-phone-code-select>`, `<x-country-select>`, or `<x-timezone-select>`.
**Update this file whenever you add or remove a component from a view.**
When modifying any component or its data source (`app/Data/Countries.php`), check all pages in the relevant section below and verify the change works correctly in each context.
---
## Data source
**`app/Data/Countries.php`** — `App\Data\Countries`
| Method | Used by component |
|---|---|
| `Countries::forPhoneCode()` | `<x-phone-code-select>` |
| `Countries::forCountry()` | `<x-country-select>` |
| `Countries::forTimezone()` | `<x-timezone-select>` |
| `Countries::all()` | All three (via the above methods) |
Adding or renaming a field in `Countries::all()` requires updating the corresponding `for*()` method too.
---
## Shared CSS / JS
The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all three component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all three component files**:
- `resources/views/components/phone-code-select.blade.php`
- `resources/views/components/country-select.blade.php`
- `resources/views/components/timezone-select.blade.php`
The `@once` Blade directive ensures the browser only receives one copy of the CSS/JS even when multiple components are on the same page.
---
## `<x-video-insights>`
**File:** `resources/views/components/video-insights.blade.php`
**Props:** `:video``Video` model instance.
**Behaviour:** Renders the Insights tab panel (`<div class="vdb-panel" id="vdb-insights">`), the drill-down modal, all `.ins-*` CSS, and all insights JS (`loadInsights`, `renderInsights`, modal openers, country/day/downloader drill-downs). Only renders if `Auth::id() === $video->user_id`. Must be placed **inside `.vdb-wrap`**, after the About panel, so the tab-switch CSS applies. The parent view must call `loadInsights()` (global, defined by this component) when the Insights tab is activated.
**Data source:** `GET /videos/{video}/insights` (JSON) + drill-down routes `/insights/country/{code}`, `/insights/day/{date}`, `/insights/downloader/{userId}`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/videos/partials/description-box.blade.php` | Inside `.vdb-wrap`, after About panel | Used by all three video type views (generic, match, music) |
| `resources/views/videos/show.blade.php` | Inside `.vdb-wrap`, after About panel | Legacy view (not rendered by controller — kept in sync) |
---
## `<x-social-links-editor>`
**File:** `resources/views/components/social-links-editor.blade.php`
**Props:** `existing` — associative array keyed by platform name (e.g. `['twitter' => 'handle', 'whatsapp' => '97312345678']`).
**Behaviour:** Dynamic add/remove rows; each row has a custom icon dropdown to pick the platform and a text input for the value. Supported platforms: `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `whatsapp`, `website`, `google_location`, `social_phone`, `social_email`. Hidden clear inputs ensure removed entries are cleared on save. Must be placed **inside a `<form>`**.
**DB columns:** `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `website` (legacy), `whatsapp`, `google_location`, `social_phone`, `social_email`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | Social tab of Edit Profile modal | `$socialExisting` array passed from `@php` block above `@section('scripts')` |
---
## `<x-date-picker>`
**File:** `resources/views/components/date-picker.blade.php`
**Stored value:** `YYYY-MM-DD` string in a hidden input (same format as `<input type="date">`).
**Props:** `name`, `id`, `value`, `label`, `required`, `class`, `style`, `minYear` (default 1900), `maxYear` (default current year).
**Behaviour:** Day grid (5 columns, 131), month list (JanuaryDecember), year searchable list (descending). Days auto-constrain on month/year change; invalid selected day resets automatically.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `birthday` | Replaces `<input type="date">` |
| `resources/views/auth/register.blade.php` | `birthday` | Registration form — mandatory |
---
## `<x-image-cropper>`
**File:** `resources/views/components/image-cropper.blade.php`
**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading).
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). After crop: POSTs base64 to `/image-upload`, optionally POSTs the path to `update-url`, then calls `callback(url)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required.
**Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`.
**Routes needed:** `image.upload` (POST `/image-upload`).
| View file | id / use | Notes |
|---|---|---|
| `resources/views/user/channel.blade.php` | `avatar` — circle 300×300 | Owner only; `update-url = profile.updateAvatar`; callback `onAvatarSaved` |
| `resources/views/user/channel.blade.php` | `banner` — square 500×160 | Owner only; `update-url = profile.updateBanner`; callback `onBannerSaved` |
| `resources/views/layouts/partials/upload-modal.blade.php` | `thumb_upload` — square 448×252 | Form mode; `target-input=thumbnail-modal`; output 1280px |
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `thumb_edit` — square 448×252 | Form mode; `target-input=edit-thumbnail-input`; output 1280px |
| `resources/views/videos/create.blade.php` | `thumb_create_mobile` — square 448×252 | Mobile; `target-input=thumbnail`; output 1280px |
| `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px |
| `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px |
| `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px |
---
## `<x-gender-select>`
**File:** `resources/views/components/gender-select.blade.php`
**Props:** `name`, `id`, `value` (ISO string: `"male"` or `"female"`), `label`, `required`, `class`, `style`.
**Behaviour:** Custom dropdown with blue ♂ (male) and pink ♀ (female) symbols. No search needed. Stores value as `"male"` or `"female"`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/auth/register.blade.php` | `gender` | Registration form — mandatory |
---
## `<x-phone-code-select>`
Stored value format: `"+973|BH"` (dial_code + pipe + ISO2).
To read only the dial code from a stored value: `explode('|', $value)[0]`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `phone_code` | Paired with `phone_number` text input |
---
## `<x-country-select>`
Stored value: ISO2 code (e.g. `"BH"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `nationality` | Edit Profile form |
| `resources/views/auth/register.blade.php` | `nationality` | Registration form — mandatory |
---
## `<x-timezone-select>`
Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `timezone` | Edit Profile form |
---
## Usage example
```blade
{{-- Phone code + number side by side --}}
<div style="display:flex; gap:8px;">
<x-phone-code-select
name="phone_code"
value="+973|BH"
label="Phone"
required
style="width:140px; flex-shrink:0;"
/>
<input type="tel" name="phone_number" class="form-control">
</div>
{{-- Country / nationality --}}
<x-country-select
name="nationality"
label="Nationality"
placeholder="Select nationality"
value="{{ old('nationality', $user->nationality) }}"
required
/>
{{-- Timezone --}}
<x-timezone-select
name="timezone"
label="Timezone"
value="{{ old('timezone', $user->timezone ?? 'Asia/Bahrain') }}"
required
/>
```
---
## Modification checklist
When you modify any of these components, work through this list:
- [ ] Update `app/Data/Countries.php` if the data structure changes
- [ ] Update all three `.blade.php` component files if shared CSS/JS changes
- [ ] Update the `for*()` method in `Countries.php` that feeds the changed component
- [ ] Re-test every page listed in the usage tables above
- [ ] Add/remove rows from the usage tables if views were added or removed

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@ -57,3 +57,14 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# NAS File Manager
NAS_PROTOCOL=smb
NAS_HOST=
NAS_PORT=445
NAS_USERNAME=
NAS_PASSWORD=
NAS_PATH=/media
NAS_SMB_SHARE=
NAS_SMB_DOMAIN=
NAS_FM_ROUTE_PREFIX=nas-file-manager

208
CLAUDE.md Normal file
View File

@ -0,0 +1,208 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**TAKEONE** (Play) is a Laravel 10 video-sharing platform with sports match annotation, HLS adaptive streaming, GPU-accelerated video processing, playlists, threaded comments, and a super-admin panel. Live at `https://video.takeone.bh`.
Tech stack: PHP 8.1+, Laravel 10, Blade templating, Vite 5, Axios, FFmpeg/FFProbe with NVIDIA NVENC, SQLite (dev) / MySQL (prod), Laravel Sanctum.
---
## Essential Commands
### Local Development
```bash
php artisan serve # Backend on http://localhost:8000
npm run dev # Vite dev server with HMR
npm run build # Production frontend build → public/build/
```
### Database
```bash
php artisan migrate
php artisan db:seed
php artisan tinker
```
### Background Workers
```bash
# Video processing (CompressVideoJob, GenerateHlsJob)
php artisan queue:work --queue=video-processing
# Orphaned file cleanup scheduler (every CLEANUP_INTERVAL_MINUTES, default 30)
php artisan schedule:run # run this every minute via cron
php artisan cleanup:orphaned-videos --dry-run # preview only
php artisan cleanup:orphaned-videos --force # delete orphans
```
### Testing
```bash
./vendor/bin/phpunit
./vendor/bin/phpunit --filter "TestName"
./vendor/bin/phpunit tests/Feature/ExampleTest.php
```
Tests use in-memory SQLite, array mail/cache/session drivers, sync queue, and BCRYPT_ROUNDS=4. Set `APP_ENV=testing`.
---
## Architecture
### Role System
Three roles stored on `users.role`: `super_admin`, `admin`, `user` (null = user). The `IsSuperAdmin` middleware guards all `/admin/*` routes. Helper methods on the User model: `isSuperAdmin()`, `isAdmin()`, `isUser()`.
### Video Lifecycle
1. `VideoController@store` validates upload, stores file, extracts metadata via FFProbe (duration, dimensions, orientation).
2. Status column transitions: `pending → processing → ready` (or `failed`).
3. `CompressVideoJob` re-encodes with NVIDIA NVENC (`h264_nvenc`, CRF 23), replaces original if smaller.
4. `GenerateHlsJob` produces 480p / 720p / 1080p HLS variants (`h264_nvenc`, preset p4) as `.m3u8` + `.ts` files.
5. Streaming served at `GET /videos/{video}/hls/{file?}` (master playlist → segments).
Queue connection is `sync` by default (runs inline); switch `QUEUE_CONNECTION=database` or `redis` for true async.
### Shorts Auto-Detection
A video is a Short when duration ≤ 60 s **and** portrait orientation. The `is_shorts` DB column allows manual override. Use model scopes `Video::shorts()` / `Video::notShorts()`.
### Trending Algorithm
`Video::scopeTrending($hours=48, $limit=50)` — weighted score: 70% recent views, 15% view velocity, 10% recency bonus, 5% likes. Only applies to videos < 10 days old with 5 recent views.
### Playlist System
- `playlist_videos` pivot carries `position`, `watched_seconds`, `watched`, `added_at`, `last_watched_at`.
- Each user has one `is_default = true` playlist ("Watch Later").
- `Playlist` model methods: `addVideo()`, `removeVideo()`, `reorder()`, `getNextVideo()`, `getPreviousVideo()`, `updateWatchProgress()`, `canView()`, `canEdit()`.
### Comment Threading & Mentions
Comments have a `parent_id` for one-level threading (replies). The `parsed_body` accessor converts `@username` syntax into clickable profile links.
### Comment Timestamp Badges
The `enhanceBody()` function in `components/video-comments.blade.php` converts timestamp syntax written in comments into clickable `._comment-time-badge` spans at render time (client-side). Supported formats:
- `@mm:ss` — single timestamp, jumps to that point (colon separator)
- `@mm.ss` — same, dot separator (legacy, still supported)
- `@mm:ss-mm:ss` or `@mm.ss-mm.ss` — time range; plays from start to end then pauses
Clicking a badge: scrolls to `#ytpWrap` (smooth), waits 500 ms, seeks `#videoPlayer` to `start`, calls `.play()`, and if a range is specified stops at `end` via a `timeupdate` listener. `@username` mention detection requires the first char to be a letter so it never collides with numeric timestamps.
### Sports Match Annotation
Videos of type `match` support three related models:
- `MatchRound` — round number, name, `start_time_seconds`
- `MatchPoint``timestamp_seconds`, action, competitor (blue/red), running score
- `CoachReview` — time-range segment with coach note and emoji
All managed via `MatchEventController` under authenticated routes.
### Key Model Scopes & Accessors
`Video` scopes: `public()`, `visibleTo($user)`, `shorts()`, `notShorts()`, `trending()`.
`Video` accessors: `url`, `thumbnail_url`, `like_count`, `view_count`, `formatted_duration`, `iso_duration`, `open_graph_image`.
`Playlist` accessors: `thumbnail_url`, `video_count`, `total_duration`, `formatted_duration`.
## Rules
**Database changes require confirmation** — if any task requires a migration, schema change, or new column, always ask before proceeding.
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.
**All buttons must use the global `.action-btn` system** — never add custom button CSS. Use these classes:
- `.action-btn` — default (bordered, bg-secondary)
- `.action-btn.action-btn-primary` or `.action-btn.primary` — brand red, for primary/submit actions
- `.action-btn.action-btn-danger` or `.action-btn.danger` — red border/text, for destructive actions
- `.action-btn.icon-only` — square padding, for icon-only buttons
Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</span></button>`. The global CSS lives in `layouts/app.blade.php`.
**Mobile layout uses a native-app scroll model** — on `max-width: 768px`, `html` and `body` are locked (`overflow: hidden; position: fixed`) so the window never scrolls. `.yt-main` is `position: fixed` spanning `top: 56px` to `bottom: calc(56px + env(safe-area-inset-bottom))` with `overflow-y: auto; -webkit-overflow-scrolling: touch`. This keeps the header and bottom nav truly fixed without any JavaScript. Consequences to remember:
- **Never use `position: sticky` inside `.yt-main` on mobile** — sticky elements float over content because the scroll container changed. Override with `position: relative !important` in the mobile media query.
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.
**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**
**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.
**Never build custom dropdowns for country, nationality, phone code, timezone, or currency** — reusable Blade components already exist for these. Always use them; never roll a new `<select>`, inline list, or custom picker:
| Need | Component | Stored value |
|---|---|---|
| Country / nationality picker | `<x-country-select name="…" />` | ISO2 code e.g. `"BH"` |
| Phone / dial-code picker | `<x-phone-code-select name="…" />` | `"+973|BH"` — split on `|` to get code alone |
| Timezone picker | `<x-timezone-select name="…" />` | IANA string e.g. `"Asia/Bahrain"` |
| Currency | read from `App\Data\Countries::all()[$iso2]['currency']` | ISO 4217 code e.g. `"BHD"` |
All three components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. The full dataset lives in `app/Data/Countries.php`. Usage is tracked in `.claude/component-usage.md` — add a row to the relevant table whenever you place one of these components in a view.
**Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception:
1. **Creating a new reusable component** → add a new section to the tracker listing the component file path, its props, and an empty usage table.
2. **Placing a component in any view** → immediately add a row to the relevant tracker table (view file path, field/slot name, any relevant notes). Do this in the same task, not later.
3. **Modifying a component** (props, markup, CSS, JS, behaviour) → open the tracker first, read every row in that component's usage table, then apply the necessary follow-up changes to every listed view before marking the task done. Never modify a component without checking its tracker entries.
4. **Removing a component from a view** → delete its row from the tracker table in the same task.
5. **Deleting a component entirely** → remove its full section from the tracker and clean up every view that was still referencing it.
The tracker is the source of truth for blast radius. If the tracker is out of date and a change breaks an unlisted page, that is a process failure — always keep it accurate.
**Match highlights sidebar must always match the video player height** — use a `ResizeObserver` on `#ytpWrap` to write `--sidebar-height` to `document.documentElement` and bind `.events-sidebar { height: var(--sidebar-height) }`. Never hardcode a pixel or viewport height for the sidebar. The pattern lives in `videos/types/match.blade.php` (`initSidebarHeightSync`).
### NAS is always enabled — it is the only storage backend
**The NAS is permanently enabled in this project. Local disk is never a storage destination — it is a temporary write buffer only. Every user file must end up on the NAS and be served from the NAS. No exceptions.**
File types and their NAS locations:
| File type | NAS path | Served via |
|---|---|---|
| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.
**Absolute rules — these must never be violated:**
1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.
2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
- Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
- Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
- Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
- Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
- Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
- Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`
3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.
4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.
5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.
6. **Never check `NasSyncService::isEnabled()` before doing a NAS operation in this project.** It is always enabled. Writing code with an `if ($nas->isEnabled())` branch that falls back to local-only storage will result in broken files the moment that branch is taken.
**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
3. Delete files with no DB match (orphans) directly with `@unlink()`
4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)
### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
- **Broadcasting**: Pusher is configured but `BroadcastServiceProvider` is commented out — not active.
- **Timezone**: `Asia/Bahrain` (set in `config/app.php`).
- **App name constant**: `config('app.name')` returns `TAKEONE`.
### Route Structure Summary
- Public: `/`, `/videos`, `/trending`, `/shorts`, `/videos/search`, `/videos/{video}`, stream/hls/download
- Auth-required: video CRUD, likes, comments, profile, settings, history, playlists, match events
- Admin (`/admin/*`, `super_admin` middleware): dashboard, user CRUD, video CRUD, orphan cleanup
- API: `GET /api/user` (Sanctum token auth)

27
README_cleanup.md Normal file
View File

@ -0,0 +1,27 @@
# Video Platform Orphan Cleanup ✅
## Core Feature Complete
**Cron Job:**
- Command: `php artisan cleanup:orphaned-videos --force`
- Scheduled: Every 30min (`CLEANUP_INTERVAL_MINUTES=30` in .env)
- Dir: Deletes `storage/app/public/videos/*` w/o DB Video::filename match (inc compressed_)
- Logs: `storage/logs/orphaned-videos.log`
**Dashboard (/admin/dashboard):**
- Gauge: Videos size %
- Button: AJAX manual clean
**Production Setup:**
```
* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1
```
**Tested:**
- Dry-run: Found/deleted 6 orphans
- Cron: Scheduled
- Logs: Active
Button CSRF issue? Use CLI manual or cron.
Self-cleaning platform ready. 🎥🧹

View File

@ -0,0 +1,10 @@
# Admin Dashboard Enhancements - Progress
## Steps:
- [x] Step 1: Add cleanup method to SuperAdminController
- [x] Step 2: Add route for cleanup
- [ ] Step 3: Add dashboard UI (button + storage gauge for videos/)
- [x] Step 4: Test *(Manual: Visit /admin/dashboard, use button; route added)*
- [x] Complete
✅ **Admin Dashboard Enhancement Complete**

View File

@ -0,0 +1,366 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class NasFreeLocalStorage extends Command
{
protected $signature = 'nas:free-local
{--dry-run : Preview what would be deleted without deleting}
{--force : Actually delete local files confirmed on NAS}
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
public function handle(NasSyncService $nas): int
{
if (! $nas->isEnabled()) {
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
return 1;
}
$dryRun = $this->option('dry-run');
$force = $this->option('force');
if (! $dryRun && ! $force) {
$this->warn('Pass --dry-run to preview, or --force to delete.');
return 1;
}
$mode = $dryRun ? 'DRY RUN — nothing will be deleted' : 'FORCE — deleting confirmed NAS files';
$this->info($mode);
$this->newLine();
$totalBytes = 0;
$toDelete = [];
// ── Videos ───────────────────────────────────────────────────────────
$this->info('Scanning video files…');
$videos = Video::all()->filter(fn (Video $v) => file_exists(storage_path('app/' . $v->path)));
if ($videos->isNotEmpty()) {
$bar = $this->output->createProgressBar($videos->count());
$bar->start();
foreach ($videos as $video) {
$localPath = storage_path('app/' . $video->path);
$dir = $nas->resolveVideoDir($video);
$meta = null;
try {
$raw = $nas->getContent("{$dir}/meta.json");
$meta = $raw ? json_decode($raw, true) : null;
} catch (\Throwable) {}
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
$bytes = filesize($localPath);
$totalBytes += $bytes;
$toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes];
}
$bar->advance();
}
$bar->finish();
$this->newLine();
} else {
$this->line(' No local video files found.');
}
// ── Thumbnails & slides (both legacy flat dir and new NAS-mirrored dirs) ──
$this->newLine();
$this->info('Scanning thumbnail and slide files…');
// Helper: check if a video's NAS dir is confirmed, using meta.json
$confirmNas = function (Video $v) use ($nas): bool {
try {
$raw = $nas->getContent($nas->resolveVideoDir($v) . '/meta.json');
$meta = $raw ? json_decode($raw, true) : null;
return is_array($meta) && ($meta['id'] ?? null) === $v->id;
} catch (\Throwable) { return false; }
};
// Legacy flat thumbnails dir
$thumbDir = storage_path('app/public/thumbnails');
if (is_dir($thumbDir)) {
foreach (glob($thumbDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "thumb:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
} else {
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "slide:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
}
// New NAS-mirrored dirs: storage/app/users/{username}/videos/{slug}/thumb.*
// slides/{id}.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($usersBase, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$absPath = $file->getPathname();
$relPath = ltrim(str_replace(storage_path('app'), '', $absPath), '/');
// Match thumbnail or slide by relative path stored in DB
$video = Video::where('thumbnail', $relPath)->first();
if ($video) {
if ($confirmNas($video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'thumb:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
continue;
}
$slide = \App\Models\VideoSlide::where('filename', $relPath)->with('video')->first();
if ($slide && $slide->video && $confirmNas($slide->video)) {
$bytes = $file->getSize(); $totalBytes += $bytes;
$toDelete[] = ['label' => 'slide:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning thumbnails.');
// ── Avatars ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning avatar files…');
// Legacy flat dir
$avatarDir = storage_path('app/public/avatars');
if (is_dir($avatarDir)) {
foreach (glob($avatarDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('avatar', $filename)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
// New structured dir: users/{slug}/profile/avatar.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
if (! is_file($file)) continue;
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
$user = User::where('avatar', $relPath)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => 'avatar:' . basename($file), 'path' => $file, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning avatars.');
// ── Banners ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning banner files…');
// Legacy flat dir
$bannerDir = storage_path('app/public/banners');
if (is_dir($bannerDir)) {
foreach (glob($bannerDir . '/*') as $file) {
if (! is_file($file)) continue;
$filename = basename($file);
$user = User::where('banner', $filename)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
}
}
}
// New structured dir: users/{slug}/profile/cover.*
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
if (! is_file($file)) continue;
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
$user = User::where('banner', $relPath)->first();
if (! $user) continue;
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file); $totalBytes += $bytes;
$toDelete[] = ['label' => 'banner:' . basename($file), 'path' => $file, 'bytes' => $bytes];
}
}
}
$this->line(' Done scanning banners.');
// ── Slideshow cache directories ───────────────────────────────────────
// The slideshow/ directory is a render cache that is always regenerated on
// demand, so its contents are safe to delete unconditionally.
$this->newLine();
$this->info('Scanning slideshow cache…');
$slideshowDir = storage_path('app/public/slideshow');
if (is_dir($slideshowDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) continue;
$bytes = $file->getSize();
$totalBytes += $bytes;
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
}
$this->line(' Done scanning slideshow cache.');
}
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
// These are on-demand local copies of NAS videos used for HTTP streaming.
// Always safe to delete — they are re-downloaded from NAS on next play.
$this->newLine();
$this->info('Scanning NAS stream cache…');
$nasCacheDir = storage_path('app/nas_cache/videos');
$ttl = (int) $this->option('cache-ttl');
$cutoff = time() - ($ttl * 3600);
if (is_dir($nasCacheDir)) {
foreach (glob("{$nasCacheDir}/*") as $file) {
if (! is_file($file)) continue;
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
}
$this->line(' Done scanning NAS stream cache.');
}
// ── Summary ───────────────────────────────────────────────────────────
$this->newLine();
if (empty($toDelete)) {
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
return 0;
}
$this->table(
['Type', 'File', 'Size'],
array_map(fn ($row) => [
$row['label'],
basename($row['path']),
$this->humanBytes($row['bytes']),
], $toDelete)
);
$this->newLine();
$this->line(sprintf(
'Found <comment>%d</comment> file(s) totalling <comment>%s</comment> that can be freed.',
count($toDelete),
$this->humanBytes($totalBytes)
));
if ($dryRun) {
$this->newLine();
$this->warn('Run with --force to delete these files.');
return 0;
}
// ── Actually delete ───────────────────────────────────────────────────
$deleted = 0;
$failed = 0;
foreach ($toDelete as $row) {
if (@unlink($row['path'])) {
$deleted++;
Log::info('nas:free-local: deleted ' . $row['path']);
} else {
$failed++;
$this->warn("Could not delete: {$row['path']}");
}
}
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
$this->pruneEmptyDirs(storage_path('app/users'));
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
$path = storage_path("app/{$rel}");
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
@rmdir($path);
}
}
$this->newLine();
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
if ($failed > 0) {
$this->warn("{$failed} file(s) could not be deleted — check permissions.");
}
return 0;
}
/**
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
*/
private function pruneEmptyDirs(string $root): void
{
if (! is_dir($root)) return;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function humanBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
return $bytes . ' B';
}
}

View File

@ -0,0 +1,435 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class NasRepairLocalFiles extends Command
{
protected $signature = 'nas:repair
{--dry-run : Preview what would be pushed without making changes}
{--force : Actually upload to NAS and remove local copies}
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
protected $description = 'Upload stuck local files to NAS, fix DB paths, and delete local copies';
private NasSyncService $nas;
public function handle(NasSyncService $nas): int
{
if (! $nas->isEnabled()) {
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
return 1;
}
$this->nas = $nas;
$dryRun = $this->option('dry-run');
$force = $this->option('force');
if (! $dryRun && ! $force) {
$this->warn('Pass --dry-run to preview, or --force to repair.');
return 1;
}
$this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies');
$this->newLine();
$totalRepaired = 0;
$totalFailed = 0;
// ── NAS-format videos / slides / thumbnails ───────────────────────────
$stuckVideos = $this->findStuckVideos();
if ($stuckVideos->isNotEmpty()) {
$this->info('Video / slide files stuck locally:');
$rows = [];
foreach ($stuckVideos as $item) {
foreach ($item['files'] as $label) {
$rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label];
}
}
$this->table(['Video ID', 'Title', 'File'], $rows);
$this->newLine();
if ($force) {
foreach ($stuckVideos as $item) {
$video = $item['video'];
$this->line(" Repairing video #{$video->id}: {$video->title}");
try {
$nas->syncVideo($video);
$nas->deleteLocalAssets($video);
if ($video->hls_path || $video->type === 'music') {
$nas->deleteLocalVideo($video);
}
$nas->pruneLocalVideoDir($video);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
Log::info("nas:repair: fixed video #{$video->id}");
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Avatars ───────────────────────────────────────────────────────────
$stuckAvatars = $this->findStuckAvatars();
if ($stuckAvatars->isNotEmpty()) {
$this->info('Avatar files stuck locally:');
$this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckAvatars as $item) {
$user = $item['user'];
$path = $item['path'];
$this->line(" Repairing avatar for {$user->username}");
try {
$nas->syncAvatar($user, $path);
$nas->deleteLocalAvatar($user);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Banners ───────────────────────────────────────────────────────────
$stuckBanners = $this->findStuckBanners();
if ($stuckBanners->isNotEmpty()) {
$this->info('Banner files stuck locally:');
$this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckBanners as $item) {
$user = $item['user'];
$path = $item['path'];
$this->line(" Repairing banner for {$user->username}");
try {
$nas->syncCover($user, $path);
$nas->deleteLocalBanner($user);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage());
}
}
$this->newLine();
}
}
// ── Legacy flat thumbnails (public/thumbnails/) ───────────────────────
$stuckThumbs = $this->findStuckLegacyThumbnails();
if ($stuckThumbs->isNotEmpty()) {
$this->info('Legacy thumbnail/slide files stuck locally:');
$this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [
$r['type'], $r['file'], $r['video_id'],
])->all());
$this->newLine();
if ($force) {
foreach ($stuckThumbs as $item) {
$this->line(" Repairing {$item['type']}: {$item['file']}");
try {
if ($item['type'] === 'thumbnail' && $item['video']) {
$nas->syncVideo($item['video']);
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
// Upload slide directly to NAS
$video = $item['video'];
$slide = $item['slide'];
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
$nas->mkdirp("{$dir}/slides");
$nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}");
}
@unlink($item['path']);
$totalRepaired++;
$this->line(' <info>✓ Done</info>');
} catch (\Throwable $e) {
$totalFailed++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
}
}
$this->newLine();
}
}
$anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty()
|| $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty();
// ── NAS orphaned video folders ─────────────────────────────────────────
$this->newLine();
$this->info('Scanning NAS for orphaned video folders (no matching DB record)…');
$nasOrphans = $nas->scanNasOrphans();
if (! empty($nasOrphans)) {
$this->table(
['NAS Directory', 'meta.json video_id'],
array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans)
);
$this->newLine();
if ($force) {
$deletedOrphans = 0;
$failedOrphans = 0;
foreach ($nasOrphans as $orphan) {
$this->line(" Deleting NAS orphan: {$orphan['dir']}");
try {
$nas->deleteNasTree($orphan['dir']);
$deletedOrphans++;
$this->line(' <info>✓ Deleted</info>');
Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
} catch (\Throwable $e) {
$failedOrphans++;
$this->warn(" ✗ Failed: {$e->getMessage()}");
Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
}
}
$totalRepaired += $deletedOrphans;
$totalFailed += $failedOrphans;
$this->newLine();
}
} else {
$this->line(' No orphaned NAS folders found.');
}
$anyIssues = $anyStuck || ! empty($nasOrphans);
// ── NAS stream cache ──────────────────────────────────────────────────
$cacheBytes = $nas->nasCacheSize();
if ($cacheBytes > 0) {
$ttl = (int) $this->option('cache-ttl');
$ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h";
$this->info(sprintf(
'NAS stream cache: <comment>%s</comment> occupying <comment>%s</comment> — will be evicted on --force (%s).',
count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)',
$this->humanBytes($cacheBytes),
$ttlLabel
));
$this->newLine();
}
if (! $anyIssues && $cacheBytes === 0) {
$this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.');
} elseif (! $anyIssues) {
$this->info('No issues found (stream cache will be evicted on --force).');
}
if ($dryRun && ($anyIssues || $cacheBytes > 0)) {
$this->warn('Run with --force to repair.');
return 0;
}
if ($force) {
$this->pruneAllLocalDirs();
$ttl = (int) $this->option('cache-ttl');
$evicted = $nas->clearNasCache($ttl);
if ($evicted > 0) {
$this->line("Evicted <comment>{$evicted}</comment> NAS stream-cache file(s).");
}
}
if ($totalRepaired > 0 || $force) {
$this->newLine();
if ($totalFailed === 0) {
$this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up.");
} else {
$this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details.");
}
}
return $totalFailed > 0 ? 1 : 0;
}
// ── Scanners ──────────────────────────────────────────────────────────────
private function findStuckVideos(): \Illuminate\Support\Collection
{
return Video::with(['user', 'slides'])->get()
->filter(fn (Video $v) => str_starts_with($v->path, 'users/'))
->map(function (Video $video) {
$files = [];
if (file_exists(storage_path('app/' . $video->path)))
$files[] = basename($video->path) . ' (video)';
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
file_exists(storage_path('app/' . $video->thumbnail)))
$files[] = basename($video->thumbnail) . ' (thumbnail)';
foreach ($video->slides as $slide) {
if (file_exists($slide->localPath()))
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
}
return $files ? ['video' => $video, 'files' => $files] : null;
})
->filter();
}
private function findStuckAvatars(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir: public/avatars/
$dir = storage_path('app/public/avatars');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = User::where('avatar', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/avatar.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
// relPath = "users/{slug}/profile/avatar.{ext}"
$user = User::where('avatar', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function findStuckBanners(): \Illuminate\Support\Collection
{
$results = collect();
// Legacy flat dir: public/banners/
$dir = storage_path('app/public/banners');
if (is_dir($dir)) {
$flat = collect(scandir($dir) ?: [])
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
->map(function ($filename) use ($dir) {
$user = User::where('banner', $filename)->first();
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
})
->filter();
$results = $results->merge($flat);
}
// New structured dir: users/{slug}/profile/cover.*
$usersBase = storage_path('app/users');
if (is_dir($usersBase)) {
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
$user = User::where('banner', $relPath)->first();
if ($user) {
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
}
}
}
return $results;
}
private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection
{
$dir = storage_path('app/public/thumbnails');
if (! is_dir($dir)) return collect();
$results = [];
foreach (scandir($dir) ?: [] as $filename) {
if ($filename === '.' || $filename === '..') continue;
$path = "{$dir}/{$filename}";
if (! is_file($path)) continue;
// Check if it's a video thumbnail
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
continue;
}
// Check if it's an old-format slide
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
}
}
return collect($results);
}
// ── Directory pruning ─────────────────────────────────────────────────────
private function pruneAllLocalDirs(): void
{
// NAS-mirrored dirs
$this->pruneEmptyDirTree(storage_path('app/users'));
// Flat asset dirs — remove if empty
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
$path = storage_path("app/{$rel}");
if (is_dir($path) && $this->isDirEmpty($path)) {
@rmdir($path);
}
}
}
/**
* Bottom-up prune: remove dirs that contain only meta.json or nothing.
*/
private function pruneEmptyDirTree(string $root): void
{
if (! is_dir($root)) return;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
private function isDirEmpty(string $dir): bool
{
return empty(array_diff(scandir($dir) ?: [], ['.', '..']));
}
private function humanBytes(int $bytes): string
{
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
return $bytes . ' B';
}
}

View File

@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
->cron("*/{$interval} * * * *")
->withoutOverlapping()
->runInBackground();
// Evict NAS stream-cache files older than 24 hours
$schedule->call(function () {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->clearNasCache(24);
}
})->daily()->name('nas-cache-evict')->withoutOverlapping();
}
/**

361
app/Data/Countries.php Normal file
View File

@ -0,0 +1,361 @@
<?php
namespace App\Data;
class Countries
{
/**
* All countries keyed by ISO2 code.
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
*/
public static function all(): array
{
// Generate flag emoji from ISO2 (two regional indicator letters)
$f = fn(string $c): string =>
mb_chr(0x1F1E6 + ord($c[0]) - 65) .
mb_chr(0x1F1E6 + ord($c[1]) - 65);
return [
// ── AFRICA ──────────────────────────────────────────────────────────
'DZ' => ['name'=>'Algeria', 'iso2'=>'DZ','iso3'=>'DZA','flag'=>$f('DZ'),'dial_code'=>'+213', 'timezone'=>'Africa/Algiers', 'currency'=>'DZD'],
'AO' => ['name'=>'Angola', 'iso2'=>'AO','iso3'=>'AGO','flag'=>$f('AO'),'dial_code'=>'+244', 'timezone'=>'Africa/Luanda', 'currency'=>'AOA'],
'BJ' => ['name'=>'Benin', 'iso2'=>'BJ','iso3'=>'BEN','flag'=>$f('BJ'),'dial_code'=>'+229', 'timezone'=>'Africa/Porto-Novo', 'currency'=>'XOF'],
'BW' => ['name'=>'Botswana', 'iso2'=>'BW','iso3'=>'BWA','flag'=>$f('BW'),'dial_code'=>'+267', 'timezone'=>'Africa/Gaborone', 'currency'=>'BWP'],
'BF' => ['name'=>'Burkina Faso', 'iso2'=>'BF','iso3'=>'BFA','flag'=>$f('BF'),'dial_code'=>'+226', 'timezone'=>'Africa/Ouagadougou', 'currency'=>'XOF'],
'BI' => ['name'=>'Burundi', 'iso2'=>'BI','iso3'=>'BDI','flag'=>$f('BI'),'dial_code'=>'+257', 'timezone'=>'Africa/Bujumbura', 'currency'=>'BIF'],
'CV' => ['name'=>'Cabo Verde', 'iso2'=>'CV','iso3'=>'CPV','flag'=>$f('CV'),'dial_code'=>'+238', 'timezone'=>'Atlantic/Cape_Verde', 'currency'=>'CVE'],
'CM' => ['name'=>'Cameroon', 'iso2'=>'CM','iso3'=>'CMR','flag'=>$f('CM'),'dial_code'=>'+237', 'timezone'=>'Africa/Douala', 'currency'=>'XAF'],
'CF' => ['name'=>'Central African Republic', 'iso2'=>'CF','iso3'=>'CAF','flag'=>$f('CF'),'dial_code'=>'+236', 'timezone'=>'Africa/Bangui', 'currency'=>'XAF'],
'TD' => ['name'=>'Chad', 'iso2'=>'TD','iso3'=>'TCD','flag'=>$f('TD'),'dial_code'=>'+235', 'timezone'=>'Africa/Ndjamena', 'currency'=>'XAF'],
'KM' => ['name'=>'Comoros', 'iso2'=>'KM','iso3'=>'COM','flag'=>$f('KM'),'dial_code'=>'+269', 'timezone'=>'Indian/Comoro', 'currency'=>'KMF'],
'CD' => ['name'=>'Congo (DRC)', 'iso2'=>'CD','iso3'=>'COD','flag'=>$f('CD'),'dial_code'=>'+243', 'timezone'=>'Africa/Kinshasa', 'currency'=>'CDF'],
'CG' => ['name'=>'Congo (Republic)', 'iso2'=>'CG','iso3'=>'COG','flag'=>$f('CG'),'dial_code'=>'+242', 'timezone'=>'Africa/Brazzaville', 'currency'=>'XAF'],
'DJ' => ['name'=>'Djibouti', 'iso2'=>'DJ','iso3'=>'DJI','flag'=>$f('DJ'),'dial_code'=>'+253', 'timezone'=>'Africa/Djibouti', 'currency'=>'DJF'],
'EG' => ['name'=>'Egypt', 'iso2'=>'EG','iso3'=>'EGY','flag'=>$f('EG'),'dial_code'=>'+20', 'timezone'=>'Africa/Cairo', 'currency'=>'EGP'],
'GQ' => ['name'=>'Equatorial Guinea', 'iso2'=>'GQ','iso3'=>'GNQ','flag'=>$f('GQ'),'dial_code'=>'+240', 'timezone'=>'Africa/Malabo', 'currency'=>'XAF'],
'ER' => ['name'=>'Eritrea', 'iso2'=>'ER','iso3'=>'ERI','flag'=>$f('ER'),'dial_code'=>'+291', 'timezone'=>'Africa/Asmara', 'currency'=>'ERN'],
'SZ' => ['name'=>'Eswatini', 'iso2'=>'SZ','iso3'=>'SWZ','flag'=>$f('SZ'),'dial_code'=>'+268', 'timezone'=>'Africa/Mbabane', 'currency'=>'SZL'],
'ET' => ['name'=>'Ethiopia', 'iso2'=>'ET','iso3'=>'ETH','flag'=>$f('ET'),'dial_code'=>'+251', 'timezone'=>'Africa/Addis_Ababa', 'currency'=>'ETB'],
'GA' => ['name'=>'Gabon', 'iso2'=>'GA','iso3'=>'GAB','flag'=>$f('GA'),'dial_code'=>'+241', 'timezone'=>'Africa/Libreville', 'currency'=>'XAF'],
'GM' => ['name'=>'Gambia', 'iso2'=>'GM','iso3'=>'GMB','flag'=>$f('GM'),'dial_code'=>'+220', 'timezone'=>'Africa/Banjul', 'currency'=>'GMD'],
'GH' => ['name'=>'Ghana', 'iso2'=>'GH','iso3'=>'GHA','flag'=>$f('GH'),'dial_code'=>'+233', 'timezone'=>'Africa/Accra', 'currency'=>'GHS'],
'GN' => ['name'=>'Guinea', 'iso2'=>'GN','iso3'=>'GIN','flag'=>$f('GN'),'dial_code'=>'+224', 'timezone'=>'Africa/Conakry', 'currency'=>'GNF'],
'GW' => ['name'=>'Guinea-Bissau', 'iso2'=>'GW','iso3'=>'GNB','flag'=>$f('GW'),'dial_code'=>'+245', 'timezone'=>'Africa/Bissau', 'currency'=>'XOF'],
'CI' => ['name'=>'Ivory Coast', 'iso2'=>'CI','iso3'=>'CIV','flag'=>$f('CI'),'dial_code'=>'+225', 'timezone'=>'Africa/Abidjan', 'currency'=>'XOF'],
'KE' => ['name'=>'Kenya', 'iso2'=>'KE','iso3'=>'KEN','flag'=>$f('KE'),'dial_code'=>'+254', 'timezone'=>'Africa/Nairobi', 'currency'=>'KES'],
'LS' => ['name'=>'Lesotho', 'iso2'=>'LS','iso3'=>'LSO','flag'=>$f('LS'),'dial_code'=>'+266', 'timezone'=>'Africa/Maseru', 'currency'=>'LSL'],
'LR' => ['name'=>'Liberia', 'iso2'=>'LR','iso3'=>'LBR','flag'=>$f('LR'),'dial_code'=>'+231', 'timezone'=>'Africa/Monrovia', 'currency'=>'LRD'],
'LY' => ['name'=>'Libya', 'iso2'=>'LY','iso3'=>'LBY','flag'=>$f('LY'),'dial_code'=>'+218', 'timezone'=>'Africa/Tripoli', 'currency'=>'LYD'],
'MG' => ['name'=>'Madagascar', 'iso2'=>'MG','iso3'=>'MDG','flag'=>$f('MG'),'dial_code'=>'+261', 'timezone'=>'Indian/Antananarivo', 'currency'=>'MGA'],
'MW' => ['name'=>'Malawi', 'iso2'=>'MW','iso3'=>'MWI','flag'=>$f('MW'),'dial_code'=>'+265', 'timezone'=>'Africa/Blantyre', 'currency'=>'MWK'],
'ML' => ['name'=>'Mali', 'iso2'=>'ML','iso3'=>'MLI','flag'=>$f('ML'),'dial_code'=>'+223', 'timezone'=>'Africa/Bamako', 'currency'=>'XOF'],
'MR' => ['name'=>'Mauritania', 'iso2'=>'MR','iso3'=>'MRT','flag'=>$f('MR'),'dial_code'=>'+222', 'timezone'=>'Africa/Nouakchott', 'currency'=>'MRU'],
'MU' => ['name'=>'Mauritius', 'iso2'=>'MU','iso3'=>'MUS','flag'=>$f('MU'),'dial_code'=>'+230', 'timezone'=>'Indian/Mauritius', 'currency'=>'MUR'],
'MA' => ['name'=>'Morocco', 'iso2'=>'MA','iso3'=>'MAR','flag'=>$f('MA'),'dial_code'=>'+212', 'timezone'=>'Africa/Casablanca', 'currency'=>'MAD'],
'MZ' => ['name'=>'Mozambique', 'iso2'=>'MZ','iso3'=>'MOZ','flag'=>$f('MZ'),'dial_code'=>'+258', 'timezone'=>'Africa/Maputo', 'currency'=>'MZN'],
'NA' => ['name'=>'Namibia', 'iso2'=>'NA','iso3'=>'NAM','flag'=>$f('NA'),'dial_code'=>'+264', 'timezone'=>'Africa/Windhoek', 'currency'=>'NAD'],
'NE' => ['name'=>'Niger', 'iso2'=>'NE','iso3'=>'NER','flag'=>$f('NE'),'dial_code'=>'+227', 'timezone'=>'Africa/Niamey', 'currency'=>'XOF'],
'NG' => ['name'=>'Nigeria', 'iso2'=>'NG','iso3'=>'NGA','flag'=>$f('NG'),'dial_code'=>'+234', 'timezone'=>'Africa/Lagos', 'currency'=>'NGN'],
'RW' => ['name'=>'Rwanda', 'iso2'=>'RW','iso3'=>'RWA','flag'=>$f('RW'),'dial_code'=>'+250', 'timezone'=>'Africa/Kigali', 'currency'=>'RWF'],
'ST' => ['name'=>'São Tomé & Príncipe', 'iso2'=>'ST','iso3'=>'STP','flag'=>$f('ST'),'dial_code'=>'+239', 'timezone'=>'Africa/Sao_Tome', 'currency'=>'STN'],
'SN' => ['name'=>'Senegal', 'iso2'=>'SN','iso3'=>'SEN','flag'=>$f('SN'),'dial_code'=>'+221', 'timezone'=>'Africa/Dakar', 'currency'=>'XOF'],
'SC' => ['name'=>'Seychelles', 'iso2'=>'SC','iso3'=>'SYC','flag'=>$f('SC'),'dial_code'=>'+248', 'timezone'=>'Indian/Mahe', 'currency'=>'SCR'],
'SL' => ['name'=>'Sierra Leone', 'iso2'=>'SL','iso3'=>'SLE','flag'=>$f('SL'),'dial_code'=>'+232', 'timezone'=>'Africa/Freetown', 'currency'=>'SLL'],
'SO' => ['name'=>'Somalia', 'iso2'=>'SO','iso3'=>'SOM','flag'=>$f('SO'),'dial_code'=>'+252', 'timezone'=>'Africa/Mogadishu', 'currency'=>'SOS'],
'ZA' => ['name'=>'South Africa', 'iso2'=>'ZA','iso3'=>'ZAF','flag'=>$f('ZA'),'dial_code'=>'+27', 'timezone'=>'Africa/Johannesburg', 'currency'=>'ZAR'],
'SS' => ['name'=>'South Sudan', 'iso2'=>'SS','iso3'=>'SSD','flag'=>$f('SS'),'dial_code'=>'+211', 'timezone'=>'Africa/Juba', 'currency'=>'SSP'],
'SD' => ['name'=>'Sudan', 'iso2'=>'SD','iso3'=>'SDN','flag'=>$f('SD'),'dial_code'=>'+249', 'timezone'=>'Africa/Khartoum', 'currency'=>'SDG'],
'TZ' => ['name'=>'Tanzania', 'iso2'=>'TZ','iso3'=>'TZA','flag'=>$f('TZ'),'dial_code'=>'+255', 'timezone'=>'Africa/Dar_es_Salaam', 'currency'=>'TZS'],
'TG' => ['name'=>'Togo', 'iso2'=>'TG','iso3'=>'TGO','flag'=>$f('TG'),'dial_code'=>'+228', 'timezone'=>'Africa/Lome', 'currency'=>'XOF'],
'TN' => ['name'=>'Tunisia', 'iso2'=>'TN','iso3'=>'TUN','flag'=>$f('TN'),'dial_code'=>'+216', 'timezone'=>'Africa/Tunis', 'currency'=>'TND'],
'UG' => ['name'=>'Uganda', 'iso2'=>'UG','iso3'=>'UGA','flag'=>$f('UG'),'dial_code'=>'+256', 'timezone'=>'Africa/Kampala', 'currency'=>'UGX'],
'ZM' => ['name'=>'Zambia', 'iso2'=>'ZM','iso3'=>'ZMB','flag'=>$f('ZM'),'dial_code'=>'+260', 'timezone'=>'Africa/Lusaka', 'currency'=>'ZMW'],
'ZW' => ['name'=>'Zimbabwe', 'iso2'=>'ZW','iso3'=>'ZWE','flag'=>$f('ZW'),'dial_code'=>'+263', 'timezone'=>'Africa/Harare', 'currency'=>'ZWL'],
// ── AMERICAS ─────────────────────────────────────────────────────────
'AG' => ['name'=>'Antigua & Barbuda', 'iso2'=>'AG','iso3'=>'ATG','flag'=>$f('AG'),'dial_code'=>'+1', 'timezone'=>'America/Antigua', 'currency'=>'XCD'],
'AR' => ['name'=>'Argentina', 'iso2'=>'AR','iso3'=>'ARG','flag'=>$f('AR'),'dial_code'=>'+54', 'timezone'=>'America/Argentina/Buenos_Aires','currency'=>'ARS'],
'BS' => ['name'=>'Bahamas', 'iso2'=>'BS','iso3'=>'BHS','flag'=>$f('BS'),'dial_code'=>'+1', 'timezone'=>'America/Nassau', 'currency'=>'BSD'],
'BB' => ['name'=>'Barbados', 'iso2'=>'BB','iso3'=>'BRB','flag'=>$f('BB'),'dial_code'=>'+1', 'timezone'=>'America/Barbados', 'currency'=>'BBD'],
'BZ' => ['name'=>'Belize', 'iso2'=>'BZ','iso3'=>'BLZ','flag'=>$f('BZ'),'dial_code'=>'+501', 'timezone'=>'America/Belize', 'currency'=>'BZD'],
'BO' => ['name'=>'Bolivia', 'iso2'=>'BO','iso3'=>'BOL','flag'=>$f('BO'),'dial_code'=>'+591', 'timezone'=>'America/La_Paz', 'currency'=>'BOB'],
'BR' => ['name'=>'Brazil', 'iso2'=>'BR','iso3'=>'BRA','flag'=>$f('BR'),'dial_code'=>'+55', 'timezone'=>'America/Sao_Paulo', 'currency'=>'BRL'],
'CA' => ['name'=>'Canada', 'iso2'=>'CA','iso3'=>'CAN','flag'=>$f('CA'),'dial_code'=>'+1', 'timezone'=>'America/Toronto', 'currency'=>'CAD'],
'CL' => ['name'=>'Chile', 'iso2'=>'CL','iso3'=>'CHL','flag'=>$f('CL'),'dial_code'=>'+56', 'timezone'=>'America/Santiago', 'currency'=>'CLP'],
'CO' => ['name'=>'Colombia', 'iso2'=>'CO','iso3'=>'COL','flag'=>$f('CO'),'dial_code'=>'+57', 'timezone'=>'America/Bogota', 'currency'=>'COP'],
'CR' => ['name'=>'Costa Rica', 'iso2'=>'CR','iso3'=>'CRI','flag'=>$f('CR'),'dial_code'=>'+506', 'timezone'=>'America/Costa_Rica', 'currency'=>'CRC'],
'CU' => ['name'=>'Cuba', 'iso2'=>'CU','iso3'=>'CUB','flag'=>$f('CU'),'dial_code'=>'+53', 'timezone'=>'America/Havana', 'currency'=>'CUP'],
'DM' => ['name'=>'Dominica', 'iso2'=>'DM','iso3'=>'DMA','flag'=>$f('DM'),'dial_code'=>'+1', 'timezone'=>'America/Dominica', 'currency'=>'XCD'],
'DO' => ['name'=>'Dominican Republic', 'iso2'=>'DO','iso3'=>'DOM','flag'=>$f('DO'),'dial_code'=>'+1', 'timezone'=>'America/Santo_Domingo', 'currency'=>'DOP'],
'EC' => ['name'=>'Ecuador', 'iso2'=>'EC','iso3'=>'ECU','flag'=>$f('EC'),'dial_code'=>'+593', 'timezone'=>'America/Guayaquil', 'currency'=>'USD'],
'SV' => ['name'=>'El Salvador', 'iso2'=>'SV','iso3'=>'SLV','flag'=>$f('SV'),'dial_code'=>'+503', 'timezone'=>'America/El_Salvador', 'currency'=>'USD'],
'GD' => ['name'=>'Grenada', 'iso2'=>'GD','iso3'=>'GRD','flag'=>$f('GD'),'dial_code'=>'+1', 'timezone'=>'America/Grenada', 'currency'=>'XCD'],
'GT' => ['name'=>'Guatemala', 'iso2'=>'GT','iso3'=>'GTM','flag'=>$f('GT'),'dial_code'=>'+502', 'timezone'=>'America/Guatemala', 'currency'=>'GTQ'],
'GY' => ['name'=>'Guyana', 'iso2'=>'GY','iso3'=>'GUY','flag'=>$f('GY'),'dial_code'=>'+592', 'timezone'=>'America/Guyana', 'currency'=>'GYD'],
'HT' => ['name'=>'Haiti', 'iso2'=>'HT','iso3'=>'HTI','flag'=>$f('HT'),'dial_code'=>'+509', 'timezone'=>'America/Port-au-Prince', 'currency'=>'HTG'],
'HN' => ['name'=>'Honduras', 'iso2'=>'HN','iso3'=>'HND','flag'=>$f('HN'),'dial_code'=>'+504', 'timezone'=>'America/Tegucigalpa', 'currency'=>'HNL'],
'JM' => ['name'=>'Jamaica', 'iso2'=>'JM','iso3'=>'JAM','flag'=>$f('JM'),'dial_code'=>'+1', 'timezone'=>'America/Jamaica', 'currency'=>'JMD'],
'MX' => ['name'=>'Mexico', 'iso2'=>'MX','iso3'=>'MEX','flag'=>$f('MX'),'dial_code'=>'+52', 'timezone'=>'America/Mexico_City', 'currency'=>'MXN'],
'NI' => ['name'=>'Nicaragua', 'iso2'=>'NI','iso3'=>'NIC','flag'=>$f('NI'),'dial_code'=>'+505', 'timezone'=>'America/Managua', 'currency'=>'NIO'],
'PA' => ['name'=>'Panama', 'iso2'=>'PA','iso3'=>'PAN','flag'=>$f('PA'),'dial_code'=>'+507', 'timezone'=>'America/Panama', 'currency'=>'PAB'],
'PY' => ['name'=>'Paraguay', 'iso2'=>'PY','iso3'=>'PRY','flag'=>$f('PY'),'dial_code'=>'+595', 'timezone'=>'America/Asuncion', 'currency'=>'PYG'],
'PE' => ['name'=>'Peru', 'iso2'=>'PE','iso3'=>'PER','flag'=>$f('PE'),'dial_code'=>'+51', 'timezone'=>'America/Lima', 'currency'=>'PEN'],
'KN' => ['name'=>'Saint Kitts & Nevis', 'iso2'=>'KN','iso3'=>'KNA','flag'=>$f('KN'),'dial_code'=>'+1', 'timezone'=>'America/St_Kitts', 'currency'=>'XCD'],
'LC' => ['name'=>'Saint Lucia', 'iso2'=>'LC','iso3'=>'LCA','flag'=>$f('LC'),'dial_code'=>'+1', 'timezone'=>'America/St_Lucia', 'currency'=>'XCD'],
'VC' => ['name'=>'Saint Vincent & Grenadines','iso2'=>'VC','iso3'=>'VCT','flag'=>$f('VC'),'dial_code'=>'+1', 'timezone'=>'America/St_Vincent', 'currency'=>'XCD'],
'SR' => ['name'=>'Suriname', 'iso2'=>'SR','iso3'=>'SUR','flag'=>$f('SR'),'dial_code'=>'+597', 'timezone'=>'America/Paramaribo', 'currency'=>'SRD'],
'TT' => ['name'=>'Trinidad & Tobago', 'iso2'=>'TT','iso3'=>'TTO','flag'=>$f('TT'),'dial_code'=>'+1', 'timezone'=>'America/Port_of_Spain', 'currency'=>'TTD'],
'US' => ['name'=>'United States', 'iso2'=>'US','iso3'=>'USA','flag'=>$f('US'),'dial_code'=>'+1', 'timezone'=>'America/New_York', 'currency'=>'USD'],
'UY' => ['name'=>'Uruguay', 'iso2'=>'UY','iso3'=>'URY','flag'=>$f('UY'),'dial_code'=>'+598', 'timezone'=>'America/Montevideo', 'currency'=>'UYU'],
'VE' => ['name'=>'Venezuela', 'iso2'=>'VE','iso3'=>'VEN','flag'=>$f('VE'),'dial_code'=>'+58', 'timezone'=>'America/Caracas', 'currency'=>'VES'],
// ── ASIA & MIDDLE EAST ────────────────────────────────────────────────
'AF' => ['name'=>'Afghanistan', 'iso2'=>'AF','iso3'=>'AFG','flag'=>$f('AF'),'dial_code'=>'+93', 'timezone'=>'Asia/Kabul', 'currency'=>'AFN'],
'AM' => ['name'=>'Armenia', 'iso2'=>'AM','iso3'=>'ARM','flag'=>$f('AM'),'dial_code'=>'+374', 'timezone'=>'Asia/Yerevan', 'currency'=>'AMD'],
'AZ' => ['name'=>'Azerbaijan', 'iso2'=>'AZ','iso3'=>'AZE','flag'=>$f('AZ'),'dial_code'=>'+994', 'timezone'=>'Asia/Baku', 'currency'=>'AZN'],
'BH' => ['name'=>'Bahrain', 'iso2'=>'BH','iso3'=>'BHR','flag'=>$f('BH'),'dial_code'=>'+973', 'timezone'=>'Asia/Bahrain', 'currency'=>'BHD'],
'BD' => ['name'=>'Bangladesh', 'iso2'=>'BD','iso3'=>'BGD','flag'=>$f('BD'),'dial_code'=>'+880', 'timezone'=>'Asia/Dhaka', 'currency'=>'BDT'],
'BT' => ['name'=>'Bhutan', 'iso2'=>'BT','iso3'=>'BTN','flag'=>$f('BT'),'dial_code'=>'+975', 'timezone'=>'Asia/Thimphu', 'currency'=>'BTN'],
'BN' => ['name'=>'Brunei', 'iso2'=>'BN','iso3'=>'BRN','flag'=>$f('BN'),'dial_code'=>'+673', 'timezone'=>'Asia/Brunei', 'currency'=>'BND'],
'KH' => ['name'=>'Cambodia', 'iso2'=>'KH','iso3'=>'KHM','flag'=>$f('KH'),'dial_code'=>'+855', 'timezone'=>'Asia/Phnom_Penh', 'currency'=>'KHR'],
'CN' => ['name'=>'China', 'iso2'=>'CN','iso3'=>'CHN','flag'=>$f('CN'),'dial_code'=>'+86', 'timezone'=>'Asia/Shanghai', 'currency'=>'CNY'],
'CY' => ['name'=>'Cyprus', 'iso2'=>'CY','iso3'=>'CYP','flag'=>$f('CY'),'dial_code'=>'+357', 'timezone'=>'Asia/Nicosia', 'currency'=>'EUR'],
'GE' => ['name'=>'Georgia', 'iso2'=>'GE','iso3'=>'GEO','flag'=>$f('GE'),'dial_code'=>'+995', 'timezone'=>'Asia/Tbilisi', 'currency'=>'GEL'],
'HK' => ['name'=>'Hong Kong', 'iso2'=>'HK','iso3'=>'HKG','flag'=>$f('HK'),'dial_code'=>'+852', 'timezone'=>'Asia/Hong_Kong', 'currency'=>'HKD'],
'IN' => ['name'=>'India', 'iso2'=>'IN','iso3'=>'IND','flag'=>$f('IN'),'dial_code'=>'+91', 'timezone'=>'Asia/Kolkata', 'currency'=>'INR'],
'ID' => ['name'=>'Indonesia', 'iso2'=>'ID','iso3'=>'IDN','flag'=>$f('ID'),'dial_code'=>'+62', 'timezone'=>'Asia/Jakarta', 'currency'=>'IDR'],
'IR' => ['name'=>'Iran', 'iso2'=>'IR','iso3'=>'IRN','flag'=>$f('IR'),'dial_code'=>'+98', 'timezone'=>'Asia/Tehran', 'currency'=>'IRR'],
'IQ' => ['name'=>'Iraq', 'iso2'=>'IQ','iso3'=>'IRQ','flag'=>$f('IQ'),'dial_code'=>'+964', 'timezone'=>'Asia/Baghdad', 'currency'=>'IQD'],
'IL' => ['name'=>'Israel', 'iso2'=>'IL','iso3'=>'ISR','flag'=>$f('IL'),'dial_code'=>'+972', 'timezone'=>'Asia/Jerusalem', 'currency'=>'ILS'],
'JP' => ['name'=>'Japan', 'iso2'=>'JP','iso3'=>'JPN','flag'=>$f('JP'),'dial_code'=>'+81', 'timezone'=>'Asia/Tokyo', 'currency'=>'JPY'],
'JO' => ['name'=>'Jordan', 'iso2'=>'JO','iso3'=>'JOR','flag'=>$f('JO'),'dial_code'=>'+962', 'timezone'=>'Asia/Amman', 'currency'=>'JOD'],
'KZ' => ['name'=>'Kazakhstan', 'iso2'=>'KZ','iso3'=>'KAZ','flag'=>$f('KZ'),'dial_code'=>'+7', 'timezone'=>'Asia/Almaty', 'currency'=>'KZT'],
'KW' => ['name'=>'Kuwait', 'iso2'=>'KW','iso3'=>'KWT','flag'=>$f('KW'),'dial_code'=>'+965', 'timezone'=>'Asia/Kuwait', 'currency'=>'KWD'],
'KG' => ['name'=>'Kyrgyzstan', 'iso2'=>'KG','iso3'=>'KGZ','flag'=>$f('KG'),'dial_code'=>'+996', 'timezone'=>'Asia/Bishkek', 'currency'=>'KGS'],
'LA' => ['name'=>'Laos', 'iso2'=>'LA','iso3'=>'LAO','flag'=>$f('LA'),'dial_code'=>'+856', 'timezone'=>'Asia/Vientiane', 'currency'=>'LAK'],
'LB' => ['name'=>'Lebanon', 'iso2'=>'LB','iso3'=>'LBN','flag'=>$f('LB'),'dial_code'=>'+961', 'timezone'=>'Asia/Beirut', 'currency'=>'LBP'],
'MO' => ['name'=>'Macau', 'iso2'=>'MO','iso3'=>'MAC','flag'=>$f('MO'),'dial_code'=>'+853', 'timezone'=>'Asia/Macau', 'currency'=>'MOP'],
'MY' => ['name'=>'Malaysia', 'iso2'=>'MY','iso3'=>'MYS','flag'=>$f('MY'),'dial_code'=>'+60', 'timezone'=>'Asia/Kuala_Lumpur', 'currency'=>'MYR'],
'MV' => ['name'=>'Maldives', 'iso2'=>'MV','iso3'=>'MDV','flag'=>$f('MV'),'dial_code'=>'+960', 'timezone'=>'Indian/Maldives', 'currency'=>'MVR'],
'MN' => ['name'=>'Mongolia', 'iso2'=>'MN','iso3'=>'MNG','flag'=>$f('MN'),'dial_code'=>'+976', 'timezone'=>'Asia/Ulaanbaatar', 'currency'=>'MNT'],
'MM' => ['name'=>'Myanmar', 'iso2'=>'MM','iso3'=>'MMR','flag'=>$f('MM'),'dial_code'=>'+95', 'timezone'=>'Asia/Yangon', 'currency'=>'MMK'],
'NP' => ['name'=>'Nepal', 'iso2'=>'NP','iso3'=>'NPL','flag'=>$f('NP'),'dial_code'=>'+977', 'timezone'=>'Asia/Kathmandu', 'currency'=>'NPR'],
'KP' => ['name'=>'North Korea', 'iso2'=>'KP','iso3'=>'PRK','flag'=>$f('KP'),'dial_code'=>'+850', 'timezone'=>'Asia/Pyongyang', 'currency'=>'KPW'],
'OM' => ['name'=>'Oman', 'iso2'=>'OM','iso3'=>'OMN','flag'=>$f('OM'),'dial_code'=>'+968', 'timezone'=>'Asia/Muscat', 'currency'=>'OMR'],
'PK' => ['name'=>'Pakistan', 'iso2'=>'PK','iso3'=>'PAK','flag'=>$f('PK'),'dial_code'=>'+92', 'timezone'=>'Asia/Karachi', 'currency'=>'PKR'],
'PS' => ['name'=>'Palestine', 'iso2'=>'PS','iso3'=>'PSE','flag'=>$f('PS'),'dial_code'=>'+970', 'timezone'=>'Asia/Hebron', 'currency'=>'ILS'],
'PH' => ['name'=>'Philippines', 'iso2'=>'PH','iso3'=>'PHL','flag'=>$f('PH'),'dial_code'=>'+63', 'timezone'=>'Asia/Manila', 'currency'=>'PHP'],
'QA' => ['name'=>'Qatar', 'iso2'=>'QA','iso3'=>'QAT','flag'=>$f('QA'),'dial_code'=>'+974', 'timezone'=>'Asia/Qatar', 'currency'=>'QAR'],
'SA' => ['name'=>'Saudi Arabia', 'iso2'=>'SA','iso3'=>'SAU','flag'=>$f('SA'),'dial_code'=>'+966', 'timezone'=>'Asia/Riyadh', 'currency'=>'SAR'],
'SG' => ['name'=>'Singapore', 'iso2'=>'SG','iso3'=>'SGP','flag'=>$f('SG'),'dial_code'=>'+65', 'timezone'=>'Asia/Singapore', 'currency'=>'SGD'],
'KR' => ['name'=>'South Korea', 'iso2'=>'KR','iso3'=>'KOR','flag'=>$f('KR'),'dial_code'=>'+82', 'timezone'=>'Asia/Seoul', 'currency'=>'KRW'],
'LK' => ['name'=>'Sri Lanka', 'iso2'=>'LK','iso3'=>'LKA','flag'=>$f('LK'),'dial_code'=>'+94', 'timezone'=>'Asia/Colombo', 'currency'=>'LKR'],
'SY' => ['name'=>'Syria', 'iso2'=>'SY','iso3'=>'SYR','flag'=>$f('SY'),'dial_code'=>'+963', 'timezone'=>'Asia/Damascus', 'currency'=>'SYP'],
'TW' => ['name'=>'Taiwan', 'iso2'=>'TW','iso3'=>'TWN','flag'=>$f('TW'),'dial_code'=>'+886', 'timezone'=>'Asia/Taipei', 'currency'=>'TWD'],
'TJ' => ['name'=>'Tajikistan', 'iso2'=>'TJ','iso3'=>'TJK','flag'=>$f('TJ'),'dial_code'=>'+992', 'timezone'=>'Asia/Dushanbe', 'currency'=>'TJS'],
'TH' => ['name'=>'Thailand', 'iso2'=>'TH','iso3'=>'THA','flag'=>$f('TH'),'dial_code'=>'+66', 'timezone'=>'Asia/Bangkok', 'currency'=>'THB'],
'TL' => ['name'=>'Timor-Leste', 'iso2'=>'TL','iso3'=>'TLS','flag'=>$f('TL'),'dial_code'=>'+670', 'timezone'=>'Asia/Dili', 'currency'=>'USD'],
'TR' => ['name'=>'Turkey', 'iso2'=>'TR','iso3'=>'TUR','flag'=>$f('TR'),'dial_code'=>'+90', 'timezone'=>'Europe/Istanbul', 'currency'=>'TRY'],
'TM' => ['name'=>'Turkmenistan', 'iso2'=>'TM','iso3'=>'TKM','flag'=>$f('TM'),'dial_code'=>'+993', 'timezone'=>'Asia/Ashgabat', 'currency'=>'TMT'],
'AE' => ['name'=>'United Arab Emirates', 'iso2'=>'AE','iso3'=>'ARE','flag'=>$f('AE'),'dial_code'=>'+971', 'timezone'=>'Asia/Dubai', 'currency'=>'AED'],
'UZ' => ['name'=>'Uzbekistan', 'iso2'=>'UZ','iso3'=>'UZB','flag'=>$f('UZ'),'dial_code'=>'+998', 'timezone'=>'Asia/Tashkent', 'currency'=>'UZS'],
'VN' => ['name'=>'Vietnam', 'iso2'=>'VN','iso3'=>'VNM','flag'=>$f('VN'),'dial_code'=>'+84', 'timezone'=>'Asia/Ho_Chi_Minh', 'currency'=>'VND'],
'YE' => ['name'=>'Yemen', 'iso2'=>'YE','iso3'=>'YEM','flag'=>$f('YE'),'dial_code'=>'+967', 'timezone'=>'Asia/Aden', 'currency'=>'YER'],
// ── EUROPE ────────────────────────────────────────────────────────────
'AL' => ['name'=>'Albania', 'iso2'=>'AL','iso3'=>'ALB','flag'=>$f('AL'),'dial_code'=>'+355', 'timezone'=>'Europe/Tirane', 'currency'=>'ALL'],
'AD' => ['name'=>'Andorra', 'iso2'=>'AD','iso3'=>'AND','flag'=>$f('AD'),'dial_code'=>'+376', 'timezone'=>'Europe/Andorra', 'currency'=>'EUR'],
'AT' => ['name'=>'Austria', 'iso2'=>'AT','iso3'=>'AUT','flag'=>$f('AT'),'dial_code'=>'+43', 'timezone'=>'Europe/Vienna', 'currency'=>'EUR'],
'BY' => ['name'=>'Belarus', 'iso2'=>'BY','iso3'=>'BLR','flag'=>$f('BY'),'dial_code'=>'+375', 'timezone'=>'Europe/Minsk', 'currency'=>'BYN'],
'BE' => ['name'=>'Belgium', 'iso2'=>'BE','iso3'=>'BEL','flag'=>$f('BE'),'dial_code'=>'+32', 'timezone'=>'Europe/Brussels', 'currency'=>'EUR'],
'BA' => ['name'=>'Bosnia & Herzegovina', 'iso2'=>'BA','iso3'=>'BIH','flag'=>$f('BA'),'dial_code'=>'+387', 'timezone'=>'Europe/Sarajevo', 'currency'=>'BAM'],
'BG' => ['name'=>'Bulgaria', 'iso2'=>'BG','iso3'=>'BGR','flag'=>$f('BG'),'dial_code'=>'+359', 'timezone'=>'Europe/Sofia', 'currency'=>'BGN'],
'HR' => ['name'=>'Croatia', 'iso2'=>'HR','iso3'=>'HRV','flag'=>$f('HR'),'dial_code'=>'+385', 'timezone'=>'Europe/Zagreb', 'currency'=>'EUR'],
'CZ' => ['name'=>'Czech Republic', 'iso2'=>'CZ','iso3'=>'CZE','flag'=>$f('CZ'),'dial_code'=>'+420', 'timezone'=>'Europe/Prague', 'currency'=>'CZK'],
'DK' => ['name'=>'Denmark', 'iso2'=>'DK','iso3'=>'DNK','flag'=>$f('DK'),'dial_code'=>'+45', 'timezone'=>'Europe/Copenhagen', 'currency'=>'DKK'],
'EE' => ['name'=>'Estonia', 'iso2'=>'EE','iso3'=>'EST','flag'=>$f('EE'),'dial_code'=>'+372', 'timezone'=>'Europe/Tallinn', 'currency'=>'EUR'],
'FI' => ['name'=>'Finland', 'iso2'=>'FI','iso3'=>'FIN','flag'=>$f('FI'),'dial_code'=>'+358', 'timezone'=>'Europe/Helsinki', 'currency'=>'EUR'],
'FR' => ['name'=>'France', 'iso2'=>'FR','iso3'=>'FRA','flag'=>$f('FR'),'dial_code'=>'+33', 'timezone'=>'Europe/Paris', 'currency'=>'EUR'],
'DE' => ['name'=>'Germany', 'iso2'=>'DE','iso3'=>'DEU','flag'=>$f('DE'),'dial_code'=>'+49', 'timezone'=>'Europe/Berlin', 'currency'=>'EUR'],
'GR' => ['name'=>'Greece', 'iso2'=>'GR','iso3'=>'GRC','flag'=>$f('GR'),'dial_code'=>'+30', 'timezone'=>'Europe/Athens', 'currency'=>'EUR'],
'HU' => ['name'=>'Hungary', 'iso2'=>'HU','iso3'=>'HUN','flag'=>$f('HU'),'dial_code'=>'+36', 'timezone'=>'Europe/Budapest', 'currency'=>'HUF'],
'IS' => ['name'=>'Iceland', 'iso2'=>'IS','iso3'=>'ISL','flag'=>$f('IS'),'dial_code'=>'+354', 'timezone'=>'Atlantic/Reykjavik', 'currency'=>'ISK'],
'IE' => ['name'=>'Ireland', 'iso2'=>'IE','iso3'=>'IRL','flag'=>$f('IE'),'dial_code'=>'+353', 'timezone'=>'Europe/Dublin', 'currency'=>'EUR'],
'IT' => ['name'=>'Italy', 'iso2'=>'IT','iso3'=>'ITA','flag'=>$f('IT'),'dial_code'=>'+39', 'timezone'=>'Europe/Rome', 'currency'=>'EUR'],
'XK' => ['name'=>'Kosovo', 'iso2'=>'XK','iso3'=>'XKX','flag'=>$f('XK'),'dial_code'=>'+383', 'timezone'=>'Europe/Belgrade', 'currency'=>'EUR'],
'LV' => ['name'=>'Latvia', 'iso2'=>'LV','iso3'=>'LVA','flag'=>$f('LV'),'dial_code'=>'+371', 'timezone'=>'Europe/Riga', 'currency'=>'EUR'],
'LI' => ['name'=>'Liechtenstein', 'iso2'=>'LI','iso3'=>'LIE','flag'=>$f('LI'),'dial_code'=>'+423', 'timezone'=>'Europe/Vaduz', 'currency'=>'CHF'],
'LT' => ['name'=>'Lithuania', 'iso2'=>'LT','iso3'=>'LTU','flag'=>$f('LT'),'dial_code'=>'+370', 'timezone'=>'Europe/Vilnius', 'currency'=>'EUR'],
'LU' => ['name'=>'Luxembourg', 'iso2'=>'LU','iso3'=>'LUX','flag'=>$f('LU'),'dial_code'=>'+352', 'timezone'=>'Europe/Luxembourg', 'currency'=>'EUR'],
'MT' => ['name'=>'Malta', 'iso2'=>'MT','iso3'=>'MLT','flag'=>$f('MT'),'dial_code'=>'+356', 'timezone'=>'Europe/Malta', 'currency'=>'EUR'],
'MD' => ['name'=>'Moldova', 'iso2'=>'MD','iso3'=>'MDA','flag'=>$f('MD'),'dial_code'=>'+373', 'timezone'=>'Europe/Chisinau', 'currency'=>'MDL'],
'MC' => ['name'=>'Monaco', 'iso2'=>'MC','iso3'=>'MCO','flag'=>$f('MC'),'dial_code'=>'+377', 'timezone'=>'Europe/Monaco', 'currency'=>'EUR'],
'ME' => ['name'=>'Montenegro', 'iso2'=>'ME','iso3'=>'MNE','flag'=>$f('ME'),'dial_code'=>'+382', 'timezone'=>'Europe/Podgorica', 'currency'=>'EUR'],
'NL' => ['name'=>'Netherlands', 'iso2'=>'NL','iso3'=>'NLD','flag'=>$f('NL'),'dial_code'=>'+31', 'timezone'=>'Europe/Amsterdam', 'currency'=>'EUR'],
'MK' => ['name'=>'North Macedonia', 'iso2'=>'MK','iso3'=>'MKD','flag'=>$f('MK'),'dial_code'=>'+389', 'timezone'=>'Europe/Skopje', 'currency'=>'MKD'],
'NO' => ['name'=>'Norway', 'iso2'=>'NO','iso3'=>'NOR','flag'=>$f('NO'),'dial_code'=>'+47', 'timezone'=>'Europe/Oslo', 'currency'=>'NOK'],
'PL' => ['name'=>'Poland', 'iso2'=>'PL','iso3'=>'POL','flag'=>$f('PL'),'dial_code'=>'+48', 'timezone'=>'Europe/Warsaw', 'currency'=>'PLN'],
'PT' => ['name'=>'Portugal', 'iso2'=>'PT','iso3'=>'PRT','flag'=>$f('PT'),'dial_code'=>'+351', 'timezone'=>'Europe/Lisbon', 'currency'=>'EUR'],
'RO' => ['name'=>'Romania', 'iso2'=>'RO','iso3'=>'ROU','flag'=>$f('RO'),'dial_code'=>'+40', 'timezone'=>'Europe/Bucharest', 'currency'=>'RON'],
'RU' => ['name'=>'Russia', 'iso2'=>'RU','iso3'=>'RUS','flag'=>$f('RU'),'dial_code'=>'+7', 'timezone'=>'Europe/Moscow', 'currency'=>'RUB'],
'SM' => ['name'=>'San Marino', 'iso2'=>'SM','iso3'=>'SMR','flag'=>$f('SM'),'dial_code'=>'+378', 'timezone'=>'Europe/San_Marino', 'currency'=>'EUR'],
'RS' => ['name'=>'Serbia', 'iso2'=>'RS','iso3'=>'SRB','flag'=>$f('RS'),'dial_code'=>'+381', 'timezone'=>'Europe/Belgrade', 'currency'=>'RSD'],
'SK' => ['name'=>'Slovakia', 'iso2'=>'SK','iso3'=>'SVK','flag'=>$f('SK'),'dial_code'=>'+421', 'timezone'=>'Europe/Bratislava', 'currency'=>'EUR'],
'SI' => ['name'=>'Slovenia', 'iso2'=>'SI','iso3'=>'SVN','flag'=>$f('SI'),'dial_code'=>'+386', 'timezone'=>'Europe/Ljubljana', 'currency'=>'EUR'],
'ES' => ['name'=>'Spain', 'iso2'=>'ES','iso3'=>'ESP','flag'=>$f('ES'),'dial_code'=>'+34', 'timezone'=>'Europe/Madrid', 'currency'=>'EUR'],
'SE' => ['name'=>'Sweden', 'iso2'=>'SE','iso3'=>'SWE','flag'=>$f('SE'),'dial_code'=>'+46', 'timezone'=>'Europe/Stockholm', 'currency'=>'SEK'],
'CH' => ['name'=>'Switzerland', 'iso2'=>'CH','iso3'=>'CHE','flag'=>$f('CH'),'dial_code'=>'+41', 'timezone'=>'Europe/Zurich', 'currency'=>'CHF'],
'UA' => ['name'=>'Ukraine', 'iso2'=>'UA','iso3'=>'UKR','flag'=>$f('UA'),'dial_code'=>'+380', 'timezone'=>'Europe/Kyiv', 'currency'=>'UAH'],
'GB' => ['name'=>'United Kingdom', 'iso2'=>'GB','iso3'=>'GBR','flag'=>$f('GB'),'dial_code'=>'+44', 'timezone'=>'Europe/London', 'currency'=>'GBP'],
'VA' => ['name'=>'Vatican City', 'iso2'=>'VA','iso3'=>'VAT','flag'=>$f('VA'),'dial_code'=>'+39', 'timezone'=>'Europe/Vatican', 'currency'=>'EUR'],
// ── OCEANIA ───────────────────────────────────────────────────────────
'AU' => ['name'=>'Australia', 'iso2'=>'AU','iso3'=>'AUS','flag'=>$f('AU'),'dial_code'=>'+61', 'timezone'=>'Australia/Sydney', 'currency'=>'AUD'],
'FJ' => ['name'=>'Fiji', 'iso2'=>'FJ','iso3'=>'FJI','flag'=>$f('FJ'),'dial_code'=>'+679', 'timezone'=>'Pacific/Fiji', 'currency'=>'FJD'],
'KI' => ['name'=>'Kiribati', 'iso2'=>'KI','iso3'=>'KIR','flag'=>$f('KI'),'dial_code'=>'+686', 'timezone'=>'Pacific/Tarawa', 'currency'=>'AUD'],
'MH' => ['name'=>'Marshall Islands', 'iso2'=>'MH','iso3'=>'MHL','flag'=>$f('MH'),'dial_code'=>'+692', 'timezone'=>'Pacific/Majuro', 'currency'=>'USD'],
'FM' => ['name'=>'Micronesia', 'iso2'=>'FM','iso3'=>'FSM','flag'=>$f('FM'),'dial_code'=>'+691', 'timezone'=>'Pacific/Pohnpei', 'currency'=>'USD'],
'NR' => ['name'=>'Nauru', 'iso2'=>'NR','iso3'=>'NRU','flag'=>$f('NR'),'dial_code'=>'+674', 'timezone'=>'Pacific/Nauru', 'currency'=>'AUD'],
'NZ' => ['name'=>'New Zealand', 'iso2'=>'NZ','iso3'=>'NZL','flag'=>$f('NZ'),'dial_code'=>'+64', 'timezone'=>'Pacific/Auckland', 'currency'=>'NZD'],
'PW' => ['name'=>'Palau', 'iso2'=>'PW','iso3'=>'PLW','flag'=>$f('PW'),'dial_code'=>'+680', 'timezone'=>'Pacific/Palau', 'currency'=>'USD'],
'PG' => ['name'=>'Papua New Guinea', 'iso2'=>'PG','iso3'=>'PNG','flag'=>$f('PG'),'dial_code'=>'+675', 'timezone'=>'Pacific/Port_Moresby', 'currency'=>'PGK'],
'WS' => ['name'=>'Samoa', 'iso2'=>'WS','iso3'=>'WSM','flag'=>$f('WS'),'dial_code'=>'+685', 'timezone'=>'Pacific/Apia', 'currency'=>'WST'],
'SB' => ['name'=>'Solomon Islands', 'iso2'=>'SB','iso3'=>'SLB','flag'=>$f('SB'),'dial_code'=>'+677', 'timezone'=>'Pacific/Guadalcanal', 'currency'=>'SBD'],
'TO' => ['name'=>'Tonga', 'iso2'=>'TO','iso3'=>'TON','flag'=>$f('TO'),'dial_code'=>'+676', 'timezone'=>'Pacific/Tongatapu', 'currency'=>'TOP'],
'TV' => ['name'=>'Tuvalu', 'iso2'=>'TV','iso3'=>'TUV','flag'=>$f('TV'),'dial_code'=>'+688', 'timezone'=>'Pacific/Funafuti', 'currency'=>'AUD'],
'VU' => ['name'=>'Vanuatu', 'iso2'=>'VU','iso3'=>'VUT','flag'=>$f('VU'),'dial_code'=>'+678', 'timezone'=>'Pacific/Efate', 'currency'=>'VUV'],
];
}
/**
* Options list for the phone-code component.
* Returns [value, flag, label, secondary, search] per entry, sorted by country name.
*/
public static function forPhoneCode(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['dial_code'] . '|' . $country['iso2'],
'flag' => $country['flag'],
'label' => $country['dial_code'],
'secondary' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['dial_code'] . ' ' . $country['iso2']),
];
}
usort($list, fn($a, $b) => strcmp($a['secondary'], $b['secondary']));
return $list;
}
/**
* Options list for the country/nationality component.
* value = iso2 code.
*/
public static function forCountry(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['iso2'],
'flag' => $country['flag'],
'label' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['iso2'] . ' ' . $country['iso3']),
];
}
usort($list, fn($a, $b) => strcmp($a['label'], $b['label']));
return $list;
}
/**
* Options list for the timezone component.
* Reads PHP's built-in timezone database and attaches country flags.
*/
public static function forTimezone(): array
{
$countries = self::all();
$list = [];
// Skip Etc/ offsets and legacy backward-compat entries; keep named timezones
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true)) {
continue;
}
if (!str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$dt = new \DateTime('now', $dtze);
$offset = $dt->getOffset();
$h = (int) floor(abs($offset) / 3600);
$m = (int) (abs($offset) % 3600 / 60);
$sign = $offset >= 0 ? '+' : '-';
$utc = 'UTC' . $sign . str_pad($h, 2, '0', STR_PAD_LEFT) . ':' . str_pad($m, 2, '0', STR_PAD_LEFT);
$location = $dtze->getLocation();
$countryCode = strtoupper($location['country_code'] ?? '');
$countryData = $countries[$countryCode] ?? null;
$flag = $countryData ? $countryData['flag'] : '🌐';
$countryName = $countryData ? $countryData['name'] : '';
$parts = explode('/', $tz);
$city = str_replace('_', ' ', end($parts));
$list[] = [
'value' => $tz,
'flag' => $flag,
'label' => str_replace('_', ' ', $tz),
'utc' => $utc,
'offset' => $offset,
'search' => strtolower($tz . ' ' . $city . ' ' . $utc . ' ' . $countryName),
];
} catch (\Exception) {
continue;
}
}
usort($list, fn($a, $b) => $a['offset'] <=> $b['offset'] ?: strcmp($a['value'], $b['value']));
return $list;
}
/**
* Returns a map of IANA timezone identifier ISO2 country code.
* Used by the country-select component to auto-detect the user's country from their device timezone.
*/
public static function forGeoTimezoneMap(): array
{
$countries = self::all();
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
$map = [];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true) || !str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$loc = $dtze->getLocation();
$iso2 = strtoupper($loc['country_code'] ?? '');
if ($iso2 && isset($countries[$iso2])) {
$map[$tz] = $iso2;
}
} catch (\Exception) {}
}
return $map;
}
}

View File

@ -3,28 +3,32 @@
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
// Redirect to home with a toast when a video URL can't be resolved
$this->renderable(function (NotFoundHttpException $e, $request) {
$prev = $e->getPrevious();
$isVideoRoute = str_starts_with($request->path(), 'videos/');
if ($isVideoRoute && ($prev instanceof ModelNotFoundException || $e->getMessage() === '')) {
return redirect('/')->with('toast_error', 'This video is no longer available.');
}
});
}
}

101
app/Helpers/Horoscope.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace App\Helpers;
class Horoscope
{
private static array $signs = [
['name' => 'Capricorn', 'symbol' => '♑', 'element' => 'Earth', 'emoji' => '🐐', 'from' => [12, 22], 'to' => [1, 19],
'traits' => ['Ambitious', 'Disciplined', 'Patient', 'Practical']],
['name' => 'Aquarius', 'symbol' => '♒', 'element' => 'Air', 'emoji' => '🏺', 'from' => [1, 20], 'to' => [2, 18],
'traits' => ['Independent', 'Progressive', 'Humanitarian', 'Inventive']],
['name' => 'Pisces', 'symbol' => '♓', 'element' => 'Water', 'emoji' => '🐟', 'from' => [2, 19], 'to' => [3, 20],
'traits' => ['Empathetic', 'Creative', 'Intuitive', 'Gentle']],
['name' => 'Aries', 'symbol' => '♈', 'element' => 'Fire', 'emoji' => '🐏', 'from' => [3, 21], 'to' => [4, 19],
'traits' => ['Courageous', 'Energetic', 'Enthusiastic', 'Bold']],
['name' => 'Taurus', 'symbol' => '♉', 'element' => 'Earth', 'emoji' => '🐂', 'from' => [4, 20], 'to' => [5, 20],
'traits' => ['Reliable', 'Patient', 'Devoted', 'Sensual']],
['name' => 'Gemini', 'symbol' => '♊', 'element' => 'Air', 'emoji' => '👥', 'from' => [5, 21], 'to' => [6, 20],
'traits' => ['Adaptable', 'Curious', 'Witty', 'Expressive']],
['name' => 'Cancer', 'symbol' => '♋', 'element' => 'Water', 'emoji' => '🦀', 'from' => [6, 21], 'to' => [7, 22],
'traits' => ['Caring', 'Protective', 'Intuitive', 'Emotional']],
['name' => 'Leo', 'symbol' => '♌', 'element' => 'Fire', 'emoji' => '🦁', 'from' => [7, 23], 'to' => [8, 22],
'traits' => ['Confident', 'Dramatic', 'Creative', 'Generous']],
['name' => 'Virgo', 'symbol' => '♍', 'element' => 'Earth', 'emoji' => '🌾', 'from' => [8, 23], 'to' => [9, 22],
'traits' => ['Analytical', 'Loyal', 'Practical', 'Diligent']],
['name' => 'Libra', 'symbol' => '♎', 'element' => 'Air', 'emoji' => '⚖️', 'from' => [9, 23], 'to' => [10, 22],
'traits' => ['Diplomatic', 'Gracious', 'Fair-minded', 'Social']],
['name' => 'Scorpio', 'symbol' => '♏', 'element' => 'Water', 'emoji' => '🦂', 'from' => [10, 23], 'to' => [11, 21],
'traits' => ['Passionate', 'Stubborn', 'Resourceful', 'Brave']],
['name' => 'Sagittarius','symbol' => '♐', 'element' => 'Fire', 'emoji' => '🏹', 'from' => [11, 22], 'to' => [12, 21],
'traits' => ['Optimistic', 'Adventurous', 'Honest', 'Philosophical']],
];
private static array $elementCompatibility = [
'Fire' => ['Fire' => 85, 'Air' => 80, 'Earth' => 45, 'Water' => 40],
'Earth' => ['Earth' => 88, 'Water' => 82, 'Fire' => 45, 'Air' => 50],
'Air' => ['Air' => 84, 'Fire' => 80, 'Water' => 48, 'Earth' => 50],
'Water' => ['Water' => 86, 'Earth' => 82, 'Fire' => 40, 'Air' => 48],
];
public static function getSign(?string $birthday): ?array
{
if (! $birthday) return null;
try {
$date = new \DateTime($birthday);
} catch (\Exception) {
return null;
}
$month = (int) $date->format('n');
$day = (int) $date->format('j');
foreach (self::$signs as $sign) {
[$fm, $fd] = $sign['from'];
[$tm, $td] = $sign['to'];
if ($fm > $tm) {
// Capricorn wraps year
if (($month === $fm && $day >= $fd) || ($month === $tm && $day <= $td)) {
return $sign;
}
} else {
if (($month === $fm && $day >= $fd) || ($month > $fm && $month < $tm) || ($month === $tm && $day <= $td)) {
return $sign;
}
}
}
return null;
}
public static function compatibility(?array $sign1, ?array $sign2): ?int
{
if (! $sign1 || ! $sign2) return null;
$base = self::$elementCompatibility[$sign1['element']][$sign2['element']] ?? 50;
// Same sign bonus
if ($sign1['name'] === $sign2['name']) {
$base = min(98, $base + 8);
}
// Deterministic jitter so different sign pairs within same element feel unique
$seed = crc32($sign1['name'] . ':' . $sign2['name']);
$jitter = (abs($seed) % 11) - 5;
return max(20, min(99, $base + $jitter));
}
public static function elementColor(string $element): string
{
return match ($element) {
'Fire' => '#ff6b35',
'Earth' => '#6abf69',
'Air' => '#64b5f6',
'Water' => '#4fc3f7',
default => '#aaa',
};
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -23,10 +24,32 @@ class AuthenticatedSessionController extends Controller
$remember = $request->filled('remember');
if (Auth::attempt($credentials, $remember)) {
$user = Auth::user();
if ($user->two_factor_enabled && $user->two_factor_secret) {
Auth::logout();
$request->session()->put('2fa_user_id', $user->id);
$request->session()->put('2fa_remember', $remember);
return redirect()->route('2fa.challenge');
}
$request->session()->regenerate();
AuditLog::record('user.login', [
'user_id' => $user->id,
'user_name' => $user->name,
'details' => ['email' => $user->email],
]);
return redirect()->intended('/videos');
}
AuditLog::record('user.login.failed', [
'user_id' => null,
'user_name' => null,
'details' => ['email' => $credentials['email']],
]);
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
]);
@ -34,6 +57,13 @@ class AuthenticatedSessionController extends Controller
public function destroy(Request $request)
{
$user = Auth::user();
if ($user) {
AuditLog::record('user.logout', [
'user_id' => $user->id,
'user_name' => $user->name,
]);
}
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Rules\NotDisposableEmail;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@ -18,22 +19,34 @@ class RegisteredUserController extends Controller
public function store(Request $request)
{
// Honeypot — bots fill hidden fields; real users never do
if ($request->filled('_hp')) {
// Silently appear to succeed to confuse the bot
return redirect()->route('login');
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
'password' => ['required', 'confirmed', Password::defaults()],
'birthday' => ['required', 'date', 'before:today'],
'gender' => ['required', 'in:male,female'],
'nationality' => ['required', 'string', 'size:2'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'birthday' => $request->birthday,
'gender' => $request->gender,
'nationality' => $request->nationality,
]);
event(new Registered($user));
auth()->login($user);
return redirect('/videos');
return redirect()->route('verification.notice');
}
}

View File

@ -3,7 +3,11 @@
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\CommentLike;
use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -24,28 +28,37 @@ class CommentController extends Controller
public function store(Request $request, Video $video)
{
$request->validate([
'body' => 'required|string|max:1000',
'body' => 'required|string|max:1000',
'parent_id' => 'nullable|exists:comments,id',
]);
$commenter = Auth::user();
$comment = $video->comments()->create([
'user_id' => Auth::id(),
'body' => $request->body,
'user_id' => $commenter->id,
'body' => $request->body,
'parent_id' => $request->parent_id,
]);
// $video->increment('comment_count'); // Disabled - was causing SQL error
$comment->load('user:id,name,avatar_url');
// Handle mentions
preg_match_all('/@(\w+)/', $request->body, $matches);
if (! empty($matches[1])) {
// Mentions found - in production, you would send notifications here
// For now, we just parse them
$comment->load('user:id,name,avatar');
// Fire notifications (never notify yourself)
if ($request->parent_id) {
// Reply — notify the parent comment's author
$parent = Comment::find($request->parent_id);
if ($parent && $parent->user_id !== $commenter->id) {
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
}
} else {
// Top-level comment — notify the video owner
if ($video->user_id !== $commenter->id) {
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
}
}
return response()->json([
'success' => true,
'comment' => $comment->load('user:id,name,avatar_url'),
'comment' => $comment->load('user:id,name,avatar'),
]);
}
@ -59,13 +72,10 @@ class CommentController extends Controller
'body' => 'required|string|max:1000',
]);
$comment->update([
'body' => $request->body,
]);
$comment->update(['body' => $request->body]);
$comment->load('user:id,name,avatar');
$comment->load('user:id,name,avatar_url');
return response()->json($comment->load('user:id,name,avatar_url'));
return response()->json($comment->load('user:id,name,avatar'));
}
public function destroy(Comment $comment)
@ -78,4 +88,34 @@ class CommentController extends Controller
return response()->json(['success' => true]);
}
public function like(Comment $comment)
{
$userId = Auth::id();
$existing = CommentLike::where('comment_id', $comment->id)
->where('user_id', $userId)
->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
CommentLike::create(['comment_id' => $comment->id, 'user_id' => $userId]);
$liked = true;
// Notify the comment author (not self-likes)
if ($comment->user_id !== $userId) {
$video = $comment->video;
if ($video) {
$comment->user->notify(
new NewCommentLikeNotification($video, $comment, Auth::user())
);
}
}
}
$count = CommentLike::where('comment_id', $comment->id)->count();
return response()->json(['liked' => $liked, 'count' => $count]);
}
}

View File

@ -9,4 +9,29 @@ use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
/**
* Generate a collision-safe filename using the authenticated user's name,
* a compact timestamp with millisecond precision, and the original extension.
*
* Format: {username}_{YYYYMMDD}_{HHmmss}_{ms}.{ext}
* Example: ghassanyusuf_20260301_120102_020.mp4
*/
protected static function generateFilename(string $extension, ?string $username = null): string
{
$user = $username ?? (auth()->user()?->name ?? 'user');
// Sanitize: lowercase, keep only alphanumeric, collapse to max 20 chars
$slug = preg_replace('/[^a-z0-9]/', '', strtolower($user));
$slug = substr($slug ?: 'user', 0, 20);
$now = now();
$ms = str_pad((int) ($now->microsecond / 1000), 3, '0', STR_PAD_LEFT);
$timestamp = $now->format('Ymd') . '_' . $now->format('His') . '_' . $ms;
$ext = ltrim(strtolower($extension), '.');
return "{$slug}_{$timestamp}.{$ext}";
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ImageUploadController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'image' => 'required|string',
'folder' => 'required|string|max:80',
'filename' => 'required|string|max:200',
]);
$imageData = $request->image;
$parts = explode(';base64,', $imageData);
$typePart = explode('image/', $parts[0]);
$extension = $typePart[1] ?? 'png';
// Sanitize extension
$extension = preg_replace('/[^a-z0-9]/', '', strtolower($extension));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'])) {
$extension = 'png';
}
$imageBinary = base64_decode($parts[1] ?? '');
if (!$imageBinary) {
return response()->json(['success' => false, 'message' => 'Invalid image data'], 422);
}
$folder = trim($request->folder, '/');
$fileName = $request->filename . '.' . $extension;
$fullPath = $folder . '/' . $fileName;
Storage::disk('public')->put($fullPath, $imageBinary);
return response()->json([
'success' => true,
'path' => $fullPath,
'url' => asset('storage/' . $fullPath),
]);
}
}

View File

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

View File

@ -4,14 +4,17 @@ namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'publicPlaylists', 'userPlaylists']);
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
}
// List user's playlists
@ -31,11 +34,100 @@ class PlaylistController extends Controller
abort(404, 'Playlist not found');
}
$videos = $playlist->videos()->orderBy('position')->paginate(20);
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return view('playlists.show', compact('playlist', 'videos'));
}
// View playlist via unguessable share token (unlisted playlists)
public function showByToken(string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return view('playlists.show', compact('playlist', 'videos'));
}
// Generate (or reuse) a per-user share tracking token and return the tracking URL
public function recordShare(Playlist $playlist)
{
$userId = Auth::id();
if ($userId) {
$existing = DB::table('playlist_shares')
->where('playlist_id', $playlist->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('playlists.accessShare', $existing->token)]);
}
}
do {
$token = Str::random(10);
} while (DB::table('playlist_shares')->where('token', $token)->exists());
DB::table('playlist_shares')->insert([
'playlist_id' => $playlist->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('playlists.accessShare', $token)]);
}
// Handle a share link click: record the access, then redirect to the playlist
public function accessShare(Request $request, string $token)
{
$share = DB::table('playlist_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$playlist = Playlist::find($share->playlist_id);
if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) {
return redirect('/');
}
$did = $request->cookie('_did') ?: (string) Str::uuid();
$seen = DB::table('playlist_share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
DB::table('playlist_share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'accessed_at' => now(),
]);
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
: route('playlists.showByToken', $playlist->share_token);
return redirect($destination)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Create new playlist form
public function create()
{
@ -48,8 +140,8 @@ class PlaylistController extends Controller
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$playlistData = [
@ -58,6 +150,7 @@ class PlaylistController extends Controller
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
'is_default' => false,
'share_token' => Str::random(32),
];
// Create playlist first to get ID for thumbnail naming
@ -66,9 +159,8 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$file->storeAs('public/thumbnails', $filename);
$playlist->update(['thumbnail' => $filename]);
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
$playlist->update(['thumbnail' => $nasPath]);
}
// Reload playlist with thumbnail
@ -123,8 +215,8 @@ class PlaylistController extends Controller
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$updateData = [
@ -135,28 +227,19 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
// Delete old thumbnail if exists
// Delete old thumbnail from NAS if exists
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
}
// Upload new thumbnail
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$file->storeAs('public/thumbnails', $filename);
$updateData['thumbnail'] = $filename;
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
}
// Handle thumbnail removal
if ($request->input('remove_thumbnail') == '1') {
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
$updateData['thumbnail'] = null;
}
}
@ -351,7 +434,7 @@ class PlaylistController extends Controller
// Play all videos in playlist (redirect to first video with playlist context)
public function playAll(Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
@ -361,17 +444,13 @@ class PlaylistController extends Controller
return back()->with('error', 'Playlist is empty.');
}
// Redirect to first video with playlist parameter
return redirect()->route('videos.show', [
'video' => $firstVideo->id,
'playlist' => $playlist->id,
]);
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
}
// Shuffle play - redirect to random video
public function shuffle(Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
@ -381,9 +460,39 @@ class PlaylistController extends Controller
return back()->with('error', 'Playlist is empty.');
}
return redirect()->route('videos.show', [
'video' => $randomVideo->id,
'playlist' => $playlist->id,
]);
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
}
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
{
$nas = app(\App\Services\NasSyncService::class);
$playlist->loadMissing('user');
$userSlug = $nas->userSlug($playlist->user);
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
}
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
{
$nas = app(\App\Services\NasSyncService::class);
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$tmpName = self::generateFilename($ext);
$file->storeAs('public/thumbnails', $tmpName);
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
$dir = dirname($nasPath);
$nas->mkdirp($dir);
$nas->putFile($tempAbs, $nasPath);
@unlink($tempAbs);
return $nasPath;
}
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
{
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
try {
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request, User $user)
{
if (Auth::id() !== $user->id) {
abort(403);
}
$request->validate([
'body' => 'nullable|string|max:2000',
'images' => 'nullable|array|max:10',
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
'video_ids' => 'nullable|array|max:10',
'video_ids.*' => 'exists:videos,id',
'video_id' => 'nullable|exists:videos,id',
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
]);
$hasImages = $request->hasFile('images');
$hasVideoIds = $request->filled('video_ids');
$hasLegacyImg = $request->hasFile('image');
$hasLegacyVid = $request->filled('video_id');
if (! $request->body && ! $hasImages && ! $hasLegacyImg && ! $hasVideoIds && ! $hasLegacyVid) {
return back()->withErrors(['body' => 'Post cannot be empty.']);
}
// Create post first — we need the ID as the folder name
$post = Post::create([
'user_id' => $user->id,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
]);
$nas = app(NasSyncService::class);
$nasMode = $nas->isEnabled();
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
if ($hasImages || $hasLegacyImg) {
if ($nasMode) {
// ── NAS primary: upload directly from PHP temp files ──────────
$nas->mkdirp($postDir);
if ($hasLegacyImg) {
$file = $request->file('image');
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/0.{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
$post->update(['image' => $nasPath]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
PostImage::create([
'post_id' => $post->id,
'filename' => $nasPath,
'sort_order' => $idx,
]);
}
}
} else {
// ── Local storage: save inside the user's posts directory ─────
$localDir = storage_path('app/' . $postDir);
@mkdir($localDir, 0755, true);
if ($hasLegacyImg) {
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
$filename = "0.{$ext}";
$request->file('image')->move($localDir, $filename);
$post->update(['image' => "{$postDir}/{$filename}"]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$filename = ($idx + 1) . ".{$ext}";
$file->move($localDir, $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => "{$postDir}/{$filename}",
'sort_order' => $idx,
]);
}
}
}
}
if ($hasVideoIds) {
foreach ($request->input('video_ids') as $idx => $videoId) {
PostVideo::create([
'post_id' => $post->id,
'video_id' => $videoId,
'sort_order' => $idx,
]);
}
}
return back()->with('toast_success', 'Post shared!');
}
public function destroy(Post $post)
{
if (Auth::id() !== $post->user_id && ! Auth::user()->isAdmin()) {
abort(403);
}
$post->loadMissing('postImages');
$nas = app(NasSyncService::class);
if ($nas->isEnabled()) {
try {
$nas->deleteNasPost($post);
} catch (\Throwable) {}
}
// Always clean up local copies (handles both legacy flat and new structured format)
$nas->deleteLocalPostImages($post);
$post->delete();
return back()->with('toast_success', 'Post deleted.');
}
public function react(Post $post)
{
$user = Auth::user();
$existing = $post->reactions()->where('user_id', $user->id)->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
$post->reactions()->create(['user_id' => $user->id, 'type' => 'like']);
$liked = true;
}
return response()->json([
'liked' => $liked,
'count' => $post->reactions()->count(),
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers;
use App\Mail\TwoFactorDisableConfirmation;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use PragmaRX\Google2FALaravel\Support\Authenticator;
class TwoFactorController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['showChallenge', 'verifyChallenge']);
}
// POST /2fa/setup — generate secret + return QR SVG
public function setup()
{
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = $google2fa->generateSecretKey();
session(['2fa_setup_secret' => $secret]);
$qrUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer($renderer);
$qrSvg = base64_encode($writer->writeString($qrUrl));
return response()->json([
'secret' => $secret,
'qr' => $qrSvg,
]);
}
// POST /2fa/enable — confirm OTP and save secret
public function enable(Request $request)
{
$request->validate(['code' => 'required|digits:6']);
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = session('2fa_setup_secret');
if (! $secret || ! $google2fa->verifyKey($secret, $request->code)) {
return back()->with('toast_error', 'Invalid code — please try again.');
}
$user->update([
'two_factor_secret' => encrypt($secret),
'two_factor_enabled' => true,
]);
session()->forget('2fa_setup_secret');
AuditLog::record('user.2fa.enabled');
return redirect()->route('channel')->with('toast_success', 'Two-factor authentication enabled!')->with('_open_tab', 'settings');
}
// POST /2fa/disable — verifies password then sends a confirmation email
public function disable(Request $request)
{
$request->validate(['password' => 'required']);
$user = Auth::user();
if (! \Hash::check($request->password, $user->password)) {
return back()->with('toast_error', 'Incorrect password.')->with('_open_tab', 'settings');
}
$confirmUrl = URL::temporarySignedRoute(
'2fa.disable.confirm',
now()->addMinutes(15),
['user' => $user->id]
);
Mail::to($user->email)->send(new TwoFactorDisableConfirmation($user, $confirmUrl));
AuditLog::record('user.2fa.disable_requested');
return redirect()->route('channel')
->with('toast_success', 'Check your email — a confirmation link has been sent to ' . $user->email . '.')
->with('_open_tab', 'settings');
}
// GET /2fa/disable/confirm?signature=...
public function confirmDisable(Request $request)
{
if (! $request->hasValidSignature()) {
abort(403, 'This confirmation link is invalid or has expired.');
}
$user = \App\Models\User::findOrFail($request->query('user'));
// Ensure the signed URL belongs to the currently authenticated user (or log them in if
// they arrive via email while not logged in on this device)
if (Auth::check() && Auth::id() !== $user->id) {
abort(403, 'This confirmation link belongs to a different account.');
}
if (! $user->two_factor_enabled) {
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication is already disabled.')
->with('_open_tab', 'settings');
}
$user->update([
'two_factor_secret' => null,
'two_factor_enabled' => false,
]);
AuditLog::record('user.2fa.disabled', ['user_id' => $user->id, 'user_name' => $user->name]);
if (! Auth::check()) {
return redirect()->route('login')
->with('toast_success', 'Two-factor authentication has been disabled. Please log in.');
}
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication has been disabled.')
->with('_open_tab', 'settings');
}
// GET /2fa/challenge
public function showChallenge()
{
if (! session('2fa_user_id')) {
return redirect()->route('login');
}
return view('auth.2fa-challenge');
}
// POST /2fa/challenge
public function verifyChallenge(Request $request)
{
$userId = session('2fa_user_id');
if (! $userId) {
return redirect()->route('login');
}
$request->validate(['code' => 'required|digits:6']);
$user = \App\Models\User::findOrFail($userId);
$google2fa = app('pragmarx.google2fa');
$secret = decrypt($user->two_factor_secret);
if (! $google2fa->verifyKey($secret, $request->code)) {
return back()->withErrors(['code' => 'Invalid code — please try again.']);
}
session()->forget('2fa_user_id');
Auth::login($user, session()->pull('2fa_remember', false));
return redirect()->intended('/videos');
}
}

View File

@ -2,13 +2,15 @@
namespace App\Http\Controllers;
use App\Helpers\Horoscope;
use App\Models\AuditLog;
use App\Models\Post;
use App\Models\User;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller
{
@ -17,7 +19,7 @@ class UserController extends Controller
$this->middleware('auth')->except(['channel']);
}
// Profile page - view own profile
// Profile page - personal overview for the authenticated user
public function profile()
{
$user = Auth::user();
@ -28,58 +30,102 @@ class UserController extends Controller
// Update profile
public function updateProfile(Request $request)
{
$user = Auth::user();
$authUser = Auth::user();
// Super admins may edit any user's profile by passing _edit_user_id
if ($authUser->isSuperAdmin() && $request->filled('_edit_user_id')) {
$user = User::findOrFail($request->input('_edit_user_id'));
} else {
$user = $authUser;
}
$request->validate([
'name' => 'required|string|max:255',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
'bio' => 'nullable|string|max:500',
'website' => 'nullable|string|max:255',
'twitter' => 'nullable|string|max:100',
'instagram' => 'nullable|string|max:100',
'facebook' => 'nullable|string|max:100',
'youtube' => 'nullable|string|max:100',
'linkedin' => 'nullable|string|max:100',
'tiktok' => 'nullable|string|max:100',
'birthday' => 'nullable|date',
'location' => 'nullable|string|max:100',
'name' => 'required|string|max:255',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
'bio' => 'nullable|string|max:500',
'birthday' => 'nullable|date',
'location' => 'nullable|string|max:100',
'gender' => 'nullable|in:male,female,prefer_not_to_say',
'nationality' => 'nullable|string|size:2',
'phone_code' => 'nullable|string|max:20',
'phone_number' => 'nullable|string|max:30',
'timezone' => 'nullable|timezone:all',
'slink' => 'nullable|array',
'slink.*.platform' => 'required_with:slink|string|max:30',
'slink.*.value' => 'required_with:slink|string|max:500',
'slink.*.visibility' => 'nullable|in:public,registered,subscribers,only_me',
]);
$data = [
'name' => $request->name,
'bio' => $request->bio,
'website' => $request->website,
'twitter' => $request->twitter,
'instagram' => $request->instagram,
'facebook' => $request->facebook,
'youtube' => $request->youtube,
'linkedin' => $request->linkedin,
'tiktok' => $request->tiktok,
'birthday' => $request->birthday,
'location' => $request->location,
'name' => $request->name,
'bio' => $request->bio,
'birthday' => $request->birthday,
'location' => $request->location,
'gender' => $request->gender ?: null,
'nationality' => $request->nationality ?: null,
'phone_code' => $request->phone_code ?: null,
'phone_number' => $request->phone_number ?: null,
'timezone' => $request->timezone ?: null,
];
$nas = app(\App\Services\NasSyncService::class);
if ($request->hasFile('avatar')) {
// Delete old avatar
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
// Delete old avatar (handles both flat and new relative-path formats)
$nas->deleteLocalAvatar($user);
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
$profileDir = $nas->localProfileDir($user);
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
@mkdir($profileDir, 0755, true);
$request->file('avatar')->move($profileDir, $destFilename);
$data['avatar'] = $relPath;
}
$user->update($data);
return redirect()->route('profile')->with('success', 'Profile updated successfully!');
// Push avatar to NAS and remove local copy when NAS is primary storage
if ($nas->isEnabled()) {
if ($request->hasFile('avatar')) {
$destPath = storage_path('app/' . $data['avatar']);
if (file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
}
}
// Sync social links
$user->socialLinks()->delete();
$order = 0;
foreach ($request->input('slink', []) as $entry) {
$platform = trim($entry['platform'] ?? '');
$value = trim($entry['value'] ?? '');
if ($platform && $value) {
$user->socialLinks()->create([
'platform' => $platform,
'value' => $value,
'visibility' => $entry['visibility'] ?? 'public',
'sort_order' => $order++,
]);
}
}
// Redirect back to profile page, or channel settings if admin edited another user
if ($authUser->isSuperAdmin() && $authUser->id !== $user->id) {
return redirect()->route('channel', $user->channel)->with('toast_success', 'Profile updated!')->with('_open_tab', 'settings');
}
return redirect()->route('profile')->with('toast_success', 'Profile updated!');
}
// Settings page
// Settings page - redirects to channel settings tab
public function settings()
{
$user = Auth::user();
return view('user.settings', compact('user'));
return redirect()->route('channel')->with('_open_tab', 'settings');
}
// Update settings (password)
@ -100,36 +146,121 @@ class UserController extends Controller
'password' => Hash::make($request->new_password),
]);
return redirect()->route('settings')->with('success', 'Password updated successfully!');
AuditLog::record('user.password_changed');
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
}
// Logout all other devices
public function logoutAllDevices(Request $request)
{
$request->validate(['password' => 'required']);
if (! Hash::check($request->password, Auth::user()->password)) {
return back()->withErrors(['logout_password' => 'Incorrect password.'])->with('_open_tab', 'settings');
}
Auth::logoutOtherDevices($request->password);
// AuthenticateSession stores a hash of the password; logoutOtherDevices rehashes it,
// so we must update the session's copy or the current session gets invalidated too.
$request->session()->put(
'password_hash_' . Auth::getDefaultDriver(),
Auth::user()->getAuthPassword()
);
AuditLog::record('user.logout_all');
return redirect()->route('channel')->with('toast_success', 'All other sessions have been logged out.')->with('_open_tab', 'settings');
}
// User's channel page - view videos
public function channel($userId = null)
public function channel($username = null)
{
if ($userId) {
$user = User::findOrFail($userId);
if ($username) {
// Look up by username slug only — never by sequential ID
$user = User::where('username', $username)->firstOrFail();
} else {
$user = Auth::user();
if (! $user) {
return redirect()->route('login');
}
$user->channel; // triggers auto-generation if missing
}
// If viewing own channel, show all videos including private
// If viewing someone else's channel, show only public videos
if (Auth::check() && Auth::user()->id === $user->id) {
$videos = Video::where('user_id', $user->id)
->latest()
->paginate(12);
$sort = request('sort', 'latest');
$isOwner = Auth::check() && Auth::user()->id === $user->id;
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
$isOwner = $isOwner && !$preview;
// Also get user's playlists for their own channel
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
} else {
$videos = Video::public()
->where('user_id', $user->id)
->latest()
->paginate(12);
$playlists = null;
$baseQuery = $isOwner
? Video::where('user_id', $user->id)
: Video::public()->where('user_id', $user->id);
$allQuery = clone $baseQuery;
switch ($sort) {
case 'popular':
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
break;
case 'oldest':
$baseQuery->oldest();
break;
default:
$baseQuery->latest();
}
return view('user.channel', compact('user', 'videos', 'playlists'));
$videos = $baseQuery->where('is_shorts', false)->get();
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
$playlists = $isOwner
? $user->playlists()->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->orderBy('created_at', 'desc')->get();
$totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id'))
->count();
// Filter social links by visibility for the current viewer
$viewer = Auth::user();
$isOwner = $viewer && $viewer->id === $user->id;
$isSubscriber = $viewer && !$isOwner && $viewer->isSubscribedTo($user);
$socialLinks = $user->socialLinks()
->orderBy('sort_order')
->get()
->filter(function ($link) use ($isOwner, $viewer, $isSubscriber) {
if ($isOwner) return true;
return match ($link->visibility) {
'public' => true,
'registered' => (bool) $viewer,
'subscribers' => $viewer && $isSubscriber,
'only_me' => false,
default => false,
};
});
// Posts for the Wall tab
$posts = Post::where('user_id', $user->id)
->with(['user', 'video', 'reactions', 'postImages', 'postVideos.video'])
->latest()
->get();
// Horoscope
$horoscope = Horoscope::getSign($user->birthday);
$viewerSign = null;
$compatibility = null;
if ($viewer && ! $isOwner && $viewer->birthday) {
$viewerSign = Horoscope::getSign($viewer->birthday);
$compatibility = Horoscope::compatibility($horoscope, $viewerSign);
}
$canEdit = $isOwner || (Auth::check() && Auth::user()->isSuperAdmin());
return view('user.channel', compact(
'user', 'videos', 'shorts', 'playlists', 'totalViews', 'sort',
'socialLinks', 'isSubscriber', 'isOwner', 'canEdit', 'posts', 'horoscope', 'viewerSign', 'compatibility',
'preview'
));
}
// Watch history
@ -151,13 +282,23 @@ class UserController extends Controller
->orWhere('user_id', $user->id);
})
->get()
->sortByDesc(function ($video) use ($videoIds) {
->sortBy(function ($video) use ($videoIds) {
return $videoIds->search($video->id);
});
return view('user.history', compact('videos'));
}
// Clear watch history
public function clearHistory()
{
\DB::table('video_views')
->where('user_id', Auth::id())
->delete();
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
}
// Liked videos
public function liked()
{
@ -214,4 +355,150 @@ class UserController extends Controller
'like_count' => $video->like_count,
]);
}
public function toggleSubscribe(User $user)
{
$me = Auth::user();
if ($me->id === $user->id) {
return response()->json(['error' => 'Cannot subscribe to yourself'], 422);
}
if ($me->isSubscribedTo($user)) {
$me->subscriptions()->detach($user->id);
$subscribed = false;
} else {
$me->subscriptions()->attach($user->id);
$subscribed = true;
}
return response()->json([
'subscribed' => $subscribed,
'subscriber_count' => $user->fresh()->subscriber_count,
]);
}
public function fetchNotifications()
{
$user = Auth::user();
$rawNotifications = $user->notifications()->latest()->take(50)->get();
// Bulk-fetch current video state for all notification types
$videoIds = $rawNotifications
->pluck('data.video_id')
->filter()
->unique()
->values();
$videos = \App\Models\Video::whereIn('id', $videoIds)
->whereIn('visibility', ['public', 'unlisted'])
->get(['id', 'thumbnail', 'visibility'])
->keyBy('id');
$notifications = $rawNotifications
->filter(function ($n) use ($videos) {
$videoId = $n->data['video_id'] ?? null;
return $videoId && $videos->has($videoId);
})
->take(30)
->map(function ($n) use ($videos) {
$data = $n->data;
$video = $videos->get($data['video_id']);
$data['video_thumbnail'] = $video?->thumbnail ?? null;
return [
'id' => $n->id,
'read' => ! is_null($n->read_at),
'time' => $n->created_at->diffForHumans(),
'data' => $data,
];
})
->values();
return response()->json([
'notifications' => $notifications,
'unread_count' => $user->unreadNotifications()->count(),
]);
}
public function markNotificationRead(string $id)
{
$notification = Auth::user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['ok' => true]);
}
public function markAllNotificationsRead()
{
Auth::user()->unreadNotifications->markAsRead();
return response()->json(['ok' => true]);
}
public function updateAvatar(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/avatars/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old avatar before moving new one in (handles both path formats)
$nas->deleteLocalAvatar($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['avatar' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
return response()->json(['ok' => true]);
}
public function updateBanner(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
$user = Auth::user();
$filename = basename($request->path);
$tempPath = storage_path('app/public/banners/' . $filename);
$nas = app(\App\Services\NasSyncService::class);
// Move temp file into the user's profile directory
$profileDir = $nas->localProfileDir($user);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
$destFilename = "cover.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
// Delete old banner before moving new one in (handles both path formats)
$nas->deleteLocalBanner($user);
@mkdir($profileDir, 0755, true);
if (file_exists($tempPath)) {
rename($tempPath, $destPath);
}
$user->update(['banner' => $relPath]);
if ($nas->isEnabled() && file_exists($destPath)) {
$nas->syncCover($user, $destPath);
$nas->deleteLocalBanner($user);
}
return response()->json(['ok' => true]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,

View File

@ -12,7 +12,7 @@ class TrustProxies extends Middleware
*
* @var array<int, string>|string|null
*/
protected $proxies;
protected $proxies = '*';
/**
* The headers that should be used to detect proxies.

View File

@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
//
'videos/*/share',
'playlists/*/share',
'videos/*/slideshow/generate',
];
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\X264;
@ -37,22 +38,46 @@ class CompressVideoJob implements ShouldQueue
return;
}
// Create compressed filename
// Create compressed file alongside the original
$compressedFilename = 'compressed_' . $video->filename;
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
try {
$ffmpeg = FFMpeg::create();
$ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
]);
$ffmpegVideo = $ffmpeg->open($originalPath);
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
// Use 'slow' preset for better compression efficiency
// GPU NVENC encoding via config
$ffmpegConfig = Config::get('ffmpeg');
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
$gpuEnabled = Setting::gpuEnabled();
$encoder = Setting::gpuEncoder();
$preset = Setting::gpuPreset();
$device = Setting::gpuDevice();
$format = new X264('aac', 'h264_nvenc');
if ($gpuEnabled) {
$videoPasses = [
"-c:v {$encoder}",
"-preset {$preset}",
'-rc vbr',
'-cq 23',
'-profile:v high',
'-pix_fmt yuv420p',
"-gpu {$device}",
];
} else {
$videoPasses = [
'-c:v libx264',
'-preset fast',
'-crf 23',
'-profile:v high',
'-pix_fmt yuv420p',
];
}
$audioPasses = ['-c:a aac', '-b:a 192k'];
$format = new X264('aac', $encoder);
foreach ($videoPasses as $pass) {
$format->addLegacyOption($pass);
}
@ -79,12 +104,13 @@ class CompressVideoJob implements ShouldQueue
'mime_type' => 'video/mp4',
]);
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
Log::info('CompressVideoJob: Video compressed', [
'video_id' => $video->id,
'original_size' => $originalSize,
'compressed_size' => $compressedSize,
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
'encoder' => 'h264_nvenc'
'encoder' => $encoder,
'gpu' => $gpuEnabled,
]);
} else {
// Compressed file is larger, delete it

View File

@ -2,17 +2,15 @@
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use FFMpeg\FFMpeg;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class GenerateHlsJob implements ShouldQueue
{
@ -35,95 +33,153 @@ class GenerateHlsJob implements ShouldQueue
return;
}
$sourcePath = storage_path('app/' . $video->path);
if (!file_exists($sourcePath)) {
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
return;
$sourcePath = storage_path('app/' . $video->path);
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
if (! file_exists($sourcePath)) {
// NAS-primary mode: file lives on NAS, download a temporary local copy
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$localCopy = $nas->ensureLocalCopy($video);
if ($localCopy) {
$sourcePath = $localCopy;
$nasDownloaded = $localCopy;
}
}
if (! file_exists($sourcePath)) {
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
return;
}
}
$hlsDir = 'public/hls/' . $video->id;
$hlsDir = 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
// Clean existing HLS
if (is_dir($hlsPath)) {
Storage::deleteDirectory($hlsDir);
}
Storage::makeDirectory($hlsDir);
$variants = [
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
];
foreach ($variants as $v) {
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
}
try {
$ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
$gpuEnabled = Setting::gpuEnabled();
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
$hwaccel = Setting::gpuHwaccel(); // cuda / none
$cmd = [escapeshellcmd($ffmpegBin)];
// Hardware-accelerated decode when GPU is in use
if ($gpuEnabled && $hwaccel !== 'none') {
$cmd[] = "-hwaccel {$hwaccel}";
$cmd[] = "-hwaccel_device {$device}";
}
$cmd[] = '-i ' . escapeshellarg($sourcePath);
// One video+audio stream per variant
$n = count($variants);
for ($i = 0; $i < $n; $i++) {
$cmd[] = '-map 0:v:0';
$cmd[] = '-map 0:a:0?';
}
// Video codec
$cmd[] = "-c:v {$encoder}";
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
$cmd[] = "-preset {$preset}";
$cmd[] = '-rc vbr';
$cmd[] = '-cq 23';
$cmd[] = "-gpu {$device}";
} else {
$cmd[] = "-preset {$preset}";
$cmd[] = '-crf 23';
}
$cmd[] = '-pix_fmt yuv420p';
// Audio codec
$cmd[] = '-c:a aac';
$cmd[] = '-b:a 128k';
$cmd[] = '-ar 48000';
// Per-variant scale + bitrate
for ($i = 0; $i < $n; $i++) {
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
}
// HLS muxer options
$cmd[] = '-g 48';
$cmd[] = '-sc_threshold 0';
$cmd[] = '-f hls';
$cmd[] = '-hls_time 6';
$cmd[] = '-hls_list_size 0';
$cmd[] = '-hls_flags independent_segments';
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
$cmd[] = '-master_pl_name playlist.m3u8';
$vsm = implode(' ', array_map(
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
array_keys($variants),
$variants
));
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
$fullCmd = implode(' ', $cmd) . ' 2>&1';
Log::info('GenerateHlsJob: Starting', [
'video_id' => $video->id,
'gpu' => $gpuEnabled,
'encoder' => $encoder,
'device' => $gpuEnabled ? $device : 'cpu',
]);
$videoMedia = $ffmpeg->open($sourcePath);
exec($fullCmd, $output, $exitCode);
// HLS variants: 480p, 720p, 1080p
$variants = [
[
'height' => 480,
'name' => '480p',
'bitrate' => 1000,
],
[
'height' => 720,
'name' => '720p',
'bitrate' => 2500,
],
[
'height' => 1080,
'name' => '1080p',
'bitrate' => 5000,
],
];
if ($exitCode !== 0) {
$tail = implode("\n", array_slice($output, -30));
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
}
$hlsOptions = [
'-c:v h264_nvenc',
'-preset p4',
'-g 48', // GOP size 2s @25fps
'-sc_threshold 0',
'-c:a aac',
'-ar 48000',
'-f hls',
'-hls_time 6',
'-hls_list_size 0',
'-hls_segment_filename',
'%v/%03d.ts',
'-hls_flags',
'delete_segments+append_list',
'-master_pl_name',
'playlist.m3u8',
];
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
$videoMedia->save(new \FFMpeg\Format\Video\X264(), $hlsPath, function ($filters) use ($variants) {
foreach ($variants as $variant) {
$filters->custom('-map 0:v:0 -map 0:a:0?')
->size("trunc(oh*a/oh/{$variant['height']})*{$variant['height']}") // Scale
->resize(new \FFMpeg\Coordinate\Dimension($variant['height'] * 16 / 9, $variant['height']))
->videoCodec($variant['bitrate'] . 'k')
->addLegacyOption('-var_stream_map v:0,name:' . $variant['name'] . ' v:1,name:720p v:2,name:1080p');
}
});
// Mark HLS ready
$video->update([
'has_hls' => true,
'hls_path' => $hlsDir,
]);
Log::info('GenerateHlsJob: HLS generated successfully', [
Log::info('GenerateHlsJob: HLS generated', [
'video_id' => $video->id,
'variants' => array_column($variants, 'name'),
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
'encoder' => $encoder,
]);
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
if ($nasDownloaded) {
// NAS-primary mode: video was fetched from NAS for HLS generation.
// The original is already on NAS — just delete the local temp copy.
@unlink($nasDownloaded);
} else {
// Local-storage mode: push the (compressed) file to NAS and free local disk.
// HLS segments stay local — per-segment SMB latency would hurt playback.
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
$nas->deleteLocalAssets($video);
}
}
} catch (\Exception $e) {
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
Storage::deleteDirectory($hlsDir);
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NasSyncVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly Video $video) {}
public function handle(NasSyncService $nas): void
{
if (! $nas->isEnabled()) return;
try {
$nas->syncVideo($this->video);
// Audio/music uploads have no further processing jobs, so it's safe
// to remove the local file immediately after a successful NAS push.
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
}
$nas->deleteLocalAssets($this->video);
$nas->pruneLocalVideoDir($this->video);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error(
'NasSyncVideoJob failed for video #' . $this->video->id .
' ("' . $this->video->title . '"): ' . $e->getMessage() .
' — local files kept. Run `php artisan nas:repair --force` to retry.'
);
}
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use App\Services\NasSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class NasToLocalMigrationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 7200; // 2 hours
private function updateProgress(array $data): void
{
Cache::put('nas_disable_progress', json_encode($data), 3600);
}
public function handle(NasSyncService $nas): void
{
$progress = [
'current' => 0,
'total' => 0,
'phase' => 'Counting files...',
'done' => false,
'error' => null,
];
$this->updateProgress($progress);
try {
// ── Count all items ───────────────────────────────────────────────
$videos = Video::where('path', 'like', 'users/%')->get();
$slides = \DB::table('video_slides')
->where('filename', 'like', 'users/%')
->get();
$usersWithAvatar = User::where('avatar', 'like', 'users/%')->get();
$usersWithBanner = User::where('banner', 'like', 'users/%')->get();
$postImages = \DB::table('post_images')
->where('filename', 'like', 'users/%')
->get();
// Count thumbnails separately (they're additional per-video downloads)
$videoThumbs = $videos->filter(fn($v) => $v->thumbnail && str_starts_with($v->thumbnail, 'users/'));
$total = $videos->count()
+ $videoThumbs->count()
+ $slides->count()
+ $usersWithAvatar->count()
+ $usersWithBanner->count()
+ $postImages->count();
$progress['total'] = $total;
$progress['phase'] = 'Downloading files from NAS...';
$this->updateProgress($progress);
// ── Download videos ───────────────────────────────────────────────
foreach ($videos as $video) {
$nasPath = $video->path;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading videos... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download thumbnails ───────────────────────────────────────────
foreach ($videoThumbs as $video) {
$nasPath = $video->thumbnail;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download thumbnail for video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading thumbnails... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download slides ───────────────────────────────────────────────
foreach ($slides as $slide) {
$nasPath = $slide->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download slide id=' . $slide->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading audio slides... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user avatars ─────────────────────────────────────────
foreach ($usersWithAvatar as $user) {
$nasPath = $user->avatar;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download avatar for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading avatars... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download user banners ─────────────────────────────────────────
foreach ($usersWithBanner as $user) {
$nasPath = $user->banner;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download banner for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading banners... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Download post images ──────────────────────────────────────────
foreach ($postImages as $img) {
$nasPath = $img->filename;
$localPath = storage_path('app/' . $nasPath);
try {
$nas->ensureLocalAsset($localPath, $nasPath);
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: failed to download post image id=' . $img->id . ' path=' . $nasPath . ': ' . $e->getMessage());
}
$progress['current']++;
$progress['phase'] = 'Downloading post images... (' . $progress['current'] . '/' . $total . ')';
$this->updateProgress($progress);
}
// ── Disable NAS ───────────────────────────────────────────────────
Setting::set('nas_sync_enabled', 'false');
$progress['done'] = true;
$progress['phase'] = 'Complete';
$this->updateProgress($progress);
Log::info('NasToLocalMigrationJob: completed. ' . $total . ' files migrated. NAS disabled.');
} catch (\Throwable $e) {
Log::error('NasToLocalMigrationJob: fatal error: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$progress['error'] = $e->getMessage();
$this->updateProgress($progress);
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use App\Models\Video;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewVideoNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Video $video, public User $uploader)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->uploader->name . ' just uploaded a new video on ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.new-video-notification',
);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TwoFactorDisableConfirmation extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user, public string $confirmUrl)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Confirm disabling Two-Factor Authentication — ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.2fa-disable-confirm',
);
}
}

86
app/Models/AuditLog.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id', 'user_name', 'action', 'subject_type', 'subject_id',
'subject_label', 'ip_address', 'user_agent', 'details', 'created_at',
];
protected $casts = [
'details' => 'array',
'created_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public static function record(string $action, array $options = []): void
{
$request = request();
$user = auth()->user();
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Forwarded-For')
?? $request->ip();
static::create([
'user_id' => $options['user_id'] ?? $user?->id,
'user_name' => $options['user_name'] ?? $user?->name,
'action' => $action,
'subject_type' => $options['subject_type'] ?? null,
'subject_id' => $options['subject_id'] ?? null,
'subject_label' => $options['subject_label'] ?? null,
'ip_address' => $ip,
'user_agent' => substr($request->userAgent() ?? '', 0, 255),
'details' => $options['details'] ?? null,
'created_at' => now(),
]);
}
// Severity grouping for UI color-coding
public function getSeverityAttribute(): string
{
return match(true) {
str_contains($this->action, 'delete') || str_contains($this->action, 'destroy') => 'danger',
str_contains($this->action, 'login.failed') => 'warning',
str_contains($this->action, 'login') => 'info',
str_contains($this->action, 'logout') => 'muted',
str_contains($this->action, 'admin.') => 'orange',
str_contains($this->action, 'upload') || str_contains($this->action, 'create') => 'success',
str_contains($this->action, '2fa') || str_contains($this->action, 'password') => 'purple',
default => 'default',
};
}
public function getActionLabelAttribute(): string
{
return match($this->action) {
'video.uploaded' => 'Video Uploaded',
'video.deleted' => 'Video Deleted',
'video.updated' => 'Video Updated',
'user.login' => 'Logged In',
'user.login.failed' => 'Login Failed',
'user.logout' => 'Logged Out',
'user.logout_all' => 'Logged Out All Devices',
'user.password_changed' => 'Password Changed',
'user.2fa.enabled' => '2FA Enabled',
'user.2fa.disabled' => '2FA Disabled',
'admin.impersonate' => 'Impersonated User',
'admin.impersonate.exit' => 'Exited Impersonation',
'admin.user.deleted' => 'Admin: User Deleted',
'admin.video.deleted' => 'Admin: Video Deleted',
'admin.user.updated' => 'Admin: User Updated',
'admin.video.updated' => 'Admin: Video Updated',
default => ucwords(str_replace(['.', '_'], ' ', $this->action)),
};
}
}

View File

@ -34,6 +34,11 @@ class Comment extends Model
return $this->hasMany(Comment::class, 'parent_id')->latest();
}
public function likes()
{
return $this->hasMany(CommentLike::class);
}
// Get mentioned users from comment body
public function getMentionedUsers()
{

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CommentLike extends Model
{
public $timestamps = false;
protected $fillable = ['comment_id', 'user_id'];
public function comment()
{
return $this->belongsTo(Comment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -16,6 +16,7 @@ class Playlist extends Model
'thumbnail',
'visibility',
'is_default',
'share_token',
];
protected $casts = [
@ -42,13 +43,12 @@ class Playlist extends Model
}
// Accessors
public function getThumbnailUrlAttribute()
public function getThumbnailUrlAttribute(): string
{
if ($this->thumbnail) {
return asset('storage/thumbnails/'.$this->thumbnail);
return route('media.thumbnail', $this->thumbnail);
}
// Generate a placeholder based on playlist name
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
}
@ -143,10 +143,10 @@ class Playlist extends Model
->first();
}
// Get shareable URL
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('playlists.show', $this->id);
return route('playlists.showByToken', $this->share_token);
}
// Scope for public playlists
@ -178,7 +178,12 @@ class Playlist extends Model
return $this->visibility === 'private';
}
// Check if user can view this playlist
public function isUnlisted()
{
return $this->visibility === 'unlisted';
}
// Check if user can view this playlist via the ID route
public function canView($user = null)
{
// Owner can always view
@ -186,10 +191,20 @@ class Playlist extends Model
return true;
}
// Public playlists can be viewed by anyone
// Only public playlists are accessible via the ID route
return $this->visibility === 'public';
}
// Check if user can view this playlist via the share-token route
public function canViewViaToken($user = null)
{
if ($user && $this->user_id === $user->id) {
return true;
}
return in_array($this->visibility, ['public', 'unlisted']);
}
// Check if user can edit this playlist
public function canEdit($user = null)
{

55
app/Models/Post.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['user_id', 'body', 'video_id', 'image'];
public function user()
{
return $this->belongsTo(User::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
public function reactions()
{
return $this->hasMany(PostReaction::class);
}
public function postImages()
{
return $this->hasMany(PostImage::class)->orderBy('sort_order');
}
public function postVideos()
{
return $this->hasMany(PostVideo::class)->with('video')->orderBy('sort_order');
}
public function isLikedBy(?User $user): bool
{
if (! $user) return false;
return $this->reactions()->where('user_id', $user->id)->exists();
}
public function getReactionCountAttribute(): int
{
return $this->reactions()->count();
}
public function getImageUrlAttribute(): ?string
{
if (! $this->image) return null;
if (str_starts_with($this->image, 'users/')) {
return route('media.post-image', $this->image);
}
return asset('storage/post_images/' . $this->image);
}
}

23
app/Models/PostImage.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostImage extends Model
{
protected $fillable = ['post_id', 'filename', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function getImageUrlAttribute(): string
{
if (str_starts_with($this->filename, 'users/')) {
return route('media.post-image', $this->filename);
}
return asset('storage/post_images/' . $this->filename);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostReaction extends Model
{
protected $fillable = ['user_id', 'post_id', 'type'];
public function user()
{
return $this->belongsTo(User::class);
}
public function post()
{
return $this->belongsTo(Post::class);
}
}

20
app/Models/PostVideo.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostVideo extends Model
{
protected $fillable = ['post_id', 'video_id', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
}

92
app/Models/Setting.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
private static array $cache = [];
public static function get(string $key, mixed $default = null): mixed
{
if (! array_key_exists($key, self::$cache)) {
$row = static::where('key', $key)->first();
self::$cache[$key] = $row ? $row->value : $default;
}
return self::$cache[$key];
}
public static function set(string $key, mixed $value): void
{
static::updateOrCreate(['key' => $key], ['value' => (string) $value]);
self::$cache[$key] = (string) $value;
}
/** Returns the configured FFmpeg binary path, falling back to the config file default. */
public static function ffmpegBinary(): string
{
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
}
public static function gpuEnabled(): bool
{
return static::get('gpu_enabled', 'true') === 'true';
}
public static function gpuDevice(): int
{
return (int) static::get('gpu_device', '0');
}
public static function gpuEncoder(): string
{
return static::gpuEnabled()
? static::get('gpu_encoder', 'h264_nvenc')
: 'libx264';
}
public static function gpuPreset(): string
{
return static::gpuEnabled()
? static::get('gpu_preset', 'p4')
: 'fast';
}
public static function gpuHwaccel(): string
{
return static::get('gpu_hwaccel', 'cuda');
}
/** Returns the full video codec flags for FFmpeg shell commands. */
public static function ffmpegVideoFlags(bool $stillImage = false): string
{
if (static::gpuEnabled()) {
$enc = static::get('gpu_encoder', 'h264_nvenc');
$preset = static::get('gpu_preset', 'p4');
$device = static::gpuDevice();
$gpuFlag = str_contains($enc, 'nvenc') ? " -gpu {$device}" : '';
return "-c:v {$enc} -preset {$preset} -rc vbr -cq 23{$gpuFlag} -pix_fmt yuv420p";
}
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** CPU-only video codec flags — used as automatic fallback when GPU encoding fails. */
public static function ffmpegVideoFlagsCpu(bool $stillImage = false): string
{
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** Returns hwaccel decode flags when the input source is a video file. */
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
$hwaccel = static::get('gpu_hwaccel', 'cuda');
$device = static::gpuDevice();
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ShareAccess extends Model
{
public $timestamps = false;
protected $guarded = [];
public function share()
{
return $this->belongsTo(VideoShare::class, 'share_id');
}
}

View File

@ -2,13 +2,15 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, HasFactory, Notifiable;
@ -29,6 +31,18 @@ class User extends Authenticatable
'tiktok',
'birthday',
'location',
'gender',
'nationality',
'phone_code',
'phone_number',
'timezone',
'whatsapp',
'google_location',
'social_phone',
'social_email',
'two_factor_secret',
'two_factor_enabled',
'banner',
];
protected $hidden = [
@ -37,10 +51,47 @@ class User extends Authenticatable
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
];
protected $appends = ['avatar_url', 'banner_url'];
// Auto-generate a unique slug-based username when creating a user without one
protected static function boot(): void
{
parent::boot();
static::creating(function (User $user) {
if (empty($user->username)) {
$user->username = static::generateUniqueUsername($user->name);
}
});
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
if ($base === '') {
$base = 'user';
}
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (static::where('username', $slug)->exists());
return $slug;
}
// Returns the channel URL slug — the username, auto-generated and saved if missing
public function getChannelAttribute(): string
{
if (empty($this->username)) {
$this->username = static::generateUniqueUsername($this->name);
$this->saveQuietly();
}
return $this->username;
}
// Relationships
public function videos()
{
@ -67,15 +118,33 @@ class User extends Authenticatable
return $this->hasMany(Playlist::class);
}
public function getAvatarUrlAttribute()
public function posts()
{
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return asset('storage/avatars/'.$this->avatar);
return route('media.avatar', $this->avatar);
}
return 'https://i.pravatar.cc/150?u='.$this->id;
}
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return route('media.banner', $this->banner);
}
return null;
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmail);
}
// Role helper methods
public function isSuperAdmin()
{
@ -92,31 +161,47 @@ class User extends Authenticatable
return $this->role === 'user' || $this->role === null;
}
// Placeholder for subscriber count (would need a separate table in full implementation)
public function getSubscriberCountAttribute()
// Users who subscribe TO this channel
public function subscribers()
{
// For now, return a placeholder - in production this would come from a subscriptions table
return rand(100, 10000);
return $this->belongsToMany(
User::class,
'user_subscriptions',
'channel_id',
'subscriber_id'
)->withPivot('created_at');
}
// Get social links as an array
public function getSocialLinksAttribute()
// Channels this user subscribes to
public function subscriptions()
{
return [
'twitter' => $this->twitter,
'instagram' => $this->instagram,
'facebook' => $this->facebook,
'youtube' => $this->youtube,
'linkedin' => $this->linkedin,
'tiktok' => $this->tiktok,
];
return $this->belongsToMany(
User::class,
'user_subscriptions',
'subscriber_id',
'channel_id'
)->withPivot('created_at');
}
public function isSubscribedTo(User $channel): bool
{
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
}
public function getSubscriberCountAttribute(): int
{
return $this->subscribers()->count();
}
// Check if user has any social links
public function socialLinks()
{
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
}
public function hasSocialLinks()
{
return $this->twitter || $this->instagram || $this->facebook ||
$this->youtube || $this->linkedin || $this->tiktok;
return $this->socialLinks()->exists();
}
// Get formatted website URL

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserSocialLink extends Model
{
protected $fillable = ['user_id', 'platform', 'value', 'visibility', 'sort_order'];
const VISIBILITIES = ['public', 'registered', 'subscribers', 'only_me'];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -25,6 +25,11 @@ class Video extends Model
'is_shorts',
'has_hls',
'hls_path',
'download_access',
'download_count',
'share_count',
'share_token',
'slideshow_video_path',
];
protected $casts = [
@ -54,19 +59,63 @@ class Video extends Model
->withTimestamps();
}
public function slides()
{
return $this->hasMany(VideoSlide::class)->orderBy('position');
}
public function hasSlideshow(): bool
{
return $this->slides()->count() > 1;
}
// ── Local filesystem path helpers ─────────────────────────────────────────
/**
* Absolute path to the video file on local disk.
* Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…").
*/
public function localVideoPath(): string
{
return storage_path('app/' . $this->path);
}
/**
* Absolute path to the thumbnail on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (path contains a slash, e.g. users//thumb.jpg)
*/
public function localThumbnailPath(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? storage_path('app/' . $this->thumbnail)
: storage_path('app/public/thumbnails/' . $this->thumbnail);
}
/**
* Storage::delete()-compatible key for the thumbnail.
*/
public function thumbnailStorageKey(): ?string
{
if (! $this->thumbnail) return null;
return str_contains($this->thumbnail, '/')
? $this->thumbnail
: 'public/thumbnails/' . $this->thumbnail;
}
// Accessors
public function getUrlAttribute()
{
return asset('storage/videos/'.$this->filename);
}
public function getThumbnailUrlAttribute()
public function getThumbnailUrlAttribute(): ?string
{
if ($this->thumbnail) {
return asset('storage/thumbnails/'.$this->thumbnail);
return route('media.thumbnail', $this->thumbnail);
}
// Return null when no thumbnail - social platforms will use their own preview
return null;
}
@ -92,10 +141,84 @@ class Video extends Model
return \DB::table('video_views')->where('video_id', $this->id)->count();
}
// Get shareable URL for the video
// ── Short opaque URL encoding ─────────────────────────────────────
// 3-round Feistel cipher over 30-bit space → base62.
// Consecutive IDs produce completely different output. Pure PHP, no extensions.
private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static function feistelRound(int $n, int $round): int
{
// Deterministic pseudo-random function per round (no extension needed)
$keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083];
return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF);
}
public static function encodeId(int $id): string
{
// Split 30-bit id into two 15-bit halves
$L = ($id >> 15) & 0x7FFF;
$R = $id & 0x7FFF;
// 3 Feistel rounds
for ($i = 0; $i < 3; $i++) {
$tmp = $L ^ self::feistelRound($R, $i);
$L = $R;
$R = $tmp;
}
$n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
$base = strlen(self::URL_ALPHABET);
$out = '';
do {
$out = self::URL_ALPHABET[$n % $base] . $out;
$n = intdiv($n, $base);
} while ($n > 0);
// Pad to fixed length so all URLs look uniform
return str_pad($out, 5, '0', STR_PAD_LEFT);
}
public static function decodeId(string $encoded): ?int
{
$base = strlen(self::URL_ALPHABET);
$n = 0;
for ($i = 0, $len = strlen($encoded); $i < $len; $i++) {
$pos = strpos(self::URL_ALPHABET, $encoded[$i]);
if ($pos === false) return null;
$n = $n * $base + $pos;
}
// Reverse Feistel
$L = ($n >> 15) & 0x7FFF;
$R = $n & 0x7FFF;
for ($i = 2; $i >= 0; $i--) {
$tmp = $R ^ self::feistelRound($L, $i);
$R = $L;
$L = $tmp;
}
return (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
}
// Route model binding — use encoded ID in URLs
public function getRouteKey()
{
return self::encodeId($this->id);
}
public function getRouteKeyName()
{
return 'id';
}
public function resolveRouteBinding($value, $field = null)
{
$realId = self::decodeId((string) $value);
if ($realId === null || $realId <= 0) {
abort(404);
}
return $this->where('id', $realId)->firstOrFail();
}
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('videos.show', $this->id);
return route('videos.showByToken', $this->share_token);
}
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
@ -213,6 +336,12 @@ class Video extends Model
return $this->type === 'match';
}
public function isAudioOnly(): bool
{
$ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION));
return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']);
}
// Shorts helpers
public function isShorts()
{
@ -331,13 +460,13 @@ class Video extends Model
// Get video stream URL for Open Graph
public function getStreamUrlAttribute()
{
return route('videos.stream', $this->id);
return route('videos.stream', $this);
}
// Get secure share URL
public function getSecureShareUrlAttribute()
{
return secure_url(route('videos.show', $this->id));
return secure_url(route('videos.show', $this));
}
// Get secure thumbnail URL

26
app/Models/VideoShare.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoShare extends Model
{
public $timestamps = false;
protected $guarded = [];
public function video()
{
return $this->belongsTo(Video::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function accesses()
{
return $this->hasMany(ShareAccess::class, 'share_id');
}
}

42
app/Models/VideoSlide.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model
{
protected $fillable = ['video_id', 'filename', 'position'];
public function video()
{
return $this->belongsTo(Video::class);
}
public function getUrlAttribute(): string
{
return route('media.thumbnail', $this->filename);
}
/**
* Absolute path to the slide image on local disk.
* Old format: storage/app/public/thumbnails/{filename}
* New format: storage/app/{relative-path} (filename contains a slash)
*/
public function localPath(): string
{
return str_contains($this->filename, '/')
? storage_path('app/' . $this->filename)
: storage_path('app/public/thumbnails/' . $this->filename);
}
/**
* Storage::delete()-compatible key for this slide file.
*/
public function storageKey(): string
{
return str_contains($this->filename, '/')
? $this->filename
: 'public/thumbnails/' . $this->filename;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentLikeNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $liker
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'comment_like',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->liker->id,
'actor_name' => $this->liker->name,
'actor_avatar' => $this->liker->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $commenter
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_comment',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->commenter->id,
'actor_name' => $this->commenter->name,
'actor_avatar' => $this->commenter->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewReplyNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $reply,
public User $replier
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_reply',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->replier->id,
'actor_name' => $this->replier->name,
'actor_avatar' => $this->replier->avatar_url,
'comment_preview' => Str::limit($this->reply->body, 80),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use App\Models\Video;
use App\Models\User;
use Illuminate\Notifications\Notification;
class NewVideoUploaded extends Notification
{
public function __construct(public Video $video, public User $uploader)
{
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_video',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'uploader_id' => $this->uploader->id,
'uploader_name' => $this->uploader->name,
'uploader_avatar' => $this->uploader->avatar_url,
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends BaseVerifyEmail
{
use Queueable;
public function toMail($notifiable): MailMessage
{
$url = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify your email address — ' . config('app.name'))
->view('emails.verify-email', [
'url' => $url,
'userName' => $notifiable->name,
]);
}
protected function verificationUrl($notifiable): string
{
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Request;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider
{
@ -27,5 +28,31 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS
URL::forceScheme('https');
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
Paginator::defaultSimpleView('partials.pagination');
// Merge NAS credentials stored in the DB into the package config at runtime.
// This way the live browser and all NAS operations use DB values without needing .env.
$this->app->booted(function () {
try {
$host = \App\Models\Setting::get('nas_host', '');
if ($host) {
config([
'nas-file-manager.connection.protocol' => \App\Models\Setting::get('nas_protocol', 'smb'),
'nas-file-manager.connection.host' => $host,
'nas-file-manager.connection.port' => (int) \App\Models\Setting::get('nas_port', 445),
'nas-file-manager.connection.username' => \App\Models\Setting::get('nas_username', ''),
'nas-file-manager.connection.password' => \App\Models\Setting::get('nas_password', ''),
'nas-file-manager.connection.path' => \App\Models\Setting::get('nas_path', '/media'),
'nas-file-manager.connection.smb_share' => \App\Models\Setting::get('nas_smb_share', ''),
'nas-file-manager.connection.smb_domain' => \App\Models\Setting::get('nas_smb_domain', ''),
]);
}
} catch (\Throwable $e) {
// DB may not exist yet (fresh install / migrations not run) — silently skip
}
});
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NotDisposableEmail implements ValidationRule
{
// Top disposable/throwaway email domains
private const BLOCKED = [
'0-mail.com','0815.ru','0clickemail.com','0wnd.net','0wnd.org','10minutemail.com',
'10minutemail.net','10minutemail.org','10minutemail.de','10mail.org','20minutemail.com',
'2prong.com','30minutemail.com','33mail.com','3d-painting.com','4warding.com',
'amilegit.com','anonbox.net','anonymail.net','anonymbox.com','antichef.com',
'antichef.net','antireg.com','antispam.de','antispammail.de','armyspy.com',
'baxomale.ht.cx','beefmilk.com','bigstring.com','binkmail.com','bio-muesli.net',
'bobmail.info','bodhi.lawlita.com','bofthew.com','bootybay.de','bossmail.de',
'bouncr.com','breakthru.com','brefmail.com','broadbandninja.com','bsnow.net',
'bugmenot.com','bumpymail.com','casualdx.com','centermail.com','centermail.net',
'chacuo.net','chammy.info','childsavetrust.org','chogmail.com','choplife.com',
'clixser.com','cmail.net','coiico.com','cool.fr.nf','correo.blogos.net',
'crapmail.org','crossroadsmail.com','curryworld.de','cust.in','dacoolest.com',
'dandikmail.com','dayrep.com','dcemail.com','deadaddress.com','deadletter.ga',
'deagot.com','dealja.com','despam.it','devnullmail.com','dfgh.net',
'digitalsanctuary.com','discardmail.com','discardmail.de','disposableaddress.com',
'disposableinbox.com','disposablemail.net','disposablemail.org','disposablemail.top',
'dispostable.com','dk4f9h7y.fun','dodgeit.com','dodgit.com','donemail.ru',
'dontreg.com','dontsendmespam.de','drdrb.com','drdrb.net','dropmail.me',
'dumpandfuck.com','dumpmail.de','dumpyemail.com','e4ward.com','easytrashmail.com',
'email60.com','emailfake.com','emailias.com','emailigo.com','emailinfive.com',
'emailisvalid.com','emaillime.com','emailmiser.com','emailna.com','emailnax.com',
'emailnew.com','emailo.pro','emailondeck.com','emailthe.net','emailtmp.com',
'emailwarden.com','emailx.at.hm','emailxfer.com','emeil.in','emeil.ir',
'emlhub.com','emltmp.com','enterto.com','ephemail.net','etranquil.com',
'evopo.com','explodemail.com','express.net.ua','eyepaste.com','fakeinbox.com',
'fakeinformation.com','fakemail.fr','fakemailgenerator.com','fakemail.net',
'fastacura.com','fastchevy.com','fastchrysler.com','fastkawasaki.com','fastmazda.com',
'fastmitsubishi.com','fastnissan.com','fastsubaru.com','fastsuzuki.com','fasttoyota.com',
'fastyamaha.com','fatflap.com','fdcserver.net','fightallspam.com','filzmail.com',
'fixmail.tk','fizmail.com','fleckens.hu','flemail.com','frapmail.com',
'friendlymail.co.uk','fuckingduh.com','fudgerub.com','fux0ringduh.com','fyxm.net',
'garliclife.com','gehensiemirnichtaufdensack.de','gelitik.in','get2mail.fr',
'getairmail.com','getonemail.com','giantmail.de','girlsundertheinfluence.com',
'gishpuppy.com','gmailnew.com','goemailgo.com','gorillaswithdirtyarmpits.com',
'gotmail.net','gotmail.org','gowikibooks.com','gowikicampus.com','gowikicars.com',
'gowikifilms.com','gowikigames.com','gowikimusic.com','gowikinetwork.com',
'gowikitravel.com','grr.la','guerillamail.biz','guerillamail.com','guerillamail.de',
'guerillamail.info','guerillamail.net','guerillamail.org','guerrillamail.biz',
'guerrillamail.com','guerrillamail.de','guerrillamail.info','guerrillamail.net',
'guerrillamail.org','guerrillamailblock.com','gustr.com','h.mintemail.com',
'haltospam.com','haqed.com','harakirimail.com','hat-geld.de','hatespam.org',
'herp.in','hidemail.de','high.net','hmamail.com','hopemail.biz',
'hulapla.de','ieatspam.eu','ieatspam.info','ieh-mail.de','ihateyoualot.info',
'iheartspam.org','ikbenspamvrij.nl','imails.info','inboxclean.com','inboxclean.org',
'incognitomail.net','incognitomail.org','insorg.org','internet-e-mail.de','jetable.fr',
'jetable.net','jetable.org','jnxjn.com','joggly.com','junk.to',
'justamail.net','kasmail.com','kaspop.com','killmail.com','killmail.net',
'klzlk.com','koszmail.pl','kurzepost.de','laoeq.com','lavabit.com',
'letthemeatspam.com','lhsdv.com','lifebyfood.com','lol.ovpn.to','lookugly.com',
'lopl.co.cc','lortemail.dk','lovemeleaveme.com','lr78.com','lukop.dk',
'm4ilweb.info','maboard.com','mail-filter.com','mail-temporaire.com','mail.by',
'mail1a.de','mail2rss.org','mail333.com','mailbidon.com','mailblocks.com',
'mailbucket.org','mailchop.com','maileater.com','maileimer.de','mailexpire.com',
'mailfa.tk','mailforspam.com','mailfree.ga','mailguard.me','mailimate.com',
'mailinatar.com','mailinater.com','mailinator.com','mailinator.net','mailinator.org',
'mailinator.us','mailinator2.com','mailincubator.com','mailismagic.com','mailjunk.cf',
'mailjunk.ga','mailjunk.gq','mailjunk.ml','mailjunk.tk','mailme.gq',
'mailme.ir','mailme24.com','mailmetrash.com','mailmoat.com','mailms.com',
'mailnull.com','mailpick.biz','mailproxsy.com','mailquack.com','mailrock.biz',
'mailscrap.com','mailseal.de','mailshell.com','mailsiphon.com','mailslapping.com',
'mailslite.com','mailsoul.com','mailsucker.net','mailtemp.info','mailtome.de',
'mailtothis.com','mailtrash.net','mailtv.net','mailzilla.com','makemetheking.com',
'mbx.cc','mega.zik.dj','meinspamschutz.de','meltmail.com','messagebeamer.de',
'mezimages.net','mintemail.com','moncourrier.fr.nf','monemail.fr.nf','monmail.fr.nf',
'msa.minsmail.com','msgos.com','mt2014.com','mt2015.com','mucincanon.com',
'mucke.de','mugglenet.com','myfastmail.com','mymailoasis.com','mynetstore.de',
'netzidiot.de','neverbox.com','nice-4u.com','nincsmail.hu','nmail.cf',
'nnot.net','nobulk.com','noclickemail.com','nodezine.com','nomail.pw',
'nomail.xl.cx','nomail2me.com','nomoremail.net','nospam.ze.tc','nospam4.us',
'nospamfor.us','nospammail.net','nospamthanks.info','notmailinator.com','nowmymail.com',
'odnorazovoe.ru','onewaymail.com','online.ms','opayq.com','ordinaryamerican.net',
'ovpn.to','owlpic.com','pecinan.com','pecinan.net','pecinan.org',
'pepbot.com','pfui.ru','pimpedupmyspace.com','plexolan.de','pookmail.com',
'privacy.net','proxymail.eu','prtnx.com','purelymail.com','putthisinyourspamdatabase.com',
'qq.com.de','quickinbox.com','rcpt.at','recode.me','recursor.net',
'regbypass.com','rklips.com','rmqkr.net','rppkn.com','rtrtr.com',
'runbox.com','s0ny.net','safe-mail.net','safetymail.info','safetypost.de',
'sandelf.de','saynotospams.com','schafmail.de','schrott-email.de','secretemail.de',
'secure-mail.biz','sexyalwasmi.top','sharedmailbox.org','sharklasers.com','shieldedmail.com',
'shiftmail.com','shitmail.de','shitmail.me','shitmail.org','shitware.nl',
'shortmail.net','sibmail.com','skeefmail.com','slaskpost.se','slopsbox.com',
'smellfear.com','snakemail.com','sneakemail.com','snkmail.com','sofimail.com',
'sogetthis.com','sohu.com.de','soisz.com','spam.la','spam.org.tr',
'spam.su','spam4.me','spamavert.com','spambob.com','spambob.net',
'spambob.org','spambog.com','spambog.de','spambog.ru','spambox.info',
'spambox.us','spamcannon.com','spamcannon.net','spamcero.com','spamcon.org',
'spamcorptastic.com','spamcowboy.com','spamcowboy.net','spamcowboy.org','spamday.com',
'spamex.com','spamfree.eu','spamfree24.de','spamfree24.eu','spamfree24.info',
'spamfree24.net','spamfree24.org','spamgourmet.com','spamgourmet.net','spamgourmet.org',
'spamherelots.com','spamhereplease.com','spamhole.com','spamify.com','spaminator.de',
'spamkill.info','spaml.com','spaml.de','spammotel.com','spamoff.de',
'spamsalad.in','spamslicer.com','spamspot.com','spamstack.net','spamthis.co.uk',
'spamthisplease.com','spamtrail.com','spamtroll.net','speed.1s.fr','speedymail.net',
'spoofmail.de','super-auswahl.de','supergreatmail.com','supermailer.jp','superrito.com',
'superstachel.de','suremail.info','sweetxxx.de','tafmail.com','tagyourself.com',
'teleworm.com','teleworm.us','temp-mail.io','temp-mail.org','temp-mail.ru',
'temp.bartbot.com','tempail.com','tempalias.com','tempe-mail.com','tempemailaddress.com',
'tempinbox.co.uk','tempinbox.com','tempmail.it','tempmail.net','tempmail.us',
'tempmail2.com','tempomail.fr','temporaryemail.net','temporaryemail.us','temporaryforwarding.com',
'temporaryinbox.com','temporarymail.org','tempsky.com','thankyou2010.com','thc.st',
'thelimestones.com','thisisnotmyrealemail.com','throam.com','throwam.com','throwaway.email',
'throwam.com','throwaway.email','throwam.com','tilien.com','tinyurl24.com',
'tmailinator.com','toiea.com','toomail.biz','top9top.com','tradermail.info',
'trash-amil.com','trash-mail.at','trash-mail.com','trash-mail.de','trash-mail.io',
'trash-mail.net','trash2009.com','trash2010.com','trash2011.com','trashcanmail.com',
'trashdevil.com','trashdevil.de','trashemail.de','trashimail.com','trashmail.at',
'trashmail.com','trashmail.es','trashmail.io','trashmail.me','trashmail.net',
'trashmail.org','trashmailer.com','trashpanda.de','trashymail.com','trillianpro.com',
'turual.com','twinmail.de','tyldd.com','uggsrock.com','upliftnow.com',
'uroid.com','username.e4ward.com','venompen.com','veryrealemail.com','vidchart.com',
'viditag.com','viewcastmedia.com','viewcastmedia.net','viewcastmedia.org','vomoto.com',
'vubby.com','walala.org','watch-harry-potter.com','wetrainbayarea.com','wetrainbayarea.org',
'wilemail.com','willhackforfood.biz','willselfdestruct.com','wmail.cf','wolfsmail.tk',
'wuzupmail.net','xagloo.com','xemaps.com','xents.com','xMailer.net',
'xmaily.com','xn--9kq967o.com','xoxox.cc','xperiae5.com','xyzfree.net',
'yahomail.org','yapped.net','yepmail.net','yourdomain.com','yopmail.com',
'yopmail.fr','yopmail.net','youremail.cf','yourewronghello.com','z1p.biz',
'za.com','zippymail.info','zoemail.net','zoemail.org','zomg.info',
'zxcv.com','zxcvbnm.com','zzz.com',
];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = strtolower(trim(substr(strrchr($value, '@'), 1)));
if (in_array($domain, self::BLOCKED, true)) {
$fail('Registrations from disposable email addresses are not allowed.');
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private static array $privateRanges = [
'127.', '::1', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.',
'192.168.',
];
public static function lookup(string $ip): array
{
foreach (self::$privateRanges as $prefix) {
if (str_starts_with($ip, $prefix)) {
return ['country' => null, 'country_name' => null];
}
}
return Cache::remember('geoip_' . md5($ip), 86400, function () use ($ip) {
try {
$response = Http::timeout(3)->get(
"http://ip-api.com/json/{$ip}",
['fields' => 'status,country,countryCode']
);
if ($response->successful() && $response->json('status') === 'success') {
return [
'country' => $response->json('countryCode'),
'country_name' => $response->json('country'),
];
}
} catch (\Throwable $e) {
Log::debug('GeoIP lookup failed for ' . $ip . ': ' . $e->getMessage());
}
return ['country' => null, 'country_name' => null];
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,15 @@
"license": "MIT",
"require": {
"php": "^8.1",
"bacon/bacon-qr-code": "^3.1",
"doctrine/dbal": "^3.0",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8",
"php-ffmpeg/php-ffmpeg": "^1.4"
"p7h/nas-file-manager": "dev-main",
"php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
@ -62,6 +66,12 @@
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"repositories": [
{
"type": "vcs",
"url": "git@github.com:itsp7h/File-Structure-package.git"
}
],
"minimum-stability": "dev",
"prefer-stable": true
}

683
composer.lock generated
View File

@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1e4c8a43b8df70dffbd0107a8308b2ec",
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@ -135,6 +190,56 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -210,6 +315,259 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/dbal",
"version": "3.10.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/cache": "< 1.11"
},
"require-dev": {
"doctrine/cache": "^1.11|^2.0",
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.34",
"slevomat/coding-standard": "8.27.1",
"squizlabs/php_codesniffer": "4.0.1",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"bin": [
"bin/doctrine-dbal"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2026-02-24T08:03:57+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/common": "<2.9"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/phpstan": "^2.1.32",
"phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
},
{
"name": "Johannes Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com"
}
],
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
"keywords": [
"event",
"event dispatcher",
"event manager",
"event system",
"events"
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
"source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
"type": "tidelift"
}
],
"time": "2026-01-29T07:11:08+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@ -2444,6 +2802,128 @@
],
"time": "2024-11-21T10:36:35+00:00"
},
{
"name": "p7h/nas-file-manager",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/itsp7h/File-Structure-package.git",
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1"
},
"require-dev": {
"composer/composer": "^2.0"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"P7H\\NasFileManager\\NasFileManagerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"P7H\\NasFileManager\\": "src/"
}
},
"scripts": {
"post-install-cmd": [
"P7H\\NasFileManager\\Installer::install"
],
"post-update-cmd": [
"P7H\\NasFileManager\\Installer::install"
]
},
"license": [
"MIT"
],
"description": "NAS folder-structure schema viewer and live file manager for Laravel",
"support": {
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
},
"time": "2026-05-14T12:07:04+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "php-ffmpeg/php-ffmpeg",
"version": "v1.4.0",
@ -2608,6 +3088,201 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
},
"time": "2024-09-05T11:56:40+00:00"
},
{
"name": "pragmarx/google2fa-laravel",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-laravel.git",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c",
"shasum": ""
},
"require": {
"laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": ">=7.0",
"pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*",
"phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12"
},
"suggest": {
"bacon/bacon-qr-code": "Required to generate inline QR Codes.",
"pragmarx/recovery": "Generate recovery codes."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Google2FA": "PragmaRX\\Google2FALaravel\\Facade"
},
"providers": [
"PragmaRX\\Google2FALaravel\\ServiceProvider"
]
},
"component": "package",
"frameworks": [
"Laravel"
],
"branch-alias": {
"dev-master": "0.2-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FALaravel\\": "src/",
"PragmaRX\\Google2FALaravel\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"Authentication",
"Two Factor Authentication",
"google2fa",
"laravel"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-laravel/issues",
"source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1"
},
"time": "2026-03-17T20:54:53+00:00"
},
{
"name": "pragmarx/google2fa-qrcode",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-qrcode.git",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"shasum": ""
},
"require": {
"php": ">=7.1",
"pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0",
"khanamiryan/qrcode-detector-decoder": "^1.0",
"phpunit/phpunit": "~4|~5|~6|~7|~8|~9"
},
"suggest": {
"bacon/bacon-qr-code": "For QR Code generation, requires imagick",
"chillerlan/php-qrcode": "For QR Code generation"
},
"type": "library",
"extra": {
"component": "package",
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FAQRCode\\": "src/",
"PragmaRX\\Google2FAQRCode\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "QR Code package for Google2FA",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa",
"qr code",
"qrcode"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues",
"source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1"
},
"time": "2025-09-19T23:02:26+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@ -8699,8 +9374,10 @@
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"minimum-stability": "dev",
"stability-flags": {
"p7h/nas-file-manager": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {

View File

@ -6,8 +6,8 @@ return [
| FFmpeg Binaries
|--------------------------------------------------------------------------
*/
'ffmpeg' => '/usr/bin/ffmpeg',
'ffprobe' => '/usr/bin/ffprobe',
'ffmpeg' => '/usr/lib/jellyfin-ffmpeg/ffmpeg',
'ffprobe' => '/usr/lib/jellyfin-ffmpeg/ffprobe',
'timeout' => 3600,
'thread_number' => 0,
// auto-detect cores

View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| NAS Connection
|--------------------------------------------------------------------------
| Protocol: sftp | ftp | ftps | smb
*/
'connection' => [
'protocol' => env('NAS_PROTOCOL', 'sftp'),
'host' => env('NAS_HOST', ''),
'port' => (int) env('NAS_PORT', 22),
'username' => env('NAS_USERNAME', ''),
'password' => env('NAS_PASSWORD', ''),
'path' => env('NAS_PATH', '/media'),
'smb_share' => env('NAS_SMB_SHARE', ''),
'smb_domain' => env('NAS_SMB_DOMAIN', ''),
],
/*
|--------------------------------------------------------------------------
| Routing
|--------------------------------------------------------------------------
*/
'route_prefix' => env('NAS_FM_ROUTE_PREFIX', 'nas-file-manager'),
'middleware' => ['web', 'auth', 'super_admin'],
/*
|--------------------------------------------------------------------------
| Authorization
|--------------------------------------------------------------------------
| Gate or permission name that controls create / rename / delete actions.
| Set to null to allow any authenticated user.
| Example: 'edit-nas' (checked with Gate::allows())
*/
'edit_gate' => null,
/*
|--------------------------------------------------------------------------
| Folder Schema
|--------------------------------------------------------------------------
| Static schema shown in the "Schema" tab.
| Each node: depth (int), label (string), path (string),
| parent_path (?string), is_template (bool), can_edit (bool)
|
| You can also pass $nodes dynamically to the Blade component:
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
*/
'schema' => [
// ── users ─────────────────────────────────────────────────────────────
['depth' => 0, 'label' => 'users', 'path' => 'users', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
['depth' => 1, 'label' => '{username}', 'path' => 'users/{username}', 'parent_path' => 'users', 'is_template' => true, 'can_edit' => false],
// ── profile ───────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'profile', 'path' => 'u/profile', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'avatar.webp', 'path' => 'profile/avatar.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'cover.webp', 'path' => 'profile/cover.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
// ── videos ────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'videos', 'path' => 'u/videos', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{video-slug}', 'path' => 'videos/{video-slug}', 'parent_path' => 'u/videos', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{video-slug}.{ext}', 'path' => 'vid/file', 'parent_path' => 'videos/{video-slug}', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => 'thumb.webp', 'path' => 'vid/thumb.webp', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'meta.json', 'path' => 'vid/meta.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'view-log.json', 'path' => 'vid/view-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'edit-log.json', 'path' => 'vid/edit-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'slides', 'path' => 'vid/slides', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 5, 'label' => '{position}.{ext}','path' => 'slide/file', 'parent_path' => 'vid/slides', 'is_template' => true, 'can_edit' => false],
// ── posts ─────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'posts', 'path' => 'u/posts', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{post_id}', 'path' => 'posts/{post_id}', 'parent_path' => 'u/posts', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{n}.{ext}', 'path' => 'post/image', 'parent_path' => 'posts/{post_id}', 'is_template' => true, 'can_edit' => false],
],
];

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->string('ip_address', 45)->nullable()->after('video_id');
$table->string('country', 2)->nullable()->after('ip_address');
$table->string('country_name', 100)->nullable()->after('country');
// Allow null so guest (unauthenticated) views can be recorded
$table->unsignedBigInteger('user_id')->nullable()->change();
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropColumn(['ip_address', 'country', 'country_name']);
$table->unsignedBigInteger('user_id')->nullable(false)->change();
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('gender', 20)->nullable()->after('birthday');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('gender');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('nationality', 2)->nullable()->after('gender'); // ISO2 e.g. "BH"
$table->string('phone_code', 20)->nullable()->after('nationality'); // e.g. "+973|BH"
$table->string('phone_number', 30)->nullable()->after('phone_code');
$table->string('timezone', 60)->nullable()->after('phone_number'); // IANA e.g. "Asia/Bahrain"
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['nationality', 'phone_code', 'phone_number', 'timezone']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('whatsapp', 30)->nullable()->after('tiktok');
$table->string('google_location', 500)->nullable()->after('whatsapp');
$table->string('social_phone', 30)->nullable()->after('google_location');
$table->string('social_email', 100)->nullable()->after('social_phone');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['whatsapp', 'google_location', 'social_phone', 'social_email']);
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_social_links', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('platform', 30);
$table->string('value', 500);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['user_id', 'sort_order']);
});
// Migrate existing individual social columns into the new table
$cols = ['twitter','instagram','facebook','youtube','linkedin','tiktok',
'website','whatsapp','google_location','social_phone','social_email'];
foreach (\DB::table('users')->get() as $user) {
$order = 0;
foreach ($cols as $col) {
$val = $user->$col ?? null;
if ($val) {
\DB::table('user_social_links')->insert([
'user_id' => $user->id,
'platform' => $col,
'value' => $val,
'sort_order' => $order++,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_social_links');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_social_links', function (Blueprint $table) {
$table->enum('visibility', ['public', 'registered', 'subscribers', 'only_me'])
->default('public')
->after('value');
});
// Default sensitive platforms to 'subscribers'
\DB::table('user_social_links')
->whereIn('platform', ['whatsapp', 'social_phone', 'social_email'])
->update(['visibility' => 'subscribers']);
}
public function down(): void
{
Schema::table('user_social_links', function (Blueprint $table) {
$table->dropColumn('visibility');
});
}
};

View File

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

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('subscriber_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('channel_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['subscriber_id', 'channel_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_subscriptions');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->string('download_access')->default('disabled')->after('allow_download');
});
// Migrate existing data
DB::table('videos')->where('allow_download', true)->update(['download_access' => 'everyone']);
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('allow_download');
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->boolean('allow_download')->default(false)->after('download_access');
});
DB::table('videos')->where('download_access', 'everyone')->update(['allow_download' => true]);
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('download_access');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body')->nullable();
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
$table->string('image')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('post_reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('type', 20)->default('like');
$table->timestamps();
$table->unique(['user_id', 'post_id']);
});
}
public function down(): void
{
Schema::dropIfExists('post_reactions');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->unsignedInteger('download_count')->default(0)->after('has_hls');
$table->unsignedInteger('share_count')->default(0)->after('download_count');
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn(['download_count', 'share_count']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('video_downloads', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name', 100)->nullable();
$table->string('type', 10)->default('video'); // 'video' or 'mp3'
$table->timestamp('downloaded_at')->useCurrent();
$table->index(['video_id', 'downloaded_at']);
$table->index(['video_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('video_downloads');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('video_shares', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('token', 12)->unique();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('share_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('share_id')->constrained('video_shares')->cascadeOnDelete();
$table->string('device_id', 64);
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name')->nullable();
$table->timestamp('accessed_at')->useCurrent();
$table->unique(['share_id', 'device_id']);
});
}
public function down(): void
{
Schema::dropIfExists('share_accesses');
Schema::dropIfExists('video_shares');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comment_likes', function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->nullable();
$table->unique(['comment_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('comment_likes');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('two_factor_secret')->nullable()->after('password');
$table->boolean('two_factor_enabled')->default(false)->after('two_factor_secret');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['two_factor_secret', 'two_factor_enabled']);
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('video_slides', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->string('filename');
$table->unsignedSmallInteger('position')->default(0);
$table->timestamps();
});
Schema::table('videos', function (Blueprint $table) {
$table->string('slideshow_video_path')->nullable()->after('thumbnail');
});
}
public function down(): void
{
Schema::dropIfExists('video_slides');
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('slideshow_video_path');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
// Seed defaults
$defaults = [
['key' => 'gpu_enabled', 'value' => 'true'],
['key' => 'gpu_device', 'value' => '0'],
['key' => 'gpu_encoder', 'value' => 'h264_nvenc'],
['key' => 'gpu_hwaccel', 'value' => 'cuda'],
['key' => 'gpu_preset', 'value' => 'p4'],
];
foreach ($defaults as $row) {
DB::table('settings')->insert(array_merge($row, [
'created_at' => now(), 'updated_at' => now(),
]));
}
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE playlists MODIFY COLUMN visibility ENUM('public', 'private', 'unlisted') NOT NULL DEFAULT 'private'");
} else {
// SQLite stores enums as CHECK constraints; use table rebuild to update the constraint.
$this->rebuildSqliteEnum(['public', 'private', 'unlisted']);
}
}
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
DB::table('playlists')->where('visibility', 'unlisted')->update(['visibility' => 'private']);
DB::statement("ALTER TABLE playlists MODIFY COLUMN visibility ENUM('public', 'private') NOT NULL DEFAULT 'private'");
} else {
DB::table('playlists')->where('visibility', 'unlisted')->update(['visibility' => 'private']);
$this->rebuildSqliteEnum(['public', 'private']);
}
}
private function rebuildSqliteEnum(array $values): void
{
$inList = implode(', ', array_map(fn($v) => "'$v'", $values));
DB::statement('PRAGMA foreign_keys = OFF');
DB::statement("
CREATE TABLE playlists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail VARCHAR(255),
visibility VARCHAR(255) CHECK (visibility IN ({$inList})) NOT NULL DEFAULT 'private',
is_default TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
");
DB::statement('INSERT INTO playlists_new SELECT * FROM playlists');
DB::statement('DROP TABLE playlists');
DB::statement('ALTER TABLE playlists_new RENAME TO playlists');
// Recreate indexes
DB::statement('CREATE INDEX playlists_user_id_visibility_index ON playlists (user_id, visibility)');
DB::statement('PRAGMA foreign_keys = ON');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\Playlist;
return new class extends Migration
{
public function up(): void
{
Schema::table('playlists', function (Blueprint $table) {
$table->string('share_token', 64)->nullable()->unique()->after('is_default');
});
// Backfill tokens for existing playlists
Playlist::whereNull('share_token')->each(function ($playlist) {
$playlist->updateQuietly(['share_token' => Str::random(32)]);
});
}
public function down(): void
{
Schema::table('playlists', function (Blueprint $table) {
$table->dropColumn('share_token');
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('playlist_shares', function (Blueprint $table) {
$table->id();
$table->foreignId('playlist_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('token', 12)->unique();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('playlist_share_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('share_id')->constrained('playlist_shares')->cascadeOnDelete();
$table->string('device_id', 64);
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name')->nullable();
$table->timestamp('accessed_at')->useCurrent();
$table->unique(['share_id', 'device_id']);
});
}
public function down(): void
{
Schema::dropIfExists('playlist_share_accesses');
Schema::dropIfExists('playlist_shares');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\Video;
return new class extends Migration
{
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->string('share_token', 64)->nullable()->unique()->after('share_count');
});
// Backfill existing videos
Video::whereNull('share_token')->each(function ($video) {
$video->updateQuietly(['share_token' => Str::random(32)]);
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('share_token');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_images', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('filename');
$table->unsignedTinyInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('post_images');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_videos', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('video_id')->nullable()->nullOnDelete();
$table->unsignedTinyInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('post_videos');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username', 50)->nullable()->unique()->after('name');
});
// Back-fill existing users with a unique slug
DB::table('users')->orderBy('id')->each(function ($user) {
$base = substr(Str::slug(trim($user->name)), 0, 18);
if ($base === '') $base = 'user';
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (DB::table('users')->where('username', $slug)->exists());
DB::table('users')->where('id', $user->id)->update(['username' => $slug]);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('banner')->nullable()->after('avatar');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('banner');
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('user_name')->nullable();
$table->string('action', 64);
$table->string('subject_type', 64)->nullable();
$table->string('subject_id', 64)->nullable();
$table->string('subject_label')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->json('details')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('user_id');
$table->index('action');
$table->index('created_at');
$table->index('ip_address');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

9
public/css/cropme.min.css vendored Normal file
View File

@ -0,0 +1,9 @@
/*!
* cropme v1.4.0
* https://shpontex.github.io/cropme
*
* Copyright 2019 shpontex
* Released under the MIT license
*
* Date: 2019-10-28T17:18:45.700Z
*/.cropme-wrapper{width:100%;height:100%}.cropme-container{position:relative;overflow:hidden;margin:0 auto}.cropme-container img{width:auto!important;cursor:move;opacity:0;touch-action:none}#img{border:5px solid red}.viewport{box-sizing:content-box!important;position:absolute;border-style:solid;margin:auto;top:0;bottom:0;right:0;left:0;box-shadow:0 0 2000px 2000px rgba(0,0,0,.5);z-index:0;pointer-events:none}.viewport.circle{border-radius:50%}.cropme-rotation-slider,.cropme-slider{text-align:center}.cropme-rotation-slider input,.cropme-slider input{-webkit-appearance:none}.cropme-rotation-slider input:disabled,.cropme-slider input:disabled{opacity:.5}.cropme-rotation-slider input::-webkit-slider-runnable-track,.cropme-slider input::-webkit-slider-runnable-track{height:3px;background:rgba(0,0,0,.5);border-radius:3px}.cropme-rotation-slider input::-webkit-slider-thumb,.cropme-slider input::-webkit-slider-thumb{-webkit-appearance:none;height:16px;width:16px;border-radius:50%;background:#ddd;margin-top:-6px}.cropme-rotation-slider input:focus,.cropme-slider input:focus{outline:none}

10
public/js/cropme.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
public/js/jquery-4.0.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/sounds/chime.wav Normal file

Binary file not shown.

View File

@ -0,0 +1,385 @@
@extends('admin.layout')
@section('title', 'Audit Logs')
@section('content')
<style>
.al-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.al-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
.al-title i { color: var(--brand); }
/* Filter bar */
.al-filters {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.al-filter-group label {
display: block; font-size: 11px; font-weight: 700;
letter-spacing: .7px; text-transform: uppercase;
color: var(--text-muted); margin-bottom: 6px;
}
.al-filter-input {
width: 100%; background: var(--bg-body); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 12px; color: var(--text);
font-size: 13px; line-height: 1;
}
.al-filter-input:focus { outline: none; border-color: var(--brand); }
.al-filter-input option { background: #1a1a1a; }
.al-filter-btn {
display: flex; gap: 8px;
}
.al-btn {
padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: none; white-space: nowrap;
}
.al-btn-primary { background: var(--brand); color: #fff; }
.al-btn-ghost { background: var(--border-light); color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.al-btn-ghost:hover { background: var(--border); color: var(--text); }
/* Stats strip */
.al-stats {
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
}
.al-stat {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 18px; font-size: 13px;
display: flex; align-items: center; gap: 8px;
}
.al-stat strong { font-size: 18px; font-weight: 700; }
.al-stat-label { color: var(--text-muted); font-size: 12px; }
/* Table */
.al-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.al-table thead tr {
background: rgba(255,255,255,.03);
border-bottom: 1px solid var(--border);
}
.al-table th {
padding: 11px 14px; text-align: left;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; color: var(--text-muted); white-space: nowrap;
}
.al-table td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.al-table tr:last-child td { border-bottom: none; }
.al-table tbody tr:hover { background: rgba(255,255,255,.02); }
/* Severity badges */
.al-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 700; white-space: nowrap;
}
.al-badge-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
.al-badge-warning { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
.al-badge-success { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
.al-badge-info { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
.al-badge-purple { background: rgba(167,139,250,.15);color: #a78bfa; border: 1px solid rgba(167,139,250,.3); }
.al-badge-orange { background: rgba(251,146,60,.15); color: #fb923c; border: 1px solid rgba(251,146,60,.3); }
.al-badge-muted { background: rgba(255,255,255,.06); color: var(--text-muted); border: 1px solid var(--border); }
.al-badge-default { background: rgba(255,255,255,.06); color: var(--text); border: 1px solid var(--border); }
/* Icon per severity */
.al-badge-danger i { color: #f87171; }
.al-badge-warning i { color: #fbbf24; }
.al-badge-success i { color: #4ade80; }
.al-badge-info i { color: #60a5fa; }
/* User cell */
.al-user { display: flex; align-items: center; gap: 8px; }
.al-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.al-user-name { font-weight: 600; }
.al-user-id { font-size: 11px; color: var(--text-muted); }
/* IP cell */
.al-ip { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.al-ip a { color: inherit; text-decoration: underline dotted; }
/* Subject cell */
.al-subject { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Details expand */
.al-details-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
.al-details-btn:hover { background: var(--border-light); color: var(--text); }
.al-details-row td {
padding: 0 !important;
border-bottom: 1px solid rgba(255,255,255,.04) !important;
}
.al-details-inner {
padding: 10px 14px 14px 44px;
font-family: monospace; font-size: 12px;
color: var(--text-muted); white-space: pre-wrap;
background: rgba(0,0,0,.15);
}
/* Pagination */
.al-pagination {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
font-size: 13px; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
}
.al-page-links { display: flex; gap: 4px; }
.al-page-links a, .al-page-links span {
padding: 5px 10px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); color: var(--text-muted); text-decoration: none;
}
.al-page-links a:hover { background: var(--border-light); color: var(--text); }
.al-page-links span.active { background: var(--brand); border-color: var(--brand); color: #fff; }
.al-page-links span.disabled { opacity: .4; cursor: not-allowed; }
/* Empty state */
.al-empty {
padding: 60px 20px; text-align: center; color: var(--text-muted);
}
.al-empty i { font-size: 48px; display: block; margin-bottom: 12px; }
</style>
<div class="al-header">
<div class="al-title">
<i class="bi bi-shield-check"></i> Audit Logs
</div>
<div style="font-size:13px;color:var(--text-muted);">
{{ $logs->total() }} events found
@if(request()->hasAny(['action','user','ip','subject','date_from','date_to']))
<a href="{{ route('admin.audit') }}" style="color:var(--brand);">clear filters</a>
@endif
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.audit') }}">
<div class="al-filters">
<div class="al-filter-group">
<label>Action Type</label>
<select name="action" class="al-filter-input">
<option value="">All Actions</option>
@foreach($actionTypes as $type)
<option value="{{ $type }}" {{ request('action') === $type ? 'selected' : '' }}>
{{ ucwords(str_replace(['.','_'], ' ', $type)) }}
</option>
@endforeach
</select>
</div>
<div class="al-filter-group">
<label>User / Email</label>
<input type="text" name="user" class="al-filter-input" value="{{ request('user') }}" placeholder="Name or email…">
</div>
<div class="al-filter-group">
<label>IP Address</label>
<input type="text" name="ip" class="al-filter-input" value="{{ request('ip') }}" placeholder="e.g. 1.2.3.4">
</div>
<div class="al-filter-group">
<label>Subject</label>
<input type="text" name="subject" class="al-filter-input" value="{{ request('subject') }}" placeholder="Video title, username…">
</div>
<div class="al-filter-group">
<label>From Date</label>
<input type="date" name="date_from" class="al-filter-input" value="{{ request('date_from') }}">
</div>
<div class="al-filter-group">
<label>To Date</label>
<input type="date" name="date_to" class="al-filter-input" value="{{ request('date_to') }}">
</div>
<div class="al-filter-group al-filter-btn">
<button type="submit" class="al-btn al-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
<a href="{{ route('admin.audit') }}" class="al-btn al-btn-ghost"><i class="bi bi-x-lg"></i></a>
</div>
</div>
</form>
{{-- Table --}}
<div class="al-table-wrap">
@if($logs->isEmpty())
<div class="al-empty">
<i class="bi bi-shield-check"></i>
No audit events found.
</div>
@else
<table class="al-table" id="auditTable">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>User</th>
<th>Subject</th>
<th>IP Address</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $log)
@php
$severity = $log->severity;
$icon = match($severity) {
'danger' => 'bi-trash3-fill',
'warning' => 'bi-exclamation-triangle-fill',
'success' => 'bi-cloud-upload-fill',
'info' => 'bi-box-arrow-in-right',
'purple' => 'bi-shield-lock-fill',
'orange' => 'bi-person-badge-fill',
'muted' => 'bi-box-arrow-right',
default => 'bi-dot',
};
$ua = $log->user_agent ?? '';
$device = match(true) {
str_contains($ua, 'Mobile') || str_contains($ua, 'Android') => '📱 Mobile',
str_contains($ua, 'Tablet') || str_contains($ua, 'iPad') => '📟 Tablet',
default => '🖥️ Desktop',
};
$browser = match(true) {
str_contains($ua, 'Firefox') => 'Firefox',
str_contains($ua, 'Chrome') && !str_contains($ua, 'Chromium') && !str_contains($ua, 'Edg') => 'Chrome',
str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome') => 'Safari',
str_contains($ua, 'Edg') => 'Edge',
str_contains($ua, 'curl') => 'cURL',
default => 'Unknown',
};
@endphp
<tr>
<td style="white-space:nowrap;">
<div style="font-weight:600;font-size:13px;">{{ $log->created_at->format('d M Y') }}</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->created_at->format('H:i:s') }}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:1px;" title="{{ $log->created_at }}">{{ $log->created_at->diffForHumans() }}</div>
</td>
<td>
<span class="al-badge al-badge-{{ $severity }}">
<i class="bi {{ $icon }}"></i>
{{ $log->action_label }}
</span>
</td>
<td>
<div class="al-user">
@if($log->user)
<img src="{{ $log->user->avatar_url }}" alt="" class="al-user-avatar">
@else
<div class="al-user-avatar" style="background:var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">
<i class="bi bi-person" style="color:var(--text-muted);"></i>
</div>
@endif
<div>
<div class="al-user-name">
@if($log->user)
<a href="{{ route('admin.users.edit', $log->user) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
{{ $log->user_name ?? 'Unknown' }}
</a>
@else
{{ $log->user_name ?? ($log->action === 'user.login.failed' ? 'Guest' : 'Unknown') }}
@endif
</div>
@if($log->user_id)
<div class="al-user-id">#{{ $log->user_id }}</div>
@endif
</div>
</div>
</td>
<td>
@if($log->subject_label)
<div class="al-subject" title="{{ $log->subject_label }}">
@if($log->subject_type === 'Video' && $log->subject_id)
<a href="{{ route('videos.show', \App\Models\Video::encodeId((int)$log->subject_id)) }}" style="color:var(--brand);text-decoration:none;" target="_blank">
{{ $log->subject_label }}
</a>
@elseif($log->subject_type === 'User' && $log->subject_id)
<a href="{{ route('admin.users.edit', $log->subject_id) }}" style="color:var(--brand);text-decoration:none;">
{{ $log->subject_label }}
</a>
@else
{{ $log->subject_label }}
@endif
</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->subject_type }}</div>
@else
<span style="color:var(--text-muted);"></span>
@endif
</td>
<td>
<div class="al-ip">
@if($log->ip_address)
<a href="{{ route('admin.audit', ['ip' => $log->ip_address]) }}" title="Filter by this IP">
{{ $log->ip_address }}
</a>
@else
<span></span>
@endif
</div>
</td>
<td style="font-size:12px;color:var(--text-muted);white-space:nowrap;">
{{ $device }} · {{ $browser }}
</td>
<td>
@if($log->details)
<button class="al-details-btn" onclick="toggleDetails({{ $log->id }})" title="View details">
<i class="bi bi-code-slash"></i>
</button>
@endif
</td>
</tr>
@if($log->details)
<tr class="al-details-row" id="details-{{ $log->id }}" style="display:none;">
<td colspan="7">
<div class="al-details-inner">{{ json_encode($log->details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</div>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
{{-- Pagination --}}
@if($logs->hasPages())
<div class="al-pagination">
<div>
Showing {{ $logs->firstItem() }}{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div>
<div class="al-page-links">
@if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
@endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage())
<span class="active">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
@else
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
@endif
</div>
</div>
@endif
@endif
</div>
<script>
function toggleDetails(id) {
const row = document.getElementById('details-' + id);
if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
@endsection

File diff suppressed because it is too large Load Diff

View File

@ -3,131 +3,310 @@
@section('title', 'Edit User')
@section('page_title', 'Edit User')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 90px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 500px) { .ef-row { grid-template-columns: 1fr; } }
/* ── Section divider ── */
.ef-section {
margin: 24px 0 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.ef-section-title {
font-size: 13px; font-weight: 600; color: var(--text-2);
white-space: nowrap;
}
.ef-section-hint { font-size: 12px; color: var(--text-3); }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar info card ── */
.ef-user-avatar {
width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
border: 3px solid var(--border);
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.users') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-person-gear"></i> Edit User
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit User Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit User</h5>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<div class="ef-grid">
{{-- ── Left: edit form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-pencil-square"></i> Account Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Name --}}
<div class="ef-field">
<label class="ef-label" for="name">Full Name</label>
<input class="ef-input @error('name') is-invalid @enderror"
id="name" name="name" type="text"
value="{{ old('name', $user->name) }}" required autocomplete="off">
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Email --}}
<div class="ef-field">
<label class="ef-label" for="email">Email Address</label>
<input class="ef-input @error('email') is-invalid @enderror"
id="email" name="email" type="email"
value="{{ old('email', $user->email) }}" required autocomplete="off">
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
{{-- Role --}}
<div class="ef-field">
<label class="ef-label" for="role">Role</label>
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
@error('role')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<hr style="border-color: var(--border-color);">
<h6 class="mb-3">Change Password (Optional)</h6>
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
@error('new_password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Password section --}}
<div class="ef-section">
<span class="ef-section-title"><i class="bi bi-lock-fill" style="color:var(--brand);"></i> Change Password</span>
<span class="ef-section-hint"> leave blank to keep current password</span>
</div>
<div class="mb-3">
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
<div class="ef-row">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password">New Password</label>
<input class="ef-input @error('new_password') is-invalid @enderror"
id="new_password" name="new_password" type="password"
placeholder="Min. 8 characters" autocomplete="new-password">
@error('new_password')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password_confirmation">Confirm Password</label>
<input class="ef-input"
id="new_password_confirmation" name="new_password_confirmation"
type="password" placeholder="Repeat password" autocomplete="new-password">
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update User
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- User Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">User Info</h5>
</div>
<div class="text-center mb-3">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
<h5 class="mt-2">{{ $user->name }}</h5>
<p class="text-secondary mb-1">{{ $user->email }}</p>
{{-- ── Right: user info sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Profile card --}}
<div class="adm-card">
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ef-user-avatar">
<div style="margin-top:12px;font-size:16px;font-weight:700;color:var(--text);">{{ $user->name }}</div>
<div style="font-size:12px;color:var(--text-2);margin:4px 0 10px;">{{ $user->email }}</div>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin">Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin">Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user">User</span>
@endif
</div>
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">User ID</span>
<span>#{{ $user->id }}</span>
</div>
{{-- Stats card --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Account Info</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Joined</span>
<span>{{ $user->created_at->format('M d, Y') }}</span>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">User ID</span>
<span class="ef-stat-val">#{{ $user->id }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Joined</span>
<span class="ef-stat-val">{{ $user->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Last active</span>
<span class="ef-stat-val">{{ $user->updated_at->diffForHumans() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Videos</span>
<span class="ef-stat-val" style="color:#818cf8;">{{ $user->videos->count() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Email verified</span>
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified" style="font-size:11px;">Verified</span>
@else
<span class="adm-badge adm-badge-unverified" style="font-size:11px;">Unverified</span>
@endif
</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Total Videos</span>
<span>{{ $user->videos->count() }}</span>
</div>
{{-- Impersonate --}}
@if(!$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}">
@csrf
<button type="submit" class="adm-btn adm-btn-impersonate" style="width:100%;">
<i class="bi bi-person-badge"></i> Impersonate User
</button>
</form>
@endif
{{-- Danger zone --}}
@if(!$user->isSuperAdmin())
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This User
</button>
</div>
@endif
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
@if(!$user->isSuperAdmin())
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Email Verified</span>
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $user->name }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.users.delete', $user->id) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endif
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog')?.addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection

View File

@ -3,164 +3,486 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
@endphp
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 100px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid, .ef-textarea.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
@media (max-width: 600px) { .ef-row-3 { grid-template-columns: 1fr; } }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar stats ── */
.ef-thumb {
width: 100%; border-radius: 10px; object-fit: cover; display: block;
background: var(--bg-2);
}
.ef-thumb-placeholder {
width: 100%; height: 160px; border-radius: 10px;
background: var(--bg-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 36px;
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
.adm-btn-warning { color: #fb923c; border-color: rgba(251,146,60,.3); }
.adm-btn-warning:hover { background: rgba(251,146,60,.1); border-color: rgba(251,146,60,.5); color: #fdba74; }
.adm-btn-warning:disabled { opacity:.5; cursor:not-allowed; pointer-events:none; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.videos') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-pencil-square"></i> Edit Video
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit Video Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit Video</h5>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
</div>
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
<div class="ef-grid">
{{-- ── Left: form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-film"></i> Video Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.videos.update', $video) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Title --}}
<div class="ef-field">
<label class="ef-label" for="title">Title</label>
<input class="ef-input @error('title') is-invalid @enderror"
id="title" name="title" type="text"
value="{{ old('title', $video->title) }}" required>
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Description --}}
<div class="ef-field">
<label class="ef-label" for="description">Description</label>
<textarea class="ef-textarea @error('description') is-invalid @enderror"
id="description" name="description">{{ old('description', $video->description) }}</textarea>
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="visibility" class="form-label">Visibility</label>
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
{{-- Visibility / Type / Status --}}
<div class="ef-row-3">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="visibility">Visibility</label>
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
</select>
@error('visibility')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="type" class="form-label">Type</label>
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="type">Type</label>
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
<option value="generic" {{ old('type', $video->type) === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
</select>
@error('type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="status">Status</label>
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
<option value="pending" {{ old('status', $video->status) === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
@error('status')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Video
{{-- Download Access --}}
<div class="ef-field" style="margin-top:18px;">
<label class="ef-label" for="download_access">Who Can Download</label>
<select class="ef-select @error('download_access') is-invalid @enderror" id="download_access" name="download_access">
<option value="disabled" {{ old('download_access', $video->download_access) === 'disabled' ? 'selected' : '' }}>No one (disabled)</option>
<option value="everyone" {{ old('download_access', $video->download_access) === 'everyone' ? 'selected' : '' }}>Everyone (guests too)</option>
<option value="registered" {{ old('download_access', $video->download_access) === 'registered' ? 'selected' : '' }}>Registered members</option>
<option value="subscribers" {{ old('download_access', $video->download_access) === 'subscribers' ? 'selected' : '' }}>Subscribers only</option>
</select>
@error('download_access')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.videos') }}" class="adm-btn">Cancel</a>
<a href="{{ route('videos.show', $video) }}" target="_blank" class="adm-btn" style="margin-left:auto;">
<i class="bi bi-play-circle"></i> View Video
</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- Video Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Video Info</h5>
{{-- ── Right: sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Thumbnail --}}
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@endif
</div>
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
@else
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
</div>
{{-- Stats --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Video Info</div>
</div>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">Owner</span>
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="color:var(--brand);font-weight:600;font-size:13px;text-decoration:none;">
{{ $video->user->name }}
</a>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Uploaded</span>
<span class="ef-stat-val">{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Views</span>
<span class="ef-stat-val" style="color:#22d3ee;">
{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Likes</span>
<span class="ef-stat-val" style="color:#f472b6;">
{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Duration</span>
<span class="ef-stat-val">{{ $video->duration ? gmdate('H:i:s', $video->duration) : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Dimensions</span>
<span class="ef-stat-val">{{ $video->width ?? '—' }} × {{ $video->height ?? '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">File size</span>
<span class="ef-stat-val">{{ $video->size ? number_format($video->size / 1024 / 1024, 1) . ' MB' : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Orientation</span>
<span class="ef-stat-val" style="text-transform:capitalize;">{{ $video->orientation ?? '—' }}</span>
</div>
</div>
</div>
{{-- Replace File --}}
<div class="ef-danger-zone" style="margin-bottom:12px;">
<div class="ef-danger-title" style="color:#fb923c;border-color:rgba(251,146,60,.2);">
<i class="bi bi-arrow-repeat me-1"></i> Replace Media File
</div>
<p style="font-size:12px;color:var(--text-2);margin:0 0 12px;line-height:1.5;">
Fix a corrupted or missing file. All stats are preserved.
</p>
<div id="adm-rfl-dropzone" onclick="document.getElementById('adm-rfl-input').click()"
style="border:2px dashed var(--border);border-radius:8px;padding:14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:8px;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='var(--border)';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:22px;color:var(--text-2);display:block;margin-bottom:4px;"></i>
<span id="adm-rfl-label" style="font-size:12px;color:var(--text-2);">Click to choose replacement file</span>
<input type="file" id="adm-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="admRflSelected(this)">
</div>
<div id="adm-rfl-info" style="display:none;background:var(--bg-2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;align-items:center;gap:8px;margin-bottom:8px;">
<i class="bi bi-file-earmark-play" style="font-size:18px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="adm-rfl-name" style="font-size:12px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="adm-rfl-size" style="font-size:11px;color:var(--text-2);margin-top:1px;"></div>
</div>
<button type="button" onclick="admRflClear()" style="background:none;border:none;color:var(--text-2);cursor:pointer;font-size:14px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="adm-rfl-status" style="display:none;margin-bottom:8px;font-size:12px;padding:7px 10px;border-radius:6px;"></div>
<button type="button" id="adm-rfl-btn" onclick="admRflSubmit()" disabled
class="adm-btn adm-btn-warning" style="width:100%;">
<i class="bi bi-arrow-repeat"></i> Replace File
</button>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This Video
</button>
</div>
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $video->title }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
@if($adminNeedsOtp)
<div style="margin-top:14px;">
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
</label>
<input type="text" id="delDialogOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
placeholder="000000"
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
</div>
@endif
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Video ID</span>
<span>#{{ $video->id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Owner</span>
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Uploaded</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">File Size</span>
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Duration</span>
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Orientation</span>
<span class="text-capitalize">{{ $video->orientation }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Dimensions</span>
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Views</span>
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Likes</span>
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
</div>
<hr style="border-color: var(--border-color);">
<div class="d-grid gap-2">
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
<i class="bi bi-play-circle"></i> View Video
</a>
</div>
<div id="delDialogError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" id="delDialogConfirmBtn" onclick="confirmDelVideo()">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
const _editVideoId = '{{ $video->getRouteKey() }}';
function openDelDialog() {
document.getElementById('delDialogError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('delDialogOtp').value = '';
document.getElementById('delDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('delDialogOtp').focus(), 100);
}
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
function confirmDelVideo() {
const btn = document.getElementById('delDialogConfirmBtn');
const errEl = document.getElementById('delDialogError');
const otpCode = _adminNeedsOtp ? document.getElementById('delDialogOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('delDialogOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otpCode);
fetch('/admin/videos/' + _editVideoId, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '/admin/videos';
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
if (_adminNeedsOtp) { document.getElementById('delDialogOtp').value = ''; document.getElementById('delDialogOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
});
}
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
// ── Admin Replace File ────────────────────────────────────────────────────
let _admRflFile = null;
const _admRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function admRflSelected(input) {
_admRflFile = input.files[0] || null;
const info = document.getElementById('adm-rfl-info');
if (_admRflFile) {
document.getElementById('adm-rfl-name').textContent = _admRflFile.name;
document.getElementById('adm-rfl-size').textContent = _admRflFmtSize(_admRflFile.size);
document.getElementById('adm-rfl-label').textContent = _admRflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
}
document.getElementById('adm-rfl-btn').disabled = !_admRflFile;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflClear() {
_admRflFile = null;
document.getElementById('adm-rfl-input').value = '';
document.getElementById('adm-rfl-info').style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
document.getElementById('adm-rfl-btn').disabled = true;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflSubmit() {
if (!_admRflFile) return;
const btn = document.getElementById('adm-rfl-btn');
const status = document.getElementById('adm-rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _admRflFile);
fetch('/videos/' + _editVideoId + '/replace-file', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
admRflClear();
setTimeout(() => location.reload(), 2200);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
});
}
</script>
@endsection

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', 'Error Logs | Admin')
@section('content')
<div style="max-width: 1400px; margin: 0 auto; padding: 24px;">
{{-- Header --}}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('admin.dashboard') }}" class="action-btn">
<i class="bi bi-arrow-left"></i> <span>Dashboard</span>
</a>
<h1 style="font-size: 22px; font-weight: 700; margin: 0;">
<i class="bi bi-bug-fill" style="color: #e61e1e; margin-right: 8px;"></i>Error Logs
</h1>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span style="font-size: 13px; color: var(--text-secondary);">{{ count($lines) }} entries shown</span>
<a href="{{ route('admin.logs') }}" class="action-btn">
<i class="bi bi-arrow-clockwise"></i> <span>Refresh</span>
</a>
<button class="action-btn" onclick="copyAll()">
<i class="bi bi-clipboard"></i> <span>Copy All</span>
</button>
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.logs') }}"
style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Search</label>
<input type="text" name="filter" value="{{ $filter }}"
placeholder="Filter by keyword…"
style="width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Level</label>
<select name="level"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
<option value="" {{ $level === '' ? 'selected' : '' }}>All levels</option>
<option value="ERROR" {{ $level === 'ERROR' ? 'selected' : '' }}>ERROR</option>
<option value="WARNING" {{ $level === 'WARNING' ? 'selected' : '' }}>WARNING</option>
<option value="INFO" {{ $level === 'INFO' ? 'selected' : '' }}>INFO</option>
<option value="DEBUG" {{ $level === 'DEBUG' ? 'selected' : '' }}>DEBUG</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Show</label>
<select name="limit"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
@foreach([50, 100, 200, 500] as $n)
<option value="{{ $n }}" {{ $limit == $n ? 'selected' : '' }}>{{ $n }} lines</option>
@endforeach
</select>
</div>
<button type="submit" class="action-btn action-btn-primary">
<i class="bi bi-search"></i> <span>Filter</span>
</button>
</form>
{{-- Log lines --}}
@if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div>
@else
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
@endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
@endforeach
</div>
@endif
</div>
<script>
function copyAll() {
const text = Array.from(document.querySelectorAll('.log-line'))
.map(el => el.textContent).join('\n');
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success'));
}
// Auto-scroll to top (newest entries are at top)
document.getElementById('logOutput')?.scrollTo(0, 0);
</script>
@endsection

View File

@ -0,0 +1,23 @@
@extends('admin.layout')
@section('title', 'NAS Storage')
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title">
<i class="bi bi-hdd-network"></i> NAS Storage
</h1>
</div>
<div class="adm-card">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
@endsection

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