- New SportsMatch model/controller and sports UI components/modal - Move share-modal to a reusable x-share-modal/x-share-button component - Add VideoSharedWithUser notification and share-to-members flow - Device/user-agent tracking on views, downloads, share accesses - ProfileVisit model + migration; subscription source tracking - Email thumbnail support; remove stale TODO files
612 lines
21 KiB
PHP
612 lines
21 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 App\Notifications\NewSubscriberNotification;
|
|
use App\Notifications\VideoLikedNotification;
|
|
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']);
|
|
}
|
|
|
|
// Typeahead search for members (used by the "Share to member" picker)
|
|
public function searchUsers(Request $request)
|
|
{
|
|
$q = trim((string) $request->query('q', ''));
|
|
if (mb_strlen($q) < 1) {
|
|
return response()->json(['users' => []]);
|
|
}
|
|
|
|
$users = User::where('id', '!=', Auth::id())
|
|
->where(function ($w) use ($q) {
|
|
$w->where('name', 'like', "%{$q}%")
|
|
->orWhere('username', 'like', "%{$q}%");
|
|
})
|
|
->orderBy('name')
|
|
->limit(8)
|
|
->get(['id', 'name', 'username', 'avatar']);
|
|
|
|
return response()->json([
|
|
'users' => $users->map(fn ($u) => [
|
|
'id' => $u->id,
|
|
'name' => $u->name,
|
|
'channel' => $u->channel,
|
|
'avatar' => $u->avatar_url,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// 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,
|
|
];
|
|
|
|
$nas = app(\App\Services\NasSyncService::class);
|
|
|
|
if ($request->hasFile('avatar')) {
|
|
// Delete old avatar (handles both flat and new relative-path formats)
|
|
$nas->deleteLocalAvatar($user);
|
|
|
|
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
|
|
$profileDir = $nas->localProfileDir($user);
|
|
$destFilename = "avatar.{$ext}";
|
|
$destPath = "{$profileDir}/{$destFilename}";
|
|
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
|
|
|
@mkdir($profileDir, 0755, true);
|
|
$request->file('avatar')->move($profileDir, $destFilename);
|
|
$data['avatar'] = $relPath;
|
|
}
|
|
|
|
$user->update($data);
|
|
|
|
// Push avatar to NAS and remove local copy when NAS is primary storage
|
|
if ($nas->isEnabled()) {
|
|
if ($request->hasFile('avatar')) {
|
|
$destPath = storage_path('app/' . $data['avatar']);
|
|
if (file_exists($destPath)) {
|
|
$nas->syncAvatar($user, $destPath);
|
|
$nas->deleteLocalAvatar($user);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync social links
|
|
$user->socialLinks()->delete();
|
|
$order = 0;
|
|
foreach ($request->input('slink', []) as $entry) {
|
|
$platform = trim($entry['platform'] ?? '');
|
|
$value = trim($entry['value'] ?? '');
|
|
if ($platform && $value) {
|
|
$user->socialLinks()->create([
|
|
'platform' => $platform,
|
|
'value' => $value,
|
|
'visibility' => $entry['visibility'] ?? 'public',
|
|
'sort_order' => $order++,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Redirect back to profile page, or channel settings if admin edited another user
|
|
if ($authUser->isSuperAdmin() && $authUser->id !== $user->id) {
|
|
return redirect()->route('channel', $user->channel)->with('toast_success', 'Profile updated!')->with('_open_tab', 'settings');
|
|
}
|
|
|
|
return redirect()->route('profile')->with('toast_success', 'Profile updated!');
|
|
}
|
|
|
|
// Settings page - 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');
|
|
}
|
|
|
|
// Save a single notification preference toggle (AJAX)
|
|
public function updateNotificationPreferences(Request $request)
|
|
{
|
|
$request->validate([
|
|
'key' => ['required', 'string', 'in:' . implode(',', array_keys(User::notifDefaults()))],
|
|
'value' => ['required', 'boolean'],
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
$prefs = $user->notification_preferences ?? [];
|
|
$prefs[$request->key] = (bool) $request->value;
|
|
$user->update(['notification_preferences' => $prefs]);
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
// 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();
|
|
$withFirstVideo = fn($q) => $q->orderBy('playlist_videos.position')->limit(1);
|
|
$playlists = $isOwner
|
|
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
|
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->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;
|
|
if ($video->user_id && $video->user_id !== $user->id) {
|
|
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'liked' => $liked,
|
|
'like_count' => $video->like_count,
|
|
]);
|
|
}
|
|
|
|
public function recordProfileVisit(Request $request, User $user)
|
|
{
|
|
// Don't record self-visits or repeated visits from the same person in the last 30 minutes
|
|
$visitorId = Auth::id();
|
|
$deviceId = $request->cookie('_did');
|
|
|
|
if ($visitorId && $visitorId === $user->id) {
|
|
return response()->json(['ok' => true, 'skipped' => 'self']);
|
|
}
|
|
|
|
$sourceVideoId = $request->integer('source_video_id') ?: null;
|
|
if ($sourceVideoId && ! Video::whereKey($sourceVideoId)->exists()) {
|
|
$sourceVideoId = null;
|
|
}
|
|
|
|
$dedup = \App\Models\ProfileVisit::where('profile_user_id', $user->id)
|
|
->where('created_at', '>=', now()->subMinutes(30))
|
|
->when($visitorId, fn ($q) => $q->where('visitor_user_id', $visitorId))
|
|
->when(! $visitorId && $deviceId, fn ($q) => $q->whereNull('visitor_user_id')->where('device_id', $deviceId))
|
|
->when($sourceVideoId, fn ($q) => $q->where('source_video_id', $sourceVideoId))
|
|
->exists();
|
|
|
|
if ($dedup) {
|
|
return response()->json(['ok' => true, 'skipped' => 'dedup']);
|
|
}
|
|
|
|
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
|
|
$geo = \App\Services\GeoIpService::lookup($ip);
|
|
|
|
\App\Models\ProfileVisit::create([
|
|
'profile_user_id' => $user->id,
|
|
'visitor_user_id' => $visitorId,
|
|
'device_id' => $deviceId,
|
|
'source_video_id' => $sourceVideoId,
|
|
'ip_address' => $ip,
|
|
'country' => $geo['country'] ?? null,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function toggleSubscribe(Request $request, 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 {
|
|
$sourceVideoId = $request->integer('source_video_id') ?: null;
|
|
if ($sourceVideoId && ! \App\Models\Video::whereKey($sourceVideoId)->exists()) {
|
|
$sourceVideoId = null;
|
|
}
|
|
$me->subscriptions()->attach($user->id, ['source_video_id' => $sourceVideoId]);
|
|
$subscribed = true;
|
|
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
|
|
}
|
|
|
|
return response()->json([
|
|
'subscribed' => $subscribed,
|
|
'subscriber_count' => $user->fresh()->subscriber_count,
|
|
]);
|
|
}
|
|
|
|
public function notificationCount()
|
|
{
|
|
return response()->json(['unread_count' => Auth::user()->unreadNotifications()->count()]);
|
|
}
|
|
|
|
public function fetchNotifications()
|
|
{
|
|
$user = Auth::user();
|
|
|
|
$rawNotifications = $user->notifications()->latest()->take(50)->get();
|
|
|
|
// Bulk-fetch video state only for video-linked notifications
|
|
$videoIds = $rawNotifications
|
|
->pluck('data.video_id')
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
|
|
$videos = $videoIds->isNotEmpty()
|
|
? \App\Models\Video::whereIn('id', $videoIds)
|
|
->whereIn('visibility', ['public', 'unlisted'])
|
|
->get(['id', 'thumbnail', 'visibility'])
|
|
->keyBy('id')
|
|
: collect();
|
|
|
|
$notifications = $rawNotifications
|
|
->filter(function ($n) use ($videos) {
|
|
$videoId = $n->data['video_id'] ?? null;
|
|
// Non-video notifications (subscriber, like, post, new_user) always pass
|
|
if (!$videoId) return true;
|
|
// Video notifications only if the video is still visible
|
|
return $videos->has($videoId);
|
|
})
|
|
->take(30)
|
|
->map(function ($n) use ($videos) {
|
|
$data = $n->data;
|
|
if (!empty($data['video_id'])) {
|
|
$data['video_thumbnail'] = $videos->get($data['video_id'])?->thumbnail ?? null;
|
|
}
|
|
return [
|
|
'id' => $n->id,
|
|
'read' => ! is_null($n->read_at),
|
|
'time' => $n->created_at->diffForHumans(),
|
|
'data' => $data,
|
|
];
|
|
})
|
|
->values();
|
|
|
|
return response()->json([
|
|
'notifications' => $notifications,
|
|
'unread_count' => $user->unreadNotifications()->count(),
|
|
]);
|
|
}
|
|
|
|
public function markNotificationRead(string $id)
|
|
{
|
|
$notification = Auth::user()->notifications()->findOrFail($id);
|
|
$notification->markAsRead();
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function markAllNotificationsRead()
|
|
{
|
|
Auth::user()->unreadNotifications->markAsRead();
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function updateAvatar(Request $request)
|
|
{
|
|
$request->validate(['path' => 'required|string|max:300']);
|
|
$user = Auth::user();
|
|
$filename = basename($request->path);
|
|
$tempPath = storage_path('app/public/avatars/' . $filename);
|
|
|
|
$nas = app(\App\Services\NasSyncService::class);
|
|
|
|
// Move temp file into the user's profile directory
|
|
$profileDir = $nas->localProfileDir($user);
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
|
$destFilename = "avatar.{$ext}";
|
|
$destPath = "{$profileDir}/{$destFilename}";
|
|
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
|
|
|
// Delete old avatar before moving new one in (handles both path formats)
|
|
$nas->deleteLocalAvatar($user);
|
|
|
|
@mkdir($profileDir, 0755, true);
|
|
if (file_exists($tempPath)) {
|
|
rename($tempPath, $destPath);
|
|
}
|
|
|
|
$user->update(['avatar' => $relPath]);
|
|
|
|
if ($nas->isEnabled() && file_exists($destPath)) {
|
|
$nas->syncAvatar($user, $destPath);
|
|
$nas->deleteLocalAvatar($user);
|
|
}
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function updateBanner(Request $request)
|
|
{
|
|
$request->validate(['path' => 'required|string|max:300']);
|
|
$user = Auth::user();
|
|
$filename = basename($request->path);
|
|
$tempPath = storage_path('app/public/banners/' . $filename);
|
|
|
|
$nas = app(\App\Services\NasSyncService::class);
|
|
|
|
// Move temp file into the user's profile directory
|
|
$profileDir = $nas->localProfileDir($user);
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
|
$destFilename = "cover.{$ext}";
|
|
$destPath = "{$profileDir}/{$destFilename}";
|
|
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
|
|
|
// Delete old banner before moving new one in (handles both path formats)
|
|
$nas->deleteLocalBanner($user);
|
|
|
|
@mkdir($profileDir, 0755, true);
|
|
if (file_exists($tempPath)) {
|
|
rename($tempPath, $destPath);
|
|
}
|
|
|
|
$user->update(['banner' => $relPath]);
|
|
|
|
if ($nas->isEnabled() && file_exists($destPath)) {
|
|
$nas->syncCover($user, $destPath);
|
|
$nas->deleteLocalBanner($user);
|
|
}
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
}
|