@intranefr/superbackend 1.7.9 → 1.7.10

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/index.js CHANGED
@@ -1,4 +1,6 @@
1
- require("dotenv").config({ path: process.env.ENV_FILE || ".env" });
1
+ if (!process.env.SUPERBACKEND_AS_MIDDLEWARE) {
2
+ require("dotenv").config({ path: process.env.ENV_FILE || ".env" });
3
+ }
2
4
  const express = require("express");
3
5
 
4
6
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intranefr/superbackend",
3
- "version": "1.7.9",
3
+ "version": "1.7.10",
4
4
  "description": "Node.js middleware that gives your project backend superpowers",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "@aws-sdk/client-s3": "^3.0.0",
45
45
  "@intranefr/superbackend": "^1.7.7",
46
46
  "axios": "^1.13.2",
47
+ "basic-auth": "^2.0.1",
47
48
  "bcryptjs": "^2.4.3",
48
49
  "cheerio": "^1.0.0-rc.12",
49
50
  "connect-mongo": "^5.1.0",
@@ -0,0 +1,146 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const waitingListPublicExportsService = require('../services/waitingListPublicExports.service');
6
+ const waitingListService = require('../services/waitingListJson.service');
7
+ const { logAudit } = require('../services/auditLogger');
8
+
9
+ /**
10
+ * GET /share/export/:name - Public export access page
11
+ */
12
+ router.get('/:name', async (req, res) => {
13
+ try {
14
+ const { name } = req.params;
15
+ const { format = 'csv', error } = req.query;
16
+
17
+ // Validate format
18
+ if (!['csv', 'json'].includes(format)) {
19
+ return res.status(400).send('Invalid format. Must be csv or json.');
20
+ }
21
+
22
+ // Find export configuration
23
+ const exportConfig = await waitingListPublicExportsService.getPublicExportByName(name);
24
+ if (!exportConfig) {
25
+ return res.status(404).send('Export not found');
26
+ }
27
+
28
+ // If no password protection, redirect to immediate download
29
+ if (!exportConfig.password) {
30
+ const downloadUrl = `/api/waiting-list/share/export?type=${encodeURIComponent(name)}&format=${format}`;
31
+ return res.redirect(downloadUrl);
32
+ }
33
+
34
+ // Show password entry page
35
+ const templatePath = path.join(__dirname, '..', '..', 'views', 'public-export-password.ejs');
36
+ fs.readFile(templatePath, 'utf8', (err, template) => {
37
+ if (err) {
38
+ console.error('Error reading template:', err);
39
+ return res.status(500).send('Error loading page');
40
+ }
41
+
42
+ try {
43
+ let html = template
44
+ .replace(/<%= exportName %>/g, exportConfig.name)
45
+ .replace(/<%= exportType %>/g, exportConfig.type)
46
+ .replace(/<%= format %>/g, format)
47
+ .replace(/<%= format\.toUpperCase\(\) %>/g, format.toUpperCase())
48
+ .replace(/<%= format === 'csv' \? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800' %>/g,
49
+ format === 'csv' ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800')
50
+ .replace(/<%= error %>/g, error || '');
51
+
52
+ // Handle error conditional
53
+ if (error) {
54
+ html = html.replace(/<% if \(error\) \{ %>/g, '').replace(/<% } %>/g, '');
55
+ } else {
56
+ // Remove the entire error div when no error
57
+ html = html.replace(/<% if \(error\) \{ %>[\s\S]*?<% } %>/g, '');
58
+ }
59
+
60
+ res.send(html);
61
+ } catch (renderErr) {
62
+ console.error('Error rendering template:', renderErr);
63
+ res.status(500).send('Error rendering page');
64
+ }
65
+ });
66
+
67
+ } catch (error) {
68
+ console.error('Public export page error:', error);
69
+ res.status(500).send('Internal server error');
70
+ }
71
+ });
72
+
73
+ /**
74
+ * POST /share/export/:name/auth - Password validation
75
+ */
76
+ router.post('/:name/auth', async (req, res) => {
77
+ try {
78
+ const { name } = req.params;
79
+ const { password, format = 'csv' } = req.body;
80
+
81
+ // Validate required fields
82
+ if (!password) {
83
+ return res.status(400).json({ error: 'Password is required' });
84
+ }
85
+
86
+ if (!['csv', 'json'].includes(format)) {
87
+ return res.status(400).json({ error: 'Invalid format' });
88
+ }
89
+
90
+ // Find export configuration
91
+ const exportConfig = await waitingListPublicExportsService.getPublicExportByName(name);
92
+ if (!exportConfig) {
93
+ return res.status(404).json({ error: 'Export not found' });
94
+ }
95
+
96
+ // Validate password
97
+ const isValidPassword = await waitingListPublicExportsService.validateExportPassword(exportConfig, password);
98
+ if (!isValidPassword) {
99
+ // Log failed attempt
100
+ await logAudit({
101
+ action: 'public.waiting_list.export.auth_failed',
102
+ entityType: 'WaitingListPublicExport',
103
+ req,
104
+ details: {
105
+ exportName: name,
106
+ ip: req.ip,
107
+ userAgent: req.headers['user-agent'],
108
+ authMethod: 'web_form'
109
+ }
110
+ });
111
+
112
+ return res.status(401).json({ error: 'Invalid password' });
113
+ }
114
+
115
+ // Set session for authenticated access
116
+ req.session.exportAuth = req.session.exportAuth || {};
117
+ req.session.exportAuth[name] = {
118
+ authenticated: true,
119
+ timestamp: Date.now(),
120
+ format: format
121
+ };
122
+
123
+ // Log successful authentication
124
+ await logAudit({
125
+ action: 'public.waiting_list.export.auth_success',
126
+ entityType: 'WaitingListPublicExport',
127
+ req,
128
+ details: {
129
+ exportName: name,
130
+ ip: req.ip,
131
+ userAgent: req.headers['user-agent'],
132
+ authMethod: 'web_form'
133
+ }
134
+ });
135
+
136
+ // Return download URL
137
+ const downloadUrl = `/api/waiting-list/share/export?type=${encodeURIComponent(name)}&format=${format}`;
138
+ res.json({ downloadUrl });
139
+
140
+ } catch (error) {
141
+ console.error('Password validation error:', error);
142
+ res.status(500).json({ error: 'Authentication failed' });
143
+ }
144
+ });
145
+
146
+ module.exports = router;
@@ -1,5 +1,7 @@
1
1
  const waitingListService = require('../services/waitingListJson.service');
2
+ const waitingListPublicExportsService = require('../services/waitingListPublicExports.service');
2
3
  const { validateEmail, sanitizeString } = require('../utils/validation');
4
+ const basicAuth = require('basic-auth');
3
5
 
4
6
  // Subscribe to waiting list
5
7
  exports.subscribe = async (req, res) => {
@@ -272,3 +274,303 @@ exports.bulkRemove = async (req, res) => {
272
274
  return res.status(500).json({ error: 'Failed to bulk remove entries' });
273
275
  }
274
276
  };
277
+
278
+ // Public export endpoint
279
+ exports.publicExport = async (req, res) => {
280
+ try {
281
+ const { type, format = 'csv', password: queryPassword } = req.query;
282
+
283
+ // Validate required parameters
284
+ if (!type) {
285
+ return res.status(400).json({
286
+ error: 'Type parameter is required',
287
+ field: 'type'
288
+ });
289
+ }
290
+
291
+ // Validate format
292
+ if (!['csv', 'json'].includes(format)) {
293
+ return res.status(400).json({
294
+ error: 'Format must be either "csv" or "json"',
295
+ field: 'format'
296
+ });
297
+ }
298
+
299
+ // Find export configuration by type
300
+ const exportConfig = await waitingListPublicExportsService.getPublicExportByName(type);
301
+ if (!exportConfig) {
302
+ return res.status(404).json({
303
+ error: 'Export configuration not found',
304
+ field: 'type'
305
+ });
306
+ }
307
+
308
+ // Check password protection (support multiple authentication methods)
309
+ let providedPassword = null;
310
+ let authMethod = 'none';
311
+
312
+ if (exportConfig.password) {
313
+ // First check session authentication (web form)
314
+ if (req.session && req.session.exportAuth && req.session.exportAuth[type] &&
315
+ req.session.exportAuth[type].authenticated &&
316
+ req.session.exportAuth[type].format === format) {
317
+ // Session is valid (5 minute expiry)
318
+ const sessionAge = Date.now() - req.session.exportAuth[type].timestamp;
319
+ if (sessionAge < 5 * 60 * 1000) { // 5 minutes
320
+ authMethod = 'session';
321
+ } else {
322
+ // Session expired, remove it
323
+ delete req.session.exportAuth[type];
324
+ }
325
+ }
326
+
327
+ // If no valid session, check query parameter
328
+ if (authMethod === 'none' && queryPassword) {
329
+ providedPassword = queryPassword;
330
+ authMethod = 'query';
331
+ } else if (authMethod === 'none') {
332
+ // Fall back to Basic Auth
333
+ const auth = basicAuth(req);
334
+ if (auth && auth.pass) {
335
+ providedPassword = auth.pass;
336
+ authMethod = 'basic';
337
+ }
338
+ }
339
+
340
+ // Validate password if we have one
341
+ if (authMethod !== 'session') {
342
+ if (!providedPassword || !await waitingListPublicExportsService.validateExportPassword(exportConfig, providedPassword)) {
343
+ // For Basic Auth, send proper challenge
344
+ if (authMethod === 'none' || authMethod === 'basic') {
345
+ res.setHeader('WWW-Authenticate', 'Basic realm="Waiting List Export"');
346
+ }
347
+ return res.status(401).json({
348
+ error: 'Authentication required',
349
+ field: 'password',
350
+ authMethod: authMethod === 'query' ? 'query' : 'basic'
351
+ });
352
+ }
353
+ }
354
+ }
355
+
356
+ // Get filtered waiting list entries
357
+ const { entries } = await waitingListService.getWaitingListEntriesAdmin({
358
+ type: exportConfig.type,
359
+ status: 'active',
360
+ limit: 100000, // Large limit to get all entries
361
+ offset: 0
362
+ });
363
+
364
+ // Record access for analytics
365
+ await waitingListPublicExportsService.recordExportAccess(exportConfig.name, req, authMethod);
366
+
367
+ // Export in requested format
368
+ if (format === 'json') {
369
+ res.setHeader('Content-Type', 'application/json');
370
+ res.setHeader('Content-Disposition', `attachment; filename="waiting-list-${exportConfig.type}-${new Date().toISOString().split('T')[0]}.json"`);
371
+
372
+ const exportData = {
373
+ export: {
374
+ name: exportConfig.name,
375
+ type: exportConfig.type,
376
+ exportedAt: new Date().toISOString(),
377
+ totalEntries: entries.length,
378
+ authMethod
379
+ },
380
+ entries: entries.map(entry => ({
381
+ email: entry.email,
382
+ type: entry.type,
383
+ status: entry.status,
384
+ referralSource: entry.referralSource,
385
+ createdAt: entry.createdAt,
386
+ updatedAt: entry.updatedAt
387
+ }))
388
+ };
389
+
390
+ return res.json(exportData);
391
+ } else {
392
+ // CSV format
393
+ res.setHeader('Content-Type', 'text/csv');
394
+ res.setHeader('Content-Disposition', `attachment; filename="waiting-list-${exportConfig.type}-${new Date().toISOString().split('T')[0]}.csv"`);
395
+
396
+ // CSV header row
397
+ const csvRows = [
398
+ ['Email', 'Type', 'Status', 'Referral Source', 'Created At', 'Updated At']
399
+ ];
400
+
401
+ // Data rows
402
+ entries.forEach(entry => {
403
+ csvRows.push([
404
+ entry.email || '',
405
+ entry.type || '',
406
+ entry.status || '',
407
+ entry.referralSource || '',
408
+ entry.createdAt ? new Date(entry.createdAt).toISOString() : '',
409
+ entry.updatedAt ? new Date(entry.updatedAt).toISOString() : ''
410
+ ]);
411
+ });
412
+
413
+ // Convert to CSV string with proper escaping
414
+ const csvContent = csvRows.map(row =>
415
+ row.map(cell => {
416
+ const str = String(cell || '');
417
+ // Escape quotes and wrap in quotes if contains comma, quote, or newline
418
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
419
+ return `"${str.replace(/"/g, '""')}"`;
420
+ }
421
+ return str;
422
+ }).join(',')
423
+ ).join('\n');
424
+
425
+ return res.send(csvContent);
426
+ }
427
+ } catch (error) {
428
+ console.error('Public export error:', error);
429
+ return res.status(500).json({ error: 'Failed to export data' });
430
+ }
431
+ };
432
+
433
+ // Admin: Get all public exports
434
+ exports.getPublicExports = async (req, res) => {
435
+ try {
436
+ const result = await waitingListPublicExportsService.getPublicExports();
437
+ res.json(result);
438
+ } catch (error) {
439
+ console.error('Get public exports error:', error);
440
+ return res.status(500).json({ error: 'Failed to get public exports' });
441
+ }
442
+ };
443
+
444
+ // Admin: Create public export
445
+ exports.createPublicExport = async (req, res) => {
446
+ try {
447
+ const { name, type, password, format = 'csv' } = req.body;
448
+
449
+ // Validate required fields
450
+ if (!name) {
451
+ return res.status(400).json({
452
+ error: 'Name is required',
453
+ field: 'name'
454
+ });
455
+ }
456
+
457
+ if (!type) {
458
+ return res.status(400).json({
459
+ error: 'Type is required',
460
+ field: 'type'
461
+ });
462
+ }
463
+
464
+ // Hash password if provided
465
+ let hashedPassword = null;
466
+ if (password) {
467
+ hashedPassword = await waitingListPublicExportsService.hashPassword(password);
468
+ }
469
+
470
+ const exportConfig = await waitingListPublicExportsService.createPublicExport({
471
+ name: name.trim(),
472
+ type: type.trim(),
473
+ password: hashedPassword,
474
+ format
475
+ }, req.user?.username || 'admin');
476
+
477
+ // Don't return password hash in response
478
+ const response = { ...exportConfig };
479
+ delete response.password;
480
+ response.hasPassword = !!password;
481
+
482
+ res.status(201).json({
483
+ message: 'Public export created successfully',
484
+ data: response
485
+ });
486
+ } catch (error) {
487
+ console.error('Create public export error:', error);
488
+
489
+ if (error.code === 'VALIDATION') {
490
+ return res.status(400).json({
491
+ error: error.message,
492
+ field: 'general'
493
+ });
494
+ }
495
+
496
+ if (error.code === 'DUPLICATE_NAME') {
497
+ return res.status(409).json({
498
+ error: error.message,
499
+ field: 'name'
500
+ });
501
+ }
502
+
503
+ return res.status(500).json({ error: 'Failed to create public export' });
504
+ }
505
+ };
506
+
507
+ // Admin: Update public export
508
+ exports.updatePublicExport = async (req, res) => {
509
+ try {
510
+ const { id } = req.params;
511
+ const { name, type, password, format } = req.body;
512
+
513
+ const updates = {};
514
+ if (name !== undefined) updates.name = name.trim();
515
+ if (type !== undefined) updates.type = type.trim();
516
+ if (format !== undefined) updates.format = format;
517
+ if (password !== undefined) {
518
+ updates.password = password ? await waitingListPublicExportsService.hashPassword(password) : null;
519
+ }
520
+
521
+ const result = await waitingListPublicExportsService.updatePublicExport(id, updates, req.user?.username || 'admin');
522
+
523
+ // Don't return password hash in response
524
+ const updatedExport = result.exports.find(e => e.id === id);
525
+ const response = { ...updatedExport };
526
+ delete response.password;
527
+ response.hasPassword = !!updatedExport.password;
528
+
529
+ res.json({
530
+ message: 'Public export updated successfully',
531
+ data: response
532
+ });
533
+ } catch (error) {
534
+ console.error('Update public export error:', error);
535
+
536
+ if (error.code === 'VALIDATION') {
537
+ return res.status(400).json({
538
+ error: error.message,
539
+ field: 'general'
540
+ });
541
+ }
542
+
543
+ if (error.code === 'NOT_FOUND') {
544
+ return res.status(404).json({
545
+ error: 'Public export not found',
546
+ field: 'id'
547
+ });
548
+ }
549
+
550
+ return res.status(500).json({ error: 'Failed to update public export' });
551
+ }
552
+ };
553
+
554
+ // Admin: Delete public export
555
+ exports.deletePublicExport = async (req, res) => {
556
+ try {
557
+ const { id } = req.params;
558
+
559
+ await waitingListPublicExportsService.deletePublicExport(id, req.user?.username || 'admin');
560
+
561
+ res.json({
562
+ message: 'Public export deleted successfully'
563
+ });
564
+ } catch (error) {
565
+ console.error('Delete public export error:', error);
566
+
567
+ if (error.code === 'NOT_FOUND') {
568
+ return res.status(404).json({
569
+ error: 'Public export not found',
570
+ field: 'id'
571
+ });
572
+ }
573
+
574
+ return res.status(500).json({ error: 'Failed to delete public export' });
575
+ }
576
+ };
package/src/middleware.js CHANGED
@@ -228,7 +228,6 @@ function createMiddleware(options = {}) {
228
228
  // Database connection
229
229
  const mongoUri =
230
230
  options.mongodbUri ||
231
- options.dbConnection ||
232
231
  process.env.MONGODB_URI ||
233
232
  process.env.MONGO_URI;
234
233
 
@@ -539,15 +538,8 @@ function createMiddleware(options = {}) {
539
538
  router.use(express.urlencoded({ extended: true }));
540
539
  }
541
540
 
542
- // Session middleware for admin authentication
543
- const sessionMongoUrl = options.mongodbUri || process.env.MONGODB_URI || process.env.MONGO_URI;
544
- const sessionStore = !isJest && sessionMongoUrl
545
- ? MongoStore.create({
546
- mongoUrl: sessionMongoUrl,
547
- collectionName: 'admin_sessions',
548
- ttl: 24 * 60 * 60 // 24 hours in seconds
549
- })
550
- : undefined;
541
+ // Session middleware - disabled for now (MongoDB at 27018 requires auth for writes)
542
+ const sessionStore = undefined;
551
543
 
552
544
  const sessionMiddleware = session({
553
545
  secret: process.env.SESSION_SECRET || 'superbackend-session-secret-fallback',
@@ -2290,6 +2282,9 @@ res.status(500).send("Error rendering page");
2290
2282
  next();
2291
2283
  });
2292
2284
 
2285
+ // Public export pages
2286
+ router.use("/share/export", require("./routes/publicExport.routes"));
2287
+
2293
2288
  // Public pages router (catch-all, must be last before error handler)
2294
2289
  router.use(require("./routes/pages.routes"));
2295
2290
 
@@ -92,11 +92,6 @@ const userSchema = new mongoose.Schema({
92
92
  timestamps: true
93
93
  });
94
94
 
95
- // Indexes
96
- userSchema.index({ email: 1 });
97
- userSchema.index({ githubId: 1 });
98
- userSchema.index({ clerkUserId: 1 });
99
-
100
95
  // Hash password before saving
101
96
  userSchema.pre('save', async function(next) {
102
97
  if (!this.isModified('passwordHash')) return next();
@@ -0,0 +1,9 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const publicExportController = require('../controllers/publicExport.controller');
4
+
5
+ // Public export access page and authentication
6
+ router.get('/:name', publicExportController);
7
+ router.post('/:name/auth', publicExportController);
8
+
9
+ module.exports = router;
@@ -4,6 +4,7 @@ const waitingListController = require('../controllers/waitingList.controller');
4
4
  const asyncHandler = require('../utils/asyncHandler');
5
5
  const { auditMiddleware } = require('../services/auditLogger');
6
6
  const rateLimiter = require('../services/rateLimiter.service');
7
+ const basicAuth = require('basic-auth');
7
8
 
8
9
  // POST /api/waiting-list/subscribe - Subscribe to waiting list
9
10
  // Rate limited by IP to prevent spam/abuse (1 request per minute)
@@ -20,4 +21,11 @@ router.get('/stats',
20
21
  asyncHandler(waitingListController.getStats)
21
22
  );
22
23
 
24
+ // GET /api/waiting-list/share/export - Public export endpoint
25
+ // Rate limited to prevent abuse (10 requests per minute)
26
+ router.get('/share/export',
27
+ rateLimiter.limit('waitingListPublicExportLimiter'),
28
+ asyncHandler(waitingListController.publicExport)
29
+ );
30
+
23
31
  module.exports = router;
@@ -9,4 +9,10 @@ router.get('/types', adminSessionAuth, asyncHandler(waitingListController.getTyp
9
9
  router.get('/export-csv', adminSessionAuth, asyncHandler(waitingListController.exportCsv));
10
10
  router.post('/bulk-remove', adminSessionAuth, asyncHandler(waitingListController.bulkRemove));
11
11
 
12
+ // Public exports management
13
+ router.get('/public-exports', adminSessionAuth, asyncHandler(waitingListController.getPublicExports));
14
+ router.post('/public-exports', adminSessionAuth, asyncHandler(waitingListController.createPublicExport));
15
+ router.put('/public-exports/:id', adminSessionAuth, asyncHandler(waitingListController.updatePublicExport));
16
+ router.delete('/public-exports/:id', adminSessionAuth, asyncHandler(waitingListController.deletePublicExport));
17
+
12
18
  module.exports = router;