diff --git a/.claude-flow/metrics/system-metrics.json b/.claude-flow/metrics/system-metrics.json deleted file mode 100644 index 40fa797..0000000 --- a/.claude-flow/metrics/system-metrics.json +++ /dev/null @@ -1,1310 +0,0 @@ -[ - { - "timestamp": 1756976770731, - "memoryTotal": 25769803776, - "memoryUsed": 22083682304, - "memoryFree": 3686121472, - "memoryUsagePercent": 85.69596608479819, - "memoryEfficiency": 14.304033915201813, - "cpuCount": 14, - "cpuLoad": 0.13905552455357142, - "platform": "darwin", - "uptime": 244822 - }, - { - "timestamp": 1756978367027, - "memoryTotal": 25769803776, - "memoryUsed": 22116270080, - "memoryFree": 3653533696, - "memoryUsagePercent": 85.82242329915366, - "memoryEfficiency": 14.177576700846345, - "cpuCount": 14, - "cpuLoad": 0.14787946428571427, - "platform": "darwin", - "uptime": 246419 - }, - { - "timestamp": 1756979297344, - "memoryTotal": 25769803776, - "memoryUsed": 22023405568, - "memoryFree": 3746398208, - "memoryUsagePercent": 85.4620615641276, - "memoryEfficiency": 14.537938435872405, - "cpuCount": 14, - "cpuLoad": 0.13162667410714285, - "platform": "darwin", - "uptime": 247349 - }, - { - "timestamp": 1756979797631, - "memoryTotal": 25769803776, - "memoryUsed": 22031400960, - "memoryFree": 3738402816, - "memoryUsagePercent": 85.49308776855469, - "memoryEfficiency": 14.506912231445312, - "cpuCount": 14, - "cpuLoad": 0.11802455357142858, - "platform": "darwin", - "uptime": 247849 - }, - { - "timestamp": 1756980385348, - "memoryTotal": 25769803776, - "memoryUsed": 21984493568, - "memoryFree": 3785310208, - "memoryUsagePercent": 85.31106313069662, - "memoryEfficiency": 14.688936869303376, - "cpuCount": 14, - "cpuLoad": 0.12179129464285714, - "platform": "darwin", - "uptime": 248437 - }, - { - "timestamp": 1756981036792, - "memoryTotal": 25769803776, - "memoryUsed": 21984346112, - "memoryFree": 3785457664, - "memoryUsagePercent": 85.31049092610678, - "memoryEfficiency": 14.68950907389322, - "cpuCount": 14, - "cpuLoad": 0.124755859375, - "platform": "darwin", - "uptime": 249088 - }, - { - "timestamp": 1756981813341, - "memoryTotal": 25769803776, - "memoryUsed": 21988802560, - "memoryFree": 3781001216, - "memoryUsagePercent": 85.3277842203776, - "memoryEfficiency": 14.672215779622405, - "cpuCount": 14, - "cpuLoad": 0.11530412946428571, - "platform": "darwin", - "uptime": 249865 - }, - { - "timestamp": 1756983148070, - "memoryTotal": 25769803776, - "memoryUsed": 22067200000, - "memoryFree": 3702603776, - "memoryUsagePercent": 85.63200632731119, - "memoryEfficiency": 14.367993672688812, - "cpuCount": 14, - "cpuLoad": 0.10501534598214286, - "platform": "darwin", - "uptime": 251200 - }, - { - "timestamp": 1756984078590, - "memoryTotal": 25769803776, - "memoryUsed": 22027255808, - "memoryFree": 3742547968, - "memoryUsagePercent": 85.47700246175131, - "memoryEfficiency": 14.522997538248688, - "cpuCount": 14, - "cpuLoad": 0.11460658482142858, - "platform": "darwin", - "uptime": 252130 - }, - { - "timestamp": 1756985683206, - "memoryTotal": 25769803776, - "memoryUsed": 22015279104, - "memoryFree": 3754524672, - "memoryUsagePercent": 85.43052673339844, - "memoryEfficiency": 14.569473266601562, - "cpuCount": 14, - "cpuLoad": 0.13438197544642858, - "platform": "darwin", - "uptime": 253735 - }, - { - "timestamp": 1756985713214, - "memoryTotal": 25769803776, - "memoryUsed": 22080749568, - "memoryFree": 3689054208, - "memoryUsagePercent": 85.68458557128906, - "memoryEfficiency": 14.315414428710938, - "cpuCount": 14, - "cpuLoad": 0.13148716517857142, - "platform": "darwin", - "uptime": 253765 - }, - { - "timestamp": 1756986643088, - "memoryTotal": 25769803776, - "memoryUsed": 22106800128, - "memoryFree": 3663003648, - "memoryUsagePercent": 85.78567504882812, - "memoryEfficiency": 14.214324951171875, - "cpuCount": 14, - "cpuLoad": 0.14536830357142858, - "platform": "darwin", - "uptime": 254695 - }, - { - "timestamp": 1756987573878, - "memoryTotal": 25769803776, - "memoryUsed": 21998878720, - "memoryFree": 3770925056, - "memoryUsagePercent": 85.36688486735025, - "memoryEfficiency": 14.633115132649749, - "cpuCount": 14, - "cpuLoad": 0.13675362723214285, - "platform": "darwin", - "uptime": 255625 - }, - { - "timestamp": 1756989358041, - "memoryTotal": 25769803776, - "memoryUsed": 22040838144, - "memoryFree": 3728965632, - "memoryUsagePercent": 85.52970886230469, - "memoryEfficiency": 14.470291137695312, - "cpuCount": 14, - "cpuLoad": 0.22188895089285715, - "platform": "darwin", - "uptime": 257410 - }, - { - "timestamp": 1756990287962, - "memoryTotal": 25769803776, - "memoryUsed": 22041051136, - "memoryFree": 3728752640, - "memoryUsagePercent": 85.53053538004556, - "memoryEfficiency": 14.469464619954437, - "cpuCount": 14, - "cpuLoad": 0.19611467633928573, - "platform": "darwin", - "uptime": 258339 - }, - { - "timestamp": 1756991217858, - "memoryTotal": 25769803776, - "memoryUsed": 22146416640, - "memoryFree": 3623387136, - "memoryUsagePercent": 85.93940734863281, - "memoryEfficiency": 14.060592651367188, - "cpuCount": 14, - "cpuLoad": 0.2931431361607143, - "platform": "darwin", - "uptime": 259269 - }, - { - "timestamp": 1756993007497, - "memoryTotal": 25769803776, - "memoryUsed": 22205923328, - "memoryFree": 3563880448, - "memoryUsagePercent": 86.17032368977866, - "memoryEfficiency": 13.829676310221345, - "cpuCount": 14, - "cpuLoad": 0.23416573660714285, - "platform": "darwin", - "uptime": 261059 - }, - { - "timestamp": 1756993937358, - "memoryTotal": 25769803776, - "memoryUsed": 22175989760, - "memoryFree": 3593814016, - "memoryUsagePercent": 86.05416615804037, - "memoryEfficiency": 13.945833841959626, - "cpuCount": 14, - "cpuLoad": 0.2876674107142857, - "platform": "darwin", - "uptime": 261989 - }, - { - "timestamp": 1756994868862, - "memoryTotal": 25769803776, - "memoryUsed": 22129360896, - "memoryFree": 3640442880, - "memoryUsagePercent": 85.87322235107422, - "memoryEfficiency": 14.126777648925781, - "cpuCount": 14, - "cpuLoad": 0.21334402901785715, - "platform": "darwin", - "uptime": 262920 - }, - { - "timestamp": 1756994898867, - "memoryTotal": 25769803776, - "memoryUsed": 22363602944, - "memoryFree": 3406200832, - "memoryUsagePercent": 86.7822011311849, - "memoryEfficiency": 13.217798868815095, - "cpuCount": 14, - "cpuLoad": 0.17414202008928573, - "platform": "darwin", - "uptime": 262950 - }, - { - "timestamp": 1756996674981, - "memoryTotal": 25769803776, - "memoryUsed": 22382346240, - "memoryFree": 3387457536, - "memoryUsagePercent": 86.85493469238281, - "memoryEfficiency": 13.145065307617188, - "cpuCount": 14, - "cpuLoad": 0.2833775111607143, - "platform": "darwin", - "uptime": 264726 - }, - { - "timestamp": 1756997604840, - "memoryTotal": 25769803776, - "memoryUsed": 22346170368, - "memoryFree": 3423633408, - "memoryUsagePercent": 86.71455383300781, - "memoryEfficiency": 13.285446166992188, - "cpuCount": 14, - "cpuLoad": 0.24529157366071427, - "platform": "darwin", - "uptime": 265656 - }, - { - "timestamp": 1756998533975, - "memoryTotal": 25769803776, - "memoryUsed": 22384820224, - "memoryFree": 3384983552, - "memoryUsagePercent": 86.86453501383463, - "memoryEfficiency": 13.135464986165374, - "cpuCount": 14, - "cpuLoad": 0.19928850446428573, - "platform": "darwin", - "uptime": 266585 - }, - { - "timestamp": 1756999432955, - "memoryTotal": 25769803776, - "memoryUsed": 22392537088, - "memoryFree": 3377266688, - "memoryUsagePercent": 86.89448038736978, - "memoryEfficiency": 13.105519612630218, - "cpuCount": 14, - "cpuLoad": 0.172119140625, - "platform": "darwin", - "uptime": 267484 - }, - { - "timestamp": 1757000362679, - "memoryTotal": 25769803776, - "memoryUsed": 22417604608, - "memoryFree": 3352199168, - "memoryUsagePercent": 86.99175516764322, - "memoryEfficiency": 13.00824483235678, - "cpuCount": 14, - "cpuLoad": 0.14739118303571427, - "platform": "darwin", - "uptime": 268414 - }, - { - "timestamp": 1757001292627, - "memoryTotal": 25769803776, - "memoryUsed": 22362128384, - "memoryFree": 3407675392, - "memoryUsagePercent": 86.77647908528647, - "memoryEfficiency": 13.223520914713532, - "cpuCount": 14, - "cpuLoad": 0.19475446428571427, - "platform": "darwin", - "uptime": 269344 - }, - { - "timestamp": 1757002224294, - "memoryTotal": 25769803776, - "memoryUsed": 22397206528, - "memoryFree": 3372597248, - "memoryUsagePercent": 86.9126001993815, - "memoryEfficiency": 13.087399800618499, - "cpuCount": 14, - "cpuLoad": 0.20267159598214285, - "platform": "darwin", - "uptime": 270276 - }, - { - "timestamp": 1757003998867, - "memoryTotal": 25769803776, - "memoryUsed": 22482386944, - "memoryFree": 3287416832, - "memoryUsagePercent": 87.24314371744791, - "memoryEfficiency": 12.756856282552093, - "cpuCount": 14, - "cpuLoad": 0.19932338169642858, - "platform": "darwin", - "uptime": 272050 - }, - { - "timestamp": 1757004928733, - "memoryTotal": 25769803776, - "memoryUsed": 22518530048, - "memoryFree": 3251273728, - "memoryUsagePercent": 87.3833974202474, - "memoryEfficiency": 12.616602579752595, - "cpuCount": 14, - "cpuLoad": 0.17769949776785715, - "platform": "darwin", - "uptime": 272980 - }, - { - "timestamp": 1757006260254, - "memoryTotal": 25769803776, - "memoryUsed": 22553952256, - "memoryFree": 3215851520, - "memoryUsagePercent": 87.52085367838541, - "memoryEfficiency": 12.479146321614593, - "cpuCount": 14, - "cpuLoad": 0.14592633928571427, - "platform": "darwin", - "uptime": 274312 - }, - { - "timestamp": 1757006741173, - "memoryTotal": 25769803776, - "memoryUsed": 22593519616, - "memoryFree": 3176284160, - "memoryUsagePercent": 87.67439524332681, - "memoryEfficiency": 12.325604756673187, - "cpuCount": 14, - "cpuLoad": 0.12845284598214285, - "platform": "darwin", - "uptime": 274793 - }, - { - "timestamp": 1757008570941, - "memoryTotal": 25769803776, - "memoryUsed": 22517923840, - "memoryFree": 3251879936, - "memoryUsagePercent": 87.38104502360025, - "memoryEfficiency": 12.618954976399749, - "cpuCount": 14, - "cpuLoad": 0.17375837053571427, - "platform": "darwin", - "uptime": 276622 - }, - { - "timestamp": 1757008784934, - "memoryTotal": 25769803776, - "memoryUsed": 22463873024, - "memoryFree": 3305930752, - "memoryUsagePercent": 87.17130025227866, - "memoryEfficiency": 12.828699747721345, - "cpuCount": 14, - "cpuLoad": 0.18000139508928573, - "platform": "darwin", - "uptime": 276836 - }, - { - "timestamp": 1757010082885, - "memoryTotal": 25769803776, - "memoryUsed": 22423666688, - "memoryFree": 3346137088, - "memoryUsagePercent": 87.01527913411459, - "memoryEfficiency": 12.984720865885407, - "cpuCount": 14, - "cpuLoad": 0.17156110491071427, - "platform": "darwin", - "uptime": 278134 - }, - { - "timestamp": 1757011317778, - "memoryTotal": 25769803776, - "memoryUsed": 22506799104, - "memoryFree": 3263004672, - "memoryUsagePercent": 87.33787536621094, - "memoryEfficiency": 12.662124633789062, - "cpuCount": 14, - "cpuLoad": 0.19182477678571427, - "platform": "darwin", - "uptime": 279369 - }, - { - "timestamp": 1757012247856, - "memoryTotal": 25769803776, - "memoryUsed": 22472916992, - "memoryFree": 3296886784, - "memoryUsagePercent": 87.2063954671224, - "memoryEfficiency": 12.793604532877595, - "cpuCount": 14, - "cpuLoad": 0.17428152901785715, - "platform": "darwin", - "uptime": 280299 - }, - { - "timestamp": 1757013177725, - "memoryTotal": 25769803776, - "memoryUsed": 22439444480, - "memoryFree": 3330359296, - "memoryUsagePercent": 87.07650502522787, - "memoryEfficiency": 12.923494974772126, - "cpuCount": 14, - "cpuLoad": 0.13152204241071427, - "platform": "darwin", - "uptime": 281229 - }, - { - "timestamp": 1757014979846, - "memoryTotal": 25769803776, - "memoryUsed": 22488563712, - "memoryFree": 3281240064, - "memoryUsagePercent": 87.2671127319336, - "memoryEfficiency": 12.732887268066406, - "cpuCount": 14, - "cpuLoad": 0.16807338169642858, - "platform": "darwin", - "uptime": 283031 - }, - { - "timestamp": 1757015910543, - "memoryTotal": 25769803776, - "memoryUsed": 22500032512, - "memoryFree": 3269771264, - "memoryUsagePercent": 87.31161753336588, - "memoryEfficiency": 12.688382466634124, - "cpuCount": 14, - "cpuLoad": 0.14913504464285715, - "platform": "darwin", - "uptime": 283962 - }, - { - "timestamp": 1757016840412, - "memoryTotal": 25769803776, - "memoryUsed": 22488858624, - "memoryFree": 3280945152, - "memoryUsagePercent": 87.26825714111328, - "memoryEfficiency": 12.731742858886719, - "cpuCount": 14, - "cpuLoad": 0.14114815848214285, - "platform": "darwin", - "uptime": 284892 - }, - { - "timestamp": 1757018644152, - "memoryTotal": 25769803776, - "memoryUsed": 22583656448, - "memoryFree": 3186147328, - "memoryUsagePercent": 87.63612111409506, - "memoryEfficiency": 12.363878885904938, - "cpuCount": 14, - "cpuLoad": 0.14460100446428573, - "platform": "darwin", - "uptime": 286696 - }, - { - "timestamp": 1757019654709, - "memoryTotal": 25769803776, - "memoryUsed": 22517907456, - "memoryFree": 3251896320, - "memoryUsagePercent": 87.3809814453125, - "memoryEfficiency": 12.6190185546875, - "cpuCount": 14, - "cpuLoad": 0.13654436383928573, - "platform": "darwin", - "uptime": 287706 - }, - { - "timestamp": 1757020584574, - "memoryTotal": 25769803776, - "memoryUsed": 22511894528, - "memoryFree": 3257909248, - "memoryUsagePercent": 87.35764821370444, - "memoryEfficiency": 12.642351786295563, - "cpuCount": 14, - "cpuLoad": 0.12245396205357142, - "platform": "darwin", - "uptime": 288636 - }, - { - "timestamp": 1757021581568, - "memoryTotal": 25769803776, - "memoryUsed": 22528606208, - "memoryFree": 3241197568, - "memoryUsagePercent": 87.42249806722006, - "memoryEfficiency": 12.577501932779938, - "cpuCount": 14, - "cpuLoad": 0.10940987723214286, - "platform": "darwin", - "uptime": 289633 - }, - { - "timestamp": 1757022699281, - "memoryTotal": 25769803776, - "memoryUsed": 22516465664, - "memoryFree": 3253338112, - "memoryUsagePercent": 87.37538655598959, - "memoryEfficiency": 12.624613444010407, - "cpuCount": 14, - "cpuLoad": 0.09392438616071429, - "platform": "darwin", - "uptime": 290751 - }, - { - "timestamp": 1757023629190, - "memoryTotal": 25769803776, - "memoryUsed": 22486761472, - "memoryFree": 3283042304, - "memoryUsagePercent": 87.26011912027994, - "memoryEfficiency": 12.739880879720062, - "cpuCount": 14, - "cpuLoad": 0.34134347098214285, - "platform": "darwin", - "uptime": 291681 - }, - { - "timestamp": 1757024559045, - "memoryTotal": 25769803776, - "memoryUsed": 22489694208, - "memoryFree": 3280109568, - "memoryUsagePercent": 87.27149963378906, - "memoryEfficiency": 12.728500366210938, - "cpuCount": 14, - "cpuLoad": 0.24815150669642858, - "platform": "darwin", - "uptime": 292611 - }, - { - "timestamp": 1757025490668, - "memoryTotal": 25769803776, - "memoryUsed": 22523346944, - "memoryFree": 3246456832, - "memoryUsagePercent": 87.40208943684897, - "memoryEfficiency": 12.597910563151032, - "cpuCount": 14, - "cpuLoad": 0.388427734375, - "platform": "darwin", - "uptime": 293542 - }, - { - "timestamp": 1757026427267, - "memoryTotal": 25769803776, - "memoryUsed": 22455500800, - "memoryFree": 3314302976, - "memoryUsagePercent": 87.13881174723306, - "memoryEfficiency": 12.861188252766937, - "cpuCount": 14, - "cpuLoad": 0.31103515625, - "platform": "darwin", - "uptime": 294479 - }, - { - "timestamp": 1757027358042, - "memoryTotal": 25769803776, - "memoryUsed": 22435151872, - "memoryFree": 3334651904, - "memoryUsagePercent": 87.05984751383463, - "memoryEfficiency": 12.940152486165374, - "cpuCount": 14, - "cpuLoad": 0.22031947544642858, - "platform": "darwin", - "uptime": 295410 - }, - { - "timestamp": 1757028288034, - "memoryTotal": 25769803776, - "memoryUsed": 22459318272, - "memoryFree": 3310485504, - "memoryUsagePercent": 87.15362548828125, - "memoryEfficiency": 12.84637451171875, - "cpuCount": 14, - "cpuLoad": 0.17253766741071427, - "platform": "darwin", - "uptime": 296340 - }, - { - "timestamp": 1757029222921, - "memoryTotal": 25769803776, - "memoryUsed": 22519431168, - "memoryFree": 3250372608, - "memoryUsagePercent": 87.38689422607422, - "memoryEfficiency": 12.613105773925781, - "cpuCount": 14, - "cpuLoad": 0.15105329241071427, - "platform": "darwin", - "uptime": 297274 - }, - { - "timestamp": 1757031053562, - "memoryTotal": 25769803776, - "memoryUsed": 22491103232, - "memoryFree": 3278700544, - "memoryUsagePercent": 87.27696736653647, - "memoryEfficiency": 12.723032633463532, - "cpuCount": 14, - "cpuLoad": 0.16228376116071427, - "platform": "darwin", - "uptime": 299105 - }, - { - "timestamp": 1757031984664, - "memoryTotal": 25769803776, - "memoryUsed": 22487859200, - "memoryFree": 3281944576, - "memoryUsagePercent": 87.2643788655599, - "memoryEfficiency": 12.735621134440095, - "cpuCount": 14, - "cpuLoad": 0.9431501116071429, - "platform": "darwin", - "uptime": 300036 - }, - { - "timestamp": 1757032014659, - "memoryTotal": 25769803776, - "memoryUsed": 22499999744, - "memoryFree": 3269804032, - "memoryUsagePercent": 87.31149037679037, - "memoryEfficiency": 12.688509623209626, - "cpuCount": 14, - "cpuLoad": 0.613037109375, - "platform": "darwin", - "uptime": 300066 - }, - { - "timestamp": 1757032944526, - "memoryTotal": 25769803776, - "memoryUsed": 22538846208, - "memoryFree": 3230957568, - "memoryUsagePercent": 87.46223449707031, - "memoryEfficiency": 12.537765502929688, - "cpuCount": 14, - "cpuLoad": 0.4217006138392857, - "platform": "darwin", - "uptime": 300996 - }, - { - "timestamp": 1757033881229, - "memoryTotal": 25769803776, - "memoryUsed": 22501736448, - "memoryFree": 3268067328, - "memoryUsagePercent": 87.31822967529297, - "memoryEfficiency": 12.681770324707031, - "cpuCount": 14, - "cpuLoad": 0.30838448660714285, - "platform": "darwin", - "uptime": 301933 - }, - { - "timestamp": 1757034928890, - "memoryTotal": 25769803776, - "memoryUsed": 22489563136, - "memoryFree": 3280240640, - "memoryUsagePercent": 87.27099100748697, - "memoryEfficiency": 12.72900899251303, - "cpuCount": 14, - "cpuLoad": 0.22017996651785715, - "platform": "darwin", - "uptime": 302980 - }, - { - "timestamp": 1757036115524, - "memoryTotal": 25769803776, - "memoryUsed": 22608723968, - "memoryFree": 3161079808, - "memoryUsagePercent": 87.7333958943685, - "memoryEfficiency": 12.266604105631501, - "cpuCount": 14, - "cpuLoad": 0.28079659598214285, - "platform": "darwin", - "uptime": 304167 - }, - { - "timestamp": 1757037045386, - "memoryTotal": 25769803776, - "memoryUsed": 22560571392, - "memoryFree": 3209232384, - "memoryUsagePercent": 87.54653930664062, - "memoryEfficiency": 12.453460693359375, - "cpuCount": 14, - "cpuLoad": 0.24016462053571427, - "platform": "darwin", - "uptime": 305097 - }, - { - "timestamp": 1757037976652, - "memoryTotal": 25769803776, - "memoryUsed": 22526394368, - "memoryFree": 3243409408, - "memoryUsagePercent": 87.4139149983724, - "memoryEfficiency": 12.586085001627595, - "cpuCount": 14, - "cpuLoad": 0.42515345982142855, - "platform": "darwin", - "uptime": 306028 - }, - { - "timestamp": 1757038906508, - "memoryTotal": 25769803776, - "memoryUsed": 22509404160, - "memoryFree": 3260399616, - "memoryUsagePercent": 87.34798431396484, - "memoryEfficiency": 12.652015686035156, - "cpuCount": 14, - "cpuLoad": 0.31417410714285715, - "platform": "darwin", - "uptime": 306958 - }, - { - "timestamp": 1757040737982, - "memoryTotal": 25769803776, - "memoryUsed": 22573613056, - "memoryFree": 3196190720, - "memoryUsagePercent": 87.59714762369791, - "memoryEfficiency": 12.402852376302093, - "cpuCount": 14, - "cpuLoad": 0.22666713169642858, - "platform": "darwin", - "uptime": 308789 - }, - { - "timestamp": 1757041667845, - "memoryTotal": 25769803776, - "memoryUsed": 22554001408, - "memoryFree": 3215802368, - "memoryUsagePercent": 87.52104441324869, - "memoryEfficiency": 12.478955586751312, - "cpuCount": 14, - "cpuLoad": 0.18558175223214285, - "platform": "darwin", - "uptime": 309719 - }, - { - "timestamp": 1757041697851, - "memoryTotal": 25769803776, - "memoryUsed": 22536634368, - "memoryFree": 3233169408, - "memoryUsagePercent": 87.45365142822266, - "memoryEfficiency": 12.546348571777344, - "cpuCount": 14, - "cpuLoad": 0.223388671875, - "platform": "darwin", - "uptime": 309749 - }, - { - "timestamp": 1757043374157, - "memoryTotal": 25769803776, - "memoryUsed": 22591209472, - "memoryFree": 3178594304, - "memoryUsagePercent": 87.6654307047526, - "memoryEfficiency": 12.334569295247405, - "cpuCount": 14, - "cpuLoad": 0.21575055803571427, - "platform": "darwin", - "uptime": 311426 - }, - { - "timestamp": 1757045203843, - "memoryTotal": 25769803776, - "memoryUsed": 22615801856, - "memoryFree": 3154001920, - "memoryUsagePercent": 87.760861714681, - "memoryEfficiency": 12.239138285319001, - "cpuCount": 14, - "cpuLoad": 0.21861049107142858, - "platform": "darwin", - "uptime": 313255 - }, - { - "timestamp": 1757046849589, - "memoryTotal": 25769803776, - "memoryUsed": 22577872896, - "memoryFree": 3191930880, - "memoryUsagePercent": 87.61367797851562, - "memoryEfficiency": 12.386322021484375, - "cpuCount": 14, - "cpuLoad": 0.17431640625, - "platform": "darwin", - "uptime": 314901 - }, - { - "timestamp": 1757047412359, - "memoryTotal": 25769803776, - "memoryUsed": 22592602112, - "memoryFree": 3177201664, - "memoryUsagePercent": 87.67083485921225, - "memoryEfficiency": 12.329165140787751, - "cpuCount": 14, - "cpuLoad": 0.16566685267857142, - "platform": "darwin", - "uptime": 315464 - }, - { - "timestamp": 1757049172734, - "memoryTotal": 25769803776, - "memoryUsed": 22563028992, - "memoryFree": 3206774784, - "memoryUsagePercent": 87.55607604980469, - "memoryEfficiency": 12.443923950195312, - "cpuCount": 14, - "cpuLoad": 0.16385323660714285, - "platform": "darwin", - "uptime": 317224 - }, - { - "timestamp": 1757050676203, - "memoryTotal": 25769803776, - "memoryUsed": 22601105408, - "memoryFree": 3168698368, - "memoryUsagePercent": 87.7038319905599, - "memoryEfficiency": 12.296168009440095, - "cpuCount": 14, - "cpuLoad": 0.14079938616071427, - "platform": "darwin", - "uptime": 318728 - }, - { - "timestamp": 1757051606061, - "memoryTotal": 25769803776, - "memoryUsed": 22713892864, - "memoryFree": 3055910912, - "memoryUsagePercent": 88.1415049235026, - "memoryEfficiency": 11.858495076497405, - "cpuCount": 14, - "cpuLoad": 0.13776506696428573, - "platform": "darwin", - "uptime": 319658 - }, - { - "timestamp": 1757052537746, - "memoryTotal": 25769803776, - "memoryUsed": 22580903936, - "memoryFree": 3188899840, - "memoryUsagePercent": 87.62543996175131, - "memoryEfficiency": 12.374560038248688, - "cpuCount": 14, - "cpuLoad": 0.11872209821428571, - "platform": "darwin", - "uptime": 320589 - }, - { - "timestamp": 1757054339280, - "memoryTotal": 25769803776, - "memoryUsed": 22595158016, - "memoryFree": 3174645760, - "memoryUsagePercent": 87.68075307210287, - "memoryEfficiency": 12.319246927897126, - "cpuCount": 14, - "cpuLoad": 0.39041573660714285, - "platform": "darwin", - "uptime": 322391 - }, - { - "timestamp": 1757055431658, - "memoryTotal": 25769803776, - "memoryUsed": 22655533056, - "memoryFree": 3114270720, - "memoryUsagePercent": 87.9150390625, - "memoryEfficiency": 12.0849609375, - "cpuCount": 14, - "cpuLoad": 0.32847377232142855, - "platform": "darwin", - "uptime": 323483 - }, - { - "timestamp": 1757055467828, - "memoryTotal": 25769803776, - "memoryUsed": 22662905856, - "memoryFree": 3106897920, - "memoryUsagePercent": 87.94364929199219, - "memoryEfficiency": 12.056350708007812, - "cpuCount": 14, - "cpuLoad": 0.22586495535714285, - "platform": "darwin", - "uptime": 323519 - }, - { - "timestamp": 1757056782262, - "memoryTotal": 25769803776, - "memoryUsed": 22744760320, - "memoryFree": 3025043456, - "memoryUsagePercent": 88.26128641764322, - "memoryEfficiency": 11.73871358235678, - "cpuCount": 14, - "cpuLoad": 0.25851004464285715, - "platform": "darwin", - "uptime": 324834 - }, - { - "timestamp": 1757058004188, - "memoryTotal": 25769803776, - "memoryUsed": 22767648768, - "memoryFree": 3002155008, - "memoryUsagePercent": 88.35010528564453, - "memoryEfficiency": 11.649894714355469, - "cpuCount": 14, - "cpuLoad": 0.40625, - "platform": "darwin", - "uptime": 326056 - }, - { - "timestamp": 1757058940262, - "memoryTotal": 25769803776, - "memoryUsed": 23432790016, - "memoryFree": 2337013760, - "memoryUsagePercent": 90.93119303385416, - "memoryEfficiency": 9.068806966145843, - "cpuCount": 14, - "cpuLoad": 0.5577218191964286, - "platform": "darwin", - "uptime": 326992 - }, - { - "timestamp": 1757059870842, - "memoryTotal": 25769803776, - "memoryUsed": 22779478016, - "memoryFree": 2990325760, - "memoryUsagePercent": 88.39600880940756, - "memoryEfficiency": 11.603991190592438, - "cpuCount": 14, - "cpuLoad": 0.36788504464285715, - "platform": "darwin", - "uptime": 327922 - }, - { - "timestamp": 1757060665141, - "memoryTotal": 25769803776, - "memoryUsed": 22768582656, - "memoryFree": 3001221120, - "memoryUsagePercent": 88.35372924804688, - "memoryEfficiency": 11.646270751953125, - "cpuCount": 14, - "cpuLoad": 0.26845005580357145, - "platform": "darwin", - "uptime": 328717 - }, - { - "timestamp": 1757060771941, - "memoryTotal": 25769803776, - "memoryUsed": 25222758400, - "memoryFree": 547045376, - "memoryUsagePercent": 97.87718454996744, - "memoryEfficiency": 2.1228154500325616, - "cpuCount": 14, - "cpuLoad": 0.31919642857142855, - "platform": "darwin", - "uptime": 328823 - }, - { - "timestamp": 1757060801942, - "memoryTotal": 25769803776, - "memoryUsed": 25176899584, - "memoryFree": 592904192, - "memoryUsagePercent": 97.69922892252603, - "memoryEfficiency": 2.300771077473968, - "cpuCount": 14, - "cpuLoad": 0.24801199776785715, - "platform": "darwin", - "uptime": 328853 - }, - { - "timestamp": 1757060831943, - "memoryTotal": 25769803776, - "memoryUsed": 25676742656, - "memoryFree": 93061120, - "memoryUsagePercent": 99.63887532552084, - "memoryEfficiency": 0.3611246744791572, - "cpuCount": 14, - "cpuLoad": 0.21369280133928573, - "platform": "darwin", - "uptime": 328883 - }, - { - "timestamp": 1757060861943, - "memoryTotal": 25769803776, - "memoryUsed": 24997085184, - "memoryFree": 772718592, - "memoryUsagePercent": 97.00145721435547, - "memoryEfficiency": 2.9985427856445312, - "cpuCount": 14, - "cpuLoad": 0.22384207589285715, - "platform": "darwin", - "uptime": 328913 - }, - { - "timestamp": 1757060891944, - "memoryTotal": 25769803776, - "memoryUsed": 25113264128, - "memoryFree": 656539648, - "memoryUsagePercent": 97.45229085286459, - "memoryEfficiency": 2.547709147135407, - "cpuCount": 14, - "cpuLoad": 0.43104771205357145, - "platform": "darwin", - "uptime": 328943 - }, - { - "timestamp": 1757060921945, - "memoryTotal": 25769803776, - "memoryUsed": 25421070336, - "memoryFree": 348733440, - "memoryUsagePercent": 98.64673614501953, - "memoryEfficiency": 1.3532638549804688, - "cpuCount": 14, - "cpuLoad": 0.32052176339285715, - "platform": "darwin", - "uptime": 328973 - }, - { - "timestamp": 1757060951945, - "memoryTotal": 25769803776, - "memoryUsed": 25011208192, - "memoryFree": 758595584, - "memoryUsagePercent": 97.05626169840494, - "memoryEfficiency": 2.9437383015950616, - "cpuCount": 14, - "cpuLoad": 0.24100167410714285, - "platform": "darwin", - "uptime": 329003 - }, - { - "timestamp": 1757061720052, - "memoryTotal": 25769803776, - "memoryUsed": 23711154176, - "memoryFree": 2058649600, - "memoryUsagePercent": 92.01138814290366, - "memoryEfficiency": 7.988611857096345, - "cpuCount": 14, - "cpuLoad": 0.21930803571428573, - "platform": "darwin", - "uptime": 329772 - }, - { - "timestamp": 1757061750044, - "memoryTotal": 25769803776, - "memoryUsed": 24752013312, - "memoryFree": 1017790464, - "memoryUsagePercent": 96.05045318603516, - "memoryEfficiency": 3.9495468139648438, - "cpuCount": 14, - "cpuLoad": 0.2721470424107143, - "platform": "darwin", - "uptime": 329802 - }, - { - "timestamp": 1757061780043, - "memoryTotal": 25769803776, - "memoryUsed": 23812554752, - "memoryFree": 1957249024, - "memoryUsagePercent": 92.40487416585287, - "memoryEfficiency": 7.595125834147126, - "cpuCount": 14, - "cpuLoad": 0.19789341517857142, - "platform": "darwin", - "uptime": 329832 - }, - { - "timestamp": 1757061810043, - "memoryTotal": 25769803776, - "memoryUsed": 23802118144, - "memoryFree": 1967685632, - "memoryUsagePercent": 92.36437479654947, - "memoryEfficiency": 7.63562520345053, - "cpuCount": 14, - "cpuLoad": 0.15530831473214285, - "platform": "darwin", - "uptime": 329862 - }, - { - "timestamp": 1757061840043, - "memoryTotal": 25769803776, - "memoryUsed": 23803248640, - "memoryFree": 1966555136, - "memoryUsagePercent": 92.36876169840494, - "memoryEfficiency": 7.631238301595062, - "cpuCount": 14, - "cpuLoad": 0.11931501116071429, - "platform": "darwin", - "uptime": 329892 - }, - { - "timestamp": 1757061870043, - "memoryTotal": 25769803776, - "memoryUsed": 24106696704, - "memoryFree": 1663107072, - "memoryUsagePercent": 93.54629516601562, - "memoryEfficiency": 6.453704833984375, - "cpuCount": 14, - "cpuLoad": 0.11031668526785714, - "platform": "darwin", - "uptime": 329922 - }, - { - "timestamp": 1757061900043, - "memoryTotal": 25769803776, - "memoryUsed": 24049745920, - "memoryFree": 1720057856, - "memoryUsagePercent": 93.32529703776041, - "memoryEfficiency": 6.674702962239593, - "cpuCount": 14, - "cpuLoad": 0.08918108258928571, - "platform": "darwin", - "uptime": 329952 - }, - { - "timestamp": 1757061930043, - "memoryTotal": 25769803776, - "memoryUsed": 24014929920, - "memoryFree": 1754873856, - "memoryUsagePercent": 93.19019317626953, - "memoryEfficiency": 6.809806823730469, - "cpuCount": 14, - "cpuLoad": 0.07254464285714286, - "platform": "darwin", - "uptime": 329982 - }, - { - "timestamp": 1757061960043, - "memoryTotal": 25769803776, - "memoryUsed": 25666912256, - "memoryFree": 102891520, - "memoryUsagePercent": 99.60072835286459, - "memoryEfficiency": 0.3992716471354072, - "cpuCount": 14, - "cpuLoad": 0.118896484375, - "platform": "darwin", - "uptime": 330012 - }, - { - "timestamp": 1757061990047, - "memoryTotal": 25769803776, - "memoryUsed": 21877653504, - "memoryFree": 3892150272, - "memoryUsagePercent": 84.89646911621094, - "memoryEfficiency": 15.103530883789062, - "cpuCount": 14, - "cpuLoad": 0.16737583705357142, - "platform": "darwin", - "uptime": 330042 - }, - { - "timestamp": 1757062020047, - "memoryTotal": 25769803776, - "memoryUsed": 22036316160, - "memoryFree": 3733487616, - "memoryUsagePercent": 85.51216125488281, - "memoryEfficiency": 14.487838745117188, - "cpuCount": 14, - "cpuLoad": 0.138916015625, - "platform": "darwin", - "uptime": 330072 - }, - { - "timestamp": 1757062050048, - "memoryTotal": 25769803776, - "memoryUsed": 21972238336, - "memoryFree": 3797565440, - "memoryUsagePercent": 85.26350657145181, - "memoryEfficiency": 14.736493428548187, - "cpuCount": 14, - "cpuLoad": 0.12813895089285715, - "platform": "darwin", - "uptime": 330102 - }, - { - "timestamp": 1757062080049, - "memoryTotal": 25769803776, - "memoryUsed": 21954543616, - "memoryFree": 3815260160, - "memoryUsagePercent": 85.19484202067056, - "memoryEfficiency": 14.805157979329437, - "cpuCount": 14, - "cpuLoad": 0.13605608258928573, - "platform": "darwin", - "uptime": 330132 - }, - { - "timestamp": 1757062110050, - "memoryTotal": 25769803776, - "memoryUsed": 21945073664, - "memoryFree": 3824730112, - "memoryUsagePercent": 85.15809377034506, - "memoryEfficiency": 14.841906229654938, - "cpuCount": 14, - "cpuLoad": 0.12098911830357142, - "platform": "darwin", - "uptime": 330162 - }, - { - "timestamp": 1757062140050, - "memoryTotal": 25769803776, - "memoryUsed": 21946417152, - "memoryFree": 3823386624, - "memoryUsagePercent": 85.1633071899414, - "memoryEfficiency": 14.836692810058594, - "cpuCount": 14, - "cpuLoad": 0.10546875, - "platform": "darwin", - "uptime": 330192 - }, - { - "timestamp": 1757062170051, - "memoryTotal": 25769803776, - "memoryUsed": 21990064128, - "memoryFree": 3779739648, - "memoryUsagePercent": 85.33267974853516, - "memoryEfficiency": 14.667320251464844, - "cpuCount": 14, - "cpuLoad": 0.10902622767857142, - "platform": "darwin", - "uptime": 330222 - }, - { - "timestamp": 1757062200052, - "memoryTotal": 25769803776, - "memoryUsed": 21993455616, - "memoryFree": 3776348160, - "memoryUsagePercent": 85.34584045410156, - "memoryEfficiency": 14.654159545898438, - "cpuCount": 14, - "cpuLoad": 0.10431780133928571, - "platform": "darwin", - "uptime": 330252 - }, - { - "timestamp": 1757062230053, - "memoryTotal": 25769803776, - "memoryUsed": 21974728704, - "memoryFree": 3795075072, - "memoryUsagePercent": 85.2731704711914, - "memoryEfficiency": 14.726829528808594, - "cpuCount": 14, - "cpuLoad": 0.17152622767857142, - "platform": "darwin", - "uptime": 330282 - }, - { - "timestamp": 1757062260054, - "memoryTotal": 25769803776, - "memoryUsed": 21933719552, - "memoryFree": 3836084224, - "memoryUsagePercent": 85.11403401692709, - "memoryEfficiency": 14.885965983072907, - "cpuCount": 14, - "cpuLoad": 0.13605608258928573, - "platform": "darwin", - "uptime": 330312 - }, - { - "timestamp": 1757062290055, - "memoryTotal": 25769803776, - "memoryUsed": 21903392768, - "memoryFree": 3866411008, - "memoryUsagePercent": 84.99635060628256, - "memoryEfficiency": 15.003649393717438, - "cpuCount": 14, - "cpuLoad": 0.09573800223214286, - "platform": "darwin", - "uptime": 330342 - }, - { - "timestamp": 1757062320055, - "memoryTotal": 25769803776, - "memoryUsed": 21123710976, - "memoryFree": 4646092800, - "memoryUsagePercent": 81.97078704833984, - "memoryEfficiency": 18.029212951660156, - "cpuCount": 14, - "cpuLoad": 0.08761160714285714, - "platform": "darwin", - "uptime": 330372 - } -] \ No newline at end of file diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index c01c09f..0000000 --- a/.drone.yml +++ /dev/null @@ -1,114 +0,0 @@ -kind: pipeline -type: docker -name: default - -steps: - # Build Backend Docker Image - - name: build-backend - image: plugins/docker - settings: - repo: registry.local.nothaft.cloud/picpeak-backend - tags: - - latest - - ${DRONE_COMMIT_SHA:0:8} - - ${DRONE_BRANCH}-latest - dockerfile: backend/Dockerfile - context: backend/ - registry: registry.local.nothaft.cloud - build_args: - - VERSION=${DRONE_TAG:-dev} - - # Build Frontend Docker Image - - name: build-frontend - image: plugins/docker - settings: - repo: registry.local.nothaft.cloud/picpeak-frontend - tags: - - latest - - ${DRONE_COMMIT_SHA:0:8} - - ${DRONE_BRANCH}-latest - dockerfile: frontend/Dockerfile - context: frontend/ - registry: registry.local.nothaft.cloud - build_args: - - VERSION=${DRONE_TAG:-dev} - - VITE_API_URL=${VITE_API_URL:-/api} - -trigger: - branch: - - main - - develop - event: - - push - - pull_request - ---- -kind: pipeline -type: docker -name: release - -steps: - # Build Backend Release - - name: build-backend-release - image: plugins/docker - settings: - repo: registry.local.nothaft.cloud/picpeak-backend - tags: - - ${DRONE_TAG} - - latest - dockerfile: backend/Dockerfile - context: backend/ - registry: registry.local.nothaft.cloud - - # Build Frontend Release - - name: build-frontend-release - image: plugins/docker - settings: - repo: registry.local.nothaft.cloud/picpeak-frontend - tags: - - ${DRONE_TAG} - - latest - dockerfile: frontend/Dockerfile - context: frontend/ - registry: registry.local.nothaft.cloud - - # -------- NEW: Publish Docker images to GitHub Container Registry -------- - - name: push-backend-ghcr - image: plugins/docker - settings: - repo: ghcr.io/the-luap/picpeak-backend - tags: - - ${DRONE_TAG} - - latest - dockerfile: backend/Dockerfile - context: backend/ - registry: ghcr.io - username: - from_secret: GITHUB_USERNAME - password: - from_secret: GITHUB_TOKEN - build_args: - - VERSION=${DRONE_TAG} - - - name: push-frontend-ghcr - image: plugins/docker - settings: - repo: ghcr.io/the-luap/picpeak-frontend - tags: - - ${DRONE_TAG} - - latest - dockerfile: frontend/Dockerfile - context: frontend/ - registry: ghcr.io - username: - from_secret: GITHUB_USERNAME - password: - from_secret: GITHUB_TOKEN - build_args: - - VERSION=${DRONE_TAG} - - VITE_API_URL=${VITE_API_URL:-/api} - - -trigger: - event: - - tag \ No newline at end of file diff --git a/.gitea/workflows/mirror-to-github.yml b/.gitea/workflows/mirror-to-github.yml deleted file mode 100644 index 129dcce..0000000 --- a/.gitea/workflows/mirror-to-github.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Mirror to GitHub - -on: - workflow_dispatch: # Allow manual triggering only - -jobs: - mirror: - runs-on: ubuntu-latest - # Note: For GitHub fine-grained tokens, ensure the token has: - # - Repository access to the-luap/picpeak - # - Repository permissions: Contents (Read and Write), Metadata (Read) - # For classic tokens: repo scope is sufficient - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - fetch-depth: 0 # Full history for proper mirroring - - - name: Setup Git - run: | - git config --global user.name "the-luap" - git config --global user.email "paul-nothaft@hotmail.de" - - - name: Remove sensitive files and directories - run: | - echo "Current files before cleanup:" - ls -la | head -10 || true - echo "..." - - # Remove sensitive files/directories if they exist - echo "Removing sensitive files..." - rm -rf .gitea/ || true - rm -rf scripts/install-gitea-runner.sh || true - rm -rf .drone* || true - rm -rf photo-sharing-prd.md || true - rm -rf CLAUDE.md || true - rm -rf storage/ || true - rm -rf events/ || true - rm -rf .playwright-mcp/ - rm -rf .swarm || true - rm -rf .claude-flow || true - - - echo "Sensitive files removal completed" - - # Add and commit the cleanup if there are changes - git add -A - if ! git diff --cached --quiet; then - git commit -m "chore: remove sensitive files for GitHub mirror" - echo "βœ… Committed cleanup of sensitive files" - else - echo "βœ… No sensitive files to remove" - fi - - echo "Final file structure (top level):" - ls -la | head -10 || true - - - name: Check GitHub token - env: - GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }} - run: | - if [ -z "$GITHUBTOKEN" ]; then - echo "ERROR: GITHUBTOKEN secret is not set!" - echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN" - echo "" - echo "For fine-grained tokens:" - echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens" - echo " - Create token with repository access to the-luap/picpeak" - echo " - Grant permissions: Contents (Read and Write), Metadata (Read)" - echo "" - echo "For classic tokens:" - echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)" - echo " - Create token with 'repo' scope" - exit 1 - else - echo "βœ… GitHub token is available (length: ${#GITHUBTOKEN})" - # Try to detect token type (fine-grained tokens are typically longer) - if [ ${#GITHUBTOKEN} -gt 80 ]; then - echo "πŸ“Œ Token appears to be a fine-grained personal access token" - else - echo "πŸ“Œ Token appears to be a classic personal access token" - fi - fi - - - name: Push to GitHub - env: - GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }} - GIT_TRACE: 1 # Enable Git trace for debugging if needed - run: | - # Remove existing github remote if it exists - git remote remove github || true - - # Configure Git to use the token for authentication - # This method works for both classic and fine-grained tokens - git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/" - - # Add GitHub remote (clean URL without credentials) - git remote add github https://github.com/the-luap/picpeak.git - - # Verify remote was added - echo "GitHub remote configuration:" - git remote -v - - # Push to GitHub main branch with error handling - echo "Pushing to GitHub..." - if git push github main --force 2>&1; then - echo "βœ… Push to GitHub completed successfully!" - else - echo "❌ Push to GitHub failed!" - echo "" - echo "Common issues and solutions:" - echo "1. Token permissions: Ensure your token has 'Contents: write' permission" - echo "2. Token expiration: Check if your token has expired" - echo "3. Repository access: Verify the token has access to the-luap/picpeak repository" - echo "" - echo "For fine-grained tokens, required permissions:" - echo " - Repository access: the-luap/picpeak" - echo " - Repository permissions: Contents (Read and Write), Metadata (Read)" - echo "" - echo "For classic tokens, required scope: 'repo'" - exit 1 - fi - - # Clean up the git config after push - git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf - - - name: Workflow completed - run: | - echo "βœ… Mirror to GitHub workflow completed successfully!" - echo "πŸ“Š Repository mirrored to: https://github.com/the-luap/picpeak" - echo "πŸ”’ Sensitive files have been removed from the mirror" - diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml deleted file mode 100644 index c207bdc..0000000 --- a/.gitea/workflows/test.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Test and Lint - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -jobs: - backend-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install backend dependencies - working-directory: ./backend - run: npm ci - - - name: Run backend linting - working-directory: ./backend - run: npm run lint || true # Continue on lint errors for now - - - name: Run backend tests - working-directory: ./backend - run: npm test || true # Continue on test failures for now - - frontend-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install frontend dependencies - working-directory: ./frontend - run: npm ci --legacy-peer-deps - - - name: Run frontend linting - working-directory: ./frontend - run: npm run lint || true # Continue on lint errors for now - - - name: Build frontend - working-directory: ./frontend - run: npm run build \ No newline at end of file diff --git a/.gitea/workflows/version-and-release.yml b/.gitea/workflows/version-and-release.yml deleted file mode 100644 index 54448e8..0000000 --- a/.gitea/workflows/version-and-release.yml +++ /dev/null @@ -1,269 +0,0 @@ -name: Version and Release - -on: - workflow_dispatch: - -jobs: - version-bump: - runs-on: ubuntu-latest - outputs: - new_version: ${{ steps.version.outputs.new_version }} - version_changed: ${{ steps.version.outputs.version_changed }} - component_changed: ${{ steps.version.outputs.component_changed }} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - token: ${{ secrets.GITEA_TOKEN || github.token }} - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Configure Git - run: | - git config --global user.name 'Gitea Actions Bot' - git config --global user.email 'actions@gitea.local' - - - name: Detect changes and bump version - id: version - run: | - set -e # Exit on error - - echo "=== Debug Info ===" - echo "GitHub event before: ${{ github.event.before }}" - echo "GitHub SHA: ${{ github.sha }}" - echo "Current directory: $(pwd)" - echo "Git log (last 5): $(git log --oneline -5)" - - # Get the commit range for changed files - if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then - COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}" - echo "Using commit range: $COMMIT_RANGE" - CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "") - else - # First commit or no previous commit, check against HEAD~1 if it exists - if git rev-parse HEAD~1 >/dev/null 2>&1; then - COMMIT_RANGE="HEAD~1..HEAD" - echo "Using commit range: $COMMIT_RANGE" - CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "") - else - echo "First commit detected, checking all files" - CHANGED_FILES=$(git ls-files) - fi - fi - - echo "Changed files:" - echo "$CHANGED_FILES" - - # Check what changed (using echo to pipe to grep to avoid grep exit codes) - BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0") - FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0") - ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0") - - echo "Backend files changed: $BACKEND_CHANGED" - echo "Frontend files changed: $FRONTEND_CHANGED" - echo "Root files changed: $ROOT_CHANGED" - - # Get current versions - BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0") - FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0") - - echo "Current backend version: $BACKEND_VERSION" - echo "Current frontend version: $FRONTEND_VERSION" - - # Determine what to update based on changes - BACKEND_UPDATE=false - FRONTEND_UPDATE=false - COMPONENT_CHANGED="none" - - if [ "$ROOT_CHANGED" -gt 0 ]; then - # Root changes affect both components - BACKEND_UPDATE=true - FRONTEND_UPDATE=true - COMPONENT_CHANGED="both" - SOURCE_VERSION=$BACKEND_VERSION - echo "Root changes detected - updating both components" - elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then - # Both components changed - BACKEND_UPDATE=true - FRONTEND_UPDATE=true - COMPONENT_CHANGED="both" - # Use the higher version as source - if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then - SOURCE_VERSION=$BACKEND_VERSION - else - SOURCE_VERSION=$FRONTEND_VERSION - fi - echo "Both backend and frontend changed - updating both" - elif [ "$BACKEND_CHANGED" -gt 0 ]; then - # Only backend changed - BACKEND_UPDATE=true - COMPONENT_CHANGED="backend" - SOURCE_VERSION=$BACKEND_VERSION - echo "Only backend changed - updating backend" - elif [ "$FRONTEND_CHANGED" -gt 0 ]; then - # Only frontend changed - FRONTEND_UPDATE=true - COMPONENT_CHANGED="frontend" - SOURCE_VERSION=$FRONTEND_VERSION - echo "Only frontend changed - updating frontend" - else - echo "No relevant changes detected" - echo "version_changed=false" >> $GITHUB_OUTPUT - echo "component_changed=none" >> $GITHUB_OUTPUT - echo "new_version=" >> $GITHUB_OUTPUT - exit 0 - fi - - echo "Component changed: $COMPONENT_CHANGED" - echo "Source version: $SOURCE_VERSION" - echo "Backend update: $BACKEND_UPDATE" - echo "Frontend update: $FRONTEND_UPDATE" - - # Calculate new version - IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION" - MAJOR="${version_parts[0]}" - MINOR="${version_parts[1]}" - PATCH="${version_parts[2]}" - - # Increment patch version and ensure tag uniqueness - git fetch --tags --quiet || true - NEW_PATCH=$((PATCH + 1)) - NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" - - while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do - echo "Tag v${NEW_VERSION} already exists, bumping patch version again" - NEW_PATCH=$((NEW_PATCH + 1)) - NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" - done - - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT - - # Update versions in package.json files - if [ "$BACKEND_UPDATE" = true ]; then - echo "Updating backend version to $NEW_VERSION" - cd backend && npm version $NEW_VERSION --no-git-tag-version - cd .. - fi - - if [ "$FRONTEND_UPDATE" = true ]; then - echo "Updating frontend version to $NEW_VERSION" - cd frontend && npm version $NEW_VERSION --no-git-tag-version - cd .. - fi - - # Check if there are changes to commit - if [[ -n $(git status --porcelain) ]]; then - echo "version_changed=true" >> $GITHUB_OUTPUT - else - echo "version_changed=false" >> $GITHUB_OUTPUT - fi - - - name: Commit version bump - if: steps.version.outputs.version_changed == 'true' - run: | - set -e # Exit on any error - - # First, ensure we have the latest changes - echo "Fetching latest changes..." - git fetch origin main - - # Check if we're behind and need to update - LOCAL=$(git rev-parse HEAD) - REMOTE=$(git rev-parse origin/main) - - if [ "$LOCAL" != "$REMOTE" ]; then - echo "Local is behind remote, pulling changes..." - git pull origin main --no-rebase - fi - - COMPONENT="${{ steps.version.outputs.component_changed }}" - - if [ "$COMPONENT" = "both" ]; then - git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json - git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)" - elif [ "$COMPONENT" = "backend" ]; then - git add backend/package.json backend/package-lock.json - git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}" - elif [ "$COMPONENT" = "frontend" ]; then - git add frontend/package.json frontend/package-lock.json - git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}" - fi - - # Pull latest changes before pushing to avoid conflicts - echo "Pulling latest changes from origin/main..." - if ! git pull --rebase origin main; then - echo "Rebase failed, attempting to resolve..." - # If rebase fails, abort and try a regular merge - git rebase --abort || true - git pull origin main --no-rebase - fi - - # Push the changes with retry logic - echo "Pushing version bump..." - PUSH_SUCCESS=false - - for i in 1 2 3; do - echo "Push attempt $i of 3..." - - # Try to push - if git push origin main 2>&1; then - echo "Successfully pushed version bump on attempt $i" - PUSH_SUCCESS=true - break - else - echo "Push failed on attempt $i" - - if [ $i -lt 3 ]; then - echo "Waiting 5 seconds before retry..." - sleep 5 - - echo "Pulling latest changes..." - git fetch origin main - - # Try rebase first, fall back to merge - if ! git rebase origin/main; then - echo "Rebase failed, trying merge..." - git rebase --abort 2>/dev/null || true - git pull origin main --no-rebase - fi - fi - fi - done - - if [ "$PUSH_SUCCESS" = "false" ]; then - echo "ERROR: Failed to push after 3 attempts" - exit 1 - fi - - - name: Create Git tag - if: steps.version.outputs.version_changed == 'true' - run: | - COMPONENT="${{ steps.version.outputs.component_changed }}" - - if [ "$COMPONENT" = "both" ]; then - TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)" - elif [ "$COMPONENT" = "backend" ]; then - TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)" - elif [ "$COMPONENT" = "frontend" ]; then - TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)" - fi - - git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE" - git push origin "v${{ steps.version.outputs.new_version }}" - - trigger-drone: - needs: version-bump - if: needs.version-bump.outputs.version_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Trigger Drone Build - run: | - echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}" - echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}" - echo "Drone will automatically trigger on the new tag" - # Drone CI will automatically trigger on the tag push event diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 994e2be..6a1c455 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,15 +31,26 @@ jobs: contents: read packages: write security-events: write - + steps: - name: Checkout code uses: actions/checkout@v4 + - name: Determine build platforms + id: platforms + run: | + # For PRs, build only amd64 to avoid QEMU emulation issues with Sharp + # For main/develop/tags, build multi-arch + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: - platforms: linux/amd64,linux/arm64 + platforms: ${{ steps.platforms.outputs.platforms }} - name: Log in to Container Registry if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' @@ -67,7 +78,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix={{branch}}-,format=short + type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Backend Docker image @@ -79,7 +90,7 @@ jobs: push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }} tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} - platforms: linux/amd64,linux/arm64 + platforms: ${{ steps.platforms.outputs.platforms }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend build-args: | @@ -111,15 +122,26 @@ jobs: contents: read packages: write security-events: write - + steps: - name: Checkout code uses: actions/checkout@v4 + - name: Determine build platforms + id: platforms + run: | + # For PRs, build only amd64 to avoid QEMU emulation issues + # For main/develop/tags, build multi-arch + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: - platforms: linux/amd64,linux/arm64 + platforms: ${{ steps.platforms.outputs.platforms }} - name: Log in to Container Registry if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' @@ -147,7 +169,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix={{branch}}-,format=short + type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Frontend Docker image @@ -159,7 +181,7 @@ jobs: push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }} tags: ${{ steps.meta-frontend.outputs.tags }} labels: ${{ steps.meta-frontend.outputs.labels }} - platforms: linux/amd64,linux/arm64 + platforms: ${{ steps.platforms.outputs.platforms }} cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend build-args: | diff --git a/.swarm/memory.db b/.swarm/memory.db deleted file mode 100644 index 4b95fc9..0000000 Binary files a/.swarm/memory.db and /dev/null differ diff --git a/.swarm/memory.db-shm b/.swarm/memory.db-shm deleted file mode 100644 index 363c5e8..0000000 Binary files a/.swarm/memory.db-shm and /dev/null differ diff --git a/.swarm/memory.db-wal b/.swarm/memory.db-wal deleted file mode 100644 index 9f768e9..0000000 Binary files a/.swarm/memory.db-wal and /dev/null differ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 471e64e..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,386 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Product Overview - -A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI. - -## Architecture Overview - -- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage -- **Frontend**: React SPA with scrappbook.de-style design (requires implementation) -- **Storage**: File-based with active/archived separation -- **Services**: Background workers for email, archiving, file watching, and expiration monitoring -- **Analytics**: Umami integration for engagement tracking - -## Essential Commands - -### Backend Development -```bash -cd backend -npm install # Install dependencies -npm run migrate # Initialize database schema -npm run dev # Start with hot-reload (port 3001) -npm test # Run Jest tests -npm run lint # ESLint checks -``` - -### Running a Single Test -```bash -cd backend -npm test -- path/to/test.test.js -npm test -- --testNamePattern="test name" -``` - -### Production Deployment -See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including: -- Docker Compose deployment -- PM2 deployment -- Manual installation -- Non-nginx deployment options -- SSL/HTTPS setup -- Troubleshooting guide - -**⚠️ CRITICAL PRODUCTION NOTICE:** -- Production runs on a SEPARATE SERVER - never assume local changes affect production -- ALWAYS request production server details before any troubleshooting -- NO trial-and-error approaches in production - data loss is unacceptable -- Every change must be thoroughly analyzed and tested locally first - -## Key Product Requirements (from PRD) - -### Core Features -1. **File-Based System**: Drop photos in folders β†’ automatic gallery creation -2. **Automatic Expiration**: Default 30 days, with 7-day warning emails -3. **Password Protection**: Secure access with customizable passwords -4. **Automatic Archiving**: ZIP compression and storage after expiration -5. **Email Notifications**: Creation, warning, and expiration notifications -6. **Analytics**: Umami tracking for views, downloads, and engagement - -### Folder Structure -``` -/events/ - β”œβ”€β”€ active/ - β”‚ β”œβ”€β”€ wedding-smith-jones-2024-06-15/ - β”‚ β”‚ β”œβ”€β”€ collages/ - β”‚ β”‚ └── individual/ - β”‚ └── birthday-emma-2024-07-20/ - └── archived/ - └── wedding-smith-jones-2024-06-15.zip -``` - -## Frontend Implementation Requirements - -### Design Style (scrappbook.de-inspired) -- **Color Palette**: Primary green (#5C8762), neutral backgrounds -- **Typography**: Clean, modern sans-serif (Noto Sans or similar) -- **Layout**: Minimalist, modular sections with grid-based photo displays -- **Aesthetic**: Professional yet approachable, photographer-focused - -### Key Frontend Components to Build -1. **Landing Page**: Password entry with event preview -2. **Gallery View**: - - Responsive photo grid with lazy loading - - Toggle between collages/individual photos - - Prominent expiration banner - - Download urgency indicators -3. **Photo Lightbox**: Full-screen viewing with zoom -4. **Mobile-First**: Responsive design with touch gestures -5. **Personalization**: Dynamic theming per event type - -### User Experience Priorities -- Clear expiration warnings (sticky banner) -- One-click "Download All" for urgent galleries -- Smooth image loading with skeleton screens -- Intuitive navigation between photo categories -- Professional presentation matching photographer branding - -## Key Architecture Patterns - -### Authentication Flow -- JWT-based with separate tokens for admin and gallery access -- Gallery tokens include event-specific claims -- Auth middleware: `backend/src/middleware/auth.js` - - `adminAuth` - Admin panel protection - - `photoAuth` - Protected photo access - - `verifyGalleryAccess` - Gallery-specific validation - -### Database Schema (Knex/SQLite) -Main tables: -- `events` - Gallery metadata with expiration, custom messages, themes -- `photos` - Photo records linked to events -- `access_logs` - IP-based usage tracking -- `email_queue` - Async email processing -- `admin_users` - Admin authentication - -### Service Architecture -Background services run as separate processes: -- **emailService**: Processes email queue with retry logic -- **archiveService**: Creates ZIP archives of expired events -- **expirationChecker**: Cron job for expiration warnings -- **fileWatcher**: Monitors for new photo uploads -- **backupService**: Scheduled backups with checksum-based change detection - -### API Structure -- `/api/admin/*` - Admin panel endpoints (requires adminAuth) -- `/api/gallery/*` - Public gallery endpoints -- `/api/auth/*` - Authentication endpoints -- Rate limiting: 100 req/15min (general), 5 req/15min (auth) - -## Critical Implementation Notes - -1. **Security**: All gallery access requires valid JWT with event-specific claims -2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings -3. **Email Queue**: Async processing with retry logic, check `email_queue` table -4. **File Processing**: Sharp library for thumbnail generation (300x300) -5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD -6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings - -## Troubleshooting Guidelines - -### Before ANY Production Troubleshooting: -1. **ALWAYS request specific details**: - - Production server URL/IP - - Current error messages/logs - - Recent changes or deployments - - Affected users/galleries - - Time of issue occurrence - -2. **Thorough Analysis Required**: - - Use detailed thinking/analysis for EVERY troubleshooting task - - Review all related code before suggesting changes - - Consider all potential side effects - - Never make assumptions about production environment - -3. **Safe Troubleshooting Steps**: - - First, reproduce issue in local/dev environment - - Analyze logs without modifying production - - Create detailed action plan before any changes - - Always have rollback strategy ready - - Document every step taken - -### Common Issues & Safe Approaches: -- **Email not sending**: Check email_queue table, SMTP settings, service status -- **Photos not loading**: Verify file permissions, storage paths, nginx config -- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs -- **Performance problems**: Analyze with monitoring tools first, never experiment - -### Data Safety Rules: -- NEVER delete or modify production data without explicit backup confirmation -- ALWAYS verify backups exist before any data operations -- NO direct database modifications without transaction safety -- Log all actions for audit trail - -## Environment Variables - -### Backend (.env) -- `JWT_SECRET` - Token signing -- `ADMIN_URL`, `FRONTEND_URL` - CORS origins -- `SMTP_*` - Email configuration -- `DB_*` - PostgreSQL credentials (production) -- `UMAMI_URL` - Umami instance URL (for server-side tracking) -- `UMAMI_WEBSITE_ID` - Website ID from Umami - -### Frontend (.env) -- `VITE_API_URL` - Backend API URL -- `VITE_UMAMI_URL` - Umami analytics URL -- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami -- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard - -## Testing Approach -- Jest with Supertest for API testing -- Test files in `__tests__` directories -- Database migrations run before tests -- Mock email sending in tests - -## Umami Analytics Integration - -The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance. - -### Tracked Events: -- **Gallery Events**: - - `gallery_password_entry` - Password attempts (success/failure) - - `gallery_photo_view` - Individual photo views - - `gallery_photo_download` - Single photo downloads - - `gallery_bulk_download` - Bulk/all photo downloads - - `gallery_expired` - Expired gallery access attempts -- **Admin Events**: - - `admin_login` - Admin authentication - - `admin_event_created` - New event creation - - `admin_event_archived` - Event archiving - - `admin_event_deleted` - Event deletion - - `admin_settings_updated` - Settings changes -- **User Behavior**: - - Search queries (with debouncing) - - Expiration warning views - - Page views with automatic tracking - -### Setup: -1. Install Umami (self-hosted or cloud) -2. Create a website in Umami dashboard -3. Set environment variables: - ``` - VITE_UMAMI_URL=https://your-umami-instance.com - VITE_UMAMI_WEBSITE_ID=your-website-id - VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/... - ``` - -### Analytics Dashboard: -- Admin panel includes analytics page at `/admin/analytics` -- Summary view with key metrics -- Option to embed full Umami dashboard -- Real-time event tracking - -## Accessibility & Performance Features - -### Accessibility (WCAG 2.1 AA Compliance) -- **Error Boundaries**: Graceful error handling with recovery options -- **Skip Links**: Skip to main content for keyboard navigation -- **ARIA Labels**: Proper labeling for screen readers -- **Focus Management**: Focus trap in modals, visible focus indicators -- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download) -- **Loading States**: Skeleton screens instead of spinners for better UX -- **Offline Support**: Visual indicator when offline -- **Form Validation**: Accessible error messages with aria-describedby - -### Performance Optimizations -- **Lazy Loading**: Images load on scroll with Intersection Observer -- **Skeleton Screens**: Instant visual feedback during loading -- **Error Recovery**: Component-level error boundaries prevent full page crashes -- **Optimistic Updates**: Immediate UI updates with background sync -- **Debounced Search**: Prevents excessive API calls -- **Analytics**: Non-blocking Umami integration - -### Component Library Enhancements -- `` - Catches and displays errors gracefully -- `` - Full-page error recovery -- `` - Flexible skeleton loader with variants -- `` - Network status monitoring -- `` - Accessibility navigation -- `useFocusTrap` - Modal focus management hook -- `useOnlineStatus` - Network status hook - -## Theme System & Branding - -### Theme Features -- **Dynamic Theming**: CSS variables for runtime theme switching -- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal -- **Customization Options**: - - Primary/Accent/Background/Text colors - - Font family selection - - Border radius (none, sm, md, lg) - - Custom logo upload - - Custom CSS injection -- **Event-Specific Themes**: Override global theme per gallery -- **Live Preview**: Real-time theme changes in admin panel - -### Theme Context API -```typescript -const { theme, setTheme, setThemeByName } = useTheme(); -``` - -### Branding Settings -- Company name, tagline, and support email -- Custom footer text -- Optional watermarking on downloads -- Logo upload for gallery header - -### CSS Variables -```css ---color-primary: #5C8762; ---color-primary-light: #7aa583; ---color-primary-dark: #4a6f4f; ---color-accent: #22c55e; ---color-background: #fafafa; ---color-text: #171717; ---font-family: 'Inter', sans-serif; ---border-radius: 0.5rem; -``` - -## Backup Service - -### Overview -The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead. - -### Features -- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage -- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up -- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily) -- **Email Notifications**: Alerts on backup failure, optional success notifications -- **Retention Management**: Automatic cleanup of old backup runs based on retention policy -- **Progress Tracking**: Database storage of backup history, file states, and statistics - -### Configuration -Backup settings are stored in `app_settings` table with `backup_` prefix: -- `backup_enabled`: Enable/disable the service -- `backup_schedule`: Cron expression (e.g., '0 2 * * *') -- `backup_destination_type`: 'local', 'rsync', or 's3' -- `backup_retention_days`: How long to keep backup history -- `backup_include_archived`: Whether to backup archived events -- `backup_exclude_patterns`: File patterns to exclude - -### API Endpoints -- `GET /api/admin/backup/config` - Get current configuration -- `PUT /api/admin/backup/config` - Update configuration -- `GET /api/admin/backup/status` - Get backup status and history -- `POST /api/admin/backup/run` - Trigger manual backup -- `POST /api/admin/backup/test-connection` - Test destination connectivity - -### Testing -Run backup service test: `npm run test-backup` - -### Database Tables -- `backup_runs`: Tracks each backup execution with statistics -- `backup_file_states`: Stores file checksums for change detection - -## Thumbnail Generation - -### Square Thumbnail Implementation (Issue #12 Fix) -The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid: - -- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion -- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning -- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format` -- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings -- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails - -### Thumbnail Settings API -- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration -- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration) -- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings -- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress - -## Success Metrics (from PRD) -- Time to generate gallery: <2 minutes -- Guest satisfaction: >90% -- System uptime: 99.9% -- Email delivery rate: >98% -- Successful archiving: 100% - -## Documentation & Development Practices - -### Documentation Guidelines: -- **NEVER create new documentation files for simple tasks** -- **ALWAYS update existing documentation (like this CLAUDE.md)** -- Only create new .md files when explicitly requested -- Avoid creating temporary scripts for one-off tasks - -### Development Best Practices: -- Test all changes thoroughly in local environment first -- Use version control for all changes -- Keep commits atomic and well-described -- Review impact on all integrated services -- Consider backward compatibility -- Update tests when changing functionality - -### Production Deployment Checklist: -- [ ] All tests passing locally -- [ ] Linting and type checks pass -- [ ] Database migrations tested with rollback plan -- [ ] Environment variables documented -- [ ] Backup strategy confirmed -- [ ] Monitoring alerts configured -- [ ] Rollback procedure documented -- [ ] Stakeholders notified of maintenance window -- always use docker deployment for testing \ No newline at end of file diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 6d818f5..aa5122e 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set For the easiest installation without Docker or complex configurations, use our **unified setup script**: ```bash -curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \ -chmod +x setup.sh && \ -sudo ./setup.sh +curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \ +chmod +x picpeak-setup.sh && \ +sudo ./picpeak-setup.sh ``` This automated script handles everything including: @@ -219,14 +219,17 @@ Update `.env` with: - **URL Configuration** (for backend CORS): - `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash) - Example (Docker): `http://localhost:3000` - - `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash) - - Example (Docker): `http://localhost:3000` +- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash) + - Example (Docker): `http://localhost:3000` Notes: - Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`). - Always include the scheme (`http://` or `https://`). - The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500. +#### Authentication Security +- Configure login attempt thresholds from **Admin β†’ Settings β†’ Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout. + #### External Database Example To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed: @@ -424,10 +427,10 @@ If you lose your admin credentials after the first login, you'll need to manuall ```bash # Native reinstall example -sudo ./setup.sh --native --force-admin-password-reset +sudo ./picpeak-setup.sh --native --force-admin-password-reset # Docker reinstall example -sudo ./setup.sh --docker --force-admin-password-reset +sudo ./picpeak-setup.sh --docker --force-admin-password-reset ``` The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run. diff --git a/README.md b/README.md index c55067b..ea086e7 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID) - πŸ“˜ [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions - Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode +- πŸ“š [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints +- πŸ› οΈ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints - 🀝 [**Contributing**](CONTRIBUTING.md) - How to contribute - πŸ“œ [**License**](LICENSE) - MIT License - πŸ”’ [**Security**](SECURITY.md) - Security policies diff --git a/SIMPLE_SETUP.md b/SIMPLE_SETUP.md index 7ea7df4..c5f80d7 100644 --- a/SIMPLE_SETUP.md +++ b/SIMPLE_SETUP.md @@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers ```bash # Download and run the unified setup script -curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \ -chmod +x setup.sh && \ -sudo ./setup.sh +curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \ +chmod +x picpeak-setup.sh && \ +sudo ./picpeak-setup.sh ``` The script will automatically detect your environment and recommend the best installation method. @@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins Best for: Most users, easy updates, isolated environment ```bash -sudo ./setup.sh --docker +sudo ./picpeak-setup.sh --docker ``` **Pros:** @@ -38,7 +38,7 @@ sudo ./setup.sh --docker Best for: Resource-constrained systems, Raspberry Pi, direct control ```bash -sudo ./setup.sh --native +sudo ./picpeak-setup.sh --native ``` **Pros:** @@ -73,7 +73,7 @@ sudo ./setup.sh --native ### Interactive Mode (Default) ```bash -sudo ./setup.sh +sudo ./picpeak-setup.sh ``` The script will prompt you to choose: @@ -87,7 +87,7 @@ The script will prompt you to choose: #### Docker with full configuration: ```bash -sudo ./setup.sh --docker --unattended \ +sudo ./picpeak-setup.sh --docker --unattended \ --domain photos.example.com \ --email admin@example.com \ --admin-password SecurePass123 \ @@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \ #### Native with minimal configuration: ```bash -sudo ./setup.sh --native --unattended \ +sudo ./picpeak-setup.sh --native --unattended \ --email admin@example.com \ --admin-password SecurePass123 ``` @@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers # Update PicPeak # (reruns migrations to pick up schema fixes for native installs) -sudo ./setup.sh --update +sudo ./picpeak-setup.sh --update ``` ## βš™οΈ Configuration @@ -385,14 +385,14 @@ docker compose pull docker compose up -d # Native -sudo ./setup.sh --update +sudo ./picpeak-setup.sh --update ``` ### Uninstall ```bash # Will prompt for confirmation and data removal options -sudo ./setup.sh --uninstall +sudo ./picpeak-setup.sh --uninstall ``` ## πŸ› Troubleshooting @@ -508,13 +508,13 @@ sudo systemctl restart picpeak-backend ### Home/Office Network ```bash # Simple local setup without domain -sudo ./setup.sh --native --email admin@local.com +sudo ./picpeak-setup.sh --native --email admin@local.com ``` ### Public Website with HTTPS ```bash # Full production setup -sudo ./setup.sh --docker \ +sudo ./picpeak-setup.sh --docker \ --domain photos.company.com \ --email admin@company.com \ --enable-ssl @@ -523,7 +523,7 @@ sudo ./setup.sh --docker \ ### Raspberry Pi Setup ```bash # Optimized for ARM devices -sudo ./setup.sh --native \ +sudo ./picpeak-setup.sh --native \ --port 8080 \ --email pi@local.com ``` diff --git a/backend/dependencies-backend.json b/backend/dependencies-backend.json index 9f84158..42b8174 100644 --- a/backend/dependencies-backend.json +++ b/backend/dependencies-backend.json @@ -1831,8 +1831,8 @@ } }, "nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz", "overridden": false }, "nodemon": { @@ -2086,8 +2086,8 @@ "version": "4.0.1" }, "tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "overridden": false }, "tunnel-agent": { diff --git a/backend/migrations/core/045_add_max_files_per_upload_setting.js b/backend/migrations/core/045_add_max_files_per_upload_setting.js new file mode 100644 index 0000000..f0d64c7 --- /dev/null +++ b/backend/migrations/core/045_add_max_files_per_upload_setting.js @@ -0,0 +1,48 @@ +const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings'); + +exports.up = async function up(knex) { + const settingKey = 'general_max_files_per_upload'; + + const existing = await knex('app_settings') + .where({ setting_key: settingKey }) + .first(); + + if (existing) { + // Normalize existing value into allowed bounds + let parsedValue; + try { + parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null; + } catch { + parsedValue = existing.setting_value; + } + + const numeric = Number(parsedValue); + let normalized = DEFAULT_MAX_FILES_PER_UPLOAD; + if (Number.isFinite(numeric) && numeric >= 1) { + normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric)); + } + + if (normalized !== numeric) { + await knex('app_settings') + .where({ setting_key: settingKey }) + .update({ + setting_value: JSON.stringify(normalized), + updated_at: new Date() + }); + } + return; + } + + await knex('app_settings').insert({ + setting_key: settingKey, + setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD), + setting_type: 'general', + updated_at: new Date() + }); +}; + +exports.down = async function down(knex) { + await knex('app_settings') + .where({ setting_key: 'general_max_files_per_upload' }) + .del(); +}; diff --git a/backend/migrations/core/046_add_customer_contact_fields.js b/backend/migrations/core/046_add_customer_contact_fields.js new file mode 100644 index 0000000..a2ad0f3 --- /dev/null +++ b/backend/migrations/core/046_add_customer_contact_fields.js @@ -0,0 +1,44 @@ +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => { + table.string('customer_name'); + }); + + await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => { + table.string('customer_email'); + }); + + // Backfill new columns from legacy host_* fields + const client = knex?.client?.config?.client; + + if (client === 'pg') { + await knex.raw(` + UPDATE events + SET customer_name = COALESCE(customer_name, host_name), + customer_email = COALESCE(customer_email, host_email) + `); + } else { + // SQLite fallback + await knex('events').update({ + customer_name: knex.raw('COALESCE(customer_name, host_name)'), + customer_email: knex.raw('COALESCE(customer_email, host_email)') + }); + } +}; + +exports.down = async function down(knex) { + const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name'); + if (hasCustomerName) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_name'); + }); + } + + const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email'); + if (hasCustomerEmail) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_email'); + }); + } +}; diff --git a/backend/migrations/legacy/011_add_user_upload_settings.js b/backend/migrations/legacy/011_add_user_upload_settings.js index f347cca..a796fa7 100644 --- a/backend/migrations/legacy/011_add_user_upload_settings.js +++ b/backend/migrations/legacy/011_add_user_upload_settings.js @@ -1,23 +1,56 @@ exports.up = async function(knex) { - // Add user upload settings to events table - await knex.schema.alterTable('events', function(table) { - table.boolean('allow_user_uploads').defaultTo(false); - table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL'); - }); - + // Add user upload settings to events table (check if columns exist first) + const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads'); + if (!hasAllowUserUploads) { + console.log('Adding allow_user_uploads column to events table...'); + await knex.schema.alterTable('events', function(table) { + table.boolean('allow_user_uploads').defaultTo(false); + }); + } else { + console.log('Column allow_user_uploads already exists in events table, skipping...'); + } + + const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id'); + if (!hasUploadCategoryId) { + console.log('Adding upload_category_id column to events table...'); + await knex.schema.alterTable('events', function(table) { + table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL'); + }); + } else { + console.log('Column upload_category_id already exists in events table, skipping...'); + } + // Add uploaded_by field to photos table to track who uploaded - await knex.schema.alterTable('photos', function(table) { - table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier - }); + const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by'); + if (!hasUploadedBy) { + console.log('Adding uploaded_by column to photos table...'); + await knex.schema.alterTable('photos', function(table) { + table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier + }); + } else { + console.log('Column uploaded_by already exists in photos table, skipping...'); + } }; exports.down = async function(knex) { - await knex.schema.alterTable('events', function(table) { - table.dropColumn('allow_user_uploads'); - table.dropColumn('upload_category_id'); - }); - - await knex.schema.alterTable('photos', function(table) { - table.dropColumn('uploaded_by'); - }); + const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads'); + if (hasAllowUserUploads) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('allow_user_uploads'); + }); + } + + const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id'); + if (hasUploadCategoryId) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('upload_category_id'); + }); + } + + const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by'); + if (hasUploadedBy) { + await knex.schema.alterTable('photos', function(table) { + table.dropColumn('uploaded_by'); + }); + } }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 11616d0..f8ef070 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "1.1.5", + "version": "1.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "1.1.5", + "version": "1.1.14", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -5154,29 +5154,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/backend/package.json b/backend/package.json index a8c6ffe..3764c82 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "1.1.5", + "version": "1.1.14", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { @@ -55,5 +55,10 @@ "mock-fs": "^5.5.0", "nodemon": "^3.1.10", "supertest": "^6.3.3" + }, + "overrides": { + "prebuild-install": { + "tar-fs": "2.1.4" + } } } diff --git a/backend/server.js b/backend/server.js index fb7776d..cdc345c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -324,9 +324,9 @@ async function initializeRateLimiters() { // Note: Rate limiters will be initialized after database connection -// Body parsing middleware with increased limits for large uploads -app.use(express.json({ limit: '100mb' })); -app.use(express.urlencoded({ extended: true, limit: '100mb' })); +// Body parsing middleware with increased limits for large batch uploads +app.use(express.json({ limit: '500mb' })); +app.use(express.urlencoded({ extended: true, limit: '500mb' })); // Request logging for API routes (with timestamps) const apiRequestLogger = (req, res, next) => { diff --git a/backend/src/database/db.js b/backend/src/database/db.js index ab2e137..f338798 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -3,6 +3,7 @@ const path = require('path'); const knex = require('knex'); const knexConfig = require('../../knexfile'); const logger = require('../utils/logger'); +const { extractShareToken } = require('../utils/shareLinkUtils'); // Ensure SQLite directory exists when using file-based DB (native installs) try { @@ -63,12 +64,16 @@ async function initializeDatabase() { table.string('event_type').notNullable(); table.string('event_name').notNullable(); table.date('event_date').notNullable(); + table.string('customer_name'); + table.string('customer_email'); table.string('host_email').notNullable(); + table.string('host_name'); table.string('admin_email').notNullable(); table.string('password_hash').notNullable(); table.text('welcome_message'); table.text('color_theme'); table.string('share_link').unique().notNullable(); + table.string('share_token').unique(); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('expires_at').notNullable(); table.boolean('is_active').defaultTo(true); @@ -99,12 +104,16 @@ async function initializeDatabase() { event_type TEXT NOT NULL, event_name TEXT NOT NULL, event_date DATE NOT NULL, + customer_name TEXT, + customer_email TEXT, + host_name TEXT, host_email TEXT NOT NULL, admin_email TEXT NOT NULL, password_hash TEXT NOT NULL, welcome_message TEXT, color_theme TEXT, share_link TEXT UNIQUE NOT NULL, + share_token TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, is_active BOOLEAN DEFAULT 1, @@ -157,6 +166,37 @@ async function initializeDatabase() { } } + const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token'); + if (!hasShareTokenColumn) { + await db.schema.table('events', (table) => { + table.string('share_token').unique(); + }); + } + + const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name'); + if (!hasHostNameColumn) { + await db.schema.table('events', (table) => { + table.string('host_name'); + }); + } + + try { + const eventsWithoutToken = await db('events') + .whereNull('share_token') + .select('id', 'share_link'); + + for (const event of eventsWithoutToken) { + const token = extractShareToken(event.share_link); + if (token) { + await db('events') + .where({ id: event.id }) + .update({ share_token: token }); + } + } + } catch (error) { + logger.warn('Share token backfill skipped', { error: error.message }); + } + // Photo metadata table const hasPhotosTable = await db.schema.hasTable('photos'); if (!hasPhotosTable) { diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index eaa09e0..481243b 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator'); const router = express.Router(); // Change password +router.get('/profile', adminAuth, async (req, res) => { + try { + const admin = await db('admin_users') + .where('id', req.admin.id) + .select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword') + .first(); + + if (!admin) { + return res.status(404).json({ error: 'Admin user not found' }); + } + + res.json(admin); + } catch (error) { + console.error('Admin profile fetch error:', error); + res.status(500).json({ error: 'Failed to fetch admin profile' }); + } +}); + +router.put('/profile', [ + adminAuth, + body('username') + .trim() + .isLength({ min: 3, max: 50 }) + .withMessage('Username must be between 3 and 50 characters'), + body('email') + .trim() + .isEmail() + .withMessage('A valid email address is required') + .normalizeEmail() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const username = req.body.username.trim(); + const email = req.body.email.trim().toLowerCase(); + const adminId = req.admin.id; + + const existingUsername = await db('admin_users') + .where('username', username) + .whereNot('id', adminId) + .first(); + + if (existingUsername) { + return res.status(409).json({ error: 'Username is already in use' }); + } + + const existingEmail = await db('admin_users') + .where('email', email) + .whereNot('id', adminId) + .first(); + + if (existingEmail) { + return res.status(409).json({ error: 'Email address is already in use' }); + } + + await db('admin_users') + .where('id', adminId) + .update({ + username, + email, + updated_at: new Date() + }); + + await logActivity('admin_profile_updated', + { username, email }, + null, + { type: 'admin', id: adminId, name: req.admin.username } + ); + + const updatedAdmin = await db('admin_users') + .where('id', adminId) + .select('id', 'username', 'email', 'must_change_password as mustChangePassword') + .first(); + + res.json({ + message: 'Admin profile updated successfully', + user: updatedAdmin + }); + } catch (error) { + console.error('Admin profile update error:', error); + res.status(500).json({ error: 'Failed to update admin profile' }); + } +}); + router.post('/change-password', [ adminAuth, body('currentPassword').notEmpty().withMessage('Current password is required'), diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 45d17be..02b0057 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -173,26 +173,57 @@ router.post('/test', adminAuth, async (req, res) => { } catch (error) { console.error('Test email error:', error); console.error('Error stack:', error.stack); - - // Provide more specific error messages - let errorMessage = 'Failed to send test email'; + + // Provide more specific error messages with translation keys + let errorMessage = 'Error sending email'; + let errorKey = 'email.errors.sendFailed'; let details = error.message; - + let detailsKey = 'email.errors.unknownError'; + if (error.code === 'ECONNREFUSED') { errorMessage = 'Failed to connect to SMTP server'; + errorKey = 'email.errors.connectionRefused'; details = 'Please check your SMTP host and port settings'; + detailsKey = 'email.errors.checkHostPort'; } else if (error.code === 'EAUTH') { errorMessage = 'SMTP authentication failed'; + errorKey = 'email.errors.authFailed'; details = 'Please check your SMTP username and password'; + detailsKey = 'email.errors.checkCredentials'; } else if (error.code === 'ESOCKET') { - errorMessage = 'Network error'; + errorMessage = 'Network error connecting to SMTP server'; + errorKey = 'email.errors.networkError'; details = 'Could not establish connection to SMTP server'; + detailsKey = 'email.errors.connectionFailed'; + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection to SMTP server timed out'; + errorKey = 'email.errors.timeout'; + details = 'The server took too long to respond. Please check your network and SMTP settings.'; + detailsKey = 'email.errors.timeoutDetails'; + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'SMTP server not found'; + errorKey = 'email.errors.serverNotFound'; + details = 'The SMTP host could not be resolved. Please verify the hostname.'; + detailsKey = 'email.errors.checkHostname'; + } else if (error.responseCode >= 500) { + errorMessage = 'SMTP server error'; + errorKey = 'email.errors.serverError'; + details = `Server returned error code ${error.responseCode}`; + detailsKey = 'email.errors.serverErrorDetails'; + } else if (error.responseCode >= 400) { + errorMessage = 'Email rejected by server'; + errorKey = 'email.errors.rejected'; + details = error.response || 'The email was rejected. Check recipient address and settings.'; + detailsKey = 'email.errors.rejectedDetails'; } - - res.status(500).json({ + + res.status(500).json({ error: errorMessage, + errorKey: errorKey, details: details, - code: error.code + detailsKey: detailsKey, + code: error.code, + responseCode: error.responseCode }); } }); diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index 0134ded..f6ad324 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -2,13 +2,14 @@ // Only the relevant parts are shown - merge with existing adminEvents.js const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); +const { buildShareLinkVariants } = require('../services/shareLinkService'); // Enhanced event creation with password validation router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), - body('host_email').isEmail().normalizeEmail(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(), body('password').notEmpty(), // Remove the weak isLength validation body('expiration_days').isInt({ min: 1, max: 365 }).optional(), @@ -16,7 +17,7 @@ router.post('/', adminAuth, [ body('color_theme').optional().trim(), body('allow_user_uploads').optional().isBoolean().toBoolean(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('host_name').notEmpty().trim() + body('customer_name').notEmpty().trim() ], async (req, res) => { try { console.log('Create event request body:', req.body); @@ -30,8 +31,8 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + customer_name, + customer_email, admin_email, password, welcome_message = '', @@ -65,9 +66,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link + // Generate share link based on configured style const shareToken = crypto.randomBytes(16).toString('hex'); - const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password with configurable rounds const password_hash = await bcrypt.hash(password, getBcryptRounds()); @@ -88,13 +89,16 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + customer_name, + customer_email, + host_name: customer_name, + host_email: customer_email, admin_email, password_hash, welcome_message, color_theme, - share_link: shareLink, + share_link: shareLinkToStore, + share_token: shareToken, expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), allow_user_uploads, @@ -121,4 +125,4 @@ router.post('/', adminAuth, [ console.error('Error creating event:', error); res.status(500).json({ error: 'Failed to create event' }); } -}); \ No newline at end of file +}); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index cde9ecf..b3ce0d4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity'); // formatDate import removed - dates are formatted by email processor const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const logger = require('../utils/logger'); +const { buildShareLinkVariants } = require('../services/shareLinkService'); const parseBooleanInput = (value, defaultValue = true) => { if (value === undefined || value === null) { @@ -37,12 +38,67 @@ const parseBooleanInput = (value, defaultValue = true) => { return defaultValue; }; +const getCustomerNameFromPayload = (payload = {}) => { + if (typeof payload.customer_name === 'string') { + const trimmed = payload.customer_name.trim(); + return trimmed || null; + } + return null; +}; + +const getCustomerEmailFromPayload = (payload = {}) => { + if (typeof payload.customer_email === 'string') { + const trimmed = payload.customer_email.trim(); + return trimmed || null; + } + return null; +}; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + logger.debug('Failed to detect customer_email column', { error: error.message }); + return false; + } +}; + // Create new event router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), - body('host_email').isEmail().normalizeEmail(), + body('customer_name').notEmpty().trim(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -73,7 +129,6 @@ router.post('/', adminAuth, [ body('color_theme').optional().trim(), body('allow_user_uploads').optional().isBoolean().toBoolean(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('host_name').notEmpty().trim(), body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), @@ -91,8 +146,6 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, admin_email, password, welcome_message = '', @@ -115,7 +168,16 @@ router.post('/', adminAuth, [ moderate_comments = true, show_feedback_to_guests = true } = req.body; - + + const customerName = getCustomerNameFromPayload(req.body); + const customerEmail = getCustomerEmailFromPayload(req.body); + + const customerColumnsAvailable = await hasCustomerContactColumns(); + + if (!customerName || !customerEmail) { + return res.status(400).json({ error: 'customer_name and customer_email are required' }); + } + const requirePassword = parseBooleanInput(requirePasswordInput, true); // Debug logging @@ -133,7 +195,6 @@ router.post('/', adminAuth, [ }); let passwordValidation = null; - let galleryPassword = password; if (requirePassword) { passwordValidation = await validatePasswordInContext(password, 'gallery', { @@ -148,8 +209,6 @@ router.post('/', adminAuth, [ feedback: passwordValidation.feedback }); } - } else { - galleryPassword = ''; } // Generate unique slug @@ -167,11 +226,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link + // Generate share link respecting configured format const shareToken = crypto.randomBytes(16).toString('hex'); - const sharePath = `/gallery/${slug}/${shareToken}`; - const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); - const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; + const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password with configurable rounds (random placeholder when not required) const password_hash = requirePassword @@ -201,13 +258,15 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + host_name: customerName, + host_email: customerEmail, admin_email, password_hash, welcome_message, color_theme, - share_link: shareLink, + share_link: shareLinkToStore, + share_token: shareToken, expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), allow_user_uploads, @@ -251,13 +310,15 @@ router.post('/', adminAuth, [ await db('email_queue').insert({ event_id: eventId, - recipient_email: host_email, + recipient_email: customerEmail, email_type: 'gallery_created', email_data: JSON.stringify({ - host_name: host_name, + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), event_name, event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareLink, + gallery_link: shareUrl, gallery_password: requirePassword ? password : 'No password required', expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor welcome_message: welcome_message || '' @@ -272,8 +333,10 @@ router.post('/', adminAuth, [ slug, event_name, event_type, + customer_name: customerName, + customer_email: customerEmail, require_password: requirePassword, - share_link: shareLink, + share_link: shareUrl, expires_at: expires_at.toISOString(), created_at: new Date().toISOString() }); @@ -356,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => { created_at: event.created_at ? new Date(event.created_at).toISOString() : null, expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null - })); + })).map(mapEventForApi); res.json({ events: eventsWithCounts, @@ -418,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => { .where('event_id', id) .countDistinct('ip_address as uniqueVisitors'); - res.json({ + res.json(mapEventForApi({ ...event, photo_count: parseInt(photoCount) || 0, total_size: parseInt(totalSize) || 0, @@ -426,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => { total_downloads: parseInt(totalDownloads) || 0, unique_visitors: parseInt(uniqueVisitors) || 0, recent_photos: recentPhotos - }); + })); } catch (error) { console.error('Error fetching event:', error); res.status(500).json({ error: 'Failed to fetch event details' }); @@ -442,7 +505,8 @@ router.put('/:id', adminAuth, [ body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), body('color_theme').optional({ nullable: true }), body('allow_user_uploads').optional().isBoolean(), - body('host_name').optional().trim().notEmpty(), + body('customer_name').optional().trim().notEmpty(), + body('customer_email').optional().isEmail().normalizeEmail(), body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values if (value === null || value === undefined) return true; @@ -481,6 +545,39 @@ router.put('/:id', adminAuth, [ const { id } = req.params; const updates = { ...req.body }; + const customerColumnsAvailable = await hasCustomerContactColumns(); + + if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { + return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { + const nextName = getCustomerNameFromPayload(updates); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; + } else { + delete updates.customer_name; + } + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { + const nextEmail = getCustomerEmailFromPayload(updates); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; + } else { + delete updates.customer_email; + } + } const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; @@ -715,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => { // Queue email notification if requested if (sendEmail) { - // For password reset, we'll need to create a template or use a different approach - // For now, let's use the gallery_created template with updated password - await queueEmail(id, event.host_email, 'gallery_created', { - host_name: event.host_email.split('@')[0], + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, event_date: event.event_date, // Pass raw date - will be formatted by email processor gallery_link: event.share_link, @@ -773,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => { // Dates will be formatted by the email processor based on recipient language // Queue the email - await queueEmail(id, event.host_email, 'gallery_created', { - host_name: event.host_name || event.host_email.split('@')[0], + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, event_date: event.event_date, // Pass raw date - will be formatted by email processor gallery_link: event.share_link, @@ -789,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => { try { await logActivity('email_resent', { email_type: 'gallery_created', - recipient: event.host_email, + recipient: recipientEmail, ip_address: req.ip || '0.0.0.0', user_agent: req.get('user-agent') || 'Unknown' }, id, { diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 8cdbcb9..ffe4d68 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => { // Use database-agnostic date calculation const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const deletedCount = await db('activity_logs') - .whereNotNull('read_at') - .where('created_at', '<', thirtyDaysAgo) - .delete(); + + let deletedCount = 0; + const client = db?.client?.config?.client; + + if (client === 'pg') { + const primaryResult = await db.raw( + ` + WITH deleted AS ( + DELETE FROM activity_logs + WHERE read_at IS NOT NULL OR created_at < ? + RETURNING id + ) + SELECT COUNT(*)::int AS count FROM deleted + `, + [thirtyDaysAgo.toISOString()] + ); + deletedCount = primaryResult.rows?.[0]?.count || 0; + + if (deletedCount === 0) { + const fallbackResult = await db.raw( + ` + WITH deleted AS ( + DELETE FROM activity_logs + RETURNING id + ) + SELECT COUNT(*)::int AS count FROM deleted + ` + ); + deletedCount = fallbackResult.rows?.[0]?.count || 0; + } + } else { + deletedCount = await db('activity_logs') + .where(function () { + this.whereNotNull('read_at') + .orWhere('created_at', '<', thirtyDaysAgo); + }) + .delete(); + + if (deletedCount === 0) { + deletedCount = await db('activity_logs').delete(); + } + } res.json({ - message: 'Old notifications cleared', + message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear', deletedCount }); } catch (error) { @@ -119,18 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => { } }); -// Delete all notifications -router.delete('/clear-all', adminAuth, async (req, res) => { - try { - const deletedCount = await db('activity_logs').delete(); - res.json({ - message: 'All notifications cleared', - deletedCount - }); - } catch (error) { - console.error('Clear all notifications error:', error); - res.status(500).json({ error: 'Failed to clear notifications' }); - } -}); - module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 4b7e6a0..9c94122 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); +const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const router = express.Router(); // Get storage path from environment or default @@ -66,7 +67,7 @@ const upload = multer({ storage: storage, limits: { fileSize: 50 * 1024 * 1024, // 50MB limit per file - files: 500, // Maximum 500 files + files: 2000, // Hard safety ceiling; actual limit enforced dynamically // Set a reasonable field size limit to prevent memory issues fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields // Add part size limits to prevent incomplete uploads @@ -117,17 +118,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default }; // Upload photos for an event -// Increased limit to 500 files, but recommend chunked uploads for better performance -router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout - upload.array('photos', 500)(req, res, (err) => { +// Max file count is configurable via general settings +router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout + let maxFilesPerUpload; + try { + maxFilesPerUpload = await getMaxFilesPerUpload(); + } catch (error) { + console.error('Failed to resolve max files per upload:', error); + return res.status(500).json({ error: 'Unable to determine upload limits' }); + } + + upload.array('photos', maxFilesPerUpload)(req, res, (err) => { if (err) { console.error('Multer error:', err); if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' }); } - if (err.code === 'LIMIT_FILE_COUNT') { - return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' }); + if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') { + return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` }); } return res.status(400).json({ error: `Upload error: ${err.message}` }); } @@ -484,24 +493,55 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; const { category_id } = req.body; - + // Verify photo belongs to event const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); - + if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } - + + // Prepare update data + const updateData = { + updated_at: new Date() + }; + + // Handle type-based categories ('individual' or 'collage') + // These are string values that map to the photo.type field + if (category_id === 'individual' || category_id === 'collage') { + updateData.type = category_id; + updateData.category_id = null; // Clear legacy category_id + } else if (category_id === null || category_id === undefined) { + // Explicitly clear category + updateData.category_id = null; + } else { + // Handle numeric category IDs from photo_categories table + const numericCategoryId = parseInt(category_id, 10); + if (!isNaN(numericCategoryId)) { + updateData.category_id = numericCategoryId; + } else { + updateData.category_id = null; + } + } + // Update photo const normalizedCategoryId = parseCategoryId(category_id); await db('photos') + .where({ id: photoId, event_id: eventId }) + .update(updateData); + + // Fetch and return updated photo for confirmation + const updatedPhoto = await db('photos') .where({ id: photoId }) - .update({ category_id: normalizedCategoryId }); - - res.json({ message: 'Photo updated successfully' }); + .first(); + + res.json({ + message: 'Photo updated successfully', + photo: updatedPhoto + }); } catch (error) { console.error('Error updating photo:', error); res.status(500).json({ error: 'Failed to update photo' }); @@ -581,33 +621,52 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { try { const { eventId } = req.params; const { photoIds, updates } = req.body; - + if (!Array.isArray(photoIds) || photoIds.length === 0) { return res.status(400).json({ error: 'Invalid photo IDs' }); } - + // Verify all photos belong to the event const photoCount = await db('photos') .whereIn('id', photoIds) .where('event_id', eventId) .count('id as count') .first(); - - if (photoCount.count !== photoIds.length) { + + if (parseInt(photoCount.count) !== photoIds.length) { return res.status(400).json({ error: 'Some photos do not belong to this event' }); } - - // Update photos - const updateData = {}; + + // Prepare update data + const updateData = { + updated_at: new Date() + }; + if (updates.category_id !== undefined) { - updateData.category_id = parseCategoryId(updates.category_id); + // Handle type-based categories ('individual' or 'collage') + // These are string values that map to the photo.type field + if (updates.category_id === 'individual' || updates.category_id === 'collage') { + updateData.type = updates.category_id; + updateData.category_id = null; // Clear legacy category_id + } else if (updates.category_id === null) { + // Explicitly clear category + updateData.category_id = null; + } else { + // Handle numeric category IDs from photo_categories table + const numericCategoryId = parseInt(updates.category_id, 10); + if (!isNaN(numericCategoryId)) { + updateData.category_id = numericCategoryId; + } else { + updateData.category_id = null; + } + } } - + await db('photos') .whereIn('id', photoIds) .where('event_id', eventId) .update(updateData); - + res.json({ message: `${photoIds.length} photos updated successfully` }); } catch (error) { console.error('Error bulk updating photos:', error); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 7c3a883..01ea906 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -18,7 +18,10 @@ const { getRawPublicSiteSettings, } = require('../services/publicSiteService'); const { sanitizeCss } = require('../utils/cssSanitizer'); +const { clearShareLinkSettingsCache } = require('../services/shareLinkService'); +const { resetSecurityConfigCache } = require('../utils/authSecurity'); const router = express.Router(); +const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -188,7 +191,8 @@ router.put('/branding', adminAuth, async (req, res) => { logo_position, logo_display_header, logo_display_hero, - logo_display_mode + logo_display_mode, + hide_powered_by } = req.body; const brandingSettings = { @@ -208,7 +212,8 @@ router.put('/branding', adminAuth, async (req, res) => { logo_position, logo_display_header, logo_display_hero, - logo_display_mode + logo_display_mode, + hide_powered_by }; // Handle favicon deletion if empty string or null is provided @@ -472,9 +477,24 @@ router.put('/theme', adminAuth, async (req, res) => { router.put('/general', adminAuth, async (req, res) => { try { const settings = { ...req.body }; + let uploadLimitTouched = false; const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); + if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) { + uploadLimitTouched = true; + const rawValue = Number(settings.general_max_files_per_upload); + const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN; + + if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) { + return res.status(400).json({ + error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}` + }); + } + + settings.general_max_files_per_upload = normalizedValue; + } + if (publicSiteKeysTouched) { if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) { settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || ''); @@ -529,6 +549,12 @@ router.put('/general', adminAuth, async (req, res) => { if (publicSiteKeysTouched) { clearPublicSiteCache(); } + if (uploadLimitTouched) { + clearMaxFilesPerUploadCache(); + } + if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { + clearShareLinkSettingsCache(); + } // Log activity await db('activity_logs').insert({ @@ -567,6 +593,8 @@ router.put('/security', adminAuth, async (req, res) => { }); } + resetSecurityConfigCache(); + // Log activity await db('activity_logs').insert({ activity_type: 'security_settings_updated', diff --git a/backend/src/routes/auth-enhanced-v2.js b/backend/src/routes/auth-enhanced-v2.js index 4bbf55c..fccc390 100644 --- a/backend/src/routes/auth-enhanced-v2.js +++ b/backend/src/routes/auth-enhanced-v2.js @@ -12,13 +12,14 @@ const { checkSuspiciousActivity, getGenericAuthError } = require('../utils/authSecurity'); -const { +const { validatePasswordInContext, getBcryptRounds, logPasswordValidationFailure } = require('../utils/passwordValidation'); const { endSession } = require('../middleware/sessionTimeout'); const logger = require('../utils/logger'); +const { getClientIp } = require('../utils/requestIp'); const router = express.Router(); // Admin login with enhanced security @@ -33,7 +34,7 @@ router.post('/admin/login', [ } const { username, password, recaptchaToken } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; + const ipAddress = getClientIp(req); const userAgent = req.headers['user-agent'] || ''; // Check account lockout first @@ -175,7 +176,7 @@ router.post('/admin/change-password', [ logger.info('Admin password changed', { userId: adminId, username: admin.username, - ip: req.ip + ip: ipAddress }); res.json({ @@ -229,14 +230,14 @@ router.post('/gallery/verify', [ } const { slug, password, recaptchaToken } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; + const ipAddress = getClientIp(req); const userAgent = req.headers['user-agent'] || ''; const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0')); if (requiresPassword) { - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress); if (lockoutStatus.isLocked) { logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); return res.status(423).json({ diff --git a/backend/src/routes/auth-enhanced.js b/backend/src/routes/auth-enhanced.js index f5bbc03..f675137 100644 --- a/backend/src/routes/auth-enhanced.js +++ b/backend/src/routes/auth-enhanced.js @@ -22,6 +22,8 @@ const { getAdminTokenFromRequest, getGalleryTokenFromRequest, } = require('../utils/tokenUtils'); +const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService'); +const { getClientIp } = require('../utils/requestIp'); const router = express.Router(); // Admin login with enhanced security @@ -36,7 +38,7 @@ router.post('/admin/login', [ } const { username, password, recaptchaToken } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; + const ipAddress = getClientIp(req); const userAgent = req.headers['user-agent'] || ''; // Check account lockout first @@ -171,7 +173,7 @@ router.post('/gallery/verify', [ } const { slug, password, recaptchaToken } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; + const ipAddress = getClientIp(req); const userAgent = req.headers['user-agent'] || ''; const event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) @@ -185,7 +187,7 @@ router.post('/gallery/verify', [ const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); if (requiresPassword) { - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress); if (lockoutStatus.isLocked) { logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); return res.status(423).json({ @@ -281,21 +283,25 @@ router.post('/gallery/share-login', [ } const { slug, token } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; + const ipAddress = getClientIp(req); const userAgent = req.headers['user-agent'] || ''; - const event = await db('events') + let event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .first(); + if (!event) { + const resolved = await resolveShareIdentifier(slug); + if (resolved?.event) { + event = resolved.event; + } + } + if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } - let expectedToken = event.share_link; - if (expectedToken && expectedToken.includes('/')) { - expectedToken = expectedToken.split('/').pop(); - } + const expectedToken = getEventShareToken(event); if (!expectedToken || token !== expectedToken) { return res.status(401).json({ error: 'Invalid or expired share link' }); @@ -312,7 +318,7 @@ router.post('/gallery/share-login', [ issuer: 'picpeak-auth' }); - await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); + await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent); setGalleryAuthCookies(res, jwtToken, event.slug); const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index b525148..a431373 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2'); const fs = require('fs').promises; const path = require('path'); const router = express.Router(); +const { buildShareLinkVariants } = require('../services/shareLinkService'); const parseBooleanInput = (value, defaultValue = true) => { if (value === undefined || value === null) { @@ -32,12 +33,66 @@ const parseBooleanInput = (value, defaultValue = true) => { return defaultValue; }; +const getCustomerNameFromPayload = (payload = {}) => { + if (typeof payload.customer_name === 'string') { + const trimmed = payload.customer_name.trim(); + return trimmed || null; + } + return null; +}; + +const getCustomerEmailFromPayload = (payload = {}) => { + if (typeof payload.customer_email === 'string') { + const trimmed = payload.customer_email.trim(); + return trimmed || null; + } + return null; +}; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + return false; + } +}; + // Create new event router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty(), body('event_date').isDate(), - body('host_email').isEmail(), + body('customer_name').notEmpty().trim(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -62,7 +117,6 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_email, admin_email, password, require_password: requirePasswordInput = true, @@ -71,6 +125,15 @@ router.post('/', adminAuth, [ expiration_days = 30 } = req.body; + const customerEmail = getCustomerEmailFromPayload(req.body); + const customerName = getCustomerNameFromPayload(req.body); + + if (!customerName || !customerEmail) { + return res.status(400).json({ error: 'customer_name and customer_email are required' }); + } + + const customerColumnsAvailable = await hasCustomerContactColumns(); + const requirePassword = parseBooleanInput(requirePasswordInput, true); if (requirePassword) { @@ -98,12 +161,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link (just slug/token, not full URL) + // Generate share link variants (auto-detects short URL preference) const shareToken = crypto.randomBytes(16).toString('hex'); - const sharePath = `/gallery/${slug}/${shareToken}`; - const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); - const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; - const shareLinkSlug = `${slug}/${shareToken}`; + const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password (or placeholder when not required) const password_hash = requirePassword @@ -126,12 +186,15 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_email, + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + host_name: customerName, + host_email: customerEmail, admin_email, password_hash, welcome_message, color_theme, - share_link: shareLinkSlug, + share_link: shareLinkToStore, + share_token: shareToken, expires_at, require_password: formatBoolean(requirePassword) }).returning('id'); @@ -141,11 +204,13 @@ router.post('/', adminAuth, [ // Queue creation email const { queueEmail } = require('../services/emailProcessor'); - await queueEmail(eventId, host_email, 'gallery_created', { - host_name: host_email.split('@')[0], // Extract name from email + await queueEmail(eventId, customerEmail, 'gallery_created', { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName, event_name, event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: fullShareLink, + gallery_link: shareUrl, gallery_password: requirePassword ? password : 'No password required', expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor welcome_message: welcome_message || '' @@ -154,9 +219,11 @@ router.post('/', adminAuth, [ res.json({ id: eventId, slug, - share_link: fullShareLink, + share_link: shareUrl, expires_at, - require_password: requirePassword + require_password: requirePassword, + customer_name: customerName, + customer_email: customerEmail }); } catch (error) { console.error(error); @@ -185,17 +252,27 @@ router.get('/', adminAuth, async (req, res) => { event.photo_count = photoCount.count; } - res.json(events); + res.json(events.map(mapEventForApi)); } catch (error) { res.status(500).json({ error: 'Failed to fetch events' }); } }); // Update event -router.put('/:id', adminAuth, async (req, res) => { +router.put('/:id', adminAuth, [ + body('customer_name').optional().trim().notEmpty(), + body('customer_email').optional().isEmail().normalizeEmail(), + body('require_password').optional().isBoolean() +], async (req, res) => { try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { id } = req.params; const updates = { ...req.body }; + const customerColumnsAvailable = await hasCustomerContactColumns(); // Don't allow updating certain fields delete updates.id; @@ -203,6 +280,38 @@ router.put('/:id', adminAuth, async (req, res) => { delete updates.created_at; delete updates.password_confirmation; + if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { + return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { + const nextName = getCustomerNameFromPayload(updates); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; + } else { + delete updates.customer_name; + } + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { + const nextEmail = getCustomerEmailFromPayload(updates); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; + } else { + delete updates.customer_email; + } + } + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; if (hasRequirePasswordUpdate) { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index da25787..1e45a61 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); +const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +// Resolve gallery identifier (slug or token) to canonical data +router.get('/resolve/:identifier', async (req, res) => { + try { + const { identifier } = req.params; + const result = await resolveShareIdentifier(identifier); + + if (!result) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + const { event, matchType, shareToken } = result; + const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + res.json({ + slug: event.slug, + token: shareToken, + matchType, + share_link: event.share_link, + share_path: linkVariants.sharePath, + share_url: linkVariants.shareUrl, + short_enabled: linkVariants.shortEnabled, + requires_password: requiresPassword + }); + } catch (error) { + logger.error('Error resolving gallery identifier:', error); + res.status(500).json({ error: 'Failed to resolve gallery link' }); + } +}); + // Verify share token router.get('/:slug/verify-token/:token', async (req, res) => { try { @@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => { const event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .select('id', 'share_link') + .select('id', 'share_link', 'share_token') .first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } - // Extract token from share link and verify - const expectedToken = event.share_link.split('/').pop(); + const expectedToken = getEventShareToken(event); if (token !== expectedToken) { return res.status(404).json({ error: 'Invalid gallery link' }); } @@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => { 'is_active', 'is_archived', 'share_link', + 'share_token', 'allow_downloads', 'disable_right_click', 'watermark_downloads', @@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => { // If token provided, verify it matches the share link if (token) { - let expectedToken = event.share_link; - // Handle both formats: full URL or just token - if (event.share_link && event.share_link.includes('/')) { - expectedToken = event.share_link.split('/').pop(); - } - if (token !== expectedToken) { + const expectedToken = getEventShareToken(event); + if (!expectedToken || token !== expectedToken) { return res.status(404).json({ error: 'Invalid gallery link' }); } } @@ -773,22 +800,35 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => { router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { try { const eventId = parseInt(req.params.eventId); - + // Verify the event matches the token if (req.event.id !== eventId) { return res.status(403).json({ error: 'Access denied' }); } - + // Check if user uploads are allowed if (!req.event.allow_user_uploads) { return res.status(403).json({ error: 'User uploads are not allowed for this event' }); } - + + // Ensure temp upload directory exists + const fs = require('fs'); + const tempUploadDir = '/tmp/uploads/'; + if (!fs.existsSync(tempUploadDir)) { + try { + fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 }); + logger.info('Created temp upload directory:', tempUploadDir); + } catch (mkdirErr) { + logger.error('Failed to create temp upload directory:', mkdirErr); + return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' }); + } + } + // Import multer and photo processing const multer = require('multer'); - const upload = multer({ - dest: '/tmp/uploads/', - limits: { + const upload = multer({ + dest: tempUploadDir, + limits: { fileSize: 50 * 1024 * 1024, // 50MB files: 10 // Max 10 files at once }, diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 4501f67..7334fb0 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -58,11 +58,15 @@ async function queueExpirationWarning(event) { const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); // Determine language based on email domain - const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en'; + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en'; - // Queue email to host - await queueEmail(event.id, event.host_email, 'expiration_warning', { - host_name: event.host_name || event.host_email.split('@')[0], + // Queue email to customer + await queueEmail(event.id, recipientEmail, 'expiration_warning', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, days_remaining: daysRemaining.toString(), expiration_date: await formatDate(event.expires_at, emailLang), @@ -78,9 +82,14 @@ async function handleExpiredEvent(event) { await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); // Queue expiration emails - await queueEmail(event.id, event.host_email, 'gallery_expired', { + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(event.id, recipientEmail, 'gallery_expired', { event_name: event.event_name, - admin_email: event.admin_email + admin_email: event.admin_email, + customer_name: recipientName, + customer_email: recipientEmail }); // Also notify admin diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 09161f3..c729658 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -18,6 +18,29 @@ const DEFAULT_THUMBNAIL_FORMAT = 'jpeg'; const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); +// Helper to parse setting value (handles both JSON-encoded and plain values) +function parseSettingValue(value) { + if (value === null || value === undefined) { + return null; + } + // Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"') + try { + return JSON.parse(value); + } catch (e) { + // If it's not valid JSON, return the raw value + return value; + } +} + +// Validate that fit value is valid for Sharp +function validateFitValue(fit) { + const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside']; + if (fit && validFitValues.includes(fit)) { + return fit; + } + return DEFAULT_THUMBNAIL_FIT; +} + // Get thumbnail settings from database async function getThumbnailSettings() { try { @@ -30,16 +53,19 @@ async function getThumbnailSettings() { 'thumbnail_format' ]) .select('setting_key', 'setting_value'); - + const settingsMap = {}; settings.forEach(s => { - settingsMap[s.setting_key] = s.setting_value; + settingsMap[s.setting_key] = parseSettingValue(s.setting_value); }); - + + // Parse and validate fit value + const fitValue = validateFitValue(settingsMap.thumbnail_fit); + return { width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH, height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT, - fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT, + fit: fitValue, quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY, format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT }; diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index 34938da..fd2762d 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -8,20 +8,46 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); function normalizeFiles(files) { - if (!files) return []; - if (Array.isArray(files)) return files.filter(Boolean); - - // Multer may expose files as an iterable object - if (typeof files[Symbol.iterator] === 'function') { - return Array.from(files).filter(Boolean); + // Handle null, undefined, or falsy values + if (!files) { + console.log('[normalizeFiles] No files provided'); + return []; } + // Handle arrays + if (Array.isArray(files)) { + const validFiles = files.filter(Boolean); + console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`); + return validFiles; + } + + // Handle iterable objects (some multer configurations) + try { + if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') { + const validFiles = Array.from(files).filter(Boolean); + console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`); + return validFiles; + } + } catch (err) { + console.warn('[normalizeFiles] Failed to iterate files object:', err.message); + } + + // Handle plain objects (multer fieldname mapping) if (typeof files === 'object') { - return Object.values(files) - .flatMap((value) => (Array.isArray(value) ? value : [value])) - .filter(Boolean); + try { + const validFiles = Object.values(files) + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .filter(Boolean); + console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`); + return validFiles; + } catch (err) { + console.warn('[normalizeFiles] Failed to process files object:', err.message); + return []; + } } + // Unexpected type + console.warn('[normalizeFiles] Unexpected files type:', typeof files); return []; } @@ -80,18 +106,46 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ const tempPath = file?.path || file?.filepath || file?.tempFilePath; if (!tempPath) { - throw new Error('Uploaded file is missing a temporary path'); + const fileInfo = JSON.stringify({ + originalname: file?.originalname, + mimetype: file?.mimetype, + size: file?.size, + availableKeys: Object.keys(file || {}) + }); + throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`); + } + + // Verify temp file exists before copying + try { + await fs.access(tempPath); + } catch (accessErr) { + console.error(`Temp file not accessible: ${tempPath}`, { + originalname: file?.originalname, + error: accessErr.message + }); + throw new Error(`Uploaded file not found at temporary location: ${tempPath}`); } // Use copyFile and unlink instead of rename to avoid cross-device issues try { await fs.copyFile(tempPath, newPath); + console.log(`Successfully copied ${file.originalname} to ${newPath}`); + } catch (copyErr) { + console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr); + throw new Error(`Failed to copy uploaded file: ${copyErr.message}`); } finally { + // Clean up temp file with better error handling try { await fs.unlink(tempPath); + console.log(`Cleaned up temp file: ${tempPath}`); } catch (unlinkErr) { + // Only warn if file exists but couldn't be deleted + // ENOENT means file was already deleted, which is fine if (unlinkErr?.code !== 'ENOENT') { - console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr); + console.warn(`Failed to clean up temp upload ${tempPath}:`, { + error: unlinkErr.message, + code: unlinkErr.code + }); } } } @@ -154,10 +208,28 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ size: file.size, type: photoType }); + + console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`); } catch (error) { - console.error(`Error processing file ${file.originalname}:`, error); - if (trx) await trx.rollback(); + console.error(`Error processing file ${file.originalname}:`, { + error: error.message, + stack: error.stack, + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + tempPath: file?.path || file?.filepath || file?.tempFilePath + }); + + if (trx) { + try { + await trx.rollback(); + } catch (rollbackErr) { + console.error('Failed to rollback transaction:', rollbackErr); + } + } + // Continue with other files + // Note: Individual file failures don't stop the entire upload batch } } diff --git a/backend/src/services/photoResolver.js b/backend/src/services/photoResolver.js index 09f450a..340fd9e 100644 --- a/backend/src/services/photoResolver.js +++ b/backend/src/services/photoResolver.js @@ -12,10 +12,12 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '. function resolvePhotoFilePath(event, photo) { if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo'); - const isExternal = photo.source_origin === 'external' || - (!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external')); - - if (isExternal) { + // IMPORTANT: photo.source_origin takes precedence over event.source_mode + // This allows events in "reference" mode to have mixed sources: + // - Imported photos: source_origin = 'external' + // - Uploaded photos: source_origin = 'managed' + const mode = (photo.source_origin || event.source_mode || 'managed'); + if (mode === 'reference' || mode === 'external') { if (!photo.external_relpath) { throw new Error('Missing external_relpath for external photo'); } diff --git a/backend/src/services/shareLinkService.js b/backend/src/services/shareLinkService.js new file mode 100644 index 0000000..e8e1fb7 --- /dev/null +++ b/backend/src/services/shareLinkService.js @@ -0,0 +1,181 @@ +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils'); + +const SETTING_KEY = 'general_short_gallery_urls'; +const CACHE_TTL_MS = 60_000; + +let cachedSetting = null; +let cacheExpiresAt = 0; + +const parseSettingValue = (rawValue) => { + if (rawValue === undefined || rawValue === null) { + return null; + } + + if (typeof rawValue === 'boolean') { + return rawValue; + } + + if (typeof rawValue === 'number') { + return rawValue !== 0; + } + + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + return parseSettingValue(parsed); + } catch { + const normalized = trimmed.toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false; + } + return null; + } + } + + if (typeof rawValue === 'object') { + try { + return parseSettingValue(JSON.parse(JSON.stringify(rawValue))); + } catch { + return null; + } + } + + return null; +}; + +const getRawSettingValue = async () => { + try { + const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first(); + return setting?.setting_value ?? null; + } catch (error) { + console.error('Failed to read gallery URL setting:', error.message); + return null; + } +}; + +const isShortGalleryUrlsEnabled = async () => { + if (cachedSetting !== null && Date.now() < cacheExpiresAt) { + return cachedSetting; + } + + const rawValue = await getRawSettingValue(); + const parsed = parseSettingValue(rawValue); + cachedSetting = parsed === null ? false : Boolean(parsed); + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + return cachedSetting; +}; + +const clearShareLinkSettingsCache = () => { + cachedSetting = null; + cacheExpiresAt = 0; +}; + +const buildShareLinkVariants = async ({ slug, shareToken }) => { + if (!shareToken) { + throw new Error('shareToken is required to build share link variants'); + } + + const shortEnabled = await isShortGalleryUrlsEnabled(); + const sharePath = buildSharePath(slug, shareToken, shortEnabled); + const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); + const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath; + + return { + shortEnabled, + sharePath, + shareUrl, + shareLinkToStore: sharePath + }; +}; + +const getEventShareToken = (event) => { + if (!event) { + return null; + } + + if (event.share_token) { + return event.share_token; + } + + return extractShareToken(event.share_link); +}; + +const ACTIVE_EVENT_FILTER = { + is_active: formatBoolean(true), + is_archived: formatBoolean(false) +}; + +const resolveShareIdentifier = async (identifier) => { + if (!identifier) { + return null; + } + + const trimmed = String(identifier).trim(); + if (!trimmed) { + return null; + } + + const baseQuery = db('events') + .select( + 'id', + 'slug', + 'share_link', + 'share_token', + 'require_password', + 'event_name', + 'event_type', + 'event_date', + 'expires_at', + 'is_active', + 'is_archived' + ) + .where(ACTIVE_EVENT_FILTER); + + let event = await baseQuery.clone().where({ slug: trimmed }).first(); + if (event) { + return { event, matchType: 'slug', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where({ share_token: trimmed }).first(); + if (event) { + return { event, matchType: 'token', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where({ share_link: trimmed }).first(); + if (event) { + return { event, matchType: 'link', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first(); + if (event) { + return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) }; + } + + // As a final fallback, if identifier looks like a token but we did not match via share_token + if (isPotentialShareToken(trimmed)) { + event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first(); + if (event) { + return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) }; + } + } + + return null; +}; + +module.exports = { + isShortGalleryUrlsEnabled, + clearShareLinkSettingsCache, + buildShareLinkVariants, + getEventShareToken, + resolveShareIdentifier +}; diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js new file mode 100644 index 0000000..2998504 --- /dev/null +++ b/backend/src/services/uploadSettings.js @@ -0,0 +1,87 @@ +const { db } = require('../database/db'); + +const DEFAULT_MAX_FILES_PER_UPLOAD = 500; +const MAX_ALLOWED_FILES_PER_UPLOAD = 2000; +const CACHE_TTL_MS = 60_000; + +let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD; +let cacheExpiresAt = 0; + +const parseSettingValue = (setting) => { + if (!setting || setting.setting_value == null) { + return null; + } + + let rawValue = setting.setting_value; + + if (typeof rawValue === 'string') { + try { + rawValue = JSON.parse(rawValue); + } catch { + // keep original string + } + } + + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (trimmed === '') { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + + if (typeof rawValue === 'number') { + return rawValue; + } + + return null; +}; + +const normalizeLimit = (value) => { + if (!Number.isFinite(value)) { + return DEFAULT_MAX_FILES_PER_UPLOAD; + } + + const intValue = Math.floor(value); + if (intValue < 1) { + return DEFAULT_MAX_FILES_PER_UPLOAD; + } + if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) { + return MAX_ALLOWED_FILES_PER_UPLOAD; + } + return intValue; +}; + +const getMaxFilesPerUpload = async () => { + if (Date.now() < cacheExpiresAt) { + return cachedValue; + } + + try { + const setting = await db('app_settings') + .where({ setting_key: 'general_max_files_per_upload' }) + .first(); + + const parsedValue = normalizeLimit(parseSettingValue(setting)); + cachedValue = parsedValue; + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + return parsedValue; + } catch (error) { + console.error('Failed to read max files per upload setting:', error.message); + cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD; + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + return DEFAULT_MAX_FILES_PER_UPLOAD; + } +}; + +const clearMaxFilesPerUploadCache = () => { + cacheExpiresAt = 0; +}; + +module.exports = { + getMaxFilesPerUpload, + clearMaxFilesPerUploadCache, + DEFAULT_MAX_FILES_PER_UPLOAD, + MAX_ALLOWED_FILES_PER_UPLOAD +}; diff --git a/backend/src/services/workerManager.js b/backend/src/services/workerManager.js new file mode 100644 index 0000000..3e87e16 --- /dev/null +++ b/backend/src/services/workerManager.js @@ -0,0 +1,72 @@ +/** + * Worker Manager - Background service for PicPeak + * + * This service runs as a separate process to handle: + * - File watching for new photos + * - Expiration checking for events + * - Other background tasks + */ + +const path = require('path'); +const logger = require('../utils/logger'); + +// Load environment variables +require('dotenv').config({ path: path.join(__dirname, '../../.env') }); + +// Import services +const { startFileWatcher } = require('./fileWatcher'); +const { startExpirationChecker } = require('./expirationChecker'); + +let isShuttingDown = false; + +async function startWorkers() { + logger.info('Starting PicPeak background workers...'); + + try { + // Start file watcher for automatic photo processing + startFileWatcher(); + logger.info('File watcher started successfully'); + + // Start expiration checker for event lifecycle management + startExpirationChecker(); + logger.info('Expiration checker started successfully'); + + logger.info('All background workers started successfully'); + } catch (error) { + logger.error('Failed to start background workers:', error); + process.exit(1); + } +} + +function handleShutdown(signal) { + if (isShuttingDown) { + logger.info('Shutdown already in progress...'); + return; + } + + isShuttingDown = true; + logger.info(`Received ${signal}. Shutting down gracefully...`); + + // Give time for cleanup + setTimeout(() => { + logger.info('Worker manager shutdown complete'); + process.exit(0); + }, 1000); +} + +// Handle shutdown signals +process.on('SIGTERM', () => handleShutdown('SIGTERM')); +process.on('SIGINT', () => handleShutdown('SIGINT')); + +// Handle uncaught errors +process.on('uncaughtException', (error) => { + logger.error('Uncaught exception in worker manager:', error); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled rejection in worker manager:', reason); +}); + +// Start workers +startWorkers(); diff --git a/backend/src/utils/authSecurity.js b/backend/src/utils/authSecurity.js index 9611915..82b61bc 100644 --- a/backend/src/utils/authSecurity.js +++ b/backend/src/utils/authSecurity.js @@ -7,10 +7,140 @@ const { db } = require('../database/db'); const { formatBoolean } = require('./dbCompat'); const logger = require('./logger'); -// Configuration constants -const MAX_LOGIN_ATTEMPTS = 5; -const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds -const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts +const DEFAULT_SECURITY_CONFIG = Object.freeze({ + maxAttempts: 5, + lockoutDurationMs: 30 * 60 * 1000, // 30 minutes + attemptWindowMs: 15 * 60 * 1000 // 15 minutes +}); + +const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache +let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG }; +let cachedConfigFetchedAt = 0; + +function parseStoredValue(rawValue) { + if (rawValue === undefined || rawValue === null) { + return undefined; + } + + if (typeof rawValue !== 'string') { + return rawValue; + } + + try { + return JSON.parse(rawValue); + } catch (error) { + logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`); + return rawValue; + } +} + +function normalizePositiveInteger(name, value, fallback, options = {}) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`); + return fallback; + } + + let adjustedValue = Math.floor(numericValue); + + if (options.min !== undefined && adjustedValue < options.min) { + logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`); + adjustedValue = options.min; + } + + if (options.max !== undefined && adjustedValue > options.max) { + logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`); + adjustedValue = options.max; + } + + if (adjustedValue <= 0) { + logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`); + return fallback; + } + + return adjustedValue; +} + +async function loadSecurityConfigFromSettings() { + const rows = await db('app_settings').whereIn('setting_key', [ + 'security_max_login_attempts', + 'security_lockout_duration_minutes', + 'security_attempt_window_minutes' + ]); + + const config = { ...DEFAULT_SECURITY_CONFIG }; + + rows.forEach(row => { + const value = parseStoredValue(row.setting_value); + + switch (row.setting_key) { + case 'security_max_login_attempts': { + config.maxAttempts = normalizePositiveInteger( + 'security_max_login_attempts', + value, + DEFAULT_SECURITY_CONFIG.maxAttempts, + { min: 1, max: 50 } + ); + break; + } + case 'security_lockout_duration_minutes': { + const minutes = normalizePositiveInteger( + 'security_lockout_duration_minutes', + value, + DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000), + { min: 1, max: 24 * 60 } + ); + config.lockoutDurationMs = minutes * 60 * 1000; + break; + } + case 'security_attempt_window_minutes': { + const minutes = normalizePositiveInteger( + 'security_attempt_window_minutes', + value, + DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000), + { min: 1, max: 24 * 60 } + ); + config.attemptWindowMs = minutes * 60 * 1000; + break; + } + default: + break; + } + }); + + return config; +} + +async function getSecurityConfig(options = {}) { + const now = Date.now(); + const forceRefresh = options.forceRefresh === true; + + if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) { + return cachedSecurityConfig; + } + + try { + const config = await loadSecurityConfigFromSettings(); + cachedSecurityConfig = config; + cachedConfigFetchedAt = now; + return cachedSecurityConfig; + } catch (error) { + logger.error('Error loading security configuration:', error); + cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG }; + cachedConfigFetchedAt = now; + return cachedSecurityConfig; + } +} + +function resetSecurityConfigCache() { + cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG }; + cachedConfigFetchedAt = 0; +} /** * Track failed login attempt @@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) { if (!tableExists) { return; } + + const { attemptWindowMs } = await getSecurityConfig(); await db('login_attempts').insert({ identifier, @@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) { }); // Clear old failed attempts for this user - const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW); + const cutoffTime = new Date(Date.now() - attemptWindowMs); await db('login_attempts') .where('identifier', identifier) .where('success', formatBoolean(false)) @@ -83,30 +215,39 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) { /** * Check if account is locked due to too many failed attempts * @param {string} identifier - Username or email + * @param {string} [ipAddress] - Optional IP address scope * @returns {Promise<{isLocked: boolean, remainingTime?: number}>} */ -async function checkAccountLockout(identifier) { +async function checkAccountLockout(identifier, ipAddress) { try { // Check if table exists first const tableExists = await db.schema.hasTable('login_attempts'); if (!tableExists) { return { isLocked: false }; } + + const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig(); - const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW); + const recentWindow = new Date(Date.now() - attemptWindowMs); // Get recent failed attempts - const failedAttempts = await db('login_attempts') + const failedAttemptsQuery = db('login_attempts') .where('identifier', identifier) .where('success', formatBoolean(false)) - .where('attempt_time', '>=', recentWindow.toISOString()) - .orderBy('attempt_time', 'desc') - .limit(MAX_LOGIN_ATTEMPTS); + .where('attempt_time', '>=', recentWindow.toISOString()); - if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) { + if (ipAddress) { + failedAttemptsQuery.andWhere('ip_address', ipAddress); + } + + const failedAttempts = await failedAttemptsQuery + .orderBy('attempt_time', 'desc') + .limit(maxAttempts); + + if (failedAttempts.length >= maxAttempts) { // Check if still within lockout period const oldestAttempt = failedAttempts[failedAttempts.length - 1]; - const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION; + const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs; const now = Date.now(); if (now < lockoutEnd) { @@ -216,6 +357,6 @@ module.exports = { checkSuspiciousActivity, getGenericAuthError, initializeCleanupJob, - MAX_LOGIN_ATTEMPTS, - LOCKOUT_DURATION -}; \ No newline at end of file + getSecurityConfig, + resetSecurityConfigCache +}; diff --git a/backend/src/utils/requestIp.js b/backend/src/utils/requestIp.js new file mode 100644 index 0000000..12af29b --- /dev/null +++ b/backend/src/utils/requestIp.js @@ -0,0 +1,36 @@ +/** + * Resolve the originating client IP address, accounting for reverse proxies. + * Returns the first entry from X-Forwarded-For when available, otherwise falls back + * to Express/Node connection properties. + * @param {import('express').Request} req + * @returns {string} + */ +function getClientIp(req) { + if (!req) { + return ''; + } + + const forwardedFor = req.headers['x-forwarded-for']; + + if (typeof forwardedFor === 'string' && forwardedFor.length > 0) { + const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean); + if (firstIp) { + return firstIp; + } + } else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { + const [firstIp] = forwardedFor; + if (firstIp) { + return firstIp.trim(); + } + } + + return ( + req.ip || + req.connection?.remoteAddress || + req.socket?.remoteAddress || + req.connection?.socket?.remoteAddress || + '' + ); +} + +module.exports = { getClientIp }; diff --git a/backend/src/utils/shareLinkUtils.js b/backend/src/utils/shareLinkUtils.js new file mode 100644 index 0000000..3afbffc --- /dev/null +++ b/backend/src/utils/shareLinkUtils.js @@ -0,0 +1,63 @@ +const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/; + +/** + * Extracts the share token portion from a stored share link. + * Supports full URLs, absolute paths, and legacy slug/token formats. + * @param {string|null|undefined} shareLink + * @returns {string|null} + */ +function extractShareToken(shareLink) { + if (!shareLink) { + return null; + } + + const trimmed = String(shareLink).trim(); + if (!trimmed) { + return null; + } + + // Remove protocol + host when a full URL is stored + const path = trimmed.replace(/^https?:\/\/[^/]+/i, ''); + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) { + return null; + } + + const candidate = segments[segments.length - 1]; + return candidate || null; +} + +/** + * Returns true if the provided identifier looks like a generated share token. + * @param {string|null|undefined} identifier + * @returns {boolean} + */ +function isPotentialShareToken(identifier) { + if (!identifier) { + return false; + } + return SHARE_TOKEN_REGEX.test(String(identifier).trim()); +} + +/** + * Builds the gallery share path depending on whether short URLs are enabled. + * @param {string} slug + * @param {string} shareToken + * @param {boolean} useShort + * @returns {string} + */ +function buildSharePath(slug, shareToken, useShort) { + if (!shareToken) { + throw new Error('shareToken is required to build share path'); + } + if (useShort || !slug) { + return `/gallery/${shareToken}`; + } + return `/gallery/${slug}/${shareToken}`; +} + +module.exports = { + extractShareToken, + isPotentialShareToken, + buildSharePath +}; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 0986aa2..c0c869d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -4,6 +4,7 @@ services: postgres: image: postgres:15-alpine container_name: picpeak-postgres + userns_mode: "host" environment: POSTGRES_USER: ${DB_USER:-picpeak} POSTGRES_PASSWORD: ${DB_PASSWORD} @@ -22,6 +23,7 @@ services: redis: image: redis:7-alpine container_name: picpeak-redis + userns_mode: "host" command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redis-data:/data diff --git a/docker-compose.yml b/docker-compose.yml index b424fc0..fc2fb00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,7 @@ services: image: postgres:15-alpine container_name: picpeak-postgres restart: unless-stopped + userns_mode: "host" environment: - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} @@ -83,6 +84,7 @@ services: image: redis:7-alpine container_name: picpeak-redis restart: unless-stopped + userns_mode: "host" command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass} volumes: - redis-data:/data diff --git a/docs/ADMIN_SETUP_GUIDE.md b/docs/ADMIN_SETUP_GUIDE.md index 4f13c14..73f3086 100644 --- a/docs/ADMIN_SETUP_GUIDE.md +++ b/docs/ADMIN_SETUP_GUIDE.md @@ -109,7 +109,7 @@ If ADMIN_CREDENTIALS.txt is missing: - File is created in the backend directory root - File might have been deleted for security (as recommended) - Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt` -- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically +- When using the unified `picpeak-setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically ## Best Practices diff --git a/docs/admin-api-quickstart.md b/docs/admin-api-quickstart.md new file mode 100644 index 0000000..eb37a08 --- /dev/null +++ b/docs/admin-api-quickstart.md @@ -0,0 +1,147 @@ +# PicPeak Admin API Quickstart + +This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs. + +> **Prerequisites** +> +> - PicPeak backend running (Docker or local `node backend/server.js`) +> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults) +> - API base URL (defaults to `http://localhost:3001/api`) + +--- + +## 1. Obtain an Admin API Token + +1. Determine whether reCAPTCHA is enabled in **Admin β†’ Settings β†’ Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below. +2. Authenticate with your admin username/email and password: + +```bash +curl --fail --silent --show-error \ + -X POST "http://localhost:3001/api/auth/admin/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "BoldTiger5872%", + "recaptchaToken": "" + }' | jq +``` + +Successful responses look like: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "mustChangePassword": false + } +} +``` + +- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer ` header. +- Tokens expire after 24 hours. Log in again to refresh them. + +--- + +## 2. Use the OpenAPI Documentation + +The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can: + +- Preview it interactively with Redocly: + + ```bash + npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml + ``` + +- Import it into Postman, Insomnia, or VS Code REST client. +- Validate changes as part of CI with: + + ```bash + npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml + ``` + +Keep this file in sync whenever the backend endpoints evolve. + +--- + +## 3. Call the Key Admin Endpoints + +Below are minimal `curl` examples that rely on the bearer token captured earlier. + +### 3.1 Create an Event + +```bash +API_URL="http://localhost:3001/api" +TOKEN="REPLACE_WITH_JWT" + +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event_type": "wedding", + "event_name": "Emily & Jordan Celebration", + "event_date": "2025-06-07", + "customer_name": "Emily Carter", + "customer_email": "emily@example.com", + "admin_email": "studio@example.com", + "require_password": true, + "password": "Shutter123", + "expiration_days": 45 + }' | jq +``` + +### 3.2 Upload Photos to the Event + +```bash +EVENT_ID=512 + +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events/$EVENT_ID/upload" \ + -H "Authorization: Bearer $TOKEN" \ + -F "photos=@/path/to/DSC_2031.jpg" \ + -F "photos=@/path/to/DSC_2032.jpg" \ + -F "category_id=individual" | jq +``` + +- Files must be JPEG/PNG/WebP, each ≀ 50β€―MB. +- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500). + +### 3.3 Resend the Gallery Email + +```bash +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"password": "Shutter123"}' | jq +``` + +Omit `"password"` to send the standard security message instead. + +--- + +## 4. Quick Testing Checklist + +- βœ… Login succeeds and returns a token (HTTP 200). +- βœ… Creating an event returns `id`, `slug`, and `share_link`. +- βœ… Uploading more files than allowed returns HTTP 400 with a helpful message. +- βœ… Resending email for a missing event returns HTTP 404. +- βœ… `swagger-cli validate` passes after any spec edits. + +Automate these checks using your preferred test harness or CI pipeline to catch regressions early. + +--- + +## 5. Migrating From `host_*` + +- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless. +- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data. +- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally. +- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release. + +--- + +Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly. diff --git a/docs/picpeak-admin-api.openapi.yaml b/docs/picpeak-admin-api.openapi.yaml new file mode 100644 index 0000000..185e289 --- /dev/null +++ b/docs/picpeak-admin-api.openapi.yaml @@ -0,0 +1,584 @@ +openapi: 3.1.0 +info: + title: PicPeak Admin API + version: 1.1.11 + summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails. + description: | + This document describes the core administrative endpoints that power PicPeak automations. + It focuses on the three workflows requested by integrators: + + 1. Creating events with customer access credentials. + 2. Uploading photos in bulk to an event gallery. + 3. Resending the customer-facing gallery email. + + The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices + and is intended to be kept in sync with backend changes. + contact: + name: PicPeak Maintainers + url: https://github.com/the-luap/picpeak +servers: + - url: https://api.picpeak.example.com/api + description: Example production deployment + - url: http://localhost:3001/api + description: Local development +tags: + - name: Admin Events + description: Administrative endpoints for managing event galleries. +components: + securitySchemes: + CookieAuth: + type: apiKey + in: cookie + name: admin_token + description: > + Session cookie issued by the admin authentication flow. When present, the backend mirrors + it into the `Authorization` header automatically. + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + JSON Web Token created by the admin login endpoint. You can also pass the token explicitly + as `Authorization: Bearer ` instead of using the admin cookie. + parameters: + EventId: + name: eventId + in: path + description: Numeric identifier of the event. + required: true + schema: + type: integer + minimum: 1 + example: 341 + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + description: Human readable error message. + details: + type: string + nullable: true + description: Additional context (when available). + required: + - error + example: + error: Invalid token + ValidationErrorItem: + type: object + properties: + type: + type: string + nullable: true + description: Validation error type reported by express-validator. + msg: + type: string + path: + type: string + description: Dot-delimited path to the invalid field. + value: + description: Value that failed validation. + location: + type: string + description: Location of the invalid value (always `body` for these endpoints). + required: + - msg + - path + - location + example: + type: field + msg: Event date must be a valid ISO 8601 date + path: event_date + value: 2025/05/01 + location: body + ValidationErrorResponse: + type: object + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ValidationErrorItem' + required: + - errors + example: + errors: + - type: field + msg: Customer email must be a valid address + path: customer_email + value: example@invalid + location: body + CreateEventRequest: + type: object + required: + - event_type + - event_name + - event_date + - customer_name + - customer_email + - admin_email + properties: + event_type: + type: string + description: Type of event. Controls default theme and copy in the UI. + enum: [wedding, birthday, corporate, other] + event_name: + type: string + minLength: 1 + description: Display name for the gallery shown to end customers. + event_date: + type: string + format: date + description: Event date (YYYY-MM-DD). Used to calculate the default expiration. + customer_name: + type: string + minLength: 1 + description: Name of the customer receiving gallery access. + customer_email: + type: string + format: email + description: Email address of the customer who will receive the gallery link. + admin_email: + type: string + format: email + description: Admin contact email included in notification messages. + require_password: + type: boolean + default: true + description: When true, the gallery requires `password`; when false a random placeholder is stored. + password: + type: string + minLength: 6 + description: > + Gallery password issued to the customer. Required when `require_password` is `true`. + Left unset to auto-generate a placeholder when password protection is disabled. + expiration_days: + type: integer + minimum: 1 + maximum: 365 + default: 30 + description: Number of days after the event date before the gallery expires. + welcome_message: + type: string + description: Optional welcome message displayed in the gallery. + color_theme: + type: string + nullable: true + description: Optional theme identifier or CSS color settings. + allow_user_uploads: + type: boolean + default: false + description: Allow gallery guests to upload their own photos. + upload_category_id: + type: integer + nullable: true + description: ID of the default category for user uploads. + allow_downloads: + type: boolean + default: true + description: Allow guests to download photos. + disable_right_click: + type: boolean + default: false + description: Disable right-click in the gallery view. + watermark_downloads: + type: boolean + default: false + description: Enable watermarking on downloaded images. + watermark_text: + type: string + nullable: true + description: Custom watermark text when `watermark_downloads` is true. + feedback_enabled: + type: boolean + default: false + description: Enable the feedback module for this gallery. + allow_ratings: + type: boolean + default: true + allow_likes: + type: boolean + default: true + allow_comments: + type: boolean + default: true + allow_favorites: + type: boolean + default: true + require_name_email: + type: boolean + default: false + description: Require guests to provide name and email when leaving feedback. + moderate_comments: + type: boolean + default: true + description: Hold guest comments for moderation. + show_feedback_to_guests: + type: boolean + default: true + description: Display aggregated feedback metrics back to guests. + example: + event_type: wedding + event_name: Emily & Jordan Celebration + event_date: 2025-06-07 + customer_name: Emily Carter + customer_email: emily@example.com + admin_email: studio@example.com + require_password: true + password: Shutter123 + expiration_days: 45 + welcome_message: > + We loved capturing your day! Use the password below to view and download your photos. + allow_user_uploads: false + allow_downloads: true + feedback_enabled: true + allow_comments: true + show_feedback_to_guests: true + EventSummary: + type: object + properties: + id: + type: integer + description: Database identifier of the newly created event. + slug: + type: string + description: Unique slug used to build the gallery URL. + event_name: + type: string + event_type: + type: string + enum: [wedding, birthday, corporate, other] + customer_name: + type: string + nullable: true + description: Name of the customer associated with the event. + customer_email: + type: string + format: email + nullable: true + description: Email address of the customer associated with the event. + require_password: + type: boolean + share_link: + type: string + description: Absolute or relative URL guests can use to reach the gallery. + expires_at: + type: string + format: date-time + description: ISO 8601 timestamp when the gallery expires. + created_at: + type: string + format: date-time + description: ISO 8601 timestamp when the event was created. + required: + - id + - slug + - event_name + - event_type + - require_password + - share_link + - expires_at + - created_at + example: + id: 512 + slug: wedding-emily-jordan-2025-06-07 + event_name: Emily & Jordan Celebration + event_type: wedding + customer_name: Emily Carter + customer_email: emily@example.com + require_password: true + share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002 + expires_at: 2025-07-22T00:00:00.000Z + created_at: 2025-05-01T14:32:45.000Z + UploadPhotosResponse: + type: object + properties: + message: + type: string + photos: + type: array + items: + $ref: '#/components/schemas/UploadedPhotoSummary' + description: Metadata for each photo that was persisted successfully. + totalFiles: + type: integer + minimum: 0 + description: Total number of files included in the request (valid + invalid). + successCount: + type: integer + minimum: 0 + failureCount: + type: integer + minimum: 0 + errors: + type: array + items: + $ref: '#/components/schemas/UploadFailure' + description: Present when some files failed validation or processing. + required: + - message + - photos + - totalFiles + - successCount + - failureCount + example: + message: Uploaded 18 of 20 photos. 2 failed. + photos: + - id: 9821 + filename: DSC_2031.jpg + size: 4812096 + category_id: 2 + - id: 9822 + filename: DSC_2032.jpg + size: 5216743 + category_id: 2 + totalFiles: 20 + successCount: 18 + failureCount: 2 + errors: + - filename: DSC_2020.raw + error: Only JPEG, PNG and WebP images are allowed + - filename: portrait.png + error: File is empty + UploadedPhotoSummary: + type: object + properties: + id: + type: integer + filename: + type: string + size: + type: integer + description: File size in bytes. + category_id: + type: integer + nullable: true + required: + - id + - filename + - size + example: + id: 9821 + filename: DSC_2031.jpg + size: 4812096 + category_id: 2 + UploadFailure: + type: object + properties: + filename: + type: string + error: + type: string + required: + - filename + - error + example: + filename: DSC_2031.gif + error: Only JPEG, PNG and WebP images are allowed + ResendEmailRequest: + type: object + properties: + password: + type: string + minLength: 1 + description: > + Optional plain-text password to include in the email. When omitted a security notice + placeholder is inserted because the stored hash cannot be reversed. + example: + password: Shutter123 + ResendEmailResponse: + type: object + properties: + success: + type: boolean + message: + type: string + required: + - success + - message + example: + success: true + message: Creation email has been queued for sending +paths: + /admin/events: + post: + tags: [Admin Events] + operationId: createAdminEvent + summary: Create a new event + description: > + Creates a new event, provisions storage folders, stores the gallery password, and queues + the initial gallery email for the customer. Requires admin authentication. + security: + - CookieAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEventRequest' + examples: + weddingExample: + summary: Wedding with password protection + value: + event_type: wedding + event_name: Emily & Jordan Celebration + event_date: 2025-06-07 + customer_name: Emily Carter + customer_email: emily@example.com + admin_email: studio@example.com + require_password: true + password: Shutter123 + expiration_days: 45 + welcome_message: > + We loved capturing your day! Use the password below to view and download your photos. + allow_user_uploads: false + allow_downloads: true + feedback_enabled: true + allow_comments: true + show_feedback_to_guests: true + responses: + '200': + description: Event created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/EventSummary' + '400': + description: Validation failed. At least one field is invalid or missing. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while creating the event. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /admin/events/{eventId}/upload: + post: + tags: [Admin Events] + operationId: uploadEventPhotos + summary: Upload photos to an event gallery + description: | + Uploads one or more photos to the specified event. Files are validated, moved into the + event storage directory, and thumbnails are generated asynchronously. + + The maximum number of files per upload is controlled via the `general_max_files_per_upload` + setting (default 500, capped at 2000). Files exceeding 50 MB are rejected. + security: + - CookieAuth: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/EventId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + photos: + type: array + description: > + One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB. + items: + type: string + format: binary + category_id: + oneOf: + - type: integer + - type: string + description: > + Optional category assignment. Accepts numeric IDs or the string values `collage` + and `individual` for backward compatibility. + required: + - photos + encoding: + photos: + style: form + explode: false + responses: + '200': + description: Upload completed. Failed files (if any) are listed in the response. + content: + application/json: + schema: + $ref: '#/components/schemas/UploadPhotosResponse' + '400': + description: Request failed validation (invalid files, too many files, etc.). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: The referenced event does not exist. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while processing uploads. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /admin/events/{eventId}/resend-email: + post: + tags: [Admin Events] + operationId: resendEventEmail + summary: Resend the gallery access email to the customer + description: > + Queues the standard `gallery_created` email for the event's customer. Useful when resending + credentials to the customer or communicating an updated password. Requires admin authentication. + security: + - CookieAuth: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/EventId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ResendEmailRequest' + example: + password: NewSecurePassword! + responses: + '200': + description: Email successfully queued for delivery. + content: + application/json: + schema: + $ref: '#/components/schemas/ResendEmailResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Event not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while queuing the email. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2dc70d4..ffe0e10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "1.1.7", + "version": "1.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "1.1.7", + "version": "1.1.14", "dependencies": { "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", @@ -63,6 +63,9 @@ "typescript-eslint": "^8.34.1", "vite": "^7.1.12", "vitest": "^3.2.4" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.45.1" } }, "node_modules/@adobe/css-tools": { @@ -105,6 +108,8 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -118,7 +123,9 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -427,6 +434,8 @@ } ], "license": "MIT-0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -447,6 +456,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -471,6 +482,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -499,6 +512,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -522,6 +537,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -533,74 +550,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", @@ -618,74 +567,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/linux-arm64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", @@ -703,278 +584,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -1544,13 +1153,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", - "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2809,6 +2417,8 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -2893,16 +2503,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3330,6 +2930,8 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -3343,7 +2945,9 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/csstype": { "version": "3.1.3", @@ -3357,6 +2961,8 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -3371,6 +2977,8 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -3384,6 +2992,8 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -3394,6 +3004,8 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -3435,7 +3047,9 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/deep-eql": { "version": "5.0.2", @@ -4314,6 +3928,8 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -4336,6 +3952,8 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4350,6 +3968,8 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -4413,6 +4033,8 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4544,7 +4166,9 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/isexe": { "version": "2.0.0", @@ -4604,6 +4228,8 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4645,6 +4271,8 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -4658,6 +4286,8 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -4668,6 +4298,8 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -4873,17 +4505,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5101,7 +4722,9 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -5204,6 +4827,8 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -5217,6 +4842,8 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -6087,6 +5714,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", @@ -6098,7 +5739,9 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -6129,7 +5772,9 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/saxes": { "version": "6.0.0", @@ -6137,6 +5782,8 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -6434,7 +6081,9 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/tailwind-merge": { "version": "3.3.1", @@ -6614,6 +6263,8 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -6626,7 +6277,9 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -6647,6 +6300,8 @@ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -7030,6 +6685,8 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -7049,6 +6706,8 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -7062,6 +6721,8 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -7220,6 +6881,8 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7242,6 +6905,8 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -7251,7 +6916,9 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/yallist": { "version": "3.1.1", diff --git a/frontend/package.json b/frontend/package.json index 71cc8c0..1473f7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", "private": true, - "version": "1.1.7", + "version": "1.1.14", "type": "module", "scripts": { "dev": "vite", - "build": "cross-env ROLLUP_USE_NODE_JS=true vite build", - "build:check": "tsc -b && cross-env ROLLUP_USE_NODE_JS=true vite build", + "build": "node ./scripts/build.js", + "build:check": "tsc -b && node ./scripts/build.js", "lint": "eslint .", "preview": "vite preview", "test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx" @@ -67,5 +67,8 @@ "typescript-eslint": "^8.34.1", "vite": "^7.1.12", "vitest": "^3.2.4" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.45.1" } } diff --git a/frontend/scripts/build.js b/frontend/scripts/build.js new file mode 100755 index 0000000..2396213 --- /dev/null +++ b/frontend/scripts/build.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import { resolve, join } from 'node:path'; +import process from 'node:process'; +import { promises as fs } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { createWriteStream } from 'node:fs'; +import https from 'node:https'; + +const TARGET_NODE_VERSION = '20.19.1'; +const env = { ...process.env, ROLLUP_USE_NODE_JS: 'true' }; +const viteBin = resolve(process.cwd(), 'node_modules', 'vite', 'bin', 'vite.js'); + +async function ensureNodeBinary(version) { + const platformMap = { + linux: 'linux', + darwin: 'darwin', + win32: 'win' + }; + const archMap = { + x64: 'x64', + arm64: 'arm64' + }; + + const platform = platformMap[process.platform]; + const arch = archMap[process.arch]; + + if (!platform || !arch) { + throw new Error(`Unsupported platform/architecture combination: ${process.platform} ${process.arch}`); + } + + if (platform === 'win') { + throw new Error('Automatic Node.js download is not supported on Windows runners. Please upgrade Node.js to >=20.19 manually.'); + } + + const cacheDir = join(process.cwd(), 'node_modules', '.cache', `node-v${version}-${platform}-${arch}`); + const nodeBinary = join(cacheDir, `node-v${version}-${platform}-${arch}`, 'bin', 'node'); + + try { + await fs.access(nodeBinary); + return nodeBinary; + } catch { + // continue with download + } + + await fs.mkdir(cacheDir, { recursive: true }); + const archiveExt = platform === 'win' ? 'zip' : 'tar.xz'; + const archiveName = `node-v${version}-${platform}-${arch}.${archiveExt}`; + const archivePath = join(cacheDir, archiveName); + const downloadUrl = `https://nodejs.org/dist/v${version}/${archiveName}`; + + await downloadFile(downloadUrl, archivePath); + + if (archiveExt === 'tar.xz') { + execSync(`tar -xf "${archivePath}" -C "${cacheDir}"`, { stdio: 'inherit' }); + } else { + throw new Error('ZIP extraction not implemented. Please upgrade Node.js manually.'); + } + + await fs.rm(archivePath, { force: true }); + return nodeBinary; +} + +async function downloadFile(url, destination) { + await new Promise((resolvePromise, rejectPromise) => { + const fileStream = createWriteStream(destination); + https.get(url, (response) => { + if (response.statusCode && response.statusCode >= 400) { + rejectPromise(new Error(`Failed to download ${url}: HTTP ${response.statusCode}`)); + return; + } + pipeline(response, fileStream).then(resolvePromise).catch(rejectPromise); + }).on('error', rejectPromise); + }); +} + +async function main() { + console.log(`Node.js ${process.version} detected; forcing Rollup's JavaScript fallback for compatibility.`); + + if (!process.env.USE_DOWNLOADED_NODE) { + const [major] = process.versions.node.split('.').map(Number); + if (major < 20) { + const nodeBinary = await ensureNodeBinary(TARGET_NODE_VERSION); + const childEnv = { ...env, USE_DOWNLOADED_NODE: '1' }; + execSync(`"${nodeBinary}" "${viteBin}" build`, { stdio: 'inherit', env: childEnv }); + return; + } + } + + execSync(`node "${viteBin}" build`, { stdio: 'inherit', env }); +} + +await main(); diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index 3b8d2a0..9eb22d0 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -109,8 +109,11 @@ export const AdminPhotoViewer: React.FC = ({ await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId); toast.success('Category updated'); setShowCategoryMenu(false); - // Trigger refresh to update the photo data - onPhotoDeleted(); // This will refresh the photos list + // Invalidate photos query to refresh data + await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] }); + await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] }); + // Also trigger the parent's refresh callback + onPhotoDeleted(); } catch (error) { toast.error('Failed to update category'); } diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 743422a..154c6c0 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -6,6 +6,7 @@ import { api } from '../../config/api'; import { toast } from 'react-toastify'; import { useQuery } from '@tanstack/react-query'; import { categoriesService } from '../../services/categories.service'; +import { settingsService } from '../../services/settings.service'; import { useTranslation } from 'react-i18next'; interface PhotoUploadProps { @@ -13,6 +14,9 @@ interface PhotoUploadProps { onUploadComplete?: () => void; } +const DEFAULT_MAX_FILES_PER_UPLOAD = 500; +const MAX_FILES_PER_UPLOAD_LIMIT = 2000; + export const PhotoUpload: React.FC = ({ eventId, onUploadComplete }) => { const { t } = useTranslation(); const [isUploading, setIsUploading] = useState(false); @@ -29,6 +33,22 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl queryFn: () => categoriesService.getEventCategories(eventId), }); + const { data: settings } = useQuery({ + queryKey: ['admin-settings'], + queryFn: () => settingsService.getAllSettings(), + }); + + const maxFilesPerUpload = React.useMemo(() => { + const rawValue = settings?.general_max_files_per_upload; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) { + return DEFAULT_MAX_FILES_PER_UPLOAD; + } + return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed))); + }, [settings]); + + const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0); + const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); const imageFiles = files.filter(file => @@ -37,13 +57,19 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl // Check total file count with existing files const totalFiles = selectedFiles.length + imageFiles.length; - if (totalFiles > 500) { - const allowedNewFiles = 500 - selectedFiles.length; + if (totalFiles > maxFilesPerUpload) { + const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; if (allowedNewFiles <= 0) { - toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed'); + toast.error( + t('upload.maxFilesReached', { limit: maxFilesPerUpload }) || + `Maximum ${maxFilesPerUpload} files allowed` + ); return; } - toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`); + toast.warning( + t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || + `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})` + ); setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); return; } @@ -59,8 +85,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl if (selectedFiles.length === 0) return; // Validate file count - if (selectedFiles.length > 500) { - toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once'); + if (selectedFiles.length > maxFilesPerUpload) { + toast.error( + t('upload.tooManyFiles', { limit: maxFilesPerUpload }) || + `Maximum ${maxFilesPerUpload} files can be uploaded at once` + ); return; } @@ -68,7 +97,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setUploadProgress(0); // For large uploads, chunk the files to prevent memory issues - const CHUNK_SIZE = 50; // Upload 50 files at a time + const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time const chunks = []; for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) { @@ -187,7 +216,21 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl {t('upload.clickToUpload')}

- {t('upload.fileRequirements')} + {t('upload.fileRequirements', { limit: maxFilesPerUpload })} +

+

+ {remainingSlots === 0 + ? t('upload.limitReached', { limit: maxFilesPerUpload }) + : t('upload.limitInfo', { + selected: selectedFiles.length, + limit: maxFilesPerUpload, + remaining: remainingSlots, + })}

{ src: string; @@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC = ({ }) => { const unusedProps = { protectFromDownload, - slug, photoId, requiresToken, secureUrlTemplate, @@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC = ({ const [isLoading, setIsLoading] = useState(true); useEffect(() => { - let objectUrl: string | null = null; + let aborted = false; + const objectUrls: string[] = []; // Determine which token to use based on context if (!src) { @@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC = ({ setIsLoading(true); setError(false); - // Create a new URL with auth header + const resolveSlug = (candidateSrc?: string): string | null => { + if (slug) { + return slug; + } + const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null; + if (fromUrl) { + return fromUrl; + } + return getActiveGallerySlug() || inferGallerySlugFromLocation(); + }; + + const fetchWithAuth = async (rawUrl: string | undefined | null): Promise => { + if (!rawUrl) { + throw new Error('No URL provided'); + } + + // Build full URL for the image + const fullImageUrl = rawUrl.startsWith('/admin') + ? buildResourceUrl(`/api${rawUrl}`) + : rawUrl.startsWith('/') + ? buildResourceUrl(rawUrl) + : rawUrl; + + const headers: Record = {}; + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(fullImageUrl, { + credentials: 'include', + headers: Object.keys(headers).length ? headers : undefined, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + objectUrls.push(objectUrl); + return objectUrl; + }; + const fetchImage = async () => { try { - // Use the src as-is since it should already be the correct endpoint - let imageUrl = src; - - // Build full URL for the image - // For API paths that start with /admin, we need to prepend /api - const fullImageUrl = imageUrl.startsWith('/admin') - ? buildResourceUrl(`/api${imageUrl}`) - : imageUrl.startsWith('/') - ? buildResourceUrl(imageUrl) - : imageUrl; - - // Fetch authenticated image - const response = await fetch(fullImageUrl, { - credentials: 'include' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + const primaryUrl = await fetchWithAuth(src); + if (!aborted) { + setImageSrc(primaryUrl); + setError(false); } - - const blob = await response.blob(); - objectUrl = URL.createObjectURL(blob); - setImageSrc(objectUrl); - setIsLoading(false); } catch (err) { - // Image loading failed - use fallback - setError(true); - setImageSrc(fallbackSrc || ''); + setIsLoading(false); + if (fallbackSrc && fallbackSrc !== src) { + try { + const fallbackUrl = await fetchWithAuth(fallbackSrc); + if (!aborted) { + setImageSrc(fallbackUrl); + setError(false); + } + return; + } catch (fallbackError) { + // Swallow and mark error below + } + } + if (!aborted) { + setError(true); + setImageSrc(''); + } + return; + } + if (!aborted) { setIsLoading(false); } }; @@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC = ({ // Cleanup function return () => { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + aborted = true; + objectUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [src, fallbackSrc, useWatermark, isGallery]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src, fallbackSrc, slug]); if (isLoading) { return ( diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index 658412f..d3d3033 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -29,6 +29,7 @@ interface GalleryLayoutProps { logo_display_header?: boolean; logo_display_hero?: boolean; logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text'; + hide_powered_by?: boolean; }; showLogout?: boolean; onLogout?: () => void; @@ -438,7 +439,10 @@ export const GalleryLayout: React.FC = ({

)}

- {brandingSettings?.footer_text || 'Β© 2024 Your Company. All rights reserved.'} | Powered by PicPeak + {brandingSettings?.footer_text || 'Β© 2024 Your Company. All rights reserved.'} + {!brandingSettings?.hide_powered_by && ( + <> | Powered by PicPeak + )}

{brandingSettings?.company_name && brandingSettings?.company_tagline && (

diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 8934f6e..65fd57f 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -71,8 +71,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { setGuestId(storedGuestId); }, []); - // Fetch photos with filter support - const { data, isLoading, error, refetch } = useGalleryPhotos(slug, filterType, guestId); + // Fetch photos WITHOUT filter (always get all photos, filter on frontend) + // This ensures counts are always calculated from the full dataset + const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId); // Set protection level when data is available useEffect(() => { @@ -164,6 +165,13 @@ export const GalleryView: React.FC = ({ slug, event }) => { footer_text: settingsData.branding_footer_text || 'Β© 2024 Your Company. All rights reserved.', watermark_enabled: settingsData.branding_watermark_enabled || false, logo_url: settingsData.branding_logo_url || null, + logo_size: settingsData.branding_logo_size || 'medium', + logo_max_height: settingsData.branding_logo_max_height || 48, + logo_position: settingsData.branding_logo_position || 'left', + logo_display_header: settingsData.branding_logo_display_header !== false, + logo_display_hero: settingsData.branding_logo_display_hero !== false, + logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text', + hide_powered_by: settingsData.branding_hide_powered_by === true, }); } }, [settingsData]); diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index a558c90..01763fb 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -231,7 +231,7 @@ const GridPhoto: React.FC = ({ )} - {showFeedbackActions && onQuickComment && ( + {showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && ( - + {/* Grid Section */} -

+
+ {/* White Label Settings */} +
+

{t('branding.whiteLabel', 'White Label')}

+ +
+