From 15c53a27bb2dbbae3f2508a29957e43162eca9cc Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 4 Mar 2026 18:06:59 +0530 Subject: [PATCH 01/11] feat(security): implement granular API keys, CORS restrictions, project rate limiting, and fix double OTP bug --- backend/controllers/project.controller.js | 85 ++++++++-- backend/controllers/schema.controller.js | 6 +- backend/middleware/projectRateLimiter.js | 30 ++++ backend/middleware/requireSecretKey.js | 8 + backend/middleware/verifyApiKey.js | 37 ++++- backend/models/Project.js | 12 +- backend/routes/data.js | 28 ++-- backend/routes/projects.js | 6 +- backend/routes/schemas.js | 12 +- backend/routes/storage.js | 14 +- backend/utils/api.js | 4 +- frontend/src/components/TryItPanel.jsx | 4 +- frontend/src/pages/CreateProject.jsx | 46 +++--- frontend/src/pages/ProjectDetails.jsx | 77 ++++----- frontend/src/pages/ProjectSettings.jsx | 186 +++++++++++++++++++++- frontend/src/pages/Signup.jsx | 4 +- 16 files changed, 432 insertions(+), 127 deletions(-) create mode 100644 backend/middleware/projectRateLimiter.js create mode 100644 backend/middleware/requireSecretKey.js diff --git a/backend/controllers/project.controller.js b/backend/controllers/project.controller.js index 39a825c6e..618024674 100644 --- a/backend/controllers/project.controller.js +++ b/backend/controllers/project.controller.js @@ -30,22 +30,27 @@ module.exports.createProject = async (req, res) => { // Validation Applied const { name, description } = createProjectSchema.parse(req.body); - const rawApiKey = generateApiKey() - const hashedkey = hashApiKey(rawApiKey); + const rawPublishableKey = generateApiKey('pk_live_'); + const hashedPublishableKey = hashApiKey(rawPublishableKey); - const rawSecret = generateApiKey() + const rawSecretKey = generateApiKey('sk_live_'); + const hashedSecretKey = hashApiKey(rawSecretKey); + + const rawJwtSecret = generateApiKey('jwt_'); const newProject = new Project({ name, description, owner: req.user._id, - apiKey: hashedkey, - jwtSecret: rawSecret + publishableKey: hashedPublishableKey, + secretKey: hashedSecretKey, + jwtSecret: rawJwtSecret }); await newProject.save(); const projectObj = newProject.toObject(); - projectObj.apiKey = rawApiKey; + projectObj.publishableKey = rawPublishableKey; + projectObj.secretKey = rawSecretKey; delete projectObj.jwtSecret; res.status(201).json(projectObj); @@ -74,14 +79,15 @@ module.exports.getSingleProject = async (req, res) => { project = await getProjectById(req.params.projectId); let projectObj; if (!project) { - project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-apiKey -jwtSecret'); + project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret'); if (!project) return res.status(404).json({ error: "Project not found." }); projectObj = project.toObject(); await setProjectById(req.params.projectId, project); } projectObj = project; - delete projectObj.apiKey; + delete projectObj.publishableKey; + delete projectObj.secretKey; delete projectObj.jwtSecret; res.json(projectObj); } catch (err) { @@ -91,25 +97,38 @@ module.exports.getSingleProject = async (req, res) => { module.exports.regenerateApiKey = async (req, res) => { try { - const newApiKey = generateApiKey(); + const { keyType } = req.body; // 'publishable' or 'secret' + + if (keyType !== 'publishable' && keyType !== 'secret') { + return res.status(400).json({ error: "Invalid keyType. Must be 'publishable' or 'secret'." }); + } + + const prefix = keyType === 'publishable' ? 'pk_live_' : 'sk_live_'; + const newApiKey = generateApiKey(prefix); const hashed = hashApiKey(newApiKey); - const oldApiProj = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('apiKey'); + const oldApiProj = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }) + .select('publishableKey secretKey'); if (!oldApiProj) return res.status(404).json({ error: "Project not found." }); - await deleteProjectByApiKeyCache(oldApiProj.apiKey); + + // Clear caches for both keys to be safe + await deleteProjectByApiKeyCache(oldApiProj.publishableKey); + await deleteProjectByApiKeyCache(oldApiProj.secretKey); + const updateField = keyType === 'publishable' ? { publishableKey: hashed } : { secretKey: hashed }; const project = await Project.findOneAndUpdate( { _id: req.params.projectId, owner: req.user._id }, - { $set: { apiKey: hashed } }, + { $set: updateField }, { new: true } ); if (!project) return res.status(404).json({ error: "Project not found." }); const projectObj = project.toObject(); - delete projectObj.apiKey; + delete projectObj.publishableKey; + delete projectObj.secretKey; delete projectObj.jwtSecret; - res.json({ apiKey: newApiKey, project: projectObj }); + res.json({ apiKey: newApiKey, keyType, project: projectObj }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -263,11 +282,12 @@ module.exports.createCollection = async (req, res) => { await deleteProjectById(projectId); await setProjectById(projectId, project); - await deleteProjectByApiKeyCache(project.apiKey); - await deleteProjectByApiKeyCache(project.apiKey); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); // Safe Response const projectObj = project.toObject(); - delete projectObj.apiKey; + delete projectObj.publishableKey; + delete projectObj.secretKey; delete projectObj.jwtSecret; res.status(201).json(projectObj); @@ -667,12 +687,43 @@ module.exports.updateProject = async (req, res) => { { new: true } ); if (!project) return res.status(404).json({ error: "Project not found." }); + + await deleteProjectById(project._id.toString()); + await setProjectById(project._id.toString(), project); + res.json(project); } catch (err) { res.status(500).json({ error: err.message }); } } +module.exports.updateAllowedDomains = async (req, res) => { + try { + const { domains } = req.body; + if (!Array.isArray(domains)) { + return res.status(400).json({ error: "domains must be an array of strings." }); + } + + const project = await Project.findOneAndUpdate( + { _id: req.params.projectId, owner: req.user._id }, + { $set: { allowedDomains: domains } }, + { new: true } + ); + + if (!project) return res.status(404).json({ error: "Project not found or access denied." }); + + // Update the cache so the verifyApiKey middleware gets the fresh domains immediately + await deleteProjectById(project._id.toString()); + await setProjectById(project._id.toString(), project); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + + res.json({ message: "Allowed domains updated", allowedDomains: project.allowedDomains }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +} + module.exports.deleteProject = async (req, res) => { try { const projectId = req.params.projectId; diff --git a/backend/controllers/schema.controller.js b/backend/controllers/schema.controller.js index 9ba31580c..7d4cf6b6b 100644 --- a/backend/controllers/schema.controller.js +++ b/backend/controllers/schema.controller.js @@ -54,13 +54,15 @@ module.exports.createSchema = async (req, res) => { // Clear redis cache await deleteProjectById(projectId.toString()); await setProjectById(projectId.toString(), fullProject); - await deleteProjectByApiKeyCache(fullProject.apiKey); + await deleteProjectByApiKeyCache(fullProject.publishableKey); + await deleteProjectByApiKeyCache(fullProject.secretKey); if (req.hashedApiKey) { await deleteProjectByApiKeyCache(req.hashedApiKey); } const projectObj = fullProject.toObject(); - delete projectObj.apiKey; + delete projectObj.publishableKey; + delete projectObj.secretKey; delete projectObj.jwtSecret; res.status(201).json({ message: "Schema created successfully", project: projectObj }); diff --git a/backend/middleware/projectRateLimiter.js b/backend/middleware/projectRateLimiter.js new file mode 100644 index 000000000..58f5a1037 --- /dev/null +++ b/backend/middleware/projectRateLimiter.js @@ -0,0 +1,30 @@ +const rateLimit = require('express-rate-limit'); +const Project = require('../models/Project'); + +const projectRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 500, + + keyGenerator: (req, res) => { + if (!req.project || !req.project._id) { + return 'unauthorized'; + } + return req.project._id.toString(); + }, + + handler: (req, res, next, options) => { + res.status(options.statusCode).json({ + error: "Too Many Requests", + message: "Project Rate limit exceeded. Please try again later." + }); + }, + + limit: async (req, res) => { + if (req.project && req.project.rateLimit) { + return req.project.rateLimit; + } + return 500; + } +}); + +module.exports = projectRateLimiter; diff --git a/backend/middleware/requireSecretKey.js b/backend/middleware/requireSecretKey.js new file mode 100644 index 000000000..4cb925c93 --- /dev/null +++ b/backend/middleware/requireSecretKey.js @@ -0,0 +1,8 @@ +module.exports = (req, res, next) => { + if (req.keyRole !== 'secret') { + return res.status(403).json({ + error: "Forbidden. This action requires a Secret Key (sk_live_...)." + }); + } + next(); +}; diff --git a/backend/middleware/verifyApiKey.js b/backend/middleware/verifyApiKey.js index d00bd17aa..d4b2c4d3b 100644 --- a/backend/middleware/verifyApiKey.js +++ b/backend/middleware/verifyApiKey.js @@ -12,6 +12,8 @@ module.exports = async (req, res, next) => { return res.status(401).json({ error: 'API key not found' }); } + const isSecret = apiKey.startsWith('sk_live_'); + const keyField = isSecret ? 'secretKey' : 'publishableKey'; const hashedApi = hashApiKey(apiKey); console.time("get project by api key cache") @@ -20,7 +22,7 @@ module.exports = async (req, res, next) => { if (!project) { console.time("get project by api key from db") - project = await Project.findOne({ apiKey: hashedApi }) + project = await Project.findOne({ [keyField]: hashedApi }) .select(` owner resources @@ -28,8 +30,9 @@ module.exports = async (req, res, next) => { databaseLimit databaseUsed storageLimit - storageUsed, + storageUsed jwtSecret + allowedDomains `) .populate('owner', 'isVerified') .lean(); @@ -54,16 +57,44 @@ module.exports = async (req, res, next) => { } console.timeEnd("checking if owner is verified") - // Ensure defaults are present (crucial for lean objects or cached POJOs) console.time("setting defaults") if (!project.resources) project.resources = {}; if (!project.resources.db) project.resources.db = { isExternal: false }; if (!project.resources.storage) project.resources.storage = { isExternal: false }; console.timeEnd("setting defaults") + if (!isSecret) { + let allowedDomains = project.allowedDomains || ['*']; + const origin = req.headers.origin || req.headers.referer; + + if (!allowedDomains.includes('*')) { + if (!origin) { + return res.status(403).json({ error: "Forbidden: Origin header missing and this key is restricted to specific domains." }); + } + + try { + const originUrl = new URL(origin).origin; + const isAllowed = allowedDomains.some(domain => { + if (domain.startsWith('*.')) { + const baseDomain = domain.substring(2); + return originUrl.endsWith(baseDomain); + } + return originUrl === domain; + }); + + if (!isAllowed) { + return res.status(403).json({ error: `Forbidden: Origin ${originUrl} is not allowed by this project's CORS policy.` }); + } + } catch (err) { + return res.status(400).json({ error: "Invalid Origin header format." }); + } + } + } + console.time("setting project and hashed api key") req.project = project; req.hashedApiKey = hashedApi; + req.keyRole = isSecret ? 'secret' : 'publishable'; console.timeEnd("setting project and hashed api key") next(); } catch (err) { diff --git a/backend/models/Project.js b/backend/models/Project.js index 81254a730..2e8f06d4b 100644 --- a/backend/models/Project.js +++ b/backend/models/Project.js @@ -29,7 +29,12 @@ const projectSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'Developer' }, - apiKey: { + publishableKey: { + type: String, + required: true, + unique: true + }, + secretKey: { type: String, required: true, unique: true @@ -40,6 +45,11 @@ const projectSchema = new mongoose.Schema({ }, collections: [collectionSchema], + allowedDomains: { + type: [String], + default: ['*'] + }, + // STORAGE LIMITS (Files) storageUsed: { type: Number, default: 0 }, storageLimit: { type: Number, default: 20 * 1024 * 1024 }, // 20MB default diff --git a/backend/routes/data.js b/backend/routes/data.js index 80cefa9b8..f2b5ef478 100644 --- a/backend/routes/data.js +++ b/backend/routes/data.js @@ -1,33 +1,31 @@ const express = require('express'); const router = express.Router(); const verifyApiKey = require('../middleware/verifyApiKey'); +const requireSecretKey = require('../middleware/requireSecretKey'); +const projectRateLimiter = require('../middleware/projectRateLimiter'); +const { getCompiledModel } = require("../utils/injectModel"); const { insertData, getAllData, getSingleDoc, updateSingleData, deleteSingleDoc } = require("../controllers/data.controller") -// Dynamic POST Route -// Example: POST /api/data/products -router.post('/:collectionName', verifyApiKey, insertData); +// POST REQ TO INSERT DATA +router.post('/:collectionName', verifyApiKey, projectRateLimiter, requireSecretKey, insertData); -// GET Route to fetch all data from a collection -// GET /api/data/products -router.get('/:collectionName', verifyApiKey, getAllData); +// GET REQ ALL DATA +router.get('/:collectionName', verifyApiKey, projectRateLimiter, getAllData); -// GET Single Item by ID -// GET /api/data/products/69235c0cc8e73cd3d7bbeab8 -router.get('/:collectionName/:id', verifyApiKey, getSingleDoc); +// GET REQ SINGLE DATA +router.get('/:collectionName/:id', verifyApiKey, projectRateLimiter, getSingleDoc); -// DELETE Single Item by ID -// DELETE /api/data/products/69235c0cc8e73cd3d7bbeab8 -router.delete('/:collectionName/:id', verifyApiKey, deleteSingleDoc); +// DELETE REQ SINGLE DATA +router.delete('/:collectionName/:id', verifyApiKey, projectRateLimiter, requireSecretKey, deleteSingleDoc); -// UPDATE Single Item by ID -// PUT /api/data/products/69235c0cc8e73cd3d7bbeab8 -router.put('/:collectionName/:id', verifyApiKey, updateSingleData); +// PUT REQ SINGLE DATA +router.put('/:collectionName/:id', verifyApiKey, projectRateLimiter, requireSecretKey, updateSingleData); module.exports = router; \ No newline at end of file diff --git a/backend/routes/projects.js b/backend/routes/projects.js index 180a289f7..ad91f08a7 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -25,7 +25,8 @@ const { updateExternalConfig, deleteExternalDbConfig, deleteExternalStorageConfig, - analytics + analytics, + updateAllowedDomains } = require("../controllers/project.controller") const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB Limit @@ -76,6 +77,9 @@ router.delete('/:projectId', authMiddleware, verifyEmail, deleteProject); // UPDATE PROJECT router.patch('/:projectId', authMiddleware, updateProject); +// UPDATE ALLOWED DOMAINS +router.patch('/:projectId/allowed-domains', authMiddleware, verifyEmail, updateAllowedDomains); + // UPDATE EXTERNAL CONFIG router.patch('/:projectId/byod-config', authMiddleware, updateExternalConfig); diff --git a/backend/routes/schemas.js b/backend/routes/schemas.js index 1ca760eec..3614bb659 100644 --- a/backend/routes/schemas.js +++ b/backend/routes/schemas.js @@ -1,14 +1,14 @@ const express = require('express'); const router = express.Router(); const verifyApiKey = require('../middleware/verifyApiKey'); +const requireSecretKey = require('../middleware/requireSecretKey'); +const projectRateLimiter = require('../middleware/projectRateLimiter'); const { checkSchema, createSchema } = require("../controllers/schema.controller"); -// GET Route to check if a schema exists -// GET /api/schemas/error_logs -router.get('/:collectionName', verifyApiKey, checkSchema); +// GET REQ FETCH SCHEMA +router.get('/:collectionName', verifyApiKey, projectRateLimiter, checkSchema); -// POST Route to create a new schema -// POST /api/schemas -router.post('/', verifyApiKey, createSchema); +// POST REQ CREATE SCHEMA +router.post('/', verifyApiKey, projectRateLimiter, requireSecretKey, createSchema); module.exports = router; diff --git a/backend/routes/storage.js b/backend/routes/storage.js index 2303032a5..62c7a8816 100644 --- a/backend/routes/storage.js +++ b/backend/routes/storage.js @@ -2,6 +2,8 @@ const express = require('express'); const router = express.Router(); const multer = require('multer'); const verifyApiKey = require('../middleware/verifyApiKey'); +const requireSecretKey = require('../middleware/requireSecretKey'); +const projectRateLimiter = require('../middleware/projectRateLimiter'); const { uploadFile, deleteFile, deleteAllFiles } = require("../controllers/storage.controller") const storage = multer.memoryStorage(); @@ -10,13 +12,13 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 } // 10MB Limit }); -// UPLOAD FILE -router.post('/upload', verifyApiKey, upload.single('file'), uploadFile); +// POST REQ UPLOAD FILE +router.post('/upload', verifyApiKey, projectRateLimiter, requireSecretKey, upload.single('file'), uploadFile); -// DELETE SINGLE FILE -router.delete('/file', verifyApiKey, deleteFile); +// DELETE REQ SINGLE FILE +router.delete('/file', verifyApiKey, projectRateLimiter, requireSecretKey, deleteFile); -// DELETE ALL FILES -router.delete('/all', verifyApiKey, deleteAllFiles); +// DELETE REQ ALL FILES +router.delete('/all', verifyApiKey, projectRateLimiter, requireSecretKey, deleteAllFiles); module.exports = router; \ No newline at end of file diff --git a/backend/utils/api.js b/backend/utils/api.js index 3b700c311..735cc2689 100644 --- a/backend/utils/api.js +++ b/backend/utils/api.js @@ -1,6 +1,6 @@ const crypto = require('crypto'); -function generateApiKey() { +function generateApiKey(prefix = 'ub_key_') { // OS level cryptographic randomnesss const bytes = crypto.randomBytes(32) @@ -9,7 +9,7 @@ function generateApiKey() { const key = bytes.toString("base64url"); // console.log(key) - return `ub_key_${key}` + return `${prefix}${key}` } diff --git a/frontend/src/components/TryItPanel.jsx b/frontend/src/components/TryItPanel.jsx index a0a80c6b9..2e9633837 100644 --- a/frontend/src/components/TryItPanel.jsx +++ b/frontend/src/components/TryItPanel.jsx @@ -20,7 +20,7 @@ export default function TryItPanel({ endpoint, method = "POST" }) { async function sendRequest() { // Stop if API key is missing if (!apiKey || apiKey.trim() === "") { - setError("You need an API key. Go to Dashboard → Create Project → Copy API key."); + setError("You need an API key. Go to your Project Details to copy your Publishable or Secret key."); return; } @@ -60,7 +60,7 @@ export default function TryItPanel({ endpoint, method = "POST" }) { className="input-field" value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder="YOUR API KEY" + placeholder="pk_live_... OR sk_live_..." /> {endpoint.includes(":") && ( diff --git a/frontend/src/pages/CreateProject.jsx b/frontend/src/pages/CreateProject.jsx index f3041c24e..ca2f8f611 100644 --- a/frontend/src/pages/CreateProject.jsx +++ b/frontend/src/pages/CreateProject.jsx @@ -94,38 +94,34 @@ function CreateProject() {
- Save this API Key immediately + Save these API Keys immediately

- For security reasons, this key will only be shown once. If you lose it, you will need to regenerate it, which may interrupt your application. + For security reasons, these keys will only be shown once. If you lose them, you will need to regenerate them.

+
+ +
+
+ {newProject.publishableKey} +
+ +
+
+
- -
-
- {newProject.apiKey} + +
+
+ {newProject.secretKey}
- +
diff --git a/frontend/src/pages/ProjectDetails.jsx b/frontend/src/pages/ProjectDetails.jsx index 45d640e7f..4c5c6ad7a 100644 --- a/frontend/src/pages/ProjectDetails.jsx +++ b/frontend/src/pages/ProjectDetails.jsx @@ -35,16 +35,16 @@ function ProjectDetails() { fetchProject(); }, [projectId, token]); - const handleRegenerateKey = async () => { - if (!window.confirm("Are you sure? The old key will stop working immediately.")) return; + const handleRegenerateKey = async (keyType) => { + if (!window.confirm(`Are you sure you want to roll your ${keyType} key? The old key will stop working immediately.`)) return; setIsRegenerating(true); try { - const res = await axios.patch(`${API_URL}/api/projects/${projectId}/regenerate-key`, {}, { + const res = await axios.patch(`${API_URL}/api/projects/${projectId}/regenerate-key`, { keyType }, { headers: { Authorization: `Bearer ${token}` } }); - setNewKey(res.data.apiKey); - toast.success("New API Key Generated!"); + setNewKey({ key: res.data.apiKey, type: keyType }); + toast.success(`New ${keyType === 'publishable' ? 'Publishable' : 'Secret'} API Key Generated!`); } catch { toast.error("Failed to regenerate key"); } finally { @@ -76,12 +76,12 @@ function ProjectDetails() { display: 'flex', justifyContent: 'center', alignItems: 'center', backdropFilter: 'blur(5px)' }}> -
+
-
+
-

New API Key Generated

+

New {newKey.type === 'publishable' ? 'Publishable' : 'Secret'} Key Generated

Please copy this key immediately. For security reasons, it will not be displayed again.

@@ -93,15 +93,15 @@ function ProjectDetails() { background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '8px', - color: '#3ECF8E', + color: newKey.type === 'secret' ? '#ef4444' : '#3ECF8E', fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '0.9rem' }}> - {newKey} + {newKey.key}
+
+
+ + +
+
+
+ +
+

Safe for frontend use (React, Next.js). Read-only access.

+
+
-
-
-
- -
- +
+
- -
- - - This key grants full write access. Keep it secure in your backend environment (.env). - +
+ + Grants full write/delete access. Keep secure in your backend (.env).
diff --git a/frontend/src/pages/ProjectSettings.jsx b/frontend/src/pages/ProjectSettings.jsx index 386bf4f42..d78c8acfe 100644 --- a/frontend/src/pages/ProjectSettings.jsx +++ b/frontend/src/pages/ProjectSettings.jsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import axios from "axios"; import { useAuth } from "../context/AuthContext"; import toast from "react-hot-toast"; -import { Trash2, AlertTriangle, Save, CheckCircle, Copy, Server } from "lucide-react"; +import { Trash2, AlertTriangle, Save, CheckCircle, Copy, Server, Globe, Plus, X } from "lucide-react"; import { API_URL } from "../config"; import ConfirmationModal from "./ConfirmationModal"; @@ -183,7 +183,13 @@ export default function ProjectSettings() {
{/* External Configuration */} -
+
+ ); } + +function AllowedDomainsForm({ project, projectId, token, onProjectUpdate }) { + const [domains, setDomains] = useState(project?.allowedDomains || []); + const [newDomain, setNewDomain] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (project?.allowedDomains) { + setDomains(project.allowedDomains); + } + }, [project]); + + const handleUpdate = async (updatedDomains) => { + setLoading(true); + try { + const res = await axios.patch( + `${API_URL}/api/projects/${projectId}/allowed-domains`, + { domains: updatedDomains }, + { headers: { Authorization: `Bearer ${token}` } } + ); + toast.success("Allowed domains updated!"); + setDomains(updatedDomains); + onProjectUpdate((prev) => ({ ...prev, allowedDomains: updatedDomains })); + } catch (err) { + toast.error(err.response?.data?.error || "Failed to update allowed domains"); + } finally { + setLoading(false); + } + }; + + const addDomain = () => { + const domain = newDomain.trim(); + if (!domain) return; + + // basic validation + if (domains.includes(domain)) { + return toast.error("Domain already added"); + } + + const updated = [...domains, domain]; + handleUpdate(updated); + setNewDomain(""); + }; + + const removeDomain = (domainToRemove) => { + const updated = domains.filter((d) => d !== domainToRemove); + handleUpdate(updated); + }; + + return ( +
+
+ +
+

+ Allowed Domains (CORS) +

+

+ Restrict which websites can use your Publishable API Key. + Use * to allow all domains, or specify domains like https://example.com or *.example.com. +

+
+ +
+
+ setNewDomain(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addDomain(); + } + }} + style={{ + flex: 1, + padding: "10px 12px", + background: "var(--color-bg-input)", + border: "1px solid var(--color-border)", + borderRadius: "8px", + color: "#fff", + }} + /> + +
+ + {domains.length === 0 ? ( +
+ No domains configured. Your publishable key won't work on the web until you add * or specific domains. +
+ ) : ( +
    + {domains.map((domain) => ( +
  • +
    + {domain === "*" ? ( + + ALLOW ALL (*) + + ) : ( + {domain} + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index 67543120a..72521621a 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -34,9 +34,7 @@ function Signup() { toast.dismiss(loadingToast); toast.success(response.data.message); - await axios.post(`${API_URL}/api/auth/send-otp`, { email: formData.email }); - - toast.success("OTP Sent! Please verify your email."); + // Redirect to OTP verification page, which automatically sends the OTP navigate('/verify-otp', { state: { email: formData.email } }); } catch (err) { From e2ddf054f7419bd8cb302e3ff7efef8f2f7e4b7d Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 4 Mar 2026 18:52:00 +0530 Subject: [PATCH 02/11] feat: implement public changelog and admin release system with BullMQ mass email queue --- backend/app.js | 2 + backend/controllers/auth.controller.js | 2 +- backend/controllers/release.controller.js | 58 +++++ backend/models/Release.js | 24 ++ backend/package-lock.json | 242 ++++++++++++++++++++- backend/package.json | 5 +- backend/queues/emailQueue.js | 40 ++++ backend/routes/releases.js | 12 + backend/utils/emailService.js | 137 ++++++++---- frontend/src/App.jsx | 16 ++ frontend/src/components/Layout/Sidebar.jsx | 5 +- frontend/src/pages/AdminCreateRelease.jsx | 124 +++++++++++ frontend/src/pages/Releases.jsx | 117 ++++++++++ 13 files changed, 722 insertions(+), 62 deletions(-) create mode 100644 backend/controllers/release.controller.js create mode 100644 backend/models/Release.js create mode 100644 backend/queues/emailQueue.js create mode 100644 backend/routes/releases.js create mode 100644 frontend/src/pages/AdminCreateRelease.jsx create mode 100644 frontend/src/pages/Releases.jsx diff --git a/backend/app.js b/backend/app.js index 447a2c321..cbc3590f0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -75,6 +75,7 @@ const dataRoute = require('./routes/data'); const userAuthRoute = require('./routes/userAuth'); const storageRoute = require('./routes/storage'); const schemaRoute = require('./routes/schemas'); +const releaseRoute = require('./routes/releases'); // ROUTES SETUP app.use('/api/auth/login', authLimiter); // Strict limiter on login @@ -85,6 +86,7 @@ app.use('/api/userAuth', limiter, logger, userAuthRoute); app.use('/api/data', limiter, cors(adminCorsOptions), logger, dataRoute); app.use('/api/schemas', limiter, cors(adminCorsOptions), logger, schemaRoute); app.use('/api/storage', limiter, cors(adminCorsOptions), logger, storageRoute); +app.use('/api/releases', releaseRoute); app.get('/api/server-ip', async (req, res) => { const ip = await getPublicIp(); diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 5ae7f2296..d47756ab7 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -4,7 +4,7 @@ const Project = require("../models/Project") const bcrypt = require("bcryptjs"); const z = require("zod"); const jwt = require("jsonwebtoken"); -const sendOtp = require("../utils/emailService"); +const { sendOtp } = require("../utils/emailService"); const crypto = require("crypto"); const { loginSchema, diff --git a/backend/controllers/release.controller.js b/backend/controllers/release.controller.js new file mode 100644 index 000000000..cba35ce25 --- /dev/null +++ b/backend/controllers/release.controller.js @@ -0,0 +1,58 @@ +const Release = require("../models/Release"); +const Developer = require("../models/Developer"); +const { emailQueue } = require("../queues/emailQueue"); + +const ADMIN_EMAIL = 'yashpouranik124@gmail.com'; + +// GET ALL RELEASES +exports.getAllReleases = async (req, res) => { + try { + const releases = await Release.find().sort({ createdAt: -1 }); + res.json(releases); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// CREATE RELEASE (Admin Only) +exports.createRelease = async (req, res) => { + try { + const { version, title, content } = req.body; + + // Verify Admin + if (req.user.email !== ADMIN_EMAIL) { + return res.status(403).json({ error: "Access denied. Admin only." }); + } + + if (!version || !title || !content) { + return res.status(400).json({ error: "Missing version, title, or content" }); + } + + const newRelease = new Release({ version, title, content }); + await newRelease.save(); + + // 1. Fetch all verified developers + const developers = await Developer.find({ isVerified: true }, 'email'); + const emails = developers.map(dev => dev.email); + + // 2. Queue emails + emails.forEach(email => { + emailQueue.add('release-email', { + email, + version, + title, + content + }); + }); + + res.status(201).json({ + message: "Release published! Emails queued.", + count: emails.length + }); + + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal Server Error" }); + } +}; diff --git a/backend/models/Release.js b/backend/models/Release.js new file mode 100644 index 000000000..ea60363c7 --- /dev/null +++ b/backend/models/Release.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +const ReleaseSchema = new mongoose.Schema({ + version: { + type: String, + required: true, + trim: true + }, + title: { + type: String, + required: true, + trim: true + }, + content: { + type: String, + required: true + }, + publishedBy: { + type: String, + default: 'yashpouranik124@gmail.com' + } +}, { timestamps: true }); + +module.exports = mongoose.model('Release', ReleaseSchema); diff --git a/backend/package-lock.json b/backend/package-lock.json index aa1f1d206..91ab395de 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,11 +11,12 @@ "dependencies": { "@supabase/supabase-js": "^2.84.0", "bcryptjs": "^3.0.2", + "bullmq": "^5.70.1", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.2.1", - "ioredis": "^5.9.2", + "ioredis": "^5.10.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.19.2", "multer": "^2.0.2", @@ -587,9 +588,9 @@ "license": "MIT" }, "node_modules/@ioredis/commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", - "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, "node_modules/@isaacs/cliui": { @@ -1038,6 +1039,84 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1967,6 +2046,64 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.70.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.70.1.tgz", + "integrity": "sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.3", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2343,6 +2480,18 @@ "node": ">= 0.10" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -2446,6 +2595,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3252,12 +3411,12 @@ "license": "ISC" }, "node_modules/ioredis": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", - "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.0", + "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", @@ -4236,6 +4395,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4525,6 +4693,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -4618,6 +4817,27 @@ "node": ">= 0.6" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5182,9 +5402,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/backend/package.json b/backend/package.json index 4c3baf6c4..837a86f36 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,11 +12,12 @@ "dependencies": { "@supabase/supabase-js": "^2.84.0", "bcryptjs": "^3.0.2", + "bullmq": "^5.70.1", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.2.1", - "ioredis": "^5.9.2", + "ioredis": "^5.10.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.19.2", "multer": "^2.0.2", @@ -29,4 +30,4 @@ "jest": "^30.2.0", "supertest": "^7.1.4" } -} \ No newline at end of file +} diff --git a/backend/queues/emailQueue.js b/backend/queues/emailQueue.js new file mode 100644 index 000000000..09318f1d1 --- /dev/null +++ b/backend/queues/emailQueue.js @@ -0,0 +1,40 @@ +const { Queue, Worker } = require('bullmq'); +const IORedis = require('ioredis'); +const { sendReleaseEmail } = require('../utils/emailService'); + +const connection = new IORedis({ + host: process.env.REDIS_HOST || '127.0.0.1', + port: process.env.REDIS_PORT || 6379, + maxRetriesPerRequest: null, +}); + +// Create the email queue +const emailQueue = new Queue('email-queue', { connection }); + +// Initialize Worker with Rate Limiting +const worker = new Worker('email-queue', async (job) => { + const { email, version, title, content } = job.data; + try { + console.log(`[Queue] Processing Release email for: ${email}`); + await sendReleaseEmail(email, { version, title, content }); + } catch (error) { + console.error(`[Queue] Failed to send email to ${email}:`, error); + throw error; + } +}, { + connection, + limiter: { + max: 1, + duration: 900000, // 1 job per 15 minutes (96 per 24 hours) - safe for 100 limit + } +}); + +worker.on('completed', (job) => { + console.log(`[Queue] Job ${job.id} completed successfully`); +}); + +worker.on('failed', (job, err) => { + console.error(`[Queue] Job ${job.id} failed:`, err); +}); + +module.exports = { emailQueue }; diff --git a/backend/routes/releases.js b/backend/routes/releases.js new file mode 100644 index 000000000..dd62156a9 --- /dev/null +++ b/backend/routes/releases.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const authorization = require('../middleware/authMiddleware'); +const { getAllReleases, createRelease } = require('../controllers/release.controller'); + +// GET ALL RELEASES (Public) +router.get('/', getAllReleases); + +// CREATE RELEASE (Admin Only) +router.post('/', authorization, createRelease); + +module.exports = router; diff --git a/backend/utils/emailService.js b/backend/utils/emailService.js index 73a88a93f..e38ebcc78 100644 --- a/backend/utils/emailService.js +++ b/backend/utils/emailService.js @@ -3,65 +3,108 @@ const { Resend } = require('resend'); const dotenv = require('dotenv'); dotenv.config(); -const resend = new Resend(process.env.RESEND_API_KEY || 're_dummy_key_for_testing'); +const resend = new Resend(process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY || 're_dummy_key_for_testing'); -async function sendOtp(email, otp, {subject = "Verify your urBackend account"}) { +async function sendOtp(email, otp, { subject = "Verify your urBackend account", customContent = null } = {}) { try { + const htmlContent = customContent || ` + + + + + + +
+ +

Verify your account

+
+ Use the following code to complete your verification process. This code will expire in 5 minutes. +
+
${otp}
+
+ If you didn't request this code, you can safely ignore this email. +
+ +
+ + + `; + const { data, error } = await resend.emails.send({ - from: 'urBackend ', + from: 'urBackend ', to: email, subject: subject, - html: ` -
-
- -

- Verify your account -

+ html: htmlContent, + replyTo: 'urbackend@apps.bitbros.in', + }); -

- Welcome to urBackend. Use the OTP below to complete your verification. -

+ if (error) console.error("[Resend Error]", error); + return { data, error }; + } catch (error) { + console.error("[Email Service Error]", error); + return { error }; + } +} -
- - ${otp} - +async function sendReleaseEmail(email, { version, title, content }) { + try { + const htmlContent = ` + + + + + + +
+ +
New Release ${version}
+

${title}

+
${content}
+ Read the full changelog + - -

- This OTP is valid for 5 minutes. Do not share it with anyone. -

- -
- -

- If you didn’t request this, you can safely ignore this email. -

- -

- © ${new Date().getFullYear()} urBackend -

-
- `, - replyTo: 'urbackend@bitbros.in', + + + `; + + const { data, error } = await resend.emails.send({ + from: 'urBackend ', + to: email, + subject: `Release: ${version} - ${title}`, + html: htmlContent, + replyTo: 'urbackend@apps.bitbros.in', }); - console.log(data); - console.log(error); + if (error) console.error("[Resend Error]", error); + return { data, error }; } catch (error) { - console.log(error); + console.error("[Release Email Error]", error); + return { error }; } } -module.exports = sendOtp; \ No newline at end of file +module.exports = { sendOtp, sendReleaseEmail }; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9a148a6b5..15e945fbd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ import CreateProject from './pages/CreateProject'; import CreateCollection from './pages/CreateCollection'; import NotFound from './pages/NotFound'; import Analytics from './pages/Analytics'; +import Releases from './pages/Releases'; +import AdminCreateRelease from './pages/AdminCreateRelease'; import Database from './pages/Database'; import Storage from './pages/Storage'; @@ -107,6 +109,20 @@ function App() { } /> + + + + } /> + + + + + + + } /> + } /> diff --git a/frontend/src/components/Layout/Sidebar.jsx b/frontend/src/components/Layout/Sidebar.jsx index eba7252ea..2b8e3b79b 100644 --- a/frontend/src/components/Layout/Sidebar.jsx +++ b/frontend/src/components/Layout/Sidebar.jsx @@ -2,7 +2,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { LayoutDashboard, Database, Shield, HardDrive, Settings, BarChart2, - ArrowLeft, FileText, UserCog, LogOut, X // Import X for close + ArrowLeft, FileText, UserCog, LogOut, X, Rocket // Import Rocket } from 'lucide-react'; function Sidebar({ logo, isOpen, onClose }) { // Props received @@ -76,6 +76,9 @@ function Sidebar({ logo, isOpen, onClose }) { // Props received Account Settings + + Changelog + )} diff --git a/frontend/src/pages/AdminCreateRelease.jsx b/frontend/src/pages/AdminCreateRelease.jsx new file mode 100644 index 000000000..4bdfcc7df --- /dev/null +++ b/frontend/src/pages/AdminCreateRelease.jsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { API_URL } from '../config'; +import { useAuth } from '../context/AuthContext'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { Send, ArrowLeft, AlertCircle } from 'lucide-react'; + +export default function AdminCreateRelease() { + const { user, token } = useAuth(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + version: '', + title: '', + content: '' + }); + + const isAdmin = user?.email === 'yashpouranik124@gmail.com'; + + useEffect(() => { + if (!isAdmin) { + toast.error("Unauthorized access."); + navigate('/releases'); + } + }, [isAdmin, navigate]); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!formData.version || !formData.title || !formData.content) { + return toast.error("Please fill all fields."); + } + + setLoading(true); + const loadToast = toast.loading("Publishing release and queuing emails..."); + + try { + const res = await axios.post(`${API_URL}/api/releases`, formData, { + headers: { Authorization: `Bearer ${token}` } + }); + toast.dismiss(loadToast); + toast.success(res.data.message); + navigate('/releases'); + } catch (err) { + toast.dismiss(loadToast); + toast.error(err.response?.data?.error || "Failed to publish release."); + } finally { + setLoading(false); + } + }; + + if (!isAdmin) return null; + + return ( +
+ + +

Create New Release

+

+ This will be visible on the public changelog and sent to all verified users. +

+ +
+ +

+ Warning: Emails will be queued immediately upon submission. + Due to rate limits, it may take several hours to reach all users. +

+
+ +
+
+ + setFormData({ ...formData, version: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, title: e.target.value })} + required + /> +
+ +
+ +