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 && (