ghassan 0b2e95ea65 Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00

432 lines
14 KiB
PHP

<?php
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;
class UserController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['channel']);
}
// Profile page - personal overview for the authenticated user
public function profile()
{
$user = Auth::user();
return view('user.profile', compact('user'));
}
// Update profile
public function updateProfile(Request $request)
{
$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',
'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,
'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')) {
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
}
$user->update($data);
// 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 - redirects to channel settings tab
public function settings()
{
return redirect()->route('channel')->with('_open_tab', 'settings');
}
// Update settings (password)
public function updateSettings(Request $request)
{
$user = Auth::user();
$request->validate([
'current_password' => 'required',
'new_password' => 'required|min:8|confirmed',
]);
if (! Hash::check($request->current_password, $user->password)) {
return back()->withErrors(['current_password' => 'Current password is incorrect']);
}
$user->update([
'password' => Hash::make($request->new_password),
]);
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($username = null)
{
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
}
$sort = request('sort', 'latest');
$isOwner = Auth::check() && Auth::user()->id === $user->id;
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
$isOwner = $isOwner && !$preview;
$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();
}
$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
public function history()
{
$user = Auth::user();
// Get videos the user has watched, ordered by most recently watched
// Include private videos since they are the user's own
$videoIds = \DB::table('video_views')
->where('user_id', $user->id)
->orderBy('watched_at', 'desc')
->pluck('video_id')
->unique();
$videos = Video::whereIn('id', $videoIds)
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id);
})
->get()
->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()
{
$user = Auth::user();
// Include private videos in liked (user's own private videos)
$videos = $user->likes()
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->latest()
->paginate(12);
return view('user.liked', compact('videos'));
}
// Like a video
public function like(Video $video)
{
$user = Auth::user();
if (! $video->isLikedBy($user)) {
$video->likes()->attach($user->id);
}
return back();
}
// Unlike a video
public function unlike(Video $video)
{
$user = Auth::user();
$video->likes()->detach($user->id);
return back();
}
// Toggle like (API)
public function toggleLike(Video $video)
{
$user = Auth::user();
if ($video->isLikedBy($user)) {
$video->likes()->detach($user->id);
$liked = false;
} else {
$video->likes()->attach($user->id);
$liked = true;
}
return response()->json([
'liked' => $liked,
'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]);
}
}