- 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>
432 lines
14 KiB
PHP
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]);
|
|
}
|
|
}
|