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 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-13 13:24:32 +03:00
1 parent d44490dfe0
commit 0b2e95ea65
161 files changed
+27496 -7250

No files matched your search

@@ -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();
@@ -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');
}
}
+57 -17
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]);
}
}
+25
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}";
}
}
@@ -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),
]);
}
}
+105 -19
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,7 +159,7 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$playlist->update(['thumbnail' => $filename]);
}
@@ -123,8 +216,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 = [
@@ -145,7 +238,7 @@ class PlaylistController extends Controller
// Upload new thumbnail
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$updateData['thumbnail'] = $filename;
}
@@ -351,7 +444,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 +454,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 +470,6 @@ 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);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
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',
// Legacy fields
'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.']);
}
$data = [
'user_id' => $user->id,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
];
// Legacy single image (backward compat)
if ($hasLegacyImg) {
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/post_images', $filename);
$data['image'] = $filename;
}
$post = Post::create($data);
// New multi-image
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
$file->storeAs('public/post_images', $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => $filename,
'sort_order' => $idx,
]);
}
}
// New multi-video
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);
}
if ($post->image) {
Storage::delete('public/post_images/' . $post->image);
}
// Delete multi-image files
foreach ($post->postImages as $postImage) {
Storage::delete('public/post_images/' . $postImage->filename);
}
$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(),
]);
}
}
+674 -26
View File
@@ -2,47 +2,242 @@
namespace App\Http\Controllers;
use App\Models\AuditLog;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class SuperAdminController extends Controller
{
public function __construct()
{
$this->middleware('super_admin');
$this->middleware('super_admin')->except(['exitImpersonation']);
}
// Manually verify a user's email
public function verifyUser(User $user)
{
if ($user->email_verified_at) {
return back()->with('error', "{$user->name} is already verified.");
}
$user->email_verified_at = now();
$user->save();
return back()->with('success', "{$user->name}'s account has been verified.");
}
// Start impersonating a user
public function impersonate(User $user)
{
if ($user->isSuperAdmin()) {
return back()->with('error', 'You cannot impersonate another super admin.');
}
$admin = \Auth::user();
AuditLog::record('admin.impersonate', [
'user_id' => $admin->id,
'user_name' => $admin->name,
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
]);
session(['impersonator_id' => \Auth::id()]);
\Auth::loginUsingId($user->id);
return redirect()->route('home')
->with('success', "You are now impersonating {$user->name}. Use the banner to exit.");
}
// Exit impersonation and return to original admin account
public function exitImpersonation()
{
$impersonatorId = session('impersonator_id');
if (! $impersonatorId) {
return redirect()->route('home');
}
$impersonatedUser = \Auth::user();
session()->forget('impersonator_id');
\Auth::loginUsingId($impersonatorId);
AuditLog::record('admin.impersonate.exit', [
'subject_type' => 'User',
'subject_id' => (string) $impersonatedUser->id,
'subject_label' => $impersonatedUser->name,
]);
return redirect()->route('admin.users')
->with('success', 'Impersonation ended. You are back as yourself.');
}
// Dashboard - Overview statistics
public function dashboard()
{
$stats = [
'total_users' => User::count(),
'total_videos' => Video::count(),
'total_views' => \DB::table('video_views')->count('id'),
'total_likes' => \DB::table('video_likes')->count('id'),
];
$now = now();
$w0 = $now->copy()->subDays(7); // start of this week window
$w1 = $now->copy()->subDays(14); // start of last week window
// Recent users
$recentUsers = User::latest()->take(5)->get();
// ── Core totals ────────────────────────────────────────────
$totalUsers = User::count();
$totalVideos = Video::count();
$totalViews = \DB::table('video_views')->count();
$totalLikes = \DB::table('video_likes')->count();
$totalComments = \DB::table('comments')->count();
// Recent videos
// ── Week-over-week growth ──────────────────────────────────
$usersThisWeek = User::where('created_at', '>=', $w0)->count();
$usersLastWeek = User::whereBetween('created_at', [$w1, $w0])->count();
$videosThisWeek = Video::where('created_at', '>=', $w0)->count();
$videosLastWeek = Video::whereBetween('created_at', [$w1, $w0])->count();
$viewsThisWeek = \DB::table('video_views')->where('watched_at', '>=', $w0)->count();
$viewsLastWeek = \DB::table('video_views')->whereBetween('watched_at', [$w1, $w0])->count();
$likesThisWeek = \DB::table('video_likes')->where('created_at', '>=', $w0)->count();
$likesLastWeek = \DB::table('video_likes')->whereBetween('created_at', [$w1, $w0])->count();
$commentsThisWeek = \DB::table('comments')->where('created_at', '>=', $w0)->count();
$growthUsers = $this->growthPct($usersLastWeek, $usersThisWeek);
$growthVideos = $this->growthPct($videosLastWeek, $videosThisWeek);
$growthViews = $this->growthPct($viewsLastWeek, $viewsThisWeek);
$growthLikes = $this->growthPct($likesLastWeek, $likesThisWeek);
$stats = compact(
'totalUsers','totalVideos','totalViews','totalLikes','totalComments',
'usersThisWeek','videosThisWeek','viewsThisWeek','likesThisWeek','commentsThisWeek',
'growthUsers','growthVideos','growthViews','growthLikes'
);
// ── 30-day daily activity (for charts) ────────────────────
$days30 = collect(range(29, 0))->map(fn($d) => $now->copy()->subDays($d)->format('Y-m-d'));
$rawUsers = User::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawVideos = Video::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawViews = \DB::table('video_views')
->selectRaw('DATE(watched_at) as d, COUNT(*) as n')
->where('watched_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$chartLabels = $days30->map(fn($d) => date('M j', strtotime($d)))->values()->toJson();
$chartDatesRaw = $days30->values()->toJson();
$chartUsersData = $days30->map(fn($d) => $rawUsers->get($d, 0))->values()->toJson();
$chartVideosData = $days30->map(fn($d) => $rawVideos->get($d, 0))->values()->toJson();
$chartViewsData = $days30->map(fn($d) => $rawViews->get($d, 0))->values()->toJson();
// ── Video status & visibility ──────────────────────────────
$videosByStatus = Video::selectRaw('status, count(*) as n')->groupBy('status')->pluck('n', 'status');
$videosByVisibility = Video::selectRaw('visibility, count(*) as n')->groupBy('visibility')->pluck('n', 'visibility');
$videosByType = Video::selectRaw('type, count(*) as n')->groupBy('type')->pluck('n', 'type');
// ── Alerts ────────────────────────────────────────────────
$failedCount = $videosByStatus->get('failed', 0);
$processingCount = $videosByStatus->get('processing', 0);
$pendingCount = $videosByStatus->get('pending', 0);
// ── Top content ───────────────────────────────────────────
$topVideos = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select(
'videos.id', 'videos.title', 'videos.thumbnail', 'videos.type',
'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count')
)
->where('videos.status', 'ready')
->orderByDesc('view_count')
->take(5)->get();
$topUploaders = \DB::table('users')
->select('users.id','users.name','users.email','users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)) as total_views')
)
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->groupBy('users.id','users.name','users.email','users.avatar')
->orderByDesc('video_count')
->take(5)->get();
// ── Engagement ────────────────────────────────────────────
$readyVideos = $videosByStatus->get('ready', 0);
$avgViewsPerVideo = $readyVideos > 0 ? round($totalViews / $readyVideos, 1) : 0;
$likeToViewRatio = $totalViews > 0 ? round(($totalLikes / $totalViews) * 100, 1) : 0;
$avgVideosPerUser = $totalUsers > 0 ? round($totalVideos / $totalUsers, 1) : 0;
// ── Viewers by country ────────────────────────────────────
$viewsByCountry = \DB::table('video_views')
->whereNotNull('country')
->selectRaw('country, country_name, COUNT(*) as total')
->groupBy('country', 'country_name')
->orderByDesc('total')
->take(20)
->get();
// ── Recent ────────────────────────────────────────────────
$recentUsers = User::latest()->take(5)->get();
$recentVideos = Video::with('user')->latest()->take(5)->get();
// Videos by status
$videosByStatus = Video::select('status', \DB::raw('count(*) as count'))
->groupBy('status')
->pluck('count', 'status');
// ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public');
// Videos by visibility
$videosByVisibility = Video::select('visibility', \DB::raw('count(*) as count'))
->groupBy('visibility')
->pluck('count', 'visibility');
$sizeVideos = $this->dirSize($disk, 'videos');
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
$sizeAvatars = $this->dirSize($disk, 'avatars');
$sizeImages = $this->dirSize($disk, 'images');
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
return view('admin.dashboard', compact('stats', 'recentUsers', 'recentVideos', 'videosByStatus', 'videosByVisibility'));
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [
'videos' => $toMb($sizeVideos),
'thumbnails' => $toMb($sizeThumbnails),
'avatars' => $toMb($sizeAvatars),
'images' => $toMb($sizeImages),
'total' => $toMb($totalPublicSize),
];
$videosDirSize = $storage['videos'];
$totalPublicSizeMb = $storage['total'];
return view('admin.dashboard', compact(
'stats',
'chartLabels','chartDatesRaw','chartUsersData','chartVideosData','chartViewsData',
'videosByStatus','videosByVisibility','videosByType',
'failedCount','processingCount','pendingCount',
'topVideos','topUploaders',
'avgViewsPerVideo','likeToViewRatio','avgVideosPerUser',
'recentUsers','recentVideos',
'storage','videosDirSize','totalPublicSizeMb','videosUsagePercent',
'viewsByCountry'
));
}
private function growthPct(int $prev, int $curr): array
{
if ($prev === 0) {
return ['pct' => $curr > 0 ? 100 : 0, 'dir' => $curr > 0 ? 'up' : 'flat'];
}
$pct = round((($curr - $prev) / $prev) * 100, 1);
return ['pct' => abs($pct), 'dir' => $pct >= 0 ? 'up' : 'down'];
}
private function dirSize($disk, string $dir): int
{
$size = 0;
foreach ($disk->allFiles($dir) as $file) {
try { $size += $disk->size($file); } catch (\Exception $e) {}
}
return $size;
}
// List all users with search/filter
@@ -126,6 +321,13 @@ class SuperAdminController extends Controller
return back()->with('error', 'You cannot delete your own account!');
}
AuditLog::record('admin.user.deleted', [
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
'details' => ['email' => $user->email, 'video_count' => $user->videos->count()],
]);
// Delete user's videos and associated files
foreach ($user->videos as $video) {
Storage::delete('public/videos/' . $video->filename);
@@ -202,6 +404,113 @@ class SuperAdminController extends Controller
return view('admin.videos', compact('videos'));
}
// Per-video analytics
public function videoAnalytics(Video $video)
{
// ── Total view events (every watch counts) ──────────────────────────
$totalViews = \DB::table('video_views')->where('video_id', $video->id)->count();
$totalLikes = \DB::table('video_likes')->where('video_id', $video->id)->count();
$totalComments = \DB::table('comments')->where('video_id', $video->id)->count();
// ── Views per day – last 30 days (raw events) ───────────────────────
$rawDaily = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(watched_at) as date, COUNT(*) as total')
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
$dailyLabels = [];
$dailyViews = [];
for ($i = 29; $i >= 0; $i--) {
$d = now()->subDays($i);
$dailyLabels[] = $d->format('M d');
$dailyViews[] = $rawDaily[$d->format('Y-m-d')]->total ?? 0;
}
// ── Fetch all view records once, join user profile data ─────────────
// Ordered newest-first so the first occurrence per viewer is their
// most recent record (country, etc. from their latest watch).
$allViewRecords = \DB::table('video_views')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->where('video_views.video_id', $video->id)
->select(
'video_views.id',
'video_views.user_id',
'video_views.ip_address',
'video_views.country',
'video_views.country_name',
'video_views.watched_at',
'users.name as viewer_name',
'users.avatar as viewer_avatar',
'users.birthday',
'users.gender'
)
->orderByDesc('video_views.watched_at')
->get();
// ── Deduplicate to one row per unique viewer ─────────────────────────
// Auth users → keyed by user_id (u_123)
// Guest users → keyed by ip_address (i_1.2.3.4)
$seenKeys = [];
$uniqueViewers = [];
foreach ($allViewRecords as $row) {
$key = $row->user_id ? 'u_' . $row->user_id : 'i_' . $row->ip_address;
if (isset($seenKeys[$key])) continue;
$seenKeys[$key] = true;
$uniqueViewers[] = $row;
}
$totalUniqueViewers = count($uniqueViewers);
$authViewers = count(array_filter($uniqueViewers, fn($v) => $v->user_id !== null));
$guestViewers = $totalUniqueViewers - $authViewers;
// ── Countries – one count per unique viewer ──────────────────────────
$countryMap = [];
foreach ($uniqueViewers as $viewer) {
if (!$viewer->country) continue;
if (!isset($countryMap[$viewer->country])) {
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
}
$countryMap[$viewer->country]['total']++;
}
usort($countryMap, fn($a, $b) => $b['total'] - $a['total']);
$viewsByCountry = collect(array_slice($countryMap, 0, 20))->map(fn($r) => (object) $r);
// ── Age groups – one count per unique viewer ─────────────────────────
$ageGroups = ['Under 18' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55+' => 0, 'Unknown' => 0];
$now = now();
foreach ($uniqueViewers as $viewer) {
if (!$viewer->birthday) { $ageGroups['Unknown']++; continue; }
$age = \Carbon\Carbon::parse($viewer->birthday)->diffInYears($now);
if ($age < 18) $ageGroups['Under 18']++;
elseif ($age < 25) $ageGroups['18–24']++;
elseif ($age < 35) $ageGroups['25–34']++;
elseif ($age < 45) $ageGroups['35–44']++;
elseif ($age < 55) $ageGroups['45–54']++;
else $ageGroups['55+']++;
}
// ── Gender – one count per unique viewer ─────────────────────────────
$genderCounts = ['Male' => 0, 'Female' => 0, 'Prefer not to say' => 0];
foreach ($uniqueViewers as $viewer) {
if ($viewer->gender === 'male') $genderCounts['Male']++;
elseif ($viewer->gender === 'female') $genderCounts['Female']++;
elseif ($viewer->gender === 'prefer_not_to_say') $genderCounts['Prefer not to say']++;
}
// ── Recent 20 individual view events ────────────────────────────────
$recentViews = $allViewRecords->take(20);
return view('admin.video-analytics', compact(
'video', 'totalViews', 'totalUniqueViewers', 'authViewers', 'guestViewers',
'totalLikes', 'totalComments', 'dailyLabels', 'dailyViews',
'viewsByCountry', 'ageGroups', 'genderCounts', 'recentViews'
));
}
// Show edit video form
public function editVideo(Video $video)
{
@@ -212,15 +521,16 @@ class SuperAdminController extends Controller
public function updateVideo(Request $request, Video $video)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'visibility' => 'required|in:public,unlisted,private',
'type' => 'required|in:generic,music,match',
'status' => 'required|in:pending,processing,ready,failed',
'is_shorts' => 'nullable|boolean',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'visibility' => 'required|in:public,unlisted,private',
'type' => 'required|in:generic,music,match',
'status' => 'required|in:pending,processing,ready,failed',
'is_shorts' => 'nullable|boolean',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
]);
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts']);
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
$video->update($data);
@@ -232,6 +542,13 @@ class SuperAdminController extends Controller
{
$videoTitle = $video->title;
AuditLog::record('admin.video.deleted', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $videoTitle,
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
]);
// Delete files
Storage::delete('public/videos/' . $video->filename);
if ($video->thumbnail) {
@@ -246,4 +563,335 @@ class SuperAdminController extends Controller
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
}
/**
* Manual cleanup orphaned videos (admin instant trigger)
*/
public function cleanupOrphanedVideos(Request $request)
{
$output = [];
$returnCode = 0;
Artisan::call('cleanup:orphaned-videos --force', [], $output);
Log::channel('orphaned-videos')->info('Manual cleanup triggered by super admin');
$disk = Storage::disk('public');
$totalPublicSize = 0;
foreach ($disk->allFiles() as $file) {
$totalPublicSize += $disk->size($file);
}
$videosDirSize = 0;
foreach ($disk->allFiles('videos') as $file) {
$videosDirSize += $disk->size($file);
}
return response()->json([
'success' => true,
'message' => 'Cleanup completed! Check logs for details.',
'stats' => [
'return_code' => $returnCode,
'videos_dir_size_mb' => round($videosDirSize / 1024 / 1024, 1),
'total_public_size_mb' => round($totalPublicSize / 1024 / 1024, 1),
],
]);
}
public function modalData(Request $request)
{
$resource = $request->get('resource', '');
$limit = min((int) $request->get('limit', 30), 100);
switch ($resource) {
case 'users':
$query = User::latest();
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->filter === 'week') $query->where('created_at', '>=', now()->subDays(7));
$items = $query->take($limit)->get();
return response()->json(['type' => 'users', 'items' => $items->map(fn($u) => [
'avatar' => $u->avatar_url,
'name' => $u->name,
'email' => $u->email,
'role' => $u->role ?? 'user',
'joined' => $u->created_at->diffForHumans(),
'url' => route('channel', $u->channel),
])]);
case 'videos':
$query = Video::with('user')->latest();
if ($request->status) $query->where('status', $request->status);
if ($request->visibility) $query->where('visibility', $request->visibility);
if ($request->type) $query->where('type', $request->type);
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->uploader_id) $query->where('user_id', $request->uploader_id);
$items = $query->take($limit)->get();
return response()->json(['type' => 'videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->user->name ?? 'Unknown',
'status' => $v->status,
'type' => $v->type,
'views' => \DB::table('video_views')->where('video_id', $v->id)->count(),
'uploaded' => $v->created_at->diffForHumans(),
'url' => route('videos.show', $v),
])]);
case 'top_videos':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->orderByDesc('view_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => $v->view_count,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'views_day':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.country', 'video_views.watched_at')
->when($request->date, fn($q) => $q->whereDate('video_views.watched_at', $request->date))
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $v->country ?? '—',
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'likes':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->having('like_count', '>', 0)->orderByDesc('like_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => 0,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'comments':
$items = \DB::table('comments')
->join('users', 'comments.user_id', '=', 'users.id')
->join('videos', 'comments.video_id', '=', 'videos.id')
->select('comments.body', 'users.name as user_name', 'videos.title as video_title', 'videos.id as video_id', 'comments.created_at')
->orderByDesc('comments.created_at')->take($limit)->get();
return response()->json(['type' => 'comments', 'items' => $items->map(fn($c) => [
'user' => $c->user_name,
'body' => \Str::limit($c->body, 120),
'video' => $c->video_title,
'time' => \Carbon\Carbon::parse($c->created_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($c->video_id)),
])]);
case 'country_viewers':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.watched_at')
->where('video_views.country', $request->country)
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $request->country,
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'uploaders':
$items = \DB::table('users')
->select('users.id', 'users.name', 'users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('COALESCE(SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)),0) as total_views'))
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('video_count')->take($limit)->get();
return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id,
'name' => $u->name,
'videos' => $u->video_count,
'views' => $u->total_views,
'url' => route('channel', $u->channel),
])]);
}
return response()->json(['error' => 'Unknown resource'], 400);
}
public function logs(Request $request)
{
$logFile = storage_path('logs/laravel.log');
$lines = [];
$filter = $request->get('filter', '');
$level = $request->get('level', '');
$limit = (int) $request->get('limit', 200);
if (file_exists($logFile)) {
$all = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
foreach ($all as $line) {
if ($filter && stripos($line, $filter) === false) continue;
if ($level && stripos($line, ".$level:") === false) continue;
$lines[] = $line;
if (count($lines) >= $limit) break;
}
}
return view('admin.logs', compact('lines', 'filter', 'level', 'limit'));
}
public function auditLogs(Request $request)
{
$query = AuditLog::query()->latest('created_at');
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('user')) {
$query->where(function ($q) use ($request) {
$q->where('user_name', 'like', '%'.$request->user.'%')
->orWhereHas('user', fn($u) => $u->where('email', 'like', '%'.$request->user.'%'));
});
}
if ($request->filled('ip')) {
$query->where('ip_address', 'like', '%'.$request->ip.'%');
}
if ($request->filled('subject')) {
$query->where('subject_label', 'like', '%'.$request->subject.'%');
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$logs = $query->paginate(50)->withQueryString();
$actionTypes = AuditLog::query()
->select('action')
->distinct()
->orderBy('action')
->pluck('action');
return view('admin.audit-logs', compact('logs', 'actionTypes'));
}
// ── Settings ──────────────────────────────────────────────────────────
public function settings()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
}
public function updateSettings(Request $request)
{
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
return back()->with('success', 'Settings saved.');
}
public function detectGpu()
{
return response()->json(['gpus' => $this->probeGpus()]);
}
private function probeGpus(): array
{
$gpus = [];
exec(
'nvidia-smi --query-gpu=index,name,memory.total,memory.free,utilization.gpu,temperature.gpu,driver_version'
. ' --format=csv,noheader,nounits 2>/dev/null',
$lines, $exit
);
if ($exit !== 0 || empty($lines)) return $gpus;
foreach ($lines as $line) {
$parts = array_map('trim', explode(',', $line));
if (count($parts) < 7) continue;
$gpus[] = [
'index' => (int) $parts[0],
'name' => $parts[1],
'mem_total' => (int) $parts[2],
'mem_free' => (int) $parts[3],
'util' => (int) $parts[4],
'temp' => (int) $parts[5],
'driver' => $parts[6],
];
}
return $gpus;
}
/**
* Quick smoke-test: encode one frame with h264_nvenc and return true if it succeeds.
* This catches CUDA compat / driver-version mismatches that nvidia-smi can't detect.
*/
private function probeNvenc(): bool
{
$ffmpeg = Setting::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
$device = Setting::gpuDevice();
exec(
escapeshellcmd($ffmpeg)
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
. " -c:v h264_nvenc -gpu {$device}"
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$out, $exit
);
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
@unlink($tmp);
return $ok;
}
public function nasStorage()
{
$nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes'));
}
}
@@ -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');
}
}
+267 -53
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,83 @@ 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,
];
if ($request->hasFile('avatar')) {
// Delete old avatar
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
}
$user->update($data);
return redirect()->route('profile')->with('success', 'Profile updated successfully!');
// 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 +127,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 +263,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 +336,96 @@ 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']);
Auth::user()->update(['avatar' => basename($request->path)]);
return response()->json(['ok' => true]);
}
public function updateBanner(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
Auth::user()->update(['banner' => basename($request->path)]);
return response()->json(['ok' => true]);
}
}
File diff suppressed because it is too large. Load diff
+1
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,
+1 -1
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.
+3 -1
View File
@@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
//
'videos/*/share',
'playlists/*/share',
'videos/*/slideshow/generate',
];
}