Implement wiring hardening, runtime API config, smoke tests, and build scripts

This commit is contained in:
Ryan Lancaster
2026-03-17 12:58:43 -04:00
parent a3949c32ee
commit ca46302aff
19 changed files with 2358 additions and 77 deletions

View File

@@ -1,12 +1,20 @@
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
RUN apk add --no-cache curl && npm install --omit=dev
COPY . .
RUN chown -R node:node /app
USER node
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:4000/health || exit 1
CMD ["node", "index.js"]

View File

@@ -1,45 +1,254 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { join, dirname } from 'path';
import { join, dirname, isAbsolute, extname } from 'path';
import { fileURLToPath } from 'url';
import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs';
import { randomBytes, randomUUID } from 'crypto';
import multer from 'multer';
import nodemailer from 'nodemailer';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, 'data');
import { mkdirSync } from 'fs';
mkdirSync(dataDir, { recursive: true });
const dbFile = join(dataDir, 'db.json');
const dbFileEnv = process.env.DB_FILE;
const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json');
const legacyDbFile = join(__dirname, 'db.json');
mkdirSync(dirname(dbFile), { recursive: true });
const adapter = new JSONFile(dbFile);
const defaultData = { members: [], projects: [], tasks: [] };
const defaultData = { members: [], projects: [], tasks: [], invites: [] };
const db = new Low(adapter, defaultData);
await db.read();
const safeReadJson = (filePath) => {
try {
if (!existsSync(filePath)) return null;
const raw = readFileSync(filePath, 'utf8').trim();
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
};
const toInitials = (name = '') =>
String(name)
.split(' ')
.filter(Boolean)
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2);
const ensureProjectShape = (project) => {
if (!Array.isArray(project.members)) project.members = [];
if (!Array.isArray(project.tasks)) project.tasks = [];
if (!Array.isArray(project.milestones)) project.milestones = [];
};
try {
await db.read();
} catch (err) {
console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err);
db.data = { ...defaultData };
await db.write();
}
if (!db.data) db.data = defaultData;
if (!db.data.invites) db.data.invites = [];
if (!db.data.members) db.data.members = [];
if (!db.data.projects) db.data.projects = [];
if (!db.data.tasks) db.data.tasks = [];
// One-time bootstrap from legacy backend/db.json when the runtime db is empty.
const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0;
if (!runtimeHasData && dbFile !== legacyDbFile) {
const legacy = safeReadJson(legacyDbFile);
if (legacy && typeof legacy === 'object') {
db.data.members = Array.isArray(legacy.members) ? legacy.members : [];
db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : [];
db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : [];
db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : [];
}
}
// Normalize projects and migrate any legacy flat tasks into nested project.tasks.
for (const project of db.data.projects) ensureProjectShape(project);
for (const task of db.data.tasks) {
const project = db.data.projects.find(p => p.id === task.projectId);
if (!project) continue;
ensureProjectShape(project);
const existing = project.tasks.find(t => t.id === task.id);
if (existing) continue;
project.tasks.push({
id: task.id || randomUUID(),
title: task.title || 'Untitled Task',
description: task.description || '',
status: task.status || 'todo',
priority: task.priority || 'medium',
dueDate: task.dueDate || '',
startDate: task.startDate || '',
assignedTo: task.assignedTo || task.memberId || '',
estimatedHours: task.estimatedHours,
actualHours: task.actualHours,
recurrence: task.recurrence || 'none',
subtasks: Array.isArray(task.subtasks) ? task.subtasks : [],
attachments: Array.isArray(task.attachments) ? task.attachments : [],
});
}
db.data.tasks = [];
for (const project of db.data.projects) {
ensureProjectShape(project);
project.members = project.members
.map(member => {
if (member && typeof member === 'object') {
return {
...member,
initials: member.initials || toInitials(member.name),
};
}
const roster = db.data.members.find(m => m.id === member);
if (!roster) return null;
return { ...roster, initials: roster.initials || toInitials(roster.name) };
})
.filter(Boolean);
}
await db.write();
// ── File uploads (multer) ───────────────────────────────────────────────────
const UPLOAD_DIR = join(__dirname, 'uploads');
mkdirSync(UPLOAD_DIR, { recursive: true });
const ALLOWED_MIME = new Set([
'image/jpeg','image/png','image/gif','image/webp','image/svg+xml',
'application/pdf','text/plain','text/csv','text/markdown',
'application/json',
'application/zip','application/x-zip-compressed',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
]);
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()),
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
else cb(new Error('File type not allowed'));
},
});
// ── Notification helper ────────────────────────────────────────────────────
async function notify({ webhookUrl, notifyEmail, subject, text, payload }) {
if (webhookUrl) {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || { subject, text }),
signal: AbortSignal.timeout(5000),
});
} catch (err) {
console.warn('Webhook notification failed', err?.message || err);
}
}
if (notifyEmail && process.env.SMTP_HOST) {
try {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
});
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local',
to: notifyEmail,
subject,
text,
});
} catch (err) {
console.warn('Email notification failed', err?.message || err);
}
}
}
const app = express();
app.use(cors());
app.use(helmet());
app.use(morgan(process.env.MORGAN_FORMAT || 'combined'));
const configuredOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
const allowedOrigins = configuredOrigins.length
? configuredOrigins
: ['http://localhost:5173', 'http://localhost:4000', 'null'];
const corsOptions = {
credentials: true,
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (origin === 'null' || origin.startsWith('file://')) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'));
},
};
app.use(cors(corsOptions));
app.use(express.json());
const limiter = rateLimit({
windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000,
max: Number(process.env.RATE_MAX) || 200,
});
const requireApiKey = (req, res, next) => {
const key = process.env.WRITE_API_KEY;
if (!key) return next();
const provided = req.get('x-api-key') || req.query.api_key;
if (provided === key) return next();
return res.status(401).json({ error: 'Unauthorized' });
};
// Health
app.get('/health', async (req, res) => {
try {
await db.read();
return res.json({ status: 'ok', timestamp: Date.now() });
} catch (err) {
return res.status(500).json({ status: 'error' });
}
});
// Member CRUD
app.get('/api/members', (req, res) => {
res.json(db.data.members);
});
app.post('/api/members', (req, res) => {
app.post('/api/members', limiter, requireApiKey, async (req, res) => {
const member = { id: Date.now().toString(), ...req.body };
member.initials = member.initials || toInitials(member.name);
db.data.members.push(member);
db.write();
await db.write();
res.status(201).json(member);
});
app.delete('/api/members/:id', (req, res) => {
app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => {
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
db.write();
for (const project of db.data.projects) {
ensureProjectShape(project);
project.members = project.members.filter(m => m.id !== req.params.id);
}
await db.write();
res.status(204).end();
});
app.put('/api/members/:id', async (req, res) => {
app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => {
const idx = db.data.members.findIndex(m => m.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
@@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => {
res.json(db.data.projects);
});
app.post('/api/projects', (req, res) => {
const project = { id: Date.now().toString(), members: [], ...req.body };
app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body };
ensureProjectShape(project);
db.data.projects.push(project);
db.write();
await db.write();
res.status(201).json(project);
});
app.put('/api/projects/:id', async (req, res) => {
app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
const updated = { ...req.body, id: req.params.id };
ensureProjectShape(updated);
if (idx === -1) db.data.projects.push(updated);
else db.data.projects[idx] = updated;
await db.write();
res.json(updated);
});
app.delete('/api/projects/:id', (req, res) => {
app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
db.write();
await db.write();
res.status(204).end();
});
// Assign member to project
app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (!project.members.includes(req.params.memberId)) {
project.members.push(req.params.memberId);
db.write();
ensureProjectShape(project);
const member = db.data.members.find(m => m.id === req.params.memberId);
if (!member) return res.status(404).json({ error: 'Member not found' });
const alreadyAssigned = project.members.some(m => m.id === member.id);
if (!alreadyAssigned) {
project.members.push({ ...member, initials: member.initials || toInitials(member.name) });
await db.write();
}
res.json(project);
});
// Tasks (assign to member in project)
app.post('/api/projects/:projectId/tasks', (req, res) => {
const { title, description, memberId } = req.body;
app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => {
const { title, description, memberId, assignedTo } = req.body;
const assigneeId = assignedTo || memberId || '';
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
db.data.tasks.push(task);
db.write();
ensureProjectShape(project);
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
return res.status(400).json({ error: 'Member not assigned to project' });
}
const task = {
id: randomUUID(),
title: String(title || '').trim() || 'Untitled Task',
description: description || '',
status: req.body.status || 'todo',
priority: req.body.priority || 'medium',
dueDate: req.body.dueDate || '',
startDate: req.body.startDate || '',
assignedTo: assigneeId,
estimatedHours: req.body.estimatedHours,
actualHours: req.body.actualHours,
recurrence: req.body.recurrence || 'none',
subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [],
attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [],
};
project.tasks.push(task);
await db.write();
res.status(201).json(task);
});
app.get('/api/projects/:projectId/tasks', (req, res) => {
const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
res.json(tasks);
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
ensureProjectShape(project);
res.json(project.tasks);
});
app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
ensureProjectShape(project);
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || '';
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
return res.status(400).json({ error: 'Member not assigned to project' });
}
project.tasks[idx] = {
...project.tasks[idx],
...req.body,
id: req.params.taskId,
assignedTo: assigneeId,
};
await db.write();
res.json(project.tasks[idx]);
});
app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
ensureProjectShape(project);
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
project.tasks.splice(idx, 1);
await db.write();
res.status(204).end();
});
// ── Static uploads ──────────────────────────────────────────────────────────
app.use('/uploads', express.static(UPLOAD_DIR));
// ── File attachments ────────────────────────────────────────────────────────
// Attachments are stored inline in project.tasks[].attachments[]
app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => {
upload.single('file')(req, res, async err => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file provided' });
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
ensureProjectShape(project);
const task = project.tasks.find(t => t.id === req.params.taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
if (!task.attachments) task.attachments = [];
const attachment = {
id: randomUUID(),
filename: req.file.filename,
originalName: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
url: `/uploads/${req.file.filename}`,
uploadedAt: new Date().toISOString(),
};
task.attachments.push(attachment);
await db.write();
res.status(201).json(attachment);
});
});
app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
ensureProjectShape(project);
const task = project.tasks.find(t => t.id === req.params.taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId);
if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' });
const [removed] = task.attachments.splice(idx, 1);
// Delete file from disk
const filePath = join(UPLOAD_DIR, removed.filename);
if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} }
await db.write();
res.status(204).end();
});
// ── Webhook/notification settings on project ────────────────────────────────
// Projects can have optional webhookUrl and notifyEmail; these are just
// stored as part of the project doc. The POST /api/projects/:id/notify endpoint
// fires a test notification.
app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found' });
const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body;
notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } });
res.json({ sent: true });
});
// ── Invite system ────────────────────────────────────────────────────────────
// Generate an invite token for a project
app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found' });
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const invite = {
id: randomUUID(),
token,
projectId: project.id,
projectName: project.name,
email: req.body.email || null,
createdAt: new Date().toISOString(),
expiresAt: expiresAt.toISOString(),
acceptedAt: null,
};
db.data.invites.push(invite);
await db.write();
// Fire email if SMTP configured and email provided
if (req.body.email) {
const appUrl = process.env.APP_URL || 'http://localhost:5173';
notify({
notifyEmail: req.body.email,
subject: `You've been invited to "${project.name}"`,
text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`,
});
}
res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id });
});
// Validate an invite token (public)
app.get('/api/invites/:token', (req, res) => {
const invite = db.data.invites.find(i => i.token === req.params.token);
if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt });
});
// Accept an invite (attaches provided member name to project if given)
app.post('/api/invites/:token/accept', limiter, async (req, res) => {
const invite = db.data.invites.find(i => i.token === req.params.token);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
invite.acceptedAt = new Date().toISOString();
// Optionally add the member to the project
if (req.body.name) {
const project = db.data.projects.find(p => p.id === invite.projectId);
if (project) {
ensureProjectShape(project);
const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
const email = (req.body.email || invite.email || '').trim();
let rosterMember = db.data.members.find(m => email && m.email === email);
if (!rosterMember) {
rosterMember = {
id: randomUUID(),
name: req.body.name,
role: req.body.role || 'Team Member',
initials,
email,
};
db.data.members.push(rosterMember);
}
if (!project.members.some(m => m.id === rosterMember.id)) {
project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) });
}
}
}
await db.write();
res.json({ projectId: invite.projectId, projectName: invite.projectName });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
const HOST = process.env.HOST || '0.0.0.0';
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));

View File

@@ -10,7 +10,12 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"lowdb": "^6.0.1"
"express-rate-limit": "^6.8.0",
"helmet": "^6.0.1",
"lowdb": "^6.0.1",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"nodemailer": "^8.0.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
@@ -43,6 +48,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -59,6 +70,24 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -122,6 +151,23 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -185,6 +231,21 @@
"fsevents": "~2.3.2"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -386,6 +447,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
"integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
"license": "MIT",
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"express": "^4 || ^5"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -555,6 +628,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz",
"integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -755,12 +837,59 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/morgan/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -770,6 +899,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -867,6 +1005,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -954,6 +1101,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1163,6 +1324,23 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1221,6 +1399,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1237,6 +1421,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -5,12 +5,18 @@
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
"dev": "nodemon index.js",
"build": "node --check index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^6.8.0",
"helmet": "^6.0.1",
"lowdb": "^6.0.1",
"cors": "^2.8.5"
"morgan": "^1.10.0",
"multer": "^2.1.1",
"nodemailer": "^8.0.2"
},
"devDependencies": {
"nodemon": "^3.0.1"