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:
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');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
'videos/*/share',
|
||||
'playlists/*/share',
|
||||
'videos/*/slideshow/generate',
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user