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
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'user_name', 'action', 'subject_type', 'subject_id',
|
||||
'subject_label', 'ip_address', 'user_agent', 'details', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'details' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public static function record(string $action, array $options = []): void
|
||||
{
|
||||
$request = request();
|
||||
$user = auth()->user();
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Forwarded-For')
|
||||
?? $request->ip();
|
||||
|
||||
static::create([
|
||||
'user_id' => $options['user_id'] ?? $user?->id,
|
||||
'user_name' => $options['user_name'] ?? $user?->name,
|
||||
'action' => $action,
|
||||
'subject_type' => $options['subject_type'] ?? null,
|
||||
'subject_id' => $options['subject_id'] ?? null,
|
||||
'subject_label' => $options['subject_label'] ?? null,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => substr($request->userAgent() ?? '', 0, 255),
|
||||
'details' => $options['details'] ?? null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Severity grouping for UI color-coding
|
||||
public function getSeverityAttribute(): string
|
||||
{
|
||||
return match(true) {
|
||||
str_contains($this->action, 'delete') || str_contains($this->action, 'destroy') => 'danger',
|
||||
str_contains($this->action, 'login.failed') => 'warning',
|
||||
str_contains($this->action, 'login') => 'info',
|
||||
str_contains($this->action, 'logout') => 'muted',
|
||||
str_contains($this->action, 'admin.') => 'orange',
|
||||
str_contains($this->action, 'upload') || str_contains($this->action, 'create') => 'success',
|
||||
str_contains($this->action, '2fa') || str_contains($this->action, 'password') => 'purple',
|
||||
default => 'default',
|
||||
};
|
||||
}
|
||||
|
||||
public function getActionLabelAttribute(): string
|
||||
{
|
||||
return match($this->action) {
|
||||
'video.uploaded' => 'Video Uploaded',
|
||||
'video.deleted' => 'Video Deleted',
|
||||
'video.updated' => 'Video Updated',
|
||||
'user.login' => 'Logged In',
|
||||
'user.login.failed' => 'Login Failed',
|
||||
'user.logout' => 'Logged Out',
|
||||
'user.logout_all' => 'Logged Out All Devices',
|
||||
'user.password_changed' => 'Password Changed',
|
||||
'user.2fa.enabled' => '2FA Enabled',
|
||||
'user.2fa.disabled' => '2FA Disabled',
|
||||
'admin.impersonate' => 'Impersonated User',
|
||||
'admin.impersonate.exit' => 'Exited Impersonation',
|
||||
'admin.user.deleted' => 'Admin: User Deleted',
|
||||
'admin.video.deleted' => 'Admin: Video Deleted',
|
||||
'admin.user.updated' => 'Admin: User Updated',
|
||||
'admin.video.updated' => 'Admin: Video Updated',
|
||||
default => ucwords(str_replace(['.', '_'], ' ', $this->action)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,11 @@ class Comment extends Model
|
||||
return $this->hasMany(Comment::class, 'parent_id')->latest();
|
||||
}
|
||||
|
||||
public function likes()
|
||||
{
|
||||
return $this->hasMany(CommentLike::class);
|
||||
}
|
||||
|
||||
// Get mentioned users from comment body
|
||||
public function getMentionedUsers()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CommentLike extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['comment_id', 'user_id'];
|
||||
|
||||
public function comment()
|
||||
{
|
||||
return $this->belongsTo(Comment::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -16,6 +16,7 @@ class Playlist extends Model
|
||||
'thumbnail',
|
||||
'visibility',
|
||||
'is_default',
|
||||
'share_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -143,10 +144,10 @@ class Playlist extends Model
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get shareable URL
|
||||
// All share URLs use the unguessable token route
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('playlists.show', $this->id);
|
||||
return route('playlists.showByToken', $this->share_token);
|
||||
}
|
||||
|
||||
// Scope for public playlists
|
||||
@@ -178,7 +179,12 @@ class Playlist extends Model
|
||||
return $this->visibility === 'private';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist
|
||||
public function isUnlisted()
|
||||
{
|
||||
return $this->visibility === 'unlisted';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist via the ID route
|
||||
public function canView($user = null)
|
||||
{
|
||||
// Owner can always view
|
||||
@@ -186,10 +192,20 @@ class Playlist extends Model
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public playlists can be viewed by anyone
|
||||
// Only public playlists are accessible via the ID route
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist via the share-token route
|
||||
public function canViewViaToken($user = null)
|
||||
{
|
||||
if ($user && $this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->visibility, ['public', 'unlisted']);
|
||||
}
|
||||
|
||||
// Check if user can edit this playlist
|
||||
public function canEdit($user = null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Post extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'body', 'video_id', 'image'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class)->withDefault();
|
||||
}
|
||||
|
||||
public function reactions()
|
||||
{
|
||||
return $this->hasMany(PostReaction::class);
|
||||
}
|
||||
|
||||
public function postImages()
|
||||
{
|
||||
return $this->hasMany(PostImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function postVideos()
|
||||
{
|
||||
return $this->hasMany(PostVideo::class)->with('video')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function isLikedBy(?User $user): bool
|
||||
{
|
||||
if (! $user) return false;
|
||||
return $this->reactions()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function getReactionCountAttribute(): int
|
||||
{
|
||||
return $this->reactions()->count();
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->image ? asset('storage/post_images/' . $this->image) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PostImage extends Model
|
||||
{
|
||||
protected $fillable = ['post_id', 'filename', 'sort_order'];
|
||||
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): string
|
||||
{
|
||||
return asset('storage/post_images/' . $this->filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PostReaction extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'post_id', 'type'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PostVideo extends Model
|
||||
{
|
||||
protected $fillable = ['post_id', 'video_id', 'sort_order'];
|
||||
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class)->withDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
private static array $cache = [];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (! array_key_exists($key, self::$cache)) {
|
||||
$row = static::where('key', $key)->first();
|
||||
self::$cache[$key] = $row ? $row->value : $default;
|
||||
}
|
||||
return self::$cache[$key];
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
static::updateOrCreate(['key' => $key], ['value' => (string) $value]);
|
||||
self::$cache[$key] = (string) $value;
|
||||
}
|
||||
|
||||
/** Returns the configured FFmpeg binary path, falling back to the config file default. */
|
||||
public static function ffmpegBinary(): string
|
||||
{
|
||||
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
|
||||
}
|
||||
|
||||
public static function gpuEnabled(): bool
|
||||
{
|
||||
return static::get('gpu_enabled', 'true') === 'true';
|
||||
}
|
||||
|
||||
public static function gpuDevice(): int
|
||||
{
|
||||
return (int) static::get('gpu_device', '0');
|
||||
}
|
||||
|
||||
public static function gpuEncoder(): string
|
||||
{
|
||||
return static::gpuEnabled()
|
||||
? static::get('gpu_encoder', 'h264_nvenc')
|
||||
: 'libx264';
|
||||
}
|
||||
|
||||
public static function gpuPreset(): string
|
||||
{
|
||||
return static::gpuEnabled()
|
||||
? static::get('gpu_preset', 'p4')
|
||||
: 'fast';
|
||||
}
|
||||
|
||||
public static function gpuHwaccel(): string
|
||||
{
|
||||
return static::get('gpu_hwaccel', 'cuda');
|
||||
}
|
||||
|
||||
/** Returns the full video codec flags for FFmpeg shell commands. */
|
||||
public static function ffmpegVideoFlags(bool $stillImage = false): string
|
||||
{
|
||||
if (static::gpuEnabled()) {
|
||||
$enc = static::get('gpu_encoder', 'h264_nvenc');
|
||||
$preset = static::get('gpu_preset', 'p4');
|
||||
$device = static::gpuDevice();
|
||||
$gpuFlag = str_contains($enc, 'nvenc') ? " -gpu {$device}" : '';
|
||||
return "-c:v {$enc} -preset {$preset} -rc vbr -cq 23{$gpuFlag} -pix_fmt yuv420p";
|
||||
}
|
||||
$tune = $stillImage ? ' -tune stillimage' : '';
|
||||
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
|
||||
}
|
||||
|
||||
/** CPU-only video codec flags — used as automatic fallback when GPU encoding fails. */
|
||||
public static function ffmpegVideoFlagsCpu(bool $stillImage = false): string
|
||||
{
|
||||
$tune = $stillImage ? ' -tune stillimage' : '';
|
||||
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
|
||||
}
|
||||
|
||||
/** Returns hwaccel decode flags when the input source is a video file. */
|
||||
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
|
||||
{
|
||||
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
|
||||
$hwaccel = static::get('gpu_hwaccel', 'cuda');
|
||||
$device = static::gpuDevice();
|
||||
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShareAccess extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $guarded = [];
|
||||
|
||||
public function share()
|
||||
{
|
||||
return $this->belongsTo(VideoShare::class, 'share_id');
|
||||
}
|
||||
}
|
||||
+105
-20
@@ -2,13 +2,15 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Notifications\VerifyEmail;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
@@ -29,6 +31,18 @@ class User extends Authenticatable
|
||||
'tiktok',
|
||||
'birthday',
|
||||
'location',
|
||||
'gender',
|
||||
'nationality',
|
||||
'phone_code',
|
||||
'phone_number',
|
||||
'timezone',
|
||||
'whatsapp',
|
||||
'google_location',
|
||||
'social_phone',
|
||||
'social_email',
|
||||
'two_factor_secret',
|
||||
'two_factor_enabled',
|
||||
'banner',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -37,10 +51,47 @@ class User extends Authenticatable
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected $appends = ['avatar_url', 'banner_url'];
|
||||
|
||||
// Auto-generate a unique slug-based username when creating a user without one
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function (User $user) {
|
||||
if (empty($user->username)) {
|
||||
$user->username = static::generateUniqueUsername($user->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
if ($base === '') {
|
||||
$base = 'user';
|
||||
}
|
||||
do {
|
||||
$slug = $base . '-' . Str::lower(Str::random(6));
|
||||
} while (static::where('username', $slug)->exists());
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
// Returns the channel URL slug — the username, auto-generated and saved if missing
|
||||
public function getChannelAttribute(): string
|
||||
{
|
||||
if (empty($this->username)) {
|
||||
$this->username = static::generateUniqueUsername($this->name);
|
||||
$this->saveQuietly();
|
||||
}
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
// Relationships
|
||||
public function videos()
|
||||
{
|
||||
@@ -67,6 +118,11 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function posts()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Post::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->avatar) {
|
||||
@@ -76,6 +132,19 @@ class User extends Authenticatable
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
}
|
||||
|
||||
public function getBannerUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->banner) {
|
||||
return asset('storage/banners/'.$this->banner);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new VerifyEmail);
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
public function isSuperAdmin()
|
||||
{
|
||||
@@ -92,31 +161,47 @@ class User extends Authenticatable
|
||||
return $this->role === 'user' || $this->role === null;
|
||||
}
|
||||
|
||||
// Placeholder for subscriber count (would need a separate table in full implementation)
|
||||
public function getSubscriberCountAttribute()
|
||||
// Users who subscribe TO this channel
|
||||
public function subscribers()
|
||||
{
|
||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
||||
return rand(100, 10000);
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_subscriptions',
|
||||
'channel_id',
|
||||
'subscriber_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
// Get social links as an array
|
||||
public function getSocialLinksAttribute()
|
||||
// Channels this user subscribes to
|
||||
public function subscriptions()
|
||||
{
|
||||
return [
|
||||
'twitter' => $this->twitter,
|
||||
'instagram' => $this->instagram,
|
||||
'facebook' => $this->facebook,
|
||||
'youtube' => $this->youtube,
|
||||
'linkedin' => $this->linkedin,
|
||||
'tiktok' => $this->tiktok,
|
||||
];
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_subscriptions',
|
||||
'subscriber_id',
|
||||
'channel_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function isSubscribedTo(User $channel): bool
|
||||
{
|
||||
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
|
||||
}
|
||||
|
||||
public function getSubscriberCountAttribute(): int
|
||||
{
|
||||
return $this->subscribers()->count();
|
||||
}
|
||||
|
||||
// Check if user has any social links
|
||||
public function socialLinks()
|
||||
{
|
||||
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function hasSocialLinks()
|
||||
{
|
||||
return $this->twitter || $this->instagram || $this->facebook ||
|
||||
$this->youtube || $this->linkedin || $this->tiktok;
|
||||
return $this->socialLinks()->exists();
|
||||
}
|
||||
|
||||
// Get formatted website URL
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserSocialLink extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'platform', 'value', 'visibility', 'sort_order'];
|
||||
|
||||
const VISIBILITIES = ['public', 'registered', 'subscribers', 'only_me'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
+99
-4
@@ -25,6 +25,11 @@ class Video extends Model
|
||||
'is_shorts',
|
||||
'has_hls',
|
||||
'hls_path',
|
||||
'download_access',
|
||||
'download_count',
|
||||
'share_count',
|
||||
'share_token',
|
||||
'slideshow_video_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -54,6 +59,16 @@ class Video extends Model
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function slides()
|
||||
{
|
||||
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function hasSlideshow(): bool
|
||||
{
|
||||
return $this->slides()->count() > 1;
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
@@ -92,10 +107,84 @@ class Video extends Model
|
||||
return \DB::table('video_views')->where('video_id', $this->id)->count();
|
||||
}
|
||||
|
||||
// Get shareable URL for the video
|
||||
// ── Short opaque URL encoding ─────────────────────────────────────
|
||||
// 3-round Feistel cipher over 30-bit space → base62.
|
||||
// Consecutive IDs produce completely different output. Pure PHP, no extensions.
|
||||
private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
private static function feistelRound(int $n, int $round): int
|
||||
{
|
||||
// Deterministic pseudo-random function per round (no extension needed)
|
||||
$keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083];
|
||||
return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF);
|
||||
}
|
||||
|
||||
public static function encodeId(int $id): string
|
||||
{
|
||||
// Split 30-bit id into two 15-bit halves
|
||||
$L = ($id >> 15) & 0x7FFF;
|
||||
$R = $id & 0x7FFF;
|
||||
// 3 Feistel rounds
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$tmp = $L ^ self::feistelRound($R, $i);
|
||||
$L = $R;
|
||||
$R = $tmp;
|
||||
}
|
||||
$n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
|
||||
$base = strlen(self::URL_ALPHABET);
|
||||
$out = '';
|
||||
do {
|
||||
$out = self::URL_ALPHABET[$n % $base] . $out;
|
||||
$n = intdiv($n, $base);
|
||||
} while ($n > 0);
|
||||
// Pad to fixed length so all URLs look uniform
|
||||
return str_pad($out, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public static function decodeId(string $encoded): ?int
|
||||
{
|
||||
$base = strlen(self::URL_ALPHABET);
|
||||
$n = 0;
|
||||
for ($i = 0, $len = strlen($encoded); $i < $len; $i++) {
|
||||
$pos = strpos(self::URL_ALPHABET, $encoded[$i]);
|
||||
if ($pos === false) return null;
|
||||
$n = $n * $base + $pos;
|
||||
}
|
||||
// Reverse Feistel
|
||||
$L = ($n >> 15) & 0x7FFF;
|
||||
$R = $n & 0x7FFF;
|
||||
for ($i = 2; $i >= 0; $i--) {
|
||||
$tmp = $R ^ self::feistelRound($L, $i);
|
||||
$R = $L;
|
||||
$L = $tmp;
|
||||
}
|
||||
return (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
|
||||
}
|
||||
|
||||
// Route model binding — use encoded ID in URLs
|
||||
public function getRouteKey()
|
||||
{
|
||||
return self::encodeId($this->id);
|
||||
}
|
||||
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
$realId = self::decodeId((string) $value);
|
||||
if ($realId === null || $realId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->where('id', $realId)->firstOrFail();
|
||||
}
|
||||
|
||||
// All share URLs use the unguessable token route
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('videos.show', $this->id);
|
||||
return route('videos.showByToken', $this->share_token);
|
||||
}
|
||||
|
||||
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
|
||||
@@ -213,6 +302,12 @@ class Video extends Model
|
||||
return $this->type === 'match';
|
||||
}
|
||||
|
||||
public function isAudioOnly(): bool
|
||||
{
|
||||
$ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']);
|
||||
}
|
||||
|
||||
// Shorts helpers
|
||||
public function isShorts()
|
||||
{
|
||||
@@ -331,13 +426,13 @@ class Video extends Model
|
||||
// Get video stream URL for Open Graph
|
||||
public function getStreamUrlAttribute()
|
||||
{
|
||||
return route('videos.stream', $this->id);
|
||||
return route('videos.stream', $this);
|
||||
}
|
||||
|
||||
// Get secure share URL
|
||||
public function getSecureShareUrlAttribute()
|
||||
{
|
||||
return secure_url(route('videos.show', $this->id));
|
||||
return secure_url(route('videos.show', $this));
|
||||
}
|
||||
|
||||
// Get secure thumbnail URL
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VideoShare extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $guarded = [];
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function accesses()
|
||||
{
|
||||
return $this->hasMany(ShareAccess::class, 'share_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VideoSlide extends Model
|
||||
{
|
||||
protected $fillable = ['video_id', 'filename', 'position'];
|
||||
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return asset('storage/thumbnails/' . $this->filename);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user