instbyte 1.9.0 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +360 -230
- package/bin/instbyte.js +27 -27
- package/client/assets/lucide.min.js +12 -0
- package/client/css/app.css +1972 -1972
- package/client/index.html +94 -94
- package/client/js/app.js +1648 -1584
- package/package.json +8 -5
- package/server/cleanup.js +27 -26
- package/server/config.js +69 -69
- package/server/db.js +60 -59
- package/server/server.js +867 -812
package/server/server.js
CHANGED
|
@@ -1,813 +1,868 @@
|
|
|
1
|
-
require("./cleanup");
|
|
2
|
-
const fs = require("fs");
|
|
3
|
-
const os = require("os");
|
|
4
|
-
const net = require("net");
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
db.
|
|
374
|
-
`
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
db.
|
|
428
|
-
`SELECT * FROM items
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
/*
|
|
622
|
-
app.
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
status: "
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
console.log("
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1
|
+
require("./cleanup");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const net = require("net");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const cookieParser = require("cookie-parser");
|
|
7
|
+
const rateLimit = require("express-rate-limit");
|
|
8
|
+
const helmet = require("helmet");
|
|
9
|
+
|
|
10
|
+
let sharp = null;
|
|
11
|
+
try { sharp = require("sharp"); } catch (e) { }
|
|
12
|
+
|
|
13
|
+
const express = require("express");
|
|
14
|
+
const http = require("http");
|
|
15
|
+
const { Server } = require("socket.io");
|
|
16
|
+
const multer = require("multer");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const db = require("./db");
|
|
19
|
+
|
|
20
|
+
const config = require("./config");
|
|
21
|
+
|
|
22
|
+
const UPLOADS_DIR = process.env.INSTBYTE_UPLOADS
|
|
23
|
+
|| path.join(__dirname, "../uploads");
|
|
24
|
+
|
|
25
|
+
// Create the uploads folder if it doesn't exist yet.
|
|
26
|
+
// Needed for Docker — the volume mount replaces anything created during build.
|
|
27
|
+
if (!fs.existsSync(UPLOADS_DIR)) {
|
|
28
|
+
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* STARTUP ORPHAN SCAN
|
|
32
|
+
Deletes any files in uploads dir that have no matching DB record.
|
|
33
|
+
Catches ghost files left by aborted uploads before fix in v1.9.1 */
|
|
34
|
+
function scanOrphans() {
|
|
35
|
+
fs.readdir(UPLOADS_DIR, (err, files) => {
|
|
36
|
+
if (err || !files || !files.length) return;
|
|
37
|
+
|
|
38
|
+
db.all("SELECT filename FROM items WHERE filename IS NOT NULL", (err, rows) => {
|
|
39
|
+
if (err) return;
|
|
40
|
+
|
|
41
|
+
const known = new Set(rows.map(r => r.filename));
|
|
42
|
+
files.forEach(file => {
|
|
43
|
+
if (!known.has(file)) {
|
|
44
|
+
const orphan = path.join(UPLOADS_DIR, file);
|
|
45
|
+
fs.unlink(orphan, err => {
|
|
46
|
+
if (!err) console.log("Orphan removed:", file);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CLIENT_DIR = path.join(__dirname, "../client");
|
|
55
|
+
|
|
56
|
+
const app = express();
|
|
57
|
+
const server = http.createServer(app);
|
|
58
|
+
const io = new Server(server, { cors: { origin: "*" } });
|
|
59
|
+
|
|
60
|
+
app.use(helmet({
|
|
61
|
+
contentSecurityPolicy: false // disable CSP for now — it would block CDN scripts
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
app.use((req, res, next) => {
|
|
65
|
+
if (req.path === '/upload') return next();
|
|
66
|
+
express.json()(req, res, next);
|
|
67
|
+
});
|
|
68
|
+
app.use(cookieParser());
|
|
69
|
+
app.use(requireAuth);
|
|
70
|
+
app.use("/uploads", express.static(UPLOADS_DIR));
|
|
71
|
+
app.use(express.static(CLIENT_DIR));
|
|
72
|
+
|
|
73
|
+
const storage = multer.diskStorage({
|
|
74
|
+
destination: (req, file, cb) => {
|
|
75
|
+
cb(null, UPLOADS_DIR);
|
|
76
|
+
},
|
|
77
|
+
filename: (req, file, cb) => {
|
|
78
|
+
const unique = Date.now() + "-" + file.originalname;
|
|
79
|
+
cb(null, unique);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const upload = multer({
|
|
84
|
+
storage,
|
|
85
|
+
limits: { fileSize: config.storage.maxFileSize },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
function hexToHsl(hex) {
|
|
90
|
+
let r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
91
|
+
let g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
92
|
+
let b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
93
|
+
|
|
94
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
95
|
+
let h, s, l = (max + min) / 2;
|
|
96
|
+
|
|
97
|
+
if (max === min) {
|
|
98
|
+
h = s = 0;
|
|
99
|
+
} else {
|
|
100
|
+
const d = max - min;
|
|
101
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
102
|
+
switch (max) {
|
|
103
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
104
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
105
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hslToHex(h, s, l) {
|
|
112
|
+
s /= 100; l /= 100;
|
|
113
|
+
const k = n => (n + h / 30) % 12;
|
|
114
|
+
const a = s * Math.min(l, 1 - l);
|
|
115
|
+
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
116
|
+
return "#" + [f(0), f(8), f(4)]
|
|
117
|
+
.map(x => Math.round(x * 255).toString(16).padStart(2, "0"))
|
|
118
|
+
.join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getLuminance(hex) {
|
|
122
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
123
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
124
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
125
|
+
const toLinear = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
126
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildPalette(hex) {
|
|
130
|
+
// Fallback if hex is invalid
|
|
131
|
+
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#111827";
|
|
132
|
+
|
|
133
|
+
const [h, s, l] = hexToHsl(hex);
|
|
134
|
+
|
|
135
|
+
// Derive secondary as complementary (180° opposite on color wheel)
|
|
136
|
+
const secondaryHex = hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l, 35));
|
|
137
|
+
|
|
138
|
+
// Text on primary — white or dark based on contrast
|
|
139
|
+
const onPrimary = getLuminance(hex) > 0.179 ? "#111827" : "#ffffff";
|
|
140
|
+
const onSecondary = getLuminance(secondaryHex) > 0.179 ? "#111827" : "#ffffff";
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
primary: hex,
|
|
144
|
+
primaryHover: hslToHex(h, s, Math.max(l - 10, 10)),
|
|
145
|
+
primaryLight: hslToHex(h, Math.min(s, 80), Math.min(l + 40, 96)),
|
|
146
|
+
primaryDark: hslToHex(h, s, Math.max(l - 20, 5)),
|
|
147
|
+
onPrimary,
|
|
148
|
+
secondary: secondaryHex,
|
|
149
|
+
secondaryHover: hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l - 10, 10)),
|
|
150
|
+
secondaryLight: hslToHex((h + 180) % 360, Math.min(s, 60), Math.min(l + 40, 96)),
|
|
151
|
+
onSecondary,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const COOKIE_NAME = "instbyte_auth";
|
|
156
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
157
|
+
|
|
158
|
+
// Active sessions — token → true. Cleared on restart, which is intentional.
|
|
159
|
+
const sessions = new Map();
|
|
160
|
+
|
|
161
|
+
function requireAuth(req, res, next) {
|
|
162
|
+
if (!config.auth.passphrase) return next(); // no passphrase set, skip
|
|
163
|
+
|
|
164
|
+
// Allow the login route itself through
|
|
165
|
+
if (req.path === "/login" || req.path === "/info" || req.path === "/health") return next();
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// Check cookie holds a valid session token
|
|
169
|
+
const cookie = req.cookies[COOKIE_NAME];
|
|
170
|
+
if (cookie && sessions.has(cookie)) return next();
|
|
171
|
+
|
|
172
|
+
// Not authenticated
|
|
173
|
+
if (req.path.startsWith("/socket.io")) return next();
|
|
174
|
+
if (req.headers["content-type"] === "application/json" || req.xhr) {
|
|
175
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
res.redirect("/login");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
/* LOGIN PAGE */
|
|
183
|
+
app.get("/login", (req, res) => {
|
|
184
|
+
if (!config.auth.passphrase) return res.redirect("/");
|
|
185
|
+
if (sessions.has(req.cookies[COOKIE_NAME])) return res.redirect("/");
|
|
186
|
+
|
|
187
|
+
const loginPalette = buildPalette(config.branding.primaryColor);
|
|
188
|
+
const loginBrandingStyle = `
|
|
189
|
+
button { background: ${loginPalette.primary}; color: ${loginPalette.onPrimary}; }
|
|
190
|
+
button:hover { background: ${loginPalette.primaryHover}; }
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
res.send(`<!DOCTYPE html>
|
|
194
|
+
<html>
|
|
195
|
+
<head>
|
|
196
|
+
<title>${config.branding.appName || "Instbyte"} — Login</title>
|
|
197
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
198
|
+
<style>
|
|
199
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
200
|
+
body {
|
|
201
|
+
font-family: system-ui;
|
|
202
|
+
background: #f3f4f6;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
min-height: 100vh;
|
|
207
|
+
}
|
|
208
|
+
.box {
|
|
209
|
+
background: #fff;
|
|
210
|
+
border-radius: 12px;
|
|
211
|
+
padding: 36px;
|
|
212
|
+
width: 100%;
|
|
213
|
+
max-width: 360px;
|
|
214
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
|
215
|
+
text-align: center;
|
|
216
|
+
}
|
|
217
|
+
.logo { font-size: 22px; font-weight: 700; color: #111827; margin-bottom: 6px; }
|
|
218
|
+
.sub { font-size: 13px; color: #9ca3af; margin-bottom: 28px; }
|
|
219
|
+
input {
|
|
220
|
+
width: 100%;
|
|
221
|
+
padding: 11px 14px;
|
|
222
|
+
border: 1px solid #e5e7eb;
|
|
223
|
+
border-radius: 8px;
|
|
224
|
+
font-size: 14px;
|
|
225
|
+
margin-bottom: 12px;
|
|
226
|
+
outline: none;
|
|
227
|
+
}
|
|
228
|
+
input:focus { border-color: #9ca3af; }
|
|
229
|
+
button {
|
|
230
|
+
width: 100%;
|
|
231
|
+
padding: 11px;
|
|
232
|
+
background: #111827;
|
|
233
|
+
color: #fff;
|
|
234
|
+
border: none;
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
font-size: 14px;
|
|
237
|
+
cursor: pointer;
|
|
238
|
+
}
|
|
239
|
+
button:hover { background: #1f2937; }
|
|
240
|
+
.error {
|
|
241
|
+
color: #b91c1c;
|
|
242
|
+
font-size: 13px;
|
|
243
|
+
margin-top: 10px;
|
|
244
|
+
display: none;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
${loginBrandingStyle}
|
|
248
|
+
|
|
249
|
+
</style>
|
|
250
|
+
</head>
|
|
251
|
+
<body>
|
|
252
|
+
<div class="box">
|
|
253
|
+
<div class="logo">${config.branding.appName || "Instbyte"}</div>
|
|
254
|
+
<div class="sub">Enter passphrase to continue</div>
|
|
255
|
+
<input type="password" id="pass" placeholder="Passphrase" autofocus
|
|
256
|
+
onkeydown="if(event.key==='Enter') submit()">
|
|
257
|
+
<button onclick="submit()">Continue</button>
|
|
258
|
+
<div class="error" id="err">Incorrect passphrase</div>
|
|
259
|
+
</div>
|
|
260
|
+
<script>
|
|
261
|
+
async function submit() {
|
|
262
|
+
const pass = document.getElementById("pass").value;
|
|
263
|
+
const res = await fetch("/login", {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json" },
|
|
266
|
+
body: JSON.stringify({ passphrase: pass })
|
|
267
|
+
});
|
|
268
|
+
if (res.ok) {
|
|
269
|
+
window.location.href = "/";
|
|
270
|
+
} else {
|
|
271
|
+
document.getElementById("err").style.display = "block";
|
|
272
|
+
document.getElementById("pass").value = "";
|
|
273
|
+
document.getElementById("pass").focus();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</script>
|
|
277
|
+
</body>
|
|
278
|
+
</html>`);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const loginLimiter = rateLimit({
|
|
282
|
+
windowMs: 15 * 60 * 1000,
|
|
283
|
+
max: 10,
|
|
284
|
+
message: { error: "Too many attempts, try again later" }
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/* LOGIN POST */
|
|
288
|
+
app.post("/login", loginLimiter, (req, res) => {
|
|
289
|
+
if (!config.auth.passphrase) return res.redirect("/");
|
|
290
|
+
|
|
291
|
+
const { passphrase } = req.body;
|
|
292
|
+
if (passphrase === config.auth.passphrase) {
|
|
293
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
294
|
+
sessions.set(token, true);
|
|
295
|
+
res.cookie(COOKIE_NAME, token, {
|
|
296
|
+
maxAge: COOKIE_MAX_AGE,
|
|
297
|
+
httpOnly: true,
|
|
298
|
+
sameSite: "strict"
|
|
299
|
+
});
|
|
300
|
+
return res.json({ ok: true });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
res.status(401).json({ error: "Incorrect passphrase" });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/* LOGOUT */
|
|
307
|
+
app.post("/logout", (req, res) => {
|
|
308
|
+
const token = req.cookies[COOKIE_NAME];
|
|
309
|
+
if (token) sessions.delete(token);
|
|
310
|
+
res.clearCookie(COOKIE_NAME);
|
|
311
|
+
res.redirect("/login");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
/* FILE UPLOAD */
|
|
316
|
+
app.post("/upload", upload.single("file"), (req, res) => {
|
|
317
|
+
if (!req.file) {
|
|
318
|
+
return res.status(400).json({ error: "No file received" });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const { channel, uploader } = req.body;
|
|
322
|
+
|
|
323
|
+
const item = {
|
|
324
|
+
type: "file",
|
|
325
|
+
filename: req.file.filename,
|
|
326
|
+
size: req.file.size,
|
|
327
|
+
channel,
|
|
328
|
+
uploader,
|
|
329
|
+
created_at: Date.now(),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
db.run(
|
|
333
|
+
`INSERT INTO items (type, filename, size, channel, uploader, created_at)
|
|
334
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
335
|
+
["file", item.filename, item.size, channel, uploader, item.created_at],
|
|
336
|
+
function (dbErr) {
|
|
337
|
+
if (dbErr) {
|
|
338
|
+
const orphan = path.join(UPLOADS_DIR, item.filename);
|
|
339
|
+
if (fs.existsSync(orphan)) fs.unlinkSync(orphan);
|
|
340
|
+
return res.status(500).json({ error: "Failed to save item" });
|
|
341
|
+
}
|
|
342
|
+
item.id = this.lastID;
|
|
343
|
+
io.emit("new-item", item);
|
|
344
|
+
res.json(item);
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Multer error handler — catches file size limit and other upload errors
|
|
350
|
+
app.use((err, req, res, next) => {
|
|
351
|
+
if (err && err.code === "LIMIT_FILE_SIZE") {
|
|
352
|
+
if (req.file) {
|
|
353
|
+
const partial = path.join(UPLOADS_DIR, req.file.filename);
|
|
354
|
+
if (fs.existsSync(partial)) fs.unlinkSync(partial);
|
|
355
|
+
}
|
|
356
|
+
return res.status(413).json({ error: "File exceeds limit" });
|
|
357
|
+
}
|
|
358
|
+
next(err);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/* TEXT/LINK */
|
|
362
|
+
app.post("/text", (req, res) => {
|
|
363
|
+
const { content, channel, uploader } = req.body;
|
|
364
|
+
|
|
365
|
+
const item = {
|
|
366
|
+
type: "text",
|
|
367
|
+
content,
|
|
368
|
+
channel,
|
|
369
|
+
uploader,
|
|
370
|
+
created_at: Date.now(),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
db.run(
|
|
374
|
+
`INSERT INTO items (type, content, channel, uploader, created_at)
|
|
375
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
376
|
+
["text", content, channel, uploader, item.created_at],
|
|
377
|
+
function (err) {
|
|
378
|
+
if (err) return res.status(500).json({ error: "Failed to save item" });
|
|
379
|
+
item.id = this.lastID;
|
|
380
|
+
io.emit("new-item", item);
|
|
381
|
+
res.json(item);
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/* DELETE ITEM */
|
|
387
|
+
app.delete("/item/:id", (req, res) => {
|
|
388
|
+
const id = req.params.id;
|
|
389
|
+
|
|
390
|
+
db.get(`SELECT * FROM items WHERE id=?`, [id], (err, item) => {
|
|
391
|
+
if (!item) return res.sendStatus(404);
|
|
392
|
+
|
|
393
|
+
if (item.filename) {
|
|
394
|
+
const filePath = path.join(UPLOADS_DIR, item.filename);
|
|
395
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
db.run(`DELETE FROM items WHERE id=?`, [id], () => {
|
|
399
|
+
seenBy.delete(parseInt(id));
|
|
400
|
+
io.emit("delete-item", id);
|
|
401
|
+
res.sendStatus(200);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/* PIN */
|
|
407
|
+
app.post("/pin/:id", (req, res) => {
|
|
408
|
+
const id = req.params.id;
|
|
409
|
+
|
|
410
|
+
db.run(
|
|
411
|
+
`UPDATE items SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE id=?`,
|
|
412
|
+
[id],
|
|
413
|
+
() => {
|
|
414
|
+
io.emit("pin-update");
|
|
415
|
+
res.sendStatus(200);
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/* GET ITEMS */
|
|
421
|
+
app.get("/items/:channel", (req, res) => {
|
|
422
|
+
const channel = req.params.channel;
|
|
423
|
+
const page = parseInt(req.query.page) || 1;
|
|
424
|
+
const limit = 10;
|
|
425
|
+
const offset = (page - 1) * limit;
|
|
426
|
+
|
|
427
|
+
db.get(
|
|
428
|
+
`SELECT COUNT(*) as count FROM items WHERE channel=? AND pinned=0`,
|
|
429
|
+
[channel],
|
|
430
|
+
(err, row) => {
|
|
431
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
432
|
+
|
|
433
|
+
const totalUnpinned = row.count;
|
|
434
|
+
const hasMore = offset + limit < totalUnpinned;
|
|
435
|
+
|
|
436
|
+
db.all(
|
|
437
|
+
`SELECT * FROM items WHERE channel=? AND pinned=0
|
|
438
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
439
|
+
[channel, limit, offset],
|
|
440
|
+
(err, unpinned) => {
|
|
441
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
442
|
+
|
|
443
|
+
if (page === 1) {
|
|
444
|
+
// only fetch pinned on first page
|
|
445
|
+
db.all(
|
|
446
|
+
`SELECT * FROM items WHERE channel=? AND pinned=1
|
|
447
|
+
ORDER BY created_at DESC`,
|
|
448
|
+
[channel],
|
|
449
|
+
(err, pinned) => {
|
|
450
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
451
|
+
res.json({ items: [...pinned, ...unpinned], hasMore, page });
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
res.json({ items: unpinned, hasMore, page });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
/* SEARCH */
|
|
465
|
+
app.get("/search/:channel/:q", (req, res) => {
|
|
466
|
+
const { channel, q } = req.params;
|
|
467
|
+
|
|
468
|
+
db.all(
|
|
469
|
+
`SELECT * FROM items
|
|
470
|
+
WHERE channel=? AND (content LIKE ? OR filename LIKE ?)
|
|
471
|
+
ORDER BY pinned DESC, created_at DESC`,
|
|
472
|
+
[channel, `%${q}%`, `%${q}%`],
|
|
473
|
+
(err, rows) => res.json(rows)
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
/* GLOBAL SEARCH */
|
|
478
|
+
app.get("/search/:q", (req, res) => {
|
|
479
|
+
const q = req.params.q;
|
|
480
|
+
|
|
481
|
+
db.all(
|
|
482
|
+
`SELECT * FROM items
|
|
483
|
+
WHERE content LIKE ? OR filename LIKE ?
|
|
484
|
+
ORDER BY channel ASC, pinned DESC, created_at DESC`,
|
|
485
|
+
[`%${q}%`, `%${q}%`],
|
|
486
|
+
(err, rows) => {
|
|
487
|
+
res.json(rows);
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.get("/channels", (req, res) => {
|
|
493
|
+
db.all("SELECT * FROM channels ORDER BY pinned DESC, id ASC", (err, rows) => {
|
|
494
|
+
res.json(rows);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
/* ADD CHANNEL */
|
|
499
|
+
app.post("/channels", (req, res) => {
|
|
500
|
+
|
|
501
|
+
const { name } = req.body;
|
|
502
|
+
if (!name) return res.status(400).json({ error: "Name required" });
|
|
503
|
+
|
|
504
|
+
const trimmed = name.trim();
|
|
505
|
+
if (trimmed.length < 1 || trimmed.length > 32) {
|
|
506
|
+
return res.status(400).json({ error: "Channel name must be 1–32 characters" });
|
|
507
|
+
}
|
|
508
|
+
if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
|
|
509
|
+
return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
|
|
513
|
+
|
|
514
|
+
if (row.count >= 10) {
|
|
515
|
+
return res.status(400).json({ error: "Max 10 channels allowed" });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
db.run("INSERT INTO channels (name) VALUES (?)", [trimmed], function (err) {
|
|
519
|
+
|
|
520
|
+
if (err) {
|
|
521
|
+
return res.status(400).json({ error: "Channel exists" });
|
|
522
|
+
}
|
|
523
|
+
io.emit("channel-added", { id: this.lastID, trimmed });
|
|
524
|
+
res.json({ id: this.lastID, name: trimmed });
|
|
525
|
+
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
/* DELETE CHANNEL */
|
|
534
|
+
app.delete("/channels/:name", (req, res) => {
|
|
535
|
+
const name = req.params.name;
|
|
536
|
+
|
|
537
|
+
db.get("SELECT * FROM channels WHERE name=?", [name], (err, ch) => {
|
|
538
|
+
if (!ch) return res.status(404).json({ error: "Channel not found" });
|
|
539
|
+
|
|
540
|
+
if (ch.pinned) {
|
|
541
|
+
return res.status(403).json({ error: "Unpin this channel before deleting" });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
|
|
545
|
+
if (row.count <= 1) {
|
|
546
|
+
return res.status(400).json({ error: "At least one channel required" });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
db.all("SELECT * FROM items WHERE channel=?", [name], (err, rows) => {
|
|
550
|
+
rows.forEach(item => {
|
|
551
|
+
if (item.filename) {
|
|
552
|
+
const filePath = path.join(__dirname, "../uploads", item.filename);
|
|
553
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
db.run("DELETE FROM items WHERE channel=?", [name], () => {
|
|
558
|
+
db.run("DELETE FROM channels WHERE name=?", [name], () => {
|
|
559
|
+
io.emit("channel-deleted", { name });
|
|
560
|
+
res.sendStatus(200);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
/* MOVE ROW */
|
|
569
|
+
app.patch("/item/:id/move", (req, res) => {
|
|
570
|
+
const { id } = req.params;
|
|
571
|
+
const { channel } = req.body;
|
|
572
|
+
|
|
573
|
+
if (!channel) return res.status(400).json({ error: "Channel required" });
|
|
574
|
+
|
|
575
|
+
db.run("UPDATE items SET channel=? WHERE id=?", [channel, id], function (err) {
|
|
576
|
+
if (err) return res.status(500).json({ error: "Move failed" });
|
|
577
|
+
io.emit("item-moved", { id: parseInt(id), channel });
|
|
578
|
+
res.json({ id, channel });
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
/* UPDATE ITEM TITLE */
|
|
583
|
+
app.patch("/item/:id/title", (req, res) => {
|
|
584
|
+
const { id } = req.params;
|
|
585
|
+
const { title } = req.body;
|
|
586
|
+
|
|
587
|
+
if (title === undefined) return res.status(400).json({ error: "Title required" });
|
|
588
|
+
|
|
589
|
+
db.run(
|
|
590
|
+
"UPDATE items SET title=? WHERE id=?",
|
|
591
|
+
[title.trim(), id],
|
|
592
|
+
function (err) {
|
|
593
|
+
if (err) return res.status(500).json({ error: "Update failed" });
|
|
594
|
+
if (this.changes === 0) return res.status(404).json({ error: "Item not found" });
|
|
595
|
+
io.emit("item-updated", { id: parseInt(id), title: title.trim() });
|
|
596
|
+
res.json({ id, title: title.trim() });
|
|
597
|
+
}
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
/* UPDATE ITEM CONTENT */
|
|
602
|
+
app.patch("/item/:id/content", (req, res) => {
|
|
603
|
+
const { id } = req.params;
|
|
604
|
+
const { content } = req.body;
|
|
605
|
+
|
|
606
|
+
if (content === undefined) return res.status(400).json({ error: "Content required" });
|
|
607
|
+
if (content.trim() === "") return res.status(400).json({ error: "Content cannot be empty" });
|
|
608
|
+
|
|
609
|
+
db.run(
|
|
610
|
+
"UPDATE items SET content=?, edited_at=? WHERE id=? AND type='text'",
|
|
611
|
+
[content.trim(), Date.now(), id],
|
|
612
|
+
function (err) {
|
|
613
|
+
if (err) return res.status(500).json({ error: "Update failed" });
|
|
614
|
+
if (this.changes === 0) return res.status(404).json({ error: "Item not found or not editable" });
|
|
615
|
+
io.emit("item-updated", { id: parseInt(id), content: content.trim(), edited_at: Date.now() });
|
|
616
|
+
res.json({ id, content: content.trim() });
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/* RENAME CHANNEL */
|
|
622
|
+
app.patch("/channels/:name", (req, res) => {
|
|
623
|
+
const oldName = req.params.name;
|
|
624
|
+
const { name: newName } = req.body;
|
|
625
|
+
if (!newName) return res.status(400).json({ error: "Name required" });
|
|
626
|
+
|
|
627
|
+
const trimmed = newName.trim();
|
|
628
|
+
if (trimmed.length < 1 || trimmed.length > 32) {
|
|
629
|
+
return res.status(400).json({ error: "Channel name must be 1–32 characters" });
|
|
630
|
+
}
|
|
631
|
+
if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
|
|
632
|
+
return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
db.get("SELECT * FROM channels WHERE name=?", [oldName], (err, row) => {
|
|
636
|
+
if (!row) return res.status(404).json({ error: "Channel not found" });
|
|
637
|
+
|
|
638
|
+
db.run("UPDATE channels SET name=? WHERE name=?", [trimmed, oldName], (err) => {
|
|
639
|
+
if (err) return res.status(400).json({ error: "Channel name already exists" });
|
|
640
|
+
|
|
641
|
+
db.run("UPDATE items SET channel=? WHERE channel=?", [trimmed, oldName], () => {
|
|
642
|
+
io.emit("channel-renamed", { oldName, newName: trimmed });
|
|
643
|
+
res.json({ oldName, newName: trimmed });
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
/* PIN CHANNEL */
|
|
650
|
+
app.post("/channels/:name/pin", (req, res) => {
|
|
651
|
+
db.run(
|
|
652
|
+
`UPDATE channels SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END WHERE name=?`,
|
|
653
|
+
[req.params.name],
|
|
654
|
+
function (err) {
|
|
655
|
+
if (err) return res.status(500).json({ error: "Pin failed" });
|
|
656
|
+
db.get("SELECT pinned FROM channels WHERE name=?", [req.params.name], (err, row) => {
|
|
657
|
+
io.emit("channel-pin-update", { name: req.params.name, pinned: row.pinned });
|
|
658
|
+
res.json({ pinned: row.pinned });
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
/* */
|
|
666
|
+
app.get("/info", (req, res) => {
|
|
667
|
+
res.json({
|
|
668
|
+
url: `http://${localIP}:${PORT}`,
|
|
669
|
+
hasAuth: !!config.auth.passphrase,
|
|
670
|
+
retention: config.storage.retention // null means "never"
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
/* BRAND */
|
|
676
|
+
app.get("/branding", (req, res) => {
|
|
677
|
+
const b = config.branding;
|
|
678
|
+
const palette = buildPalette(b.primaryColor);
|
|
679
|
+
|
|
680
|
+
res.json({
|
|
681
|
+
appName: b.appName || "Instbyte",
|
|
682
|
+
hasLogo: !!b.logoPath,
|
|
683
|
+
palette
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
/* HEALTH MONITOR */
|
|
688
|
+
app.get("/health", (req, res) => {
|
|
689
|
+
res.json({
|
|
690
|
+
status: "ok",
|
|
691
|
+
uptime: Math.floor(process.uptime()),
|
|
692
|
+
version: require("../package.json").version
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
/* FAVICON */
|
|
698
|
+
app.get("/favicon-dynamic.png", async (req, res) => {
|
|
699
|
+
const b = config.branding;
|
|
700
|
+
|
|
701
|
+
// User provided their own favicon — serve it directly
|
|
702
|
+
if (b.faviconPath) {
|
|
703
|
+
const fp = path.resolve(process.cwd(), b.faviconPath);
|
|
704
|
+
if (fs.existsSync(fp)) return res.sendFile(fp);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Try to generate favicon from logo using sharp
|
|
708
|
+
if (b.logoPath && sharp) {
|
|
709
|
+
const lp = path.resolve(process.cwd(), b.logoPath);
|
|
710
|
+
if (fs.existsSync(lp)) {
|
|
711
|
+
try {
|
|
712
|
+
const buf = await sharp(lp).resize(32, 32).png().toBuffer();
|
|
713
|
+
res.set("Content-Type", "image/png");
|
|
714
|
+
return res.send(buf);
|
|
715
|
+
} catch (e) { }
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Fall back to default favicon
|
|
720
|
+
const defaultFavicon = path.join(__dirname, "../client/assets/favicon.png");
|
|
721
|
+
if (fs.existsSync(defaultFavicon)) return res.sendFile(defaultFavicon);
|
|
722
|
+
|
|
723
|
+
res.sendStatus(404);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
/* LOGO */
|
|
729
|
+
app.get("/logo-dynamic.png", (req, res) => {
|
|
730
|
+
const b = config.branding;
|
|
731
|
+
|
|
732
|
+
if (b.logoPath) {
|
|
733
|
+
const lp = path.resolve(process.cwd(), b.logoPath);
|
|
734
|
+
if (fs.existsSync(lp)) return res.sendFile(lp);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Fall back to default logo
|
|
738
|
+
const defaultLogo = path.join(__dirname, "../client/assets/logo.png");
|
|
739
|
+
if (fs.existsSync(defaultLogo)) return res.sendFile(defaultLogo);
|
|
740
|
+
|
|
741
|
+
res.sendStatus(404);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
/* ============================
|
|
745
|
+
SOCKET CONNECTION LOGGING
|
|
746
|
+
============================ */
|
|
747
|
+
// in-memory seen tracking — item id → Set of socket ids
|
|
748
|
+
// resets on server restart, no DB needed
|
|
749
|
+
const seenBy = new Map();
|
|
750
|
+
|
|
751
|
+
let connectedUsers = 0;
|
|
752
|
+
|
|
753
|
+
io.on("connection", (socket) => {
|
|
754
|
+
connectedUsers++;
|
|
755
|
+
io.emit("user-count", connectedUsers);
|
|
756
|
+
|
|
757
|
+
let username = "Unknown";
|
|
758
|
+
|
|
759
|
+
socket.on("join", (name) => {
|
|
760
|
+
username = name || "Unknown";
|
|
761
|
+
console.log(username + " connected | total:", connectedUsers);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
socket.on("seen", ({ id, name }) => {
|
|
765
|
+
if (!id || !name) return;
|
|
766
|
+
if (!seenBy.has(id)) seenBy.set(id, new Set());
|
|
767
|
+
seenBy.get(id).add(name); // name instead of socket.id
|
|
768
|
+
const count = seenBy.get(id).size;
|
|
769
|
+
console.log(`seen: item ${id} | count: ${count}`);
|
|
770
|
+
io.emit("seen-update", { id, count });
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
socket.on("disconnect", () => {
|
|
774
|
+
connectedUsers--;
|
|
775
|
+
console.log(username + " disconnected | total:", connectedUsers);
|
|
776
|
+
io.emit("user-count", connectedUsers);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
/* ============================
|
|
782
|
+
SHOW LOCAL + LAN URL
|
|
783
|
+
============================ */
|
|
784
|
+
|
|
785
|
+
function getLocalIP() {
|
|
786
|
+
const nets = os.networkInterfaces();
|
|
787
|
+
const candidates = [];
|
|
788
|
+
|
|
789
|
+
for (const name of Object.keys(nets)) {
|
|
790
|
+
for (const net of nets[name]) {
|
|
791
|
+
if (net.family !== "IPv4" || net.internal) continue;
|
|
792
|
+
|
|
793
|
+
const n = name.toLowerCase();
|
|
794
|
+
if (/loopback|vmware|virtualbox|vethernet|wsl|hyper|utun|tun|tap|docker|br-|vbox/.test(n)) continue;
|
|
795
|
+
|
|
796
|
+
candidates.push({ name, address: net.address });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const preferred =
|
|
801
|
+
candidates.find(c => c.address.startsWith("192.168.")) ||
|
|
802
|
+
candidates.find(c => c.address.startsWith("10.")) ||
|
|
803
|
+
candidates.find(c => c.address.startsWith("172.16.")) ||
|
|
804
|
+
candidates[0];
|
|
805
|
+
|
|
806
|
+
return preferred ? preferred.address : "localhost";
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function findFreePort(start) {
|
|
810
|
+
return new Promise((resolve) => {
|
|
811
|
+
const srv = net.createServer();
|
|
812
|
+
srv.listen(start, () => {
|
|
813
|
+
const port = srv.address().port;
|
|
814
|
+
srv.close(() => resolve(port));
|
|
815
|
+
});
|
|
816
|
+
srv.on("error", () => resolve(findFreePort(start + 1)));
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const PREFERRED = parseInt(process.env.PORT) || config.server.port;
|
|
821
|
+
|
|
822
|
+
const localIP = getLocalIP();
|
|
823
|
+
|
|
824
|
+
let PORT;
|
|
825
|
+
|
|
826
|
+
findFreePort(PREFERRED).then(p => {
|
|
827
|
+
PORT = p;
|
|
828
|
+
server.listen(PORT, () => {
|
|
829
|
+
console.log("\nInstbyte running");
|
|
830
|
+
console.log("Local: http://localhost:" + PORT);
|
|
831
|
+
console.log("Network: http://" + localIP + ":" + PORT);
|
|
832
|
+
if (PORT !== PREFERRED) {
|
|
833
|
+
console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
|
|
834
|
+
}
|
|
835
|
+
console.log("");
|
|
836
|
+
scanOrphans(); // clean up any pre-v1.9.1 ghost files
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
// ========================
|
|
842
|
+
// GRACEFUL SHUTDOWN
|
|
843
|
+
// ========================
|
|
844
|
+
function shutdown(signal) {
|
|
845
|
+
console.log(`\n${signal} received — shutting down gracefully...`);
|
|
846
|
+
|
|
847
|
+
// stop accepting new connections
|
|
848
|
+
server.close(() => {
|
|
849
|
+
console.log("HTTP server closed");
|
|
850
|
+
|
|
851
|
+
// close database connection
|
|
852
|
+
db.close((err) => {
|
|
853
|
+
if (err) console.error("Error closing database:", err);
|
|
854
|
+
else console.log("Database connection closed");
|
|
855
|
+
console.log("Shutdown complete");
|
|
856
|
+
process.exit(0);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// force exit after 10 seconds if something hangs
|
|
861
|
+
setTimeout(() => {
|
|
862
|
+
console.error("Forced shutdown after timeout");
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}, 10000);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
813
868
|
process.on("SIGINT", () => shutdown("SIGINT"));
|