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:
ghassanandClaude Sonnet 4.6 committed 2026-05-13 13:24:32 +03:00
1 parent d44490dfe0
commit 0b2e95ea65
161 files changed
+27496 -7250

No files matched your search

+361
View File
@@ -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;
}
}
+12 -8
View File
@@ -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.');
}
});
}
}
+101
View File
@@ -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');
}
}
+57 -17
View File
@@ -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]);
}
}
+25
View File
@@ -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),
]);
}
}
+105 -19
View File
@@ -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);
}
}
+126
View File
@@ -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(),
]);
}
}
+674 -26
View File
@@ -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');
}
}
+267 -53
View File
@@ -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
+1
View File
@@ -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,
+1 -1
View File
@@ -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.
+3 -1
View File
@@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
//
'videos/*/share',
'playlists/*/share',
'videos/*/slideshow/generate',
];
}
+36 -10
View File
@@ -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
View File
@@ -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
}
}
}
+34
View File
@@ -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',
);
}
}
+33
View File
@@ -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',
);
}
}
+86
View File
@@ -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)),
};
}
}
+5
View File
@@ -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()
{
+22
View File
@@ -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
View File
@@ -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)
{
+51
View File
@@ -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;
}
}
+20
View File
@@ -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);
}
}
+20
View File
@@ -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);
}
}
+20
View File
@@ -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();
}
}
+92
View File
@@ -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} ";
}
}
+16
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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');
}
}
+20
View File
@@ -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),
];
}
}
+33
View File
@@ -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,
];
}
}
+39
View File
@@ -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
View File
@@ -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');
}
}
+141
View File
@@ -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.');
}
}
}
+46
View File
@@ -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];
});
}
}