Initial commit - Church Music Database
This commit is contained in:
52
new-site/backend/middleware/auth.js
Normal file
52
new-site/backend/middleware/auth.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "No token provided" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET || "your-super-secret-jwt-key",
|
||||
);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === "TokenExpiredError") {
|
||||
return res.status(401).json({ error: "Token expired" });
|
||||
}
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
const isAdmin = (req, res, next) => {
|
||||
if (!req.user || req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
authorize,
|
||||
isAdmin,
|
||||
};
|
||||
258
new-site/backend/middleware/cache.js
Normal file
258
new-site/backend/middleware/cache.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Response Cache Middleware for Express
|
||||
*
|
||||
* Provides in-memory caching for API responses to reduce database load
|
||||
* and improve response times. Especially useful for frequently accessed
|
||||
* data like songs, lists, and stats.
|
||||
*
|
||||
* Features:
|
||||
* - Configurable TTL per route
|
||||
* - Cache invalidation on mutations
|
||||
* - ETag support for conditional requests
|
||||
* - Memory-efficient with automatic cleanup
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
// Cache storage
|
||||
const cache = new Map();
|
||||
|
||||
// Default TTL values (in seconds)
|
||||
const DEFAULT_TTL = {
|
||||
"/api/songs": 300, // 5 minutes for song list
|
||||
"/api/lists": 120, // 2 minutes for worship lists
|
||||
"/api/profiles": 300, // 5 minutes for profiles
|
||||
"/api/stats": 60, // 1 minute for stats
|
||||
default: 60, // 1 minute default
|
||||
};
|
||||
|
||||
// Cache entry structure: { data, etag, timestamp, ttl }
|
||||
|
||||
/**
|
||||
* Generate a cache key from request
|
||||
*/
|
||||
function generateCacheKey(req) {
|
||||
const baseKey = `${req.method}:${req.originalUrl}`;
|
||||
// Include query parameters in key
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ETag from response data
|
||||
*/
|
||||
function generateETag(data) {
|
||||
return crypto.createHash("md5").update(JSON.stringify(data)).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache entry is still valid
|
||||
*/
|
||||
function isCacheValid(entry) {
|
||||
if (!entry) return false;
|
||||
const now = Date.now();
|
||||
return now - entry.timestamp < entry.ttl * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTL for a specific route
|
||||
*/
|
||||
function getTTL(path) {
|
||||
// Strip query params for TTL lookup
|
||||
const basePath = path.split("?")[0];
|
||||
|
||||
// Check for exact match
|
||||
if (DEFAULT_TTL[basePath]) {
|
||||
return DEFAULT_TTL[basePath];
|
||||
}
|
||||
|
||||
// Check for prefix match
|
||||
for (const [key, ttl] of Object.entries(DEFAULT_TTL)) {
|
||||
if (basePath.startsWith(key)) {
|
||||
return ttl;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_TTL.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache middleware - only caches GET requests
|
||||
*/
|
||||
function cacheMiddleware(options = {}) {
|
||||
return (req, res, next) => {
|
||||
// Only cache GET requests
|
||||
if (req.method !== "GET") {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip caching for authenticated user-specific data
|
||||
const skipPaths = ["/api/auth/me", "/api/admin/"];
|
||||
if (skipPaths.some((path) => req.originalUrl.startsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cacheKey = generateCacheKey(req);
|
||||
const cachedEntry = cache.get(cacheKey);
|
||||
|
||||
// Check if we have valid cached data
|
||||
if (isCacheValid(cachedEntry)) {
|
||||
// Check If-None-Match header for conditional request
|
||||
const clientETag = req.headers["if-none-match"];
|
||||
if (clientETag && clientETag === cachedEntry.etag) {
|
||||
return res.status(304).end(); // Not Modified
|
||||
}
|
||||
|
||||
// Return cached response
|
||||
res.set("X-Cache", "HIT");
|
||||
res.set("ETag", cachedEntry.etag);
|
||||
res.set(
|
||||
"Cache-Control",
|
||||
`private, max-age=${Math.floor((cachedEntry.ttl * 1000 - (Date.now() - cachedEntry.timestamp)) / 1000)}`,
|
||||
);
|
||||
return res.json(cachedEntry.data);
|
||||
}
|
||||
|
||||
// Cache miss - capture the response
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = (data) => {
|
||||
// Only cache successful responses
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const ttl = options.ttl || getTTL(req.originalUrl);
|
||||
const etag = generateETag(data);
|
||||
|
||||
cache.set(cacheKey, {
|
||||
data,
|
||||
etag,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
});
|
||||
|
||||
res.set("X-Cache", "MISS");
|
||||
res.set("ETag", etag);
|
||||
res.set("Cache-Control", `private, max-age=${ttl}`);
|
||||
}
|
||||
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache entries matching a pattern
|
||||
*/
|
||||
function invalidateCache(pattern) {
|
||||
if (typeof pattern === "string") {
|
||||
// Invalidate specific key
|
||||
cache.delete(pattern);
|
||||
|
||||
// Also invalidate any keys that start with pattern
|
||||
for (const key of cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
} else if (pattern instanceof RegExp) {
|
||||
// Invalidate matching pattern
|
||||
for (const key of cache.keys()) {
|
||||
if (pattern.test(key)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidation middleware for mutations (POST, PUT, DELETE)
|
||||
*/
|
||||
function invalidationMiddleware(req, res, next) {
|
||||
// Skip for GET requests
|
||||
if (req.method === "GET") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = (data) => {
|
||||
// Invalidate related caches on successful mutations
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const basePath = req.baseUrl || "";
|
||||
|
||||
// Invalidate caches based on route
|
||||
if (basePath.includes("/songs") || req.originalUrl.includes("/songs")) {
|
||||
invalidateCache("/api/songs");
|
||||
invalidateCache("/api/stats");
|
||||
}
|
||||
if (basePath.includes("/lists") || req.originalUrl.includes("/lists")) {
|
||||
invalidateCache("/api/lists");
|
||||
invalidateCache("/api/stats");
|
||||
}
|
||||
if (
|
||||
basePath.includes("/profiles") ||
|
||||
req.originalUrl.includes("/profiles")
|
||||
) {
|
||||
invalidateCache("/api/profiles");
|
||||
invalidateCache("/api/stats");
|
||||
}
|
||||
}
|
||||
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
function getCacheStats() {
|
||||
let validEntries = 0;
|
||||
let expiredEntries = 0;
|
||||
|
||||
for (const entry of cache.values()) {
|
||||
if (isCacheValid(entry)) {
|
||||
validEntries++;
|
||||
} else {
|
||||
expiredEntries++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: cache.size,
|
||||
validEntries,
|
||||
expiredEntries,
|
||||
keys: Array.from(cache.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic cache cleanup (remove expired entries)
|
||||
*/
|
||||
function startCacheCleanup(intervalMs = 60000) {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of cache.entries()) {
|
||||
if (!isCacheValid(entry)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cacheMiddleware,
|
||||
invalidationMiddleware,
|
||||
invalidateCache,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
startCacheCleanup,
|
||||
};
|
||||
41
new-site/backend/middleware/errorHandler.js
Normal file
41
new-site/backend/middleware/errorHandler.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export const notFound = (req, res, next) => {
|
||||
res.status(404).json({
|
||||
error: "Not Found",
|
||||
message: `Cannot ${req.method} ${req.originalUrl}`,
|
||||
});
|
||||
};
|
||||
|
||||
export const errorHandler = (err, req, res, next) => {
|
||||
console.error("Error:", err);
|
||||
|
||||
// Handle validation errors
|
||||
if (err.name === "ValidationError") {
|
||||
return res.status(400).json({
|
||||
error: "Validation Error",
|
||||
details: err.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle duplicate key errors
|
||||
if (err.code === 11000) {
|
||||
return res.status(400).json({
|
||||
error: "Duplicate Entry",
|
||||
message: "A record with this value already exists",
|
||||
});
|
||||
}
|
||||
|
||||
// Handle JWT errors
|
||||
if (err.name === "JsonWebTokenError") {
|
||||
return res.status(401).json({
|
||||
error: "Invalid Token",
|
||||
message: "Your session is invalid",
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
const statusCode = err.statusCode || 500;
|
||||
res.status(statusCode).json({
|
||||
error: err.message || "Internal Server Error",
|
||||
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
|
||||
});
|
||||
};
|
||||
79
new-site/backend/middleware/validate.js
Normal file
79
new-site/backend/middleware/validate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { body, validationResult } from "express-validator";
|
||||
|
||||
export const validate = (validations) => {
|
||||
return async (req, res, next) => {
|
||||
await Promise.all(validations.map((validation) => validation.run(req)));
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (errors.isEmpty()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
error: "Validation Error",
|
||||
details: errors.array().map((err) => ({
|
||||
field: err.path,
|
||||
message: err.msg,
|
||||
})),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Auth validations
|
||||
export const loginValidation = [
|
||||
body("email").isEmail().normalizeEmail().withMessage("Valid email required"),
|
||||
body("password")
|
||||
.isLength({ min: 6 })
|
||||
.withMessage("Password must be at least 6 characters"),
|
||||
];
|
||||
|
||||
export const registerValidation = [
|
||||
body("name")
|
||||
.trim()
|
||||
.isLength({ min: 2 })
|
||||
.withMessage("Name must be at least 2 characters"),
|
||||
body("email").isEmail().normalizeEmail().withMessage("Valid email required"),
|
||||
body("password")
|
||||
.isLength({ min: 8 })
|
||||
.withMessage("Password must be at least 8 characters")
|
||||
.matches(/\d/)
|
||||
.withMessage("Password must contain at least one number"),
|
||||
];
|
||||
|
||||
// Song validations
|
||||
export const songValidation = [
|
||||
body("title").trim().notEmpty().withMessage("Title is required"),
|
||||
body("key")
|
||||
.optional()
|
||||
.isIn([
|
||||
"C",
|
||||
"C#",
|
||||
"Db",
|
||||
"D",
|
||||
"D#",
|
||||
"Eb",
|
||||
"E",
|
||||
"F",
|
||||
"F#",
|
||||
"Gb",
|
||||
"G",
|
||||
"G#",
|
||||
"Ab",
|
||||
"A",
|
||||
"A#",
|
||||
"Bb",
|
||||
"B",
|
||||
]),
|
||||
body("tempo")
|
||||
.optional()
|
||||
.isInt({ min: 40, max: 220 })
|
||||
.withMessage("Tempo must be between 40 and 220"),
|
||||
body("lyrics").optional().isString(),
|
||||
];
|
||||
|
||||
// List validations
|
||||
export const listValidation = [
|
||||
body("name").trim().notEmpty().withMessage("Name is required"),
|
||||
body("date").optional().isISO8601().withMessage("Valid date required"),
|
||||
body("songs").optional().isArray(),
|
||||
];
|
||||
Reference in New Issue
Block a user