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,361 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class Countries
|
||||
{
|
||||
/**
|
||||
* All countries keyed by ISO2 code.
|
||||
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
// Generate flag emoji from ISO2 (two regional indicator letters)
|
||||
$f = fn(string $c): string =>
|
||||
mb_chr(0x1F1E6 + ord($c[0]) - 65) .
|
||||
mb_chr(0x1F1E6 + ord($c[1]) - 65);
|
||||
|
||||
return [
|
||||
|
||||
// ── AFRICA ──────────────────────────────────────────────────────────
|
||||
'DZ' => ['name'=>'Algeria', 'iso2'=>'DZ','iso3'=>'DZA','flag'=>$f('DZ'),'dial_code'=>'+213', 'timezone'=>'Africa/Algiers', 'currency'=>'DZD'],
|
||||
'AO' => ['name'=>'Angola', 'iso2'=>'AO','iso3'=>'AGO','flag'=>$f('AO'),'dial_code'=>'+244', 'timezone'=>'Africa/Luanda', 'currency'=>'AOA'],
|
||||
'BJ' => ['name'=>'Benin', 'iso2'=>'BJ','iso3'=>'BEN','flag'=>$f('BJ'),'dial_code'=>'+229', 'timezone'=>'Africa/Porto-Novo', 'currency'=>'XOF'],
|
||||
'BW' => ['name'=>'Botswana', 'iso2'=>'BW','iso3'=>'BWA','flag'=>$f('BW'),'dial_code'=>'+267', 'timezone'=>'Africa/Gaborone', 'currency'=>'BWP'],
|
||||
'BF' => ['name'=>'Burkina Faso', 'iso2'=>'BF','iso3'=>'BFA','flag'=>$f('BF'),'dial_code'=>'+226', 'timezone'=>'Africa/Ouagadougou', 'currency'=>'XOF'],
|
||||
'BI' => ['name'=>'Burundi', 'iso2'=>'BI','iso3'=>'BDI','flag'=>$f('BI'),'dial_code'=>'+257', 'timezone'=>'Africa/Bujumbura', 'currency'=>'BIF'],
|
||||
'CV' => ['name'=>'Cabo Verde', 'iso2'=>'CV','iso3'=>'CPV','flag'=>$f('CV'),'dial_code'=>'+238', 'timezone'=>'Atlantic/Cape_Verde', 'currency'=>'CVE'],
|
||||
'CM' => ['name'=>'Cameroon', 'iso2'=>'CM','iso3'=>'CMR','flag'=>$f('CM'),'dial_code'=>'+237', 'timezone'=>'Africa/Douala', 'currency'=>'XAF'],
|
||||
'CF' => ['name'=>'Central African Republic', 'iso2'=>'CF','iso3'=>'CAF','flag'=>$f('CF'),'dial_code'=>'+236', 'timezone'=>'Africa/Bangui', 'currency'=>'XAF'],
|
||||
'TD' => ['name'=>'Chad', 'iso2'=>'TD','iso3'=>'TCD','flag'=>$f('TD'),'dial_code'=>'+235', 'timezone'=>'Africa/Ndjamena', 'currency'=>'XAF'],
|
||||
'KM' => ['name'=>'Comoros', 'iso2'=>'KM','iso3'=>'COM','flag'=>$f('KM'),'dial_code'=>'+269', 'timezone'=>'Indian/Comoro', 'currency'=>'KMF'],
|
||||
'CD' => ['name'=>'Congo (DRC)', 'iso2'=>'CD','iso3'=>'COD','flag'=>$f('CD'),'dial_code'=>'+243', 'timezone'=>'Africa/Kinshasa', 'currency'=>'CDF'],
|
||||
'CG' => ['name'=>'Congo (Republic)', 'iso2'=>'CG','iso3'=>'COG','flag'=>$f('CG'),'dial_code'=>'+242', 'timezone'=>'Africa/Brazzaville', 'currency'=>'XAF'],
|
||||
'DJ' => ['name'=>'Djibouti', 'iso2'=>'DJ','iso3'=>'DJI','flag'=>$f('DJ'),'dial_code'=>'+253', 'timezone'=>'Africa/Djibouti', 'currency'=>'DJF'],
|
||||
'EG' => ['name'=>'Egypt', 'iso2'=>'EG','iso3'=>'EGY','flag'=>$f('EG'),'dial_code'=>'+20', 'timezone'=>'Africa/Cairo', 'currency'=>'EGP'],
|
||||
'GQ' => ['name'=>'Equatorial Guinea', 'iso2'=>'GQ','iso3'=>'GNQ','flag'=>$f('GQ'),'dial_code'=>'+240', 'timezone'=>'Africa/Malabo', 'currency'=>'XAF'],
|
||||
'ER' => ['name'=>'Eritrea', 'iso2'=>'ER','iso3'=>'ERI','flag'=>$f('ER'),'dial_code'=>'+291', 'timezone'=>'Africa/Asmara', 'currency'=>'ERN'],
|
||||
'SZ' => ['name'=>'Eswatini', 'iso2'=>'SZ','iso3'=>'SWZ','flag'=>$f('SZ'),'dial_code'=>'+268', 'timezone'=>'Africa/Mbabane', 'currency'=>'SZL'],
|
||||
'ET' => ['name'=>'Ethiopia', 'iso2'=>'ET','iso3'=>'ETH','flag'=>$f('ET'),'dial_code'=>'+251', 'timezone'=>'Africa/Addis_Ababa', 'currency'=>'ETB'],
|
||||
'GA' => ['name'=>'Gabon', 'iso2'=>'GA','iso3'=>'GAB','flag'=>$f('GA'),'dial_code'=>'+241', 'timezone'=>'Africa/Libreville', 'currency'=>'XAF'],
|
||||
'GM' => ['name'=>'Gambia', 'iso2'=>'GM','iso3'=>'GMB','flag'=>$f('GM'),'dial_code'=>'+220', 'timezone'=>'Africa/Banjul', 'currency'=>'GMD'],
|
||||
'GH' => ['name'=>'Ghana', 'iso2'=>'GH','iso3'=>'GHA','flag'=>$f('GH'),'dial_code'=>'+233', 'timezone'=>'Africa/Accra', 'currency'=>'GHS'],
|
||||
'GN' => ['name'=>'Guinea', 'iso2'=>'GN','iso3'=>'GIN','flag'=>$f('GN'),'dial_code'=>'+224', 'timezone'=>'Africa/Conakry', 'currency'=>'GNF'],
|
||||
'GW' => ['name'=>'Guinea-Bissau', 'iso2'=>'GW','iso3'=>'GNB','flag'=>$f('GW'),'dial_code'=>'+245', 'timezone'=>'Africa/Bissau', 'currency'=>'XOF'],
|
||||
'CI' => ['name'=>'Ivory Coast', 'iso2'=>'CI','iso3'=>'CIV','flag'=>$f('CI'),'dial_code'=>'+225', 'timezone'=>'Africa/Abidjan', 'currency'=>'XOF'],
|
||||
'KE' => ['name'=>'Kenya', 'iso2'=>'KE','iso3'=>'KEN','flag'=>$f('KE'),'dial_code'=>'+254', 'timezone'=>'Africa/Nairobi', 'currency'=>'KES'],
|
||||
'LS' => ['name'=>'Lesotho', 'iso2'=>'LS','iso3'=>'LSO','flag'=>$f('LS'),'dial_code'=>'+266', 'timezone'=>'Africa/Maseru', 'currency'=>'LSL'],
|
||||
'LR' => ['name'=>'Liberia', 'iso2'=>'LR','iso3'=>'LBR','flag'=>$f('LR'),'dial_code'=>'+231', 'timezone'=>'Africa/Monrovia', 'currency'=>'LRD'],
|
||||
'LY' => ['name'=>'Libya', 'iso2'=>'LY','iso3'=>'LBY','flag'=>$f('LY'),'dial_code'=>'+218', 'timezone'=>'Africa/Tripoli', 'currency'=>'LYD'],
|
||||
'MG' => ['name'=>'Madagascar', 'iso2'=>'MG','iso3'=>'MDG','flag'=>$f('MG'),'dial_code'=>'+261', 'timezone'=>'Indian/Antananarivo', 'currency'=>'MGA'],
|
||||
'MW' => ['name'=>'Malawi', 'iso2'=>'MW','iso3'=>'MWI','flag'=>$f('MW'),'dial_code'=>'+265', 'timezone'=>'Africa/Blantyre', 'currency'=>'MWK'],
|
||||
'ML' => ['name'=>'Mali', 'iso2'=>'ML','iso3'=>'MLI','flag'=>$f('ML'),'dial_code'=>'+223', 'timezone'=>'Africa/Bamako', 'currency'=>'XOF'],
|
||||
'MR' => ['name'=>'Mauritania', 'iso2'=>'MR','iso3'=>'MRT','flag'=>$f('MR'),'dial_code'=>'+222', 'timezone'=>'Africa/Nouakchott', 'currency'=>'MRU'],
|
||||
'MU' => ['name'=>'Mauritius', 'iso2'=>'MU','iso3'=>'MUS','flag'=>$f('MU'),'dial_code'=>'+230', 'timezone'=>'Indian/Mauritius', 'currency'=>'MUR'],
|
||||
'MA' => ['name'=>'Morocco', 'iso2'=>'MA','iso3'=>'MAR','flag'=>$f('MA'),'dial_code'=>'+212', 'timezone'=>'Africa/Casablanca', 'currency'=>'MAD'],
|
||||
'MZ' => ['name'=>'Mozambique', 'iso2'=>'MZ','iso3'=>'MOZ','flag'=>$f('MZ'),'dial_code'=>'+258', 'timezone'=>'Africa/Maputo', 'currency'=>'MZN'],
|
||||
'NA' => ['name'=>'Namibia', 'iso2'=>'NA','iso3'=>'NAM','flag'=>$f('NA'),'dial_code'=>'+264', 'timezone'=>'Africa/Windhoek', 'currency'=>'NAD'],
|
||||
'NE' => ['name'=>'Niger', 'iso2'=>'NE','iso3'=>'NER','flag'=>$f('NE'),'dial_code'=>'+227', 'timezone'=>'Africa/Niamey', 'currency'=>'XOF'],
|
||||
'NG' => ['name'=>'Nigeria', 'iso2'=>'NG','iso3'=>'NGA','flag'=>$f('NG'),'dial_code'=>'+234', 'timezone'=>'Africa/Lagos', 'currency'=>'NGN'],
|
||||
'RW' => ['name'=>'Rwanda', 'iso2'=>'RW','iso3'=>'RWA','flag'=>$f('RW'),'dial_code'=>'+250', 'timezone'=>'Africa/Kigali', 'currency'=>'RWF'],
|
||||
'ST' => ['name'=>'São Tomé & Príncipe', 'iso2'=>'ST','iso3'=>'STP','flag'=>$f('ST'),'dial_code'=>'+239', 'timezone'=>'Africa/Sao_Tome', 'currency'=>'STN'],
|
||||
'SN' => ['name'=>'Senegal', 'iso2'=>'SN','iso3'=>'SEN','flag'=>$f('SN'),'dial_code'=>'+221', 'timezone'=>'Africa/Dakar', 'currency'=>'XOF'],
|
||||
'SC' => ['name'=>'Seychelles', 'iso2'=>'SC','iso3'=>'SYC','flag'=>$f('SC'),'dial_code'=>'+248', 'timezone'=>'Indian/Mahe', 'currency'=>'SCR'],
|
||||
'SL' => ['name'=>'Sierra Leone', 'iso2'=>'SL','iso3'=>'SLE','flag'=>$f('SL'),'dial_code'=>'+232', 'timezone'=>'Africa/Freetown', 'currency'=>'SLL'],
|
||||
'SO' => ['name'=>'Somalia', 'iso2'=>'SO','iso3'=>'SOM','flag'=>$f('SO'),'dial_code'=>'+252', 'timezone'=>'Africa/Mogadishu', 'currency'=>'SOS'],
|
||||
'ZA' => ['name'=>'South Africa', 'iso2'=>'ZA','iso3'=>'ZAF','flag'=>$f('ZA'),'dial_code'=>'+27', 'timezone'=>'Africa/Johannesburg', 'currency'=>'ZAR'],
|
||||
'SS' => ['name'=>'South Sudan', 'iso2'=>'SS','iso3'=>'SSD','flag'=>$f('SS'),'dial_code'=>'+211', 'timezone'=>'Africa/Juba', 'currency'=>'SSP'],
|
||||
'SD' => ['name'=>'Sudan', 'iso2'=>'SD','iso3'=>'SDN','flag'=>$f('SD'),'dial_code'=>'+249', 'timezone'=>'Africa/Khartoum', 'currency'=>'SDG'],
|
||||
'TZ' => ['name'=>'Tanzania', 'iso2'=>'TZ','iso3'=>'TZA','flag'=>$f('TZ'),'dial_code'=>'+255', 'timezone'=>'Africa/Dar_es_Salaam', 'currency'=>'TZS'],
|
||||
'TG' => ['name'=>'Togo', 'iso2'=>'TG','iso3'=>'TGO','flag'=>$f('TG'),'dial_code'=>'+228', 'timezone'=>'Africa/Lome', 'currency'=>'XOF'],
|
||||
'TN' => ['name'=>'Tunisia', 'iso2'=>'TN','iso3'=>'TUN','flag'=>$f('TN'),'dial_code'=>'+216', 'timezone'=>'Africa/Tunis', 'currency'=>'TND'],
|
||||
'UG' => ['name'=>'Uganda', 'iso2'=>'UG','iso3'=>'UGA','flag'=>$f('UG'),'dial_code'=>'+256', 'timezone'=>'Africa/Kampala', 'currency'=>'UGX'],
|
||||
'ZM' => ['name'=>'Zambia', 'iso2'=>'ZM','iso3'=>'ZMB','flag'=>$f('ZM'),'dial_code'=>'+260', 'timezone'=>'Africa/Lusaka', 'currency'=>'ZMW'],
|
||||
'ZW' => ['name'=>'Zimbabwe', 'iso2'=>'ZW','iso3'=>'ZWE','flag'=>$f('ZW'),'dial_code'=>'+263', 'timezone'=>'Africa/Harare', 'currency'=>'ZWL'],
|
||||
|
||||
// ── AMERICAS ─────────────────────────────────────────────────────────
|
||||
'AG' => ['name'=>'Antigua & Barbuda', 'iso2'=>'AG','iso3'=>'ATG','flag'=>$f('AG'),'dial_code'=>'+1', 'timezone'=>'America/Antigua', 'currency'=>'XCD'],
|
||||
'AR' => ['name'=>'Argentina', 'iso2'=>'AR','iso3'=>'ARG','flag'=>$f('AR'),'dial_code'=>'+54', 'timezone'=>'America/Argentina/Buenos_Aires','currency'=>'ARS'],
|
||||
'BS' => ['name'=>'Bahamas', 'iso2'=>'BS','iso3'=>'BHS','flag'=>$f('BS'),'dial_code'=>'+1', 'timezone'=>'America/Nassau', 'currency'=>'BSD'],
|
||||
'BB' => ['name'=>'Barbados', 'iso2'=>'BB','iso3'=>'BRB','flag'=>$f('BB'),'dial_code'=>'+1', 'timezone'=>'America/Barbados', 'currency'=>'BBD'],
|
||||
'BZ' => ['name'=>'Belize', 'iso2'=>'BZ','iso3'=>'BLZ','flag'=>$f('BZ'),'dial_code'=>'+501', 'timezone'=>'America/Belize', 'currency'=>'BZD'],
|
||||
'BO' => ['name'=>'Bolivia', 'iso2'=>'BO','iso3'=>'BOL','flag'=>$f('BO'),'dial_code'=>'+591', 'timezone'=>'America/La_Paz', 'currency'=>'BOB'],
|
||||
'BR' => ['name'=>'Brazil', 'iso2'=>'BR','iso3'=>'BRA','flag'=>$f('BR'),'dial_code'=>'+55', 'timezone'=>'America/Sao_Paulo', 'currency'=>'BRL'],
|
||||
'CA' => ['name'=>'Canada', 'iso2'=>'CA','iso3'=>'CAN','flag'=>$f('CA'),'dial_code'=>'+1', 'timezone'=>'America/Toronto', 'currency'=>'CAD'],
|
||||
'CL' => ['name'=>'Chile', 'iso2'=>'CL','iso3'=>'CHL','flag'=>$f('CL'),'dial_code'=>'+56', 'timezone'=>'America/Santiago', 'currency'=>'CLP'],
|
||||
'CO' => ['name'=>'Colombia', 'iso2'=>'CO','iso3'=>'COL','flag'=>$f('CO'),'dial_code'=>'+57', 'timezone'=>'America/Bogota', 'currency'=>'COP'],
|
||||
'CR' => ['name'=>'Costa Rica', 'iso2'=>'CR','iso3'=>'CRI','flag'=>$f('CR'),'dial_code'=>'+506', 'timezone'=>'America/Costa_Rica', 'currency'=>'CRC'],
|
||||
'CU' => ['name'=>'Cuba', 'iso2'=>'CU','iso3'=>'CUB','flag'=>$f('CU'),'dial_code'=>'+53', 'timezone'=>'America/Havana', 'currency'=>'CUP'],
|
||||
'DM' => ['name'=>'Dominica', 'iso2'=>'DM','iso3'=>'DMA','flag'=>$f('DM'),'dial_code'=>'+1', 'timezone'=>'America/Dominica', 'currency'=>'XCD'],
|
||||
'DO' => ['name'=>'Dominican Republic', 'iso2'=>'DO','iso3'=>'DOM','flag'=>$f('DO'),'dial_code'=>'+1', 'timezone'=>'America/Santo_Domingo', 'currency'=>'DOP'],
|
||||
'EC' => ['name'=>'Ecuador', 'iso2'=>'EC','iso3'=>'ECU','flag'=>$f('EC'),'dial_code'=>'+593', 'timezone'=>'America/Guayaquil', 'currency'=>'USD'],
|
||||
'SV' => ['name'=>'El Salvador', 'iso2'=>'SV','iso3'=>'SLV','flag'=>$f('SV'),'dial_code'=>'+503', 'timezone'=>'America/El_Salvador', 'currency'=>'USD'],
|
||||
'GD' => ['name'=>'Grenada', 'iso2'=>'GD','iso3'=>'GRD','flag'=>$f('GD'),'dial_code'=>'+1', 'timezone'=>'America/Grenada', 'currency'=>'XCD'],
|
||||
'GT' => ['name'=>'Guatemala', 'iso2'=>'GT','iso3'=>'GTM','flag'=>$f('GT'),'dial_code'=>'+502', 'timezone'=>'America/Guatemala', 'currency'=>'GTQ'],
|
||||
'GY' => ['name'=>'Guyana', 'iso2'=>'GY','iso3'=>'GUY','flag'=>$f('GY'),'dial_code'=>'+592', 'timezone'=>'America/Guyana', 'currency'=>'GYD'],
|
||||
'HT' => ['name'=>'Haiti', 'iso2'=>'HT','iso3'=>'HTI','flag'=>$f('HT'),'dial_code'=>'+509', 'timezone'=>'America/Port-au-Prince', 'currency'=>'HTG'],
|
||||
'HN' => ['name'=>'Honduras', 'iso2'=>'HN','iso3'=>'HND','flag'=>$f('HN'),'dial_code'=>'+504', 'timezone'=>'America/Tegucigalpa', 'currency'=>'HNL'],
|
||||
'JM' => ['name'=>'Jamaica', 'iso2'=>'JM','iso3'=>'JAM','flag'=>$f('JM'),'dial_code'=>'+1', 'timezone'=>'America/Jamaica', 'currency'=>'JMD'],
|
||||
'MX' => ['name'=>'Mexico', 'iso2'=>'MX','iso3'=>'MEX','flag'=>$f('MX'),'dial_code'=>'+52', 'timezone'=>'America/Mexico_City', 'currency'=>'MXN'],
|
||||
'NI' => ['name'=>'Nicaragua', 'iso2'=>'NI','iso3'=>'NIC','flag'=>$f('NI'),'dial_code'=>'+505', 'timezone'=>'America/Managua', 'currency'=>'NIO'],
|
||||
'PA' => ['name'=>'Panama', 'iso2'=>'PA','iso3'=>'PAN','flag'=>$f('PA'),'dial_code'=>'+507', 'timezone'=>'America/Panama', 'currency'=>'PAB'],
|
||||
'PY' => ['name'=>'Paraguay', 'iso2'=>'PY','iso3'=>'PRY','flag'=>$f('PY'),'dial_code'=>'+595', 'timezone'=>'America/Asuncion', 'currency'=>'PYG'],
|
||||
'PE' => ['name'=>'Peru', 'iso2'=>'PE','iso3'=>'PER','flag'=>$f('PE'),'dial_code'=>'+51', 'timezone'=>'America/Lima', 'currency'=>'PEN'],
|
||||
'KN' => ['name'=>'Saint Kitts & Nevis', 'iso2'=>'KN','iso3'=>'KNA','flag'=>$f('KN'),'dial_code'=>'+1', 'timezone'=>'America/St_Kitts', 'currency'=>'XCD'],
|
||||
'LC' => ['name'=>'Saint Lucia', 'iso2'=>'LC','iso3'=>'LCA','flag'=>$f('LC'),'dial_code'=>'+1', 'timezone'=>'America/St_Lucia', 'currency'=>'XCD'],
|
||||
'VC' => ['name'=>'Saint Vincent & Grenadines','iso2'=>'VC','iso3'=>'VCT','flag'=>$f('VC'),'dial_code'=>'+1', 'timezone'=>'America/St_Vincent', 'currency'=>'XCD'],
|
||||
'SR' => ['name'=>'Suriname', 'iso2'=>'SR','iso3'=>'SUR','flag'=>$f('SR'),'dial_code'=>'+597', 'timezone'=>'America/Paramaribo', 'currency'=>'SRD'],
|
||||
'TT' => ['name'=>'Trinidad & Tobago', 'iso2'=>'TT','iso3'=>'TTO','flag'=>$f('TT'),'dial_code'=>'+1', 'timezone'=>'America/Port_of_Spain', 'currency'=>'TTD'],
|
||||
'US' => ['name'=>'United States', 'iso2'=>'US','iso3'=>'USA','flag'=>$f('US'),'dial_code'=>'+1', 'timezone'=>'America/New_York', 'currency'=>'USD'],
|
||||
'UY' => ['name'=>'Uruguay', 'iso2'=>'UY','iso3'=>'URY','flag'=>$f('UY'),'dial_code'=>'+598', 'timezone'=>'America/Montevideo', 'currency'=>'UYU'],
|
||||
'VE' => ['name'=>'Venezuela', 'iso2'=>'VE','iso3'=>'VEN','flag'=>$f('VE'),'dial_code'=>'+58', 'timezone'=>'America/Caracas', 'currency'=>'VES'],
|
||||
|
||||
// ── ASIA & MIDDLE EAST ────────────────────────────────────────────────
|
||||
'AF' => ['name'=>'Afghanistan', 'iso2'=>'AF','iso3'=>'AFG','flag'=>$f('AF'),'dial_code'=>'+93', 'timezone'=>'Asia/Kabul', 'currency'=>'AFN'],
|
||||
'AM' => ['name'=>'Armenia', 'iso2'=>'AM','iso3'=>'ARM','flag'=>$f('AM'),'dial_code'=>'+374', 'timezone'=>'Asia/Yerevan', 'currency'=>'AMD'],
|
||||
'AZ' => ['name'=>'Azerbaijan', 'iso2'=>'AZ','iso3'=>'AZE','flag'=>$f('AZ'),'dial_code'=>'+994', 'timezone'=>'Asia/Baku', 'currency'=>'AZN'],
|
||||
'BH' => ['name'=>'Bahrain', 'iso2'=>'BH','iso3'=>'BHR','flag'=>$f('BH'),'dial_code'=>'+973', 'timezone'=>'Asia/Bahrain', 'currency'=>'BHD'],
|
||||
'BD' => ['name'=>'Bangladesh', 'iso2'=>'BD','iso3'=>'BGD','flag'=>$f('BD'),'dial_code'=>'+880', 'timezone'=>'Asia/Dhaka', 'currency'=>'BDT'],
|
||||
'BT' => ['name'=>'Bhutan', 'iso2'=>'BT','iso3'=>'BTN','flag'=>$f('BT'),'dial_code'=>'+975', 'timezone'=>'Asia/Thimphu', 'currency'=>'BTN'],
|
||||
'BN' => ['name'=>'Brunei', 'iso2'=>'BN','iso3'=>'BRN','flag'=>$f('BN'),'dial_code'=>'+673', 'timezone'=>'Asia/Brunei', 'currency'=>'BND'],
|
||||
'KH' => ['name'=>'Cambodia', 'iso2'=>'KH','iso3'=>'KHM','flag'=>$f('KH'),'dial_code'=>'+855', 'timezone'=>'Asia/Phnom_Penh', 'currency'=>'KHR'],
|
||||
'CN' => ['name'=>'China', 'iso2'=>'CN','iso3'=>'CHN','flag'=>$f('CN'),'dial_code'=>'+86', 'timezone'=>'Asia/Shanghai', 'currency'=>'CNY'],
|
||||
'CY' => ['name'=>'Cyprus', 'iso2'=>'CY','iso3'=>'CYP','flag'=>$f('CY'),'dial_code'=>'+357', 'timezone'=>'Asia/Nicosia', 'currency'=>'EUR'],
|
||||
'GE' => ['name'=>'Georgia', 'iso2'=>'GE','iso3'=>'GEO','flag'=>$f('GE'),'dial_code'=>'+995', 'timezone'=>'Asia/Tbilisi', 'currency'=>'GEL'],
|
||||
'HK' => ['name'=>'Hong Kong', 'iso2'=>'HK','iso3'=>'HKG','flag'=>$f('HK'),'dial_code'=>'+852', 'timezone'=>'Asia/Hong_Kong', 'currency'=>'HKD'],
|
||||
'IN' => ['name'=>'India', 'iso2'=>'IN','iso3'=>'IND','flag'=>$f('IN'),'dial_code'=>'+91', 'timezone'=>'Asia/Kolkata', 'currency'=>'INR'],
|
||||
'ID' => ['name'=>'Indonesia', 'iso2'=>'ID','iso3'=>'IDN','flag'=>$f('ID'),'dial_code'=>'+62', 'timezone'=>'Asia/Jakarta', 'currency'=>'IDR'],
|
||||
'IR' => ['name'=>'Iran', 'iso2'=>'IR','iso3'=>'IRN','flag'=>$f('IR'),'dial_code'=>'+98', 'timezone'=>'Asia/Tehran', 'currency'=>'IRR'],
|
||||
'IQ' => ['name'=>'Iraq', 'iso2'=>'IQ','iso3'=>'IRQ','flag'=>$f('IQ'),'dial_code'=>'+964', 'timezone'=>'Asia/Baghdad', 'currency'=>'IQD'],
|
||||
'IL' => ['name'=>'Israel', 'iso2'=>'IL','iso3'=>'ISR','flag'=>$f('IL'),'dial_code'=>'+972', 'timezone'=>'Asia/Jerusalem', 'currency'=>'ILS'],
|
||||
'JP' => ['name'=>'Japan', 'iso2'=>'JP','iso3'=>'JPN','flag'=>$f('JP'),'dial_code'=>'+81', 'timezone'=>'Asia/Tokyo', 'currency'=>'JPY'],
|
||||
'JO' => ['name'=>'Jordan', 'iso2'=>'JO','iso3'=>'JOR','flag'=>$f('JO'),'dial_code'=>'+962', 'timezone'=>'Asia/Amman', 'currency'=>'JOD'],
|
||||
'KZ' => ['name'=>'Kazakhstan', 'iso2'=>'KZ','iso3'=>'KAZ','flag'=>$f('KZ'),'dial_code'=>'+7', 'timezone'=>'Asia/Almaty', 'currency'=>'KZT'],
|
||||
'KW' => ['name'=>'Kuwait', 'iso2'=>'KW','iso3'=>'KWT','flag'=>$f('KW'),'dial_code'=>'+965', 'timezone'=>'Asia/Kuwait', 'currency'=>'KWD'],
|
||||
'KG' => ['name'=>'Kyrgyzstan', 'iso2'=>'KG','iso3'=>'KGZ','flag'=>$f('KG'),'dial_code'=>'+996', 'timezone'=>'Asia/Bishkek', 'currency'=>'KGS'],
|
||||
'LA' => ['name'=>'Laos', 'iso2'=>'LA','iso3'=>'LAO','flag'=>$f('LA'),'dial_code'=>'+856', 'timezone'=>'Asia/Vientiane', 'currency'=>'LAK'],
|
||||
'LB' => ['name'=>'Lebanon', 'iso2'=>'LB','iso3'=>'LBN','flag'=>$f('LB'),'dial_code'=>'+961', 'timezone'=>'Asia/Beirut', 'currency'=>'LBP'],
|
||||
'MO' => ['name'=>'Macau', 'iso2'=>'MO','iso3'=>'MAC','flag'=>$f('MO'),'dial_code'=>'+853', 'timezone'=>'Asia/Macau', 'currency'=>'MOP'],
|
||||
'MY' => ['name'=>'Malaysia', 'iso2'=>'MY','iso3'=>'MYS','flag'=>$f('MY'),'dial_code'=>'+60', 'timezone'=>'Asia/Kuala_Lumpur', 'currency'=>'MYR'],
|
||||
'MV' => ['name'=>'Maldives', 'iso2'=>'MV','iso3'=>'MDV','flag'=>$f('MV'),'dial_code'=>'+960', 'timezone'=>'Indian/Maldives', 'currency'=>'MVR'],
|
||||
'MN' => ['name'=>'Mongolia', 'iso2'=>'MN','iso3'=>'MNG','flag'=>$f('MN'),'dial_code'=>'+976', 'timezone'=>'Asia/Ulaanbaatar', 'currency'=>'MNT'],
|
||||
'MM' => ['name'=>'Myanmar', 'iso2'=>'MM','iso3'=>'MMR','flag'=>$f('MM'),'dial_code'=>'+95', 'timezone'=>'Asia/Yangon', 'currency'=>'MMK'],
|
||||
'NP' => ['name'=>'Nepal', 'iso2'=>'NP','iso3'=>'NPL','flag'=>$f('NP'),'dial_code'=>'+977', 'timezone'=>'Asia/Kathmandu', 'currency'=>'NPR'],
|
||||
'KP' => ['name'=>'North Korea', 'iso2'=>'KP','iso3'=>'PRK','flag'=>$f('KP'),'dial_code'=>'+850', 'timezone'=>'Asia/Pyongyang', 'currency'=>'KPW'],
|
||||
'OM' => ['name'=>'Oman', 'iso2'=>'OM','iso3'=>'OMN','flag'=>$f('OM'),'dial_code'=>'+968', 'timezone'=>'Asia/Muscat', 'currency'=>'OMR'],
|
||||
'PK' => ['name'=>'Pakistan', 'iso2'=>'PK','iso3'=>'PAK','flag'=>$f('PK'),'dial_code'=>'+92', 'timezone'=>'Asia/Karachi', 'currency'=>'PKR'],
|
||||
'PS' => ['name'=>'Palestine', 'iso2'=>'PS','iso3'=>'PSE','flag'=>$f('PS'),'dial_code'=>'+970', 'timezone'=>'Asia/Hebron', 'currency'=>'ILS'],
|
||||
'PH' => ['name'=>'Philippines', 'iso2'=>'PH','iso3'=>'PHL','flag'=>$f('PH'),'dial_code'=>'+63', 'timezone'=>'Asia/Manila', 'currency'=>'PHP'],
|
||||
'QA' => ['name'=>'Qatar', 'iso2'=>'QA','iso3'=>'QAT','flag'=>$f('QA'),'dial_code'=>'+974', 'timezone'=>'Asia/Qatar', 'currency'=>'QAR'],
|
||||
'SA' => ['name'=>'Saudi Arabia', 'iso2'=>'SA','iso3'=>'SAU','flag'=>$f('SA'),'dial_code'=>'+966', 'timezone'=>'Asia/Riyadh', 'currency'=>'SAR'],
|
||||
'SG' => ['name'=>'Singapore', 'iso2'=>'SG','iso3'=>'SGP','flag'=>$f('SG'),'dial_code'=>'+65', 'timezone'=>'Asia/Singapore', 'currency'=>'SGD'],
|
||||
'KR' => ['name'=>'South Korea', 'iso2'=>'KR','iso3'=>'KOR','flag'=>$f('KR'),'dial_code'=>'+82', 'timezone'=>'Asia/Seoul', 'currency'=>'KRW'],
|
||||
'LK' => ['name'=>'Sri Lanka', 'iso2'=>'LK','iso3'=>'LKA','flag'=>$f('LK'),'dial_code'=>'+94', 'timezone'=>'Asia/Colombo', 'currency'=>'LKR'],
|
||||
'SY' => ['name'=>'Syria', 'iso2'=>'SY','iso3'=>'SYR','flag'=>$f('SY'),'dial_code'=>'+963', 'timezone'=>'Asia/Damascus', 'currency'=>'SYP'],
|
||||
'TW' => ['name'=>'Taiwan', 'iso2'=>'TW','iso3'=>'TWN','flag'=>$f('TW'),'dial_code'=>'+886', 'timezone'=>'Asia/Taipei', 'currency'=>'TWD'],
|
||||
'TJ' => ['name'=>'Tajikistan', 'iso2'=>'TJ','iso3'=>'TJK','flag'=>$f('TJ'),'dial_code'=>'+992', 'timezone'=>'Asia/Dushanbe', 'currency'=>'TJS'],
|
||||
'TH' => ['name'=>'Thailand', 'iso2'=>'TH','iso3'=>'THA','flag'=>$f('TH'),'dial_code'=>'+66', 'timezone'=>'Asia/Bangkok', 'currency'=>'THB'],
|
||||
'TL' => ['name'=>'Timor-Leste', 'iso2'=>'TL','iso3'=>'TLS','flag'=>$f('TL'),'dial_code'=>'+670', 'timezone'=>'Asia/Dili', 'currency'=>'USD'],
|
||||
'TR' => ['name'=>'Turkey', 'iso2'=>'TR','iso3'=>'TUR','flag'=>$f('TR'),'dial_code'=>'+90', 'timezone'=>'Europe/Istanbul', 'currency'=>'TRY'],
|
||||
'TM' => ['name'=>'Turkmenistan', 'iso2'=>'TM','iso3'=>'TKM','flag'=>$f('TM'),'dial_code'=>'+993', 'timezone'=>'Asia/Ashgabat', 'currency'=>'TMT'],
|
||||
'AE' => ['name'=>'United Arab Emirates', 'iso2'=>'AE','iso3'=>'ARE','flag'=>$f('AE'),'dial_code'=>'+971', 'timezone'=>'Asia/Dubai', 'currency'=>'AED'],
|
||||
'UZ' => ['name'=>'Uzbekistan', 'iso2'=>'UZ','iso3'=>'UZB','flag'=>$f('UZ'),'dial_code'=>'+998', 'timezone'=>'Asia/Tashkent', 'currency'=>'UZS'],
|
||||
'VN' => ['name'=>'Vietnam', 'iso2'=>'VN','iso3'=>'VNM','flag'=>$f('VN'),'dial_code'=>'+84', 'timezone'=>'Asia/Ho_Chi_Minh', 'currency'=>'VND'],
|
||||
'YE' => ['name'=>'Yemen', 'iso2'=>'YE','iso3'=>'YEM','flag'=>$f('YE'),'dial_code'=>'+967', 'timezone'=>'Asia/Aden', 'currency'=>'YER'],
|
||||
|
||||
// ── EUROPE ────────────────────────────────────────────────────────────
|
||||
'AL' => ['name'=>'Albania', 'iso2'=>'AL','iso3'=>'ALB','flag'=>$f('AL'),'dial_code'=>'+355', 'timezone'=>'Europe/Tirane', 'currency'=>'ALL'],
|
||||
'AD' => ['name'=>'Andorra', 'iso2'=>'AD','iso3'=>'AND','flag'=>$f('AD'),'dial_code'=>'+376', 'timezone'=>'Europe/Andorra', 'currency'=>'EUR'],
|
||||
'AT' => ['name'=>'Austria', 'iso2'=>'AT','iso3'=>'AUT','flag'=>$f('AT'),'dial_code'=>'+43', 'timezone'=>'Europe/Vienna', 'currency'=>'EUR'],
|
||||
'BY' => ['name'=>'Belarus', 'iso2'=>'BY','iso3'=>'BLR','flag'=>$f('BY'),'dial_code'=>'+375', 'timezone'=>'Europe/Minsk', 'currency'=>'BYN'],
|
||||
'BE' => ['name'=>'Belgium', 'iso2'=>'BE','iso3'=>'BEL','flag'=>$f('BE'),'dial_code'=>'+32', 'timezone'=>'Europe/Brussels', 'currency'=>'EUR'],
|
||||
'BA' => ['name'=>'Bosnia & Herzegovina', 'iso2'=>'BA','iso3'=>'BIH','flag'=>$f('BA'),'dial_code'=>'+387', 'timezone'=>'Europe/Sarajevo', 'currency'=>'BAM'],
|
||||
'BG' => ['name'=>'Bulgaria', 'iso2'=>'BG','iso3'=>'BGR','flag'=>$f('BG'),'dial_code'=>'+359', 'timezone'=>'Europe/Sofia', 'currency'=>'BGN'],
|
||||
'HR' => ['name'=>'Croatia', 'iso2'=>'HR','iso3'=>'HRV','flag'=>$f('HR'),'dial_code'=>'+385', 'timezone'=>'Europe/Zagreb', 'currency'=>'EUR'],
|
||||
'CZ' => ['name'=>'Czech Republic', 'iso2'=>'CZ','iso3'=>'CZE','flag'=>$f('CZ'),'dial_code'=>'+420', 'timezone'=>'Europe/Prague', 'currency'=>'CZK'],
|
||||
'DK' => ['name'=>'Denmark', 'iso2'=>'DK','iso3'=>'DNK','flag'=>$f('DK'),'dial_code'=>'+45', 'timezone'=>'Europe/Copenhagen', 'currency'=>'DKK'],
|
||||
'EE' => ['name'=>'Estonia', 'iso2'=>'EE','iso3'=>'EST','flag'=>$f('EE'),'dial_code'=>'+372', 'timezone'=>'Europe/Tallinn', 'currency'=>'EUR'],
|
||||
'FI' => ['name'=>'Finland', 'iso2'=>'FI','iso3'=>'FIN','flag'=>$f('FI'),'dial_code'=>'+358', 'timezone'=>'Europe/Helsinki', 'currency'=>'EUR'],
|
||||
'FR' => ['name'=>'France', 'iso2'=>'FR','iso3'=>'FRA','flag'=>$f('FR'),'dial_code'=>'+33', 'timezone'=>'Europe/Paris', 'currency'=>'EUR'],
|
||||
'DE' => ['name'=>'Germany', 'iso2'=>'DE','iso3'=>'DEU','flag'=>$f('DE'),'dial_code'=>'+49', 'timezone'=>'Europe/Berlin', 'currency'=>'EUR'],
|
||||
'GR' => ['name'=>'Greece', 'iso2'=>'GR','iso3'=>'GRC','flag'=>$f('GR'),'dial_code'=>'+30', 'timezone'=>'Europe/Athens', 'currency'=>'EUR'],
|
||||
'HU' => ['name'=>'Hungary', 'iso2'=>'HU','iso3'=>'HUN','flag'=>$f('HU'),'dial_code'=>'+36', 'timezone'=>'Europe/Budapest', 'currency'=>'HUF'],
|
||||
'IS' => ['name'=>'Iceland', 'iso2'=>'IS','iso3'=>'ISL','flag'=>$f('IS'),'dial_code'=>'+354', 'timezone'=>'Atlantic/Reykjavik', 'currency'=>'ISK'],
|
||||
'IE' => ['name'=>'Ireland', 'iso2'=>'IE','iso3'=>'IRL','flag'=>$f('IE'),'dial_code'=>'+353', 'timezone'=>'Europe/Dublin', 'currency'=>'EUR'],
|
||||
'IT' => ['name'=>'Italy', 'iso2'=>'IT','iso3'=>'ITA','flag'=>$f('IT'),'dial_code'=>'+39', 'timezone'=>'Europe/Rome', 'currency'=>'EUR'],
|
||||
'XK' => ['name'=>'Kosovo', 'iso2'=>'XK','iso3'=>'XKX','flag'=>$f('XK'),'dial_code'=>'+383', 'timezone'=>'Europe/Belgrade', 'currency'=>'EUR'],
|
||||
'LV' => ['name'=>'Latvia', 'iso2'=>'LV','iso3'=>'LVA','flag'=>$f('LV'),'dial_code'=>'+371', 'timezone'=>'Europe/Riga', 'currency'=>'EUR'],
|
||||
'LI' => ['name'=>'Liechtenstein', 'iso2'=>'LI','iso3'=>'LIE','flag'=>$f('LI'),'dial_code'=>'+423', 'timezone'=>'Europe/Vaduz', 'currency'=>'CHF'],
|
||||
'LT' => ['name'=>'Lithuania', 'iso2'=>'LT','iso3'=>'LTU','flag'=>$f('LT'),'dial_code'=>'+370', 'timezone'=>'Europe/Vilnius', 'currency'=>'EUR'],
|
||||
'LU' => ['name'=>'Luxembourg', 'iso2'=>'LU','iso3'=>'LUX','flag'=>$f('LU'),'dial_code'=>'+352', 'timezone'=>'Europe/Luxembourg', 'currency'=>'EUR'],
|
||||
'MT' => ['name'=>'Malta', 'iso2'=>'MT','iso3'=>'MLT','flag'=>$f('MT'),'dial_code'=>'+356', 'timezone'=>'Europe/Malta', 'currency'=>'EUR'],
|
||||
'MD' => ['name'=>'Moldova', 'iso2'=>'MD','iso3'=>'MDA','flag'=>$f('MD'),'dial_code'=>'+373', 'timezone'=>'Europe/Chisinau', 'currency'=>'MDL'],
|
||||
'MC' => ['name'=>'Monaco', 'iso2'=>'MC','iso3'=>'MCO','flag'=>$f('MC'),'dial_code'=>'+377', 'timezone'=>'Europe/Monaco', 'currency'=>'EUR'],
|
||||
'ME' => ['name'=>'Montenegro', 'iso2'=>'ME','iso3'=>'MNE','flag'=>$f('ME'),'dial_code'=>'+382', 'timezone'=>'Europe/Podgorica', 'currency'=>'EUR'],
|
||||
'NL' => ['name'=>'Netherlands', 'iso2'=>'NL','iso3'=>'NLD','flag'=>$f('NL'),'dial_code'=>'+31', 'timezone'=>'Europe/Amsterdam', 'currency'=>'EUR'],
|
||||
'MK' => ['name'=>'North Macedonia', 'iso2'=>'MK','iso3'=>'MKD','flag'=>$f('MK'),'dial_code'=>'+389', 'timezone'=>'Europe/Skopje', 'currency'=>'MKD'],
|
||||
'NO' => ['name'=>'Norway', 'iso2'=>'NO','iso3'=>'NOR','flag'=>$f('NO'),'dial_code'=>'+47', 'timezone'=>'Europe/Oslo', 'currency'=>'NOK'],
|
||||
'PL' => ['name'=>'Poland', 'iso2'=>'PL','iso3'=>'POL','flag'=>$f('PL'),'dial_code'=>'+48', 'timezone'=>'Europe/Warsaw', 'currency'=>'PLN'],
|
||||
'PT' => ['name'=>'Portugal', 'iso2'=>'PT','iso3'=>'PRT','flag'=>$f('PT'),'dial_code'=>'+351', 'timezone'=>'Europe/Lisbon', 'currency'=>'EUR'],
|
||||
'RO' => ['name'=>'Romania', 'iso2'=>'RO','iso3'=>'ROU','flag'=>$f('RO'),'dial_code'=>'+40', 'timezone'=>'Europe/Bucharest', 'currency'=>'RON'],
|
||||
'RU' => ['name'=>'Russia', 'iso2'=>'RU','iso3'=>'RUS','flag'=>$f('RU'),'dial_code'=>'+7', 'timezone'=>'Europe/Moscow', 'currency'=>'RUB'],
|
||||
'SM' => ['name'=>'San Marino', 'iso2'=>'SM','iso3'=>'SMR','flag'=>$f('SM'),'dial_code'=>'+378', 'timezone'=>'Europe/San_Marino', 'currency'=>'EUR'],
|
||||
'RS' => ['name'=>'Serbia', 'iso2'=>'RS','iso3'=>'SRB','flag'=>$f('RS'),'dial_code'=>'+381', 'timezone'=>'Europe/Belgrade', 'currency'=>'RSD'],
|
||||
'SK' => ['name'=>'Slovakia', 'iso2'=>'SK','iso3'=>'SVK','flag'=>$f('SK'),'dial_code'=>'+421', 'timezone'=>'Europe/Bratislava', 'currency'=>'EUR'],
|
||||
'SI' => ['name'=>'Slovenia', 'iso2'=>'SI','iso3'=>'SVN','flag'=>$f('SI'),'dial_code'=>'+386', 'timezone'=>'Europe/Ljubljana', 'currency'=>'EUR'],
|
||||
'ES' => ['name'=>'Spain', 'iso2'=>'ES','iso3'=>'ESP','flag'=>$f('ES'),'dial_code'=>'+34', 'timezone'=>'Europe/Madrid', 'currency'=>'EUR'],
|
||||
'SE' => ['name'=>'Sweden', 'iso2'=>'SE','iso3'=>'SWE','flag'=>$f('SE'),'dial_code'=>'+46', 'timezone'=>'Europe/Stockholm', 'currency'=>'SEK'],
|
||||
'CH' => ['name'=>'Switzerland', 'iso2'=>'CH','iso3'=>'CHE','flag'=>$f('CH'),'dial_code'=>'+41', 'timezone'=>'Europe/Zurich', 'currency'=>'CHF'],
|
||||
'UA' => ['name'=>'Ukraine', 'iso2'=>'UA','iso3'=>'UKR','flag'=>$f('UA'),'dial_code'=>'+380', 'timezone'=>'Europe/Kyiv', 'currency'=>'UAH'],
|
||||
'GB' => ['name'=>'United Kingdom', 'iso2'=>'GB','iso3'=>'GBR','flag'=>$f('GB'),'dial_code'=>'+44', 'timezone'=>'Europe/London', 'currency'=>'GBP'],
|
||||
'VA' => ['name'=>'Vatican City', 'iso2'=>'VA','iso3'=>'VAT','flag'=>$f('VA'),'dial_code'=>'+39', 'timezone'=>'Europe/Vatican', 'currency'=>'EUR'],
|
||||
|
||||
// ── OCEANIA ───────────────────────────────────────────────────────────
|
||||
'AU' => ['name'=>'Australia', 'iso2'=>'AU','iso3'=>'AUS','flag'=>$f('AU'),'dial_code'=>'+61', 'timezone'=>'Australia/Sydney', 'currency'=>'AUD'],
|
||||
'FJ' => ['name'=>'Fiji', 'iso2'=>'FJ','iso3'=>'FJI','flag'=>$f('FJ'),'dial_code'=>'+679', 'timezone'=>'Pacific/Fiji', 'currency'=>'FJD'],
|
||||
'KI' => ['name'=>'Kiribati', 'iso2'=>'KI','iso3'=>'KIR','flag'=>$f('KI'),'dial_code'=>'+686', 'timezone'=>'Pacific/Tarawa', 'currency'=>'AUD'],
|
||||
'MH' => ['name'=>'Marshall Islands', 'iso2'=>'MH','iso3'=>'MHL','flag'=>$f('MH'),'dial_code'=>'+692', 'timezone'=>'Pacific/Majuro', 'currency'=>'USD'],
|
||||
'FM' => ['name'=>'Micronesia', 'iso2'=>'FM','iso3'=>'FSM','flag'=>$f('FM'),'dial_code'=>'+691', 'timezone'=>'Pacific/Pohnpei', 'currency'=>'USD'],
|
||||
'NR' => ['name'=>'Nauru', 'iso2'=>'NR','iso3'=>'NRU','flag'=>$f('NR'),'dial_code'=>'+674', 'timezone'=>'Pacific/Nauru', 'currency'=>'AUD'],
|
||||
'NZ' => ['name'=>'New Zealand', 'iso2'=>'NZ','iso3'=>'NZL','flag'=>$f('NZ'),'dial_code'=>'+64', 'timezone'=>'Pacific/Auckland', 'currency'=>'NZD'],
|
||||
'PW' => ['name'=>'Palau', 'iso2'=>'PW','iso3'=>'PLW','flag'=>$f('PW'),'dial_code'=>'+680', 'timezone'=>'Pacific/Palau', 'currency'=>'USD'],
|
||||
'PG' => ['name'=>'Papua New Guinea', 'iso2'=>'PG','iso3'=>'PNG','flag'=>$f('PG'),'dial_code'=>'+675', 'timezone'=>'Pacific/Port_Moresby', 'currency'=>'PGK'],
|
||||
'WS' => ['name'=>'Samoa', 'iso2'=>'WS','iso3'=>'WSM','flag'=>$f('WS'),'dial_code'=>'+685', 'timezone'=>'Pacific/Apia', 'currency'=>'WST'],
|
||||
'SB' => ['name'=>'Solomon Islands', 'iso2'=>'SB','iso3'=>'SLB','flag'=>$f('SB'),'dial_code'=>'+677', 'timezone'=>'Pacific/Guadalcanal', 'currency'=>'SBD'],
|
||||
'TO' => ['name'=>'Tonga', 'iso2'=>'TO','iso3'=>'TON','flag'=>$f('TO'),'dial_code'=>'+676', 'timezone'=>'Pacific/Tongatapu', 'currency'=>'TOP'],
|
||||
'TV' => ['name'=>'Tuvalu', 'iso2'=>'TV','iso3'=>'TUV','flag'=>$f('TV'),'dial_code'=>'+688', 'timezone'=>'Pacific/Funafuti', 'currency'=>'AUD'],
|
||||
'VU' => ['name'=>'Vanuatu', 'iso2'=>'VU','iso3'=>'VUT','flag'=>$f('VU'),'dial_code'=>'+678', 'timezone'=>'Pacific/Efate', 'currency'=>'VUV'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options list for the phone-code component.
|
||||
* Returns [value, flag, label, secondary, search] per entry, sorted by country name.
|
||||
*/
|
||||
public static function forPhoneCode(): array
|
||||
{
|
||||
$list = [];
|
||||
foreach (self::all() as $country) {
|
||||
$list[] = [
|
||||
'value' => $country['dial_code'] . '|' . $country['iso2'],
|
||||
'flag' => $country['flag'],
|
||||
'label' => $country['dial_code'],
|
||||
'secondary' => $country['name'],
|
||||
'search' => strtolower($country['name'] . ' ' . $country['dial_code'] . ' ' . $country['iso2']),
|
||||
];
|
||||
}
|
||||
usort($list, fn($a, $b) => strcmp($a['secondary'], $b['secondary']));
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options list for the country/nationality component.
|
||||
* value = iso2 code.
|
||||
*/
|
||||
public static function forCountry(): array
|
||||
{
|
||||
$list = [];
|
||||
foreach (self::all() as $country) {
|
||||
$list[] = [
|
||||
'value' => $country['iso2'],
|
||||
'flag' => $country['flag'],
|
||||
'label' => $country['name'],
|
||||
'search' => strtolower($country['name'] . ' ' . $country['iso2'] . ' ' . $country['iso3']),
|
||||
];
|
||||
}
|
||||
usort($list, fn($a, $b) => strcmp($a['label'], $b['label']));
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options list for the timezone component.
|
||||
* Reads PHP's built-in timezone database and attaches country flags.
|
||||
*/
|
||||
public static function forTimezone(): array
|
||||
{
|
||||
$countries = self::all();
|
||||
$list = [];
|
||||
|
||||
// Skip Etc/ offsets and legacy backward-compat entries; keep named timezones
|
||||
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
|
||||
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
|
||||
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
|
||||
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
|
||||
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
|
||||
|
||||
foreach (\DateTimeZone::listIdentifiers() as $tz) {
|
||||
$region = explode('/', $tz)[0];
|
||||
if (in_array($region, $skip, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($tz, '/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$dtze = new \DateTimeZone($tz);
|
||||
$dt = new \DateTime('now', $dtze);
|
||||
$offset = $dt->getOffset();
|
||||
|
||||
$h = (int) floor(abs($offset) / 3600);
|
||||
$m = (int) (abs($offset) % 3600 / 60);
|
||||
$sign = $offset >= 0 ? '+' : '-';
|
||||
$utc = 'UTC' . $sign . str_pad($h, 2, '0', STR_PAD_LEFT) . ':' . str_pad($m, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
$location = $dtze->getLocation();
|
||||
$countryCode = strtoupper($location['country_code'] ?? '');
|
||||
$countryData = $countries[$countryCode] ?? null;
|
||||
$flag = $countryData ? $countryData['flag'] : '🌐';
|
||||
$countryName = $countryData ? $countryData['name'] : '';
|
||||
|
||||
$parts = explode('/', $tz);
|
||||
$city = str_replace('_', ' ', end($parts));
|
||||
|
||||
$list[] = [
|
||||
'value' => $tz,
|
||||
'flag' => $flag,
|
||||
'label' => str_replace('_', ' ', $tz),
|
||||
'utc' => $utc,
|
||||
'offset' => $offset,
|
||||
'search' => strtolower($tz . ' ' . $city . ' ' . $utc . ' ' . $countryName),
|
||||
];
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
usort($list, fn($a, $b) => $a['offset'] <=> $b['offset'] ?: strcmp($a['value'], $b['value']));
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of IANA timezone identifier → ISO2 country code.
|
||||
* Used by the country-select component to auto-detect the user's country from their device timezone.
|
||||
*/
|
||||
public static function forGeoTimezoneMap(): array
|
||||
{
|
||||
$countries = self::all();
|
||||
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
|
||||
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
|
||||
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
|
||||
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
|
||||
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
|
||||
|
||||
$map = [];
|
||||
foreach (\DateTimeZone::listIdentifiers() as $tz) {
|
||||
$region = explode('/', $tz)[0];
|
||||
if (in_array($region, $skip, true) || !str_contains($tz, '/')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$dtze = new \DateTimeZone($tz);
|
||||
$loc = $dtze->getLocation();
|
||||
$iso2 = strtoupper($loc['country_code'] ?? '');
|
||||
if ($iso2 && isset($countries[$iso2])) {
|
||||
$map[$tz] = $iso2;
|
||||
}
|
||||
} catch (\Exception) {}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,32 @@
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
/**
|
||||
* The list of the inputs that are never flashed to the session on validation exceptions.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register the exception handling callbacks for the application.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
//
|
||||
});
|
||||
|
||||
// Redirect to home with a toast when a video URL can't be resolved
|
||||
$this->renderable(function (NotFoundHttpException $e, $request) {
|
||||
$prev = $e->getPrevious();
|
||||
$isVideoRoute = str_starts_with($request->path(), 'videos/');
|
||||
|
||||
if ($isVideoRoute && ($prev instanceof ModelNotFoundException || $e->getMessage() === '')) {
|
||||
return redirect('/')->with('toast_error', 'This video is no longer available.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class Horoscope
|
||||
{
|
||||
private static array $signs = [
|
||||
['name' => 'Capricorn', 'symbol' => '♑', 'element' => 'Earth', 'emoji' => '🐐', 'from' => [12, 22], 'to' => [1, 19],
|
||||
'traits' => ['Ambitious', 'Disciplined', 'Patient', 'Practical']],
|
||||
['name' => 'Aquarius', 'symbol' => '♒', 'element' => 'Air', 'emoji' => '🏺', 'from' => [1, 20], 'to' => [2, 18],
|
||||
'traits' => ['Independent', 'Progressive', 'Humanitarian', 'Inventive']],
|
||||
['name' => 'Pisces', 'symbol' => '♓', 'element' => 'Water', 'emoji' => '🐟', 'from' => [2, 19], 'to' => [3, 20],
|
||||
'traits' => ['Empathetic', 'Creative', 'Intuitive', 'Gentle']],
|
||||
['name' => 'Aries', 'symbol' => '♈', 'element' => 'Fire', 'emoji' => '🐏', 'from' => [3, 21], 'to' => [4, 19],
|
||||
'traits' => ['Courageous', 'Energetic', 'Enthusiastic', 'Bold']],
|
||||
['name' => 'Taurus', 'symbol' => '♉', 'element' => 'Earth', 'emoji' => '🐂', 'from' => [4, 20], 'to' => [5, 20],
|
||||
'traits' => ['Reliable', 'Patient', 'Devoted', 'Sensual']],
|
||||
['name' => 'Gemini', 'symbol' => '♊', 'element' => 'Air', 'emoji' => '👥', 'from' => [5, 21], 'to' => [6, 20],
|
||||
'traits' => ['Adaptable', 'Curious', 'Witty', 'Expressive']],
|
||||
['name' => 'Cancer', 'symbol' => '♋', 'element' => 'Water', 'emoji' => '🦀', 'from' => [6, 21], 'to' => [7, 22],
|
||||
'traits' => ['Caring', 'Protective', 'Intuitive', 'Emotional']],
|
||||
['name' => 'Leo', 'symbol' => '♌', 'element' => 'Fire', 'emoji' => '🦁', 'from' => [7, 23], 'to' => [8, 22],
|
||||
'traits' => ['Confident', 'Dramatic', 'Creative', 'Generous']],
|
||||
['name' => 'Virgo', 'symbol' => '♍', 'element' => 'Earth', 'emoji' => '🌾', 'from' => [8, 23], 'to' => [9, 22],
|
||||
'traits' => ['Analytical', 'Loyal', 'Practical', 'Diligent']],
|
||||
['name' => 'Libra', 'symbol' => '♎', 'element' => 'Air', 'emoji' => '⚖️', 'from' => [9, 23], 'to' => [10, 22],
|
||||
'traits' => ['Diplomatic', 'Gracious', 'Fair-minded', 'Social']],
|
||||
['name' => 'Scorpio', 'symbol' => '♏', 'element' => 'Water', 'emoji' => '🦂', 'from' => [10, 23], 'to' => [11, 21],
|
||||
'traits' => ['Passionate', 'Stubborn', 'Resourceful', 'Brave']],
|
||||
['name' => 'Sagittarius','symbol' => '♐', 'element' => 'Fire', 'emoji' => '🏹', 'from' => [11, 22], 'to' => [12, 21],
|
||||
'traits' => ['Optimistic', 'Adventurous', 'Honest', 'Philosophical']],
|
||||
];
|
||||
|
||||
private static array $elementCompatibility = [
|
||||
'Fire' => ['Fire' => 85, 'Air' => 80, 'Earth' => 45, 'Water' => 40],
|
||||
'Earth' => ['Earth' => 88, 'Water' => 82, 'Fire' => 45, 'Air' => 50],
|
||||
'Air' => ['Air' => 84, 'Fire' => 80, 'Water' => 48, 'Earth' => 50],
|
||||
'Water' => ['Water' => 86, 'Earth' => 82, 'Fire' => 40, 'Air' => 48],
|
||||
];
|
||||
|
||||
public static function getSign(?string $birthday): ?array
|
||||
{
|
||||
if (! $birthday) return null;
|
||||
|
||||
try {
|
||||
$date = new \DateTime($birthday);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$month = (int) $date->format('n');
|
||||
$day = (int) $date->format('j');
|
||||
|
||||
foreach (self::$signs as $sign) {
|
||||
[$fm, $fd] = $sign['from'];
|
||||
[$tm, $td] = $sign['to'];
|
||||
|
||||
if ($fm > $tm) {
|
||||
// Capricorn wraps year
|
||||
if (($month === $fm && $day >= $fd) || ($month === $tm && $day <= $td)) {
|
||||
return $sign;
|
||||
}
|
||||
} else {
|
||||
if (($month === $fm && $day >= $fd) || ($month > $fm && $month < $tm) || ($month === $tm && $day <= $td)) {
|
||||
return $sign;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function compatibility(?array $sign1, ?array $sign2): ?int
|
||||
{
|
||||
if (! $sign1 || ! $sign2) return null;
|
||||
|
||||
$base = self::$elementCompatibility[$sign1['element']][$sign2['element']] ?? 50;
|
||||
|
||||
// Same sign bonus
|
||||
if ($sign1['name'] === $sign2['name']) {
|
||||
$base = min(98, $base + 8);
|
||||
}
|
||||
|
||||
// Deterministic jitter so different sign pairs within same element feel unique
|
||||
$seed = crc32($sign1['name'] . ':' . $sign2['name']);
|
||||
$jitter = (abs($seed) % 11) - 5;
|
||||
|
||||
return max(20, min(99, $base + $jitter));
|
||||
}
|
||||
|
||||
public static function elementColor(string $element): string
|
||||
{
|
||||
return match ($element) {
|
||||
'Fire' => '#ff6b35',
|
||||
'Earth' => '#6abf69',
|
||||
'Air' => '#64b5f6',
|
||||
'Water' => '#4fc3f7',
|
||||
default => '#aaa',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
@@ -42,17 +43,41 @@ class CompressVideoJob implements ShouldQueue
|
||||
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
||||
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||
]);
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
|
||||
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
||||
// Use 'slow' preset for better compression efficiency
|
||||
// GPU NVENC encoding via config
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
|
||||
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
|
||||
$gpuEnabled = Setting::gpuEnabled();
|
||||
$encoder = Setting::gpuEncoder();
|
||||
$preset = Setting::gpuPreset();
|
||||
$device = Setting::gpuDevice();
|
||||
|
||||
$format = new X264('aac', 'h264_nvenc');
|
||||
if ($gpuEnabled) {
|
||||
$videoPasses = [
|
||||
"-c:v {$encoder}",
|
||||
"-preset {$preset}",
|
||||
'-rc vbr',
|
||||
'-cq 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
"-gpu {$device}",
|
||||
];
|
||||
} else {
|
||||
$videoPasses = [
|
||||
'-c:v libx264',
|
||||
'-preset fast',
|
||||
'-crf 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
];
|
||||
}
|
||||
$audioPasses = ['-c:a aac', '-b:a 192k'];
|
||||
|
||||
$format = new X264('aac', $encoder);
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
@@ -79,12 +104,13 @@ class CompressVideoJob implements ShouldQueue
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => 'h264_nvenc'
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
|
||||
+94
-66
@@ -2,17 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateHlsJob implements ShouldQueue
|
||||
{
|
||||
@@ -41,83 +39,114 @@ class GenerateHlsJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$hlsDir = 'public/hls/' . $video->id;
|
||||
$hlsDir = 'public/hls/' . $video->id;
|
||||
$hlsPath = storage_path('app/' . $hlsDir);
|
||||
|
||||
// Clean existing HLS
|
||||
if (is_dir($hlsPath)) {
|
||||
Storage::deleteDirectory($hlsDir);
|
||||
}
|
||||
|
||||
Storage::makeDirectory($hlsDir);
|
||||
|
||||
$variants = [
|
||||
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
|
||||
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
|
||||
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
|
||||
];
|
||||
|
||||
foreach ($variants as $v) {
|
||||
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
|
||||
$gpuEnabled = Setting::gpuEnabled();
|
||||
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
|
||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
|
||||
$hwaccel = Setting::gpuHwaccel(); // cuda / none
|
||||
|
||||
$cmd = [escapeshellcmd($ffmpegBin)];
|
||||
|
||||
// Hardware-accelerated decode when GPU is in use
|
||||
if ($gpuEnabled && $hwaccel !== 'none') {
|
||||
$cmd[] = "-hwaccel {$hwaccel}";
|
||||
$cmd[] = "-hwaccel_device {$device}";
|
||||
}
|
||||
|
||||
$cmd[] = '-i ' . escapeshellarg($sourcePath);
|
||||
|
||||
// One video+audio stream per variant
|
||||
$n = count($variants);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$cmd[] = '-map 0:v:0';
|
||||
$cmd[] = '-map 0:a:0?';
|
||||
}
|
||||
|
||||
// Video codec
|
||||
$cmd[] = "-c:v {$encoder}";
|
||||
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-rc vbr';
|
||||
$cmd[] = '-cq 23';
|
||||
$cmd[] = "-gpu {$device}";
|
||||
} else {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-crf 23';
|
||||
}
|
||||
$cmd[] = '-pix_fmt yuv420p';
|
||||
|
||||
// Audio codec
|
||||
$cmd[] = '-c:a aac';
|
||||
$cmd[] = '-b:a 128k';
|
||||
$cmd[] = '-ar 48000';
|
||||
|
||||
// Per-variant scale + bitrate
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
|
||||
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
|
||||
}
|
||||
|
||||
// HLS muxer options
|
||||
$cmd[] = '-g 48';
|
||||
$cmd[] = '-sc_threshold 0';
|
||||
$cmd[] = '-f hls';
|
||||
$cmd[] = '-hls_time 6';
|
||||
$cmd[] = '-hls_list_size 0';
|
||||
$cmd[] = '-hls_flags independent_segments';
|
||||
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
|
||||
$cmd[] = '-master_pl_name playlist.m3u8';
|
||||
|
||||
$vsm = implode(' ', array_map(
|
||||
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
|
||||
array_keys($variants),
|
||||
$variants
|
||||
));
|
||||
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
|
||||
|
||||
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
|
||||
|
||||
$fullCmd = implode(' ', $cmd) . ' 2>&1';
|
||||
|
||||
Log::info('GenerateHlsJob: Starting', [
|
||||
'video_id' => $video->id,
|
||||
'gpu' => $gpuEnabled,
|
||||
'encoder' => $encoder,
|
||||
'device' => $gpuEnabled ? $device : 'cpu',
|
||||
]);
|
||||
|
||||
$videoMedia = $ffmpeg->open($sourcePath);
|
||||
exec($fullCmd, $output, $exitCode);
|
||||
|
||||
// HLS variants: 480p, 720p, 1080p
|
||||
$variants = [
|
||||
[
|
||||
'height' => 480,
|
||||
'name' => '480p',
|
||||
'bitrate' => 1000,
|
||||
],
|
||||
[
|
||||
'height' => 720,
|
||||
'name' => '720p',
|
||||
'bitrate' => 2500,
|
||||
],
|
||||
[
|
||||
'height' => 1080,
|
||||
'name' => '1080p',
|
||||
'bitrate' => 5000,
|
||||
],
|
||||
];
|
||||
if ($exitCode !== 0) {
|
||||
$tail = implode("\n", array_slice($output, -30));
|
||||
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
|
||||
}
|
||||
|
||||
$hlsOptions = [
|
||||
'-c:v h264_nvenc',
|
||||
'-preset p4',
|
||||
'-g 48', // GOP size 2s @25fps
|
||||
'-sc_threshold 0',
|
||||
'-c:a aac',
|
||||
'-ar 48000',
|
||||
'-f hls',
|
||||
'-hls_time 6',
|
||||
'-hls_list_size 0',
|
||||
'-hls_segment_filename',
|
||||
'%v/%03d.ts',
|
||||
'-hls_flags',
|
||||
'delete_segments+append_list',
|
||||
'-master_pl_name',
|
||||
'playlist.m3u8',
|
||||
];
|
||||
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
|
||||
|
||||
$videoMedia->save(new \FFMpeg\Format\Video\X264(), $hlsPath, function ($filters) use ($variants) {
|
||||
foreach ($variants as $variant) {
|
||||
$filters->custom('-map 0:v:0 -map 0:a:0?')
|
||||
->size("trunc(oh*a/oh/{$variant['height']})*{$variant['height']}") // Scale
|
||||
->resize(new \FFMpeg\Coordinate\Dimension($variant['height'] * 16 / 9, $variant['height']))
|
||||
->videoCodec($variant['bitrate'] . 'k')
|
||||
->addLegacyOption('-var_stream_map v:0,name:' . $variant['name'] . ' v:1,name:720p v:2,name:1080p');
|
||||
}
|
||||
});
|
||||
|
||||
// Mark HLS ready
|
||||
$video->update([
|
||||
'has_hls' => true,
|
||||
'hls_path' => $hlsDir,
|
||||
]);
|
||||
|
||||
Log::info('GenerateHlsJob: HLS generated successfully', [
|
||||
Log::info('GenerateHlsJob: HLS generated', [
|
||||
'video_id' => $video->id,
|
||||
'variants' => array_column($variants, 'name'),
|
||||
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
|
||||
'encoder' => $encoder,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -126,4 +155,3 @@ class GenerateHlsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NewVideoNotification extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public Video $video, public User $uploader)
|
||||
{
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: $this->uploader->name . ' just uploaded a new video on ' . config('app.name'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.new-video-notification',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TwoFactorDisableConfirmation extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user, public string $confirmUrl)
|
||||
{
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Confirm disabling Two-Factor Authentication — ' . config('app.name'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.2fa-disable-confirm',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewCommentLikeNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public Video $video,
|
||||
public Comment $comment,
|
||||
public User $liker
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'comment_like',
|
||||
'video_id' => $this->video->id,
|
||||
'video_route_key' => $this->video->getRouteKey(),
|
||||
'video_title' => $this->video->title,
|
||||
'video_thumbnail' => $this->video->thumbnail,
|
||||
'actor_id' => $this->liker->id,
|
||||
'actor_name' => $this->liker->name,
|
||||
'actor_avatar' => $this->liker->avatar_url,
|
||||
'comment_preview' => Str::limit($this->comment->body, 80),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewCommentNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public Video $video,
|
||||
public Comment $comment,
|
||||
public User $commenter
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'new_comment',
|
||||
'video_id' => $this->video->id,
|
||||
'video_route_key' => $this->video->getRouteKey(),
|
||||
'video_title' => $this->video->title,
|
||||
'video_thumbnail' => $this->video->thumbnail,
|
||||
'actor_id' => $this->commenter->id,
|
||||
'actor_name' => $this->commenter->name,
|
||||
'actor_avatar' => $this->commenter->avatar_url,
|
||||
'comment_preview' => Str::limit($this->comment->body, 80),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NewReplyNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public Video $video,
|
||||
public Comment $reply,
|
||||
public User $replier
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'new_reply',
|
||||
'video_id' => $this->video->id,
|
||||
'video_route_key' => $this->video->getRouteKey(),
|
||||
'video_title' => $this->video->title,
|
||||
'video_thumbnail' => $this->video->thumbnail,
|
||||
'actor_id' => $this->replier->id,
|
||||
'actor_name' => $this->replier->name,
|
||||
'actor_avatar' => $this->replier->avatar_url,
|
||||
'comment_preview' => Str::limit($this->reply->body, 80),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\User;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class NewVideoUploaded extends Notification
|
||||
{
|
||||
public function __construct(public Video $video, public User $uploader)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'new_video',
|
||||
'video_id' => $this->video->id,
|
||||
'video_route_key' => $this->video->getRouteKey(),
|
||||
'video_title' => $this->video->title,
|
||||
'video_thumbnail' => $this->video->thumbnail,
|
||||
'uploader_id' => $this->uploader->id,
|
||||
'uploader_name' => $this->uploader->name,
|
||||
'uploader_avatar' => $this->uploader->avatar_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class VerifyEmail extends BaseVerifyEmail
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$url = $this->verificationUrl($notifiable);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Verify your email address — ' . config('app.name'))
|
||||
->view('emails.verify-email', [
|
||||
'url' => $url,
|
||||
'userName' => $notifiable->name,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function verificationUrl($notifiable): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
||||
[
|
||||
'id' => $notifiable->getKey(),
|
||||
'hash' => sha1($notifiable->getEmailForVerification()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -27,5 +28,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Force HTTPS
|
||||
URL::forceScheme('https');
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
Paginator::defaultSimpleView('partials.pagination');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class NotDisposableEmail implements ValidationRule
|
||||
{
|
||||
// Top disposable/throwaway email domains
|
||||
private const BLOCKED = [
|
||||
'0-mail.com','0815.ru','0clickemail.com','0wnd.net','0wnd.org','10minutemail.com',
|
||||
'10minutemail.net','10minutemail.org','10minutemail.de','10mail.org','20minutemail.com',
|
||||
'2prong.com','30minutemail.com','33mail.com','3d-painting.com','4warding.com',
|
||||
'amilegit.com','anonbox.net','anonymail.net','anonymbox.com','antichef.com',
|
||||
'antichef.net','antireg.com','antispam.de','antispammail.de','armyspy.com',
|
||||
'baxomale.ht.cx','beefmilk.com','bigstring.com','binkmail.com','bio-muesli.net',
|
||||
'bobmail.info','bodhi.lawlita.com','bofthew.com','bootybay.de','bossmail.de',
|
||||
'bouncr.com','breakthru.com','brefmail.com','broadbandninja.com','bsnow.net',
|
||||
'bugmenot.com','bumpymail.com','casualdx.com','centermail.com','centermail.net',
|
||||
'chacuo.net','chammy.info','childsavetrust.org','chogmail.com','choplife.com',
|
||||
'clixser.com','cmail.net','coiico.com','cool.fr.nf','correo.blogos.net',
|
||||
'crapmail.org','crossroadsmail.com','curryworld.de','cust.in','dacoolest.com',
|
||||
'dandikmail.com','dayrep.com','dcemail.com','deadaddress.com','deadletter.ga',
|
||||
'deagot.com','dealja.com','despam.it','devnullmail.com','dfgh.net',
|
||||
'digitalsanctuary.com','discardmail.com','discardmail.de','disposableaddress.com',
|
||||
'disposableinbox.com','disposablemail.net','disposablemail.org','disposablemail.top',
|
||||
'dispostable.com','dk4f9h7y.fun','dodgeit.com','dodgit.com','donemail.ru',
|
||||
'dontreg.com','dontsendmespam.de','drdrb.com','drdrb.net','dropmail.me',
|
||||
'dumpandfuck.com','dumpmail.de','dumpyemail.com','e4ward.com','easytrashmail.com',
|
||||
'email60.com','emailfake.com','emailias.com','emailigo.com','emailinfive.com',
|
||||
'emailisvalid.com','emaillime.com','emailmiser.com','emailna.com','emailnax.com',
|
||||
'emailnew.com','emailo.pro','emailondeck.com','emailthe.net','emailtmp.com',
|
||||
'emailwarden.com','emailx.at.hm','emailxfer.com','emeil.in','emeil.ir',
|
||||
'emlhub.com','emltmp.com','enterto.com','ephemail.net','etranquil.com',
|
||||
'evopo.com','explodemail.com','express.net.ua','eyepaste.com','fakeinbox.com',
|
||||
'fakeinformation.com','fakemail.fr','fakemailgenerator.com','fakemail.net',
|
||||
'fastacura.com','fastchevy.com','fastchrysler.com','fastkawasaki.com','fastmazda.com',
|
||||
'fastmitsubishi.com','fastnissan.com','fastsubaru.com','fastsuzuki.com','fasttoyota.com',
|
||||
'fastyamaha.com','fatflap.com','fdcserver.net','fightallspam.com','filzmail.com',
|
||||
'fixmail.tk','fizmail.com','fleckens.hu','flemail.com','frapmail.com',
|
||||
'friendlymail.co.uk','fuckingduh.com','fudgerub.com','fux0ringduh.com','fyxm.net',
|
||||
'garliclife.com','gehensiemirnichtaufdensack.de','gelitik.in','get2mail.fr',
|
||||
'getairmail.com','getonemail.com','giantmail.de','girlsundertheinfluence.com',
|
||||
'gishpuppy.com','gmailnew.com','goemailgo.com','gorillaswithdirtyarmpits.com',
|
||||
'gotmail.net','gotmail.org','gowikibooks.com','gowikicampus.com','gowikicars.com',
|
||||
'gowikifilms.com','gowikigames.com','gowikimusic.com','gowikinetwork.com',
|
||||
'gowikitravel.com','grr.la','guerillamail.biz','guerillamail.com','guerillamail.de',
|
||||
'guerillamail.info','guerillamail.net','guerillamail.org','guerrillamail.biz',
|
||||
'guerrillamail.com','guerrillamail.de','guerrillamail.info','guerrillamail.net',
|
||||
'guerrillamail.org','guerrillamailblock.com','gustr.com','h.mintemail.com',
|
||||
'haltospam.com','haqed.com','harakirimail.com','hat-geld.de','hatespam.org',
|
||||
'herp.in','hidemail.de','high.net','hmamail.com','hopemail.biz',
|
||||
'hulapla.de','ieatspam.eu','ieatspam.info','ieh-mail.de','ihateyoualot.info',
|
||||
'iheartspam.org','ikbenspamvrij.nl','imails.info','inboxclean.com','inboxclean.org',
|
||||
'incognitomail.net','incognitomail.org','insorg.org','internet-e-mail.de','jetable.fr',
|
||||
'jetable.net','jetable.org','jnxjn.com','joggly.com','junk.to',
|
||||
'justamail.net','kasmail.com','kaspop.com','killmail.com','killmail.net',
|
||||
'klzlk.com','koszmail.pl','kurzepost.de','laoeq.com','lavabit.com',
|
||||
'letthemeatspam.com','lhsdv.com','lifebyfood.com','lol.ovpn.to','lookugly.com',
|
||||
'lopl.co.cc','lortemail.dk','lovemeleaveme.com','lr78.com','lukop.dk',
|
||||
'm4ilweb.info','maboard.com','mail-filter.com','mail-temporaire.com','mail.by',
|
||||
'mail1a.de','mail2rss.org','mail333.com','mailbidon.com','mailblocks.com',
|
||||
'mailbucket.org','mailchop.com','maileater.com','maileimer.de','mailexpire.com',
|
||||
'mailfa.tk','mailforspam.com','mailfree.ga','mailguard.me','mailimate.com',
|
||||
'mailinatar.com','mailinater.com','mailinator.com','mailinator.net','mailinator.org',
|
||||
'mailinator.us','mailinator2.com','mailincubator.com','mailismagic.com','mailjunk.cf',
|
||||
'mailjunk.ga','mailjunk.gq','mailjunk.ml','mailjunk.tk','mailme.gq',
|
||||
'mailme.ir','mailme24.com','mailmetrash.com','mailmoat.com','mailms.com',
|
||||
'mailnull.com','mailpick.biz','mailproxsy.com','mailquack.com','mailrock.biz',
|
||||
'mailscrap.com','mailseal.de','mailshell.com','mailsiphon.com','mailslapping.com',
|
||||
'mailslite.com','mailsoul.com','mailsucker.net','mailtemp.info','mailtome.de',
|
||||
'mailtothis.com','mailtrash.net','mailtv.net','mailzilla.com','makemetheking.com',
|
||||
'mbx.cc','mega.zik.dj','meinspamschutz.de','meltmail.com','messagebeamer.de',
|
||||
'mezimages.net','mintemail.com','moncourrier.fr.nf','monemail.fr.nf','monmail.fr.nf',
|
||||
'msa.minsmail.com','msgos.com','mt2014.com','mt2015.com','mucincanon.com',
|
||||
'mucke.de','mugglenet.com','myfastmail.com','mymailoasis.com','mynetstore.de',
|
||||
'netzidiot.de','neverbox.com','nice-4u.com','nincsmail.hu','nmail.cf',
|
||||
'nnot.net','nobulk.com','noclickemail.com','nodezine.com','nomail.pw',
|
||||
'nomail.xl.cx','nomail2me.com','nomoremail.net','nospam.ze.tc','nospam4.us',
|
||||
'nospamfor.us','nospammail.net','nospamthanks.info','notmailinator.com','nowmymail.com',
|
||||
'odnorazovoe.ru','onewaymail.com','online.ms','opayq.com','ordinaryamerican.net',
|
||||
'ovpn.to','owlpic.com','pecinan.com','pecinan.net','pecinan.org',
|
||||
'pepbot.com','pfui.ru','pimpedupmyspace.com','plexolan.de','pookmail.com',
|
||||
'privacy.net','proxymail.eu','prtnx.com','purelymail.com','putthisinyourspamdatabase.com',
|
||||
'qq.com.de','quickinbox.com','rcpt.at','recode.me','recursor.net',
|
||||
'regbypass.com','rklips.com','rmqkr.net','rppkn.com','rtrtr.com',
|
||||
'runbox.com','s0ny.net','safe-mail.net','safetymail.info','safetypost.de',
|
||||
'sandelf.de','saynotospams.com','schafmail.de','schrott-email.de','secretemail.de',
|
||||
'secure-mail.biz','sexyalwasmi.top','sharedmailbox.org','sharklasers.com','shieldedmail.com',
|
||||
'shiftmail.com','shitmail.de','shitmail.me','shitmail.org','shitware.nl',
|
||||
'shortmail.net','sibmail.com','skeefmail.com','slaskpost.se','slopsbox.com',
|
||||
'smellfear.com','snakemail.com','sneakemail.com','snkmail.com','sofimail.com',
|
||||
'sogetthis.com','sohu.com.de','soisz.com','spam.la','spam.org.tr',
|
||||
'spam.su','spam4.me','spamavert.com','spambob.com','spambob.net',
|
||||
'spambob.org','spambog.com','spambog.de','spambog.ru','spambox.info',
|
||||
'spambox.us','spamcannon.com','spamcannon.net','spamcero.com','spamcon.org',
|
||||
'spamcorptastic.com','spamcowboy.com','spamcowboy.net','spamcowboy.org','spamday.com',
|
||||
'spamex.com','spamfree.eu','spamfree24.de','spamfree24.eu','spamfree24.info',
|
||||
'spamfree24.net','spamfree24.org','spamgourmet.com','spamgourmet.net','spamgourmet.org',
|
||||
'spamherelots.com','spamhereplease.com','spamhole.com','spamify.com','spaminator.de',
|
||||
'spamkill.info','spaml.com','spaml.de','spammotel.com','spamoff.de',
|
||||
'spamsalad.in','spamslicer.com','spamspot.com','spamstack.net','spamthis.co.uk',
|
||||
'spamthisplease.com','spamtrail.com','spamtroll.net','speed.1s.fr','speedymail.net',
|
||||
'spoofmail.de','super-auswahl.de','supergreatmail.com','supermailer.jp','superrito.com',
|
||||
'superstachel.de','suremail.info','sweetxxx.de','tafmail.com','tagyourself.com',
|
||||
'teleworm.com','teleworm.us','temp-mail.io','temp-mail.org','temp-mail.ru',
|
||||
'temp.bartbot.com','tempail.com','tempalias.com','tempe-mail.com','tempemailaddress.com',
|
||||
'tempinbox.co.uk','tempinbox.com','tempmail.it','tempmail.net','tempmail.us',
|
||||
'tempmail2.com','tempomail.fr','temporaryemail.net','temporaryemail.us','temporaryforwarding.com',
|
||||
'temporaryinbox.com','temporarymail.org','tempsky.com','thankyou2010.com','thc.st',
|
||||
'thelimestones.com','thisisnotmyrealemail.com','throam.com','throwam.com','throwaway.email',
|
||||
'throwam.com','throwaway.email','throwam.com','tilien.com','tinyurl24.com',
|
||||
'tmailinator.com','toiea.com','toomail.biz','top9top.com','tradermail.info',
|
||||
'trash-amil.com','trash-mail.at','trash-mail.com','trash-mail.de','trash-mail.io',
|
||||
'trash-mail.net','trash2009.com','trash2010.com','trash2011.com','trashcanmail.com',
|
||||
'trashdevil.com','trashdevil.de','trashemail.de','trashimail.com','trashmail.at',
|
||||
'trashmail.com','trashmail.es','trashmail.io','trashmail.me','trashmail.net',
|
||||
'trashmail.org','trashmailer.com','trashpanda.de','trashymail.com','trillianpro.com',
|
||||
'turual.com','twinmail.de','tyldd.com','uggsrock.com','upliftnow.com',
|
||||
'uroid.com','username.e4ward.com','venompen.com','veryrealemail.com','vidchart.com',
|
||||
'viditag.com','viewcastmedia.com','viewcastmedia.net','viewcastmedia.org','vomoto.com',
|
||||
'vubby.com','walala.org','watch-harry-potter.com','wetrainbayarea.com','wetrainbayarea.org',
|
||||
'wilemail.com','willhackforfood.biz','willselfdestruct.com','wmail.cf','wolfsmail.tk',
|
||||
'wuzupmail.net','xagloo.com','xemaps.com','xents.com','xMailer.net',
|
||||
'xmaily.com','xn--9kq967o.com','xoxox.cc','xperiae5.com','xyzfree.net',
|
||||
'yahomail.org','yapped.net','yepmail.net','yourdomain.com','yopmail.com',
|
||||
'yopmail.fr','yopmail.net','youremail.cf','yourewronghello.com','z1p.biz',
|
||||
'za.com','zippymail.info','zoemail.net','zoemail.org','zomg.info',
|
||||
'zxcv.com','zxcvbnm.com','zzz.com',
|
||||
];
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$domain = strtolower(trim(substr(strrchr($value, '@'), 1)));
|
||||
|
||||
if (in_array($domain, self::BLOCKED, true)) {
|
||||
$fail('Registrations from disposable email addresses are not allowed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GeoIpService
|
||||
{
|
||||
private static array $privateRanges = [
|
||||
'127.', '::1', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
|
||||
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
|
||||
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.',
|
||||
'192.168.',
|
||||
];
|
||||
|
||||
public static function lookup(string $ip): array
|
||||
{
|
||||
foreach (self::$privateRanges as $prefix) {
|
||||
if (str_starts_with($ip, $prefix)) {
|
||||
return ['country' => null, 'country_name' => null];
|
||||
}
|
||||
}
|
||||
|
||||
return Cache::remember('geoip_' . md5($ip), 86400, function () use ($ip) {
|
||||
try {
|
||||
$response = Http::timeout(3)->get(
|
||||
"http://ip-api.com/json/{$ip}",
|
||||
['fields' => 'status,country,countryCode']
|
||||
);
|
||||
|
||||
if ($response->successful() && $response->json('status') === 'success') {
|
||||
return [
|
||||
'country' => $response->json('countryCode'),
|
||||
'country_name' => $response->json('country'),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('GeoIP lookup failed for ' . $ip . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return ['country' => null, 'country_name' => null];
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user