diff --git a/components/polls/index.js b/components/polls/index.js index 98139b1..b0f11a0 100644 --- a/components/polls/index.js +++ b/components/polls/index.js @@ -1,7 +1,7 @@ const md5 = require('md5'); const mongoose = require("mongoose"); -const { getPkhfromPk } = require("@taquito/utils"); +const { getPkhfromPk, validateAddress, ValidationResult } = require("@taquito/utils"); const { getInputFromSigPayload, getCurrentBlock, @@ -19,6 +19,39 @@ const ChoiceModel = require("../../db/models/Choice.model"); const { getEthCurrentBlockNumber, getEthTotalSupply } = require("../../utils-eth"); +/** + * Validate the optional funding request carried inside a signed poll payload. + * Returns undefined when nothing was requested, throws when the shape is wrong. + * + * @param {{recipient: string, amount: string}} [fundingRequest] + * @returns {{recipient: string, amount: string}|undefined} + */ +function validateFundingRequest(fundingRequest) { + if (fundingRequest === undefined || fundingRequest === null) { + return undefined; + } + + if (typeof fundingRequest !== "object" || Array.isArray(fundingRequest)) { + throw new Error("Invalid funding request"); + } + + const { recipient, amount } = fundingRequest; + + if (typeof recipient !== "string" || validateAddress(recipient) !== ValidationResult.VALID) { + throw new Error("Invalid funding request recipient"); + } + + // Decimal string with at most 6 decimals, strictly greater than zero. + if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) { + throw new Error("Invalid funding request amount"); + } + if (Number(amount) <= 0) { + throw new Error("Invalid funding request amount"); + } + + return { recipient, amount: amount.trim() }; +} + function validateExternalLink(externalLink) { if (!externalLink || typeof externalLink !== 'string') { return ''; @@ -181,6 +214,7 @@ const addPoll = async (req, response) => { isXTZ, } = payload; const daoID = payload?.daoID || payload?.daoId; + const fundingRequest = validateFundingRequest(payload?.fundingRequest); console.log("Payload", payload) if (choices.length === 0) { throw new Error("No choices sent in the request"); @@ -238,6 +272,7 @@ const addPoll = async (req, response) => { payloadBytes, payloadBytesHash, cidLink: "", + ...(fundingRequest ? { fundingRequest } : {}), }; const createdPoll = await PollModel.create(PollData); @@ -305,6 +340,8 @@ const addPoll = async (req, response) => { isXTZ, } = values; + const fundingRequest = validateFundingRequest(values?.fundingRequest); + const author = getPkhfromPk(publicKey); const currentTime = new Date().valueOf(); @@ -387,6 +424,7 @@ const addPoll = async (req, response) => { payloadBytes, signature, cidLink: "", + ...(fundingRequest ? { fundingRequest } : {}), }; const createdPoll = await PollModel.create([PollData], { session }); @@ -433,8 +471,97 @@ const addPoll = async (req, response) => { } }; +/** + * POST /polls/:id/link-proposal + * + * Records the on-chain proposal a lite poll was promoted to. The request is + * signed the same way poll creation and voting are, so requireSignature has + * already verified the signature by the time we get here; what is left is to + * check that the signer is allowed to link this particular poll. + * + * Authorized signers: the poll author, or the creator/admin of the poll's + * community. The DAO model has no dedicated admin field; the creator is the + * first entry of members (see createDAO, which seeds members with the + * creator's address), so that entry is treated as the admin. + */ +const linkProposal = async (req, response) => { + const { payloadBytes, publicKey } = req.body; + const network = req.body.network; + const { id } = req.params; + + try { + let payload = req.payloadObj; + if (!payload) { + payload = getInputFromSigPayload(payloadBytes); + } + + const { daoAddress, proposalKey } = payload || {}; + const proposalNetwork = payload?.network || network; + + if (typeof daoAddress !== "string" || validateAddress(daoAddress) !== ValidationResult.VALID) { + throw new Error("Invalid DAO address"); + } + if (typeof proposalKey !== "string" || !proposalKey.trim()) { + throw new Error("Invalid proposal key"); + } + if (typeof proposalNetwork !== "string" || !proposalNetwork.trim()) { + throw new Error("Invalid network"); + } + + if (!mongoose.isValidObjectId(id)) { + return response.status(404).send({ message: "Poll not found" }); + } + + const poll = await PollModel.findById(id); + if (!poll) { + return response.status(404).send({ message: "Poll not found" }); + } + + // Etherlink signs with the address itself, Tezos with the public key. + const signer = network?.startsWith("etherlink") + ? publicKey + : getPkhfromPk(publicKey); + + const isAuthor = + typeof poll.author === "string" && + poll.author.toLowerCase() === String(signer).toLowerCase(); + + let isDaoAdmin = false; + if (!isAuthor) { + const daoQuery = mongoose.isValidObjectId(poll.daoID) + ? { _id: poll.daoID } + : { address: { $regex: new RegExp(`^${poll.daoID}$`, "i") } }; + const dao = await DaoModel.findOne(daoQuery).lean(); + // members[0] is the DAO creator, treated as the community admin. + const admin = dao?.members?.[0]; + isDaoAdmin = + Boolean(admin) && String(admin).toLowerCase() === String(signer).toLowerCase(); + } + + if (!isAuthor && !isDaoAdmin) { + return response.status(403).send({ message: "Not authorized to link this poll" }); + } + + poll.onchainProposal = { + daoAddress, + proposalKey: proposalKey.trim(), + network: proposalNetwork.trim(), + }; + await poll.save(); + + return response.status(200).json({ ok: true }); + } catch (error) { + console.log("error: ", error); + return response.status(400).send({ + message: error.message, + }); + } +}; + module.exports = { getPollById, getPollsById, addPoll, + linkProposal, + validateFundingRequest, }; diff --git a/components/subscriptions/index.js b/components/subscriptions/index.js new file mode 100644 index 0000000..c9e6a16 --- /dev/null +++ b/components/subscriptions/index.js @@ -0,0 +1,265 @@ +const crypto = require("crypto"); + +const { validateAddress, ValidationResult } = require("@taquito/utils"); + +const SubscriptionModel = require("../../db/models/Subscription.model"); +const { + sendMail, + getAppUrl, + getPublicApiUrl, +} = require("../../services/mailer.service"); + +// Networks the app supports for Tezos DAO alerts. +const SUPPORTED_NETWORKS = ["mainnet", "ghostnet", "shadownet"]; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/; + +const RATE_LIMIT_MAX = 5; +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; + +/** + * Per-email throttle for subscription requests. + * + * NOTE: this map lives in the memory of a single serverless container, so it + * only throttles requests that happen to reach the same warm instance and it + * is reset on every cold start. It is a cheap first line of defence against + * accidental loops and casual abuse, not a real rate limiter. A durable + * limiter would need a shared store (Mongo TTL collection or Redis). + */ +const rateLimitBuckets = new Map(); + +const isRateLimited = (email) => { + const now = Date.now(); + const hits = (rateLimitBuckets.get(email) || []).filter( + (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS + ); + + if (hits.length >= RATE_LIMIT_MAX) { + rateLimitBuckets.set(email, hits); + return true; + } + + hits.push(now); + rateLimitBuckets.set(email, hits); + + // Opportunistic cleanup so the map does not grow without bound. + if (rateLimitBuckets.size > 5000) { + for (const [key, timestamps] of rateLimitBuckets) { + if (timestamps.every((timestamp) => now - timestamp >= RATE_LIMIT_WINDOW_MS)) { + rateLimitBuckets.delete(key); + } + } + } + + return false; +}; + +const resetRateLimit = () => rateLimitBuckets.clear(); + +const generateToken = () => crypto.randomBytes(32).toString("hex"); + +const isValidEmail = (email) => + typeof email === "string" && email.length <= 254 && EMAIL_REGEX.test(email.trim()); + +const isValidContractAddress = (address) => + typeof address === "string" && + address.startsWith("KT1") && + validateAddress(address) === ValidationResult.VALID; + +const isSupportedNetwork = (network) => + typeof network === "string" && SUPPORTED_NETWORKS.includes(network.toLowerCase()); + +const unsubscribeUrl = (token) => + `${getPublicApiUrl()}/subscriptions/unsubscribe/${token}`; + +const confirmUrl = (token) => + `${getPublicApiUrl()}/subscriptions/confirm/${token}`; + +const escapeHtml = (value) => + String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +// Optional display name sent by the frontend; plain text only, kept short. +const cleanDaoName = (value) => + typeof value === "string" + ? value.replace(/[\r\n\t<>]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80) + : ""; + +const buildConfirmationEmail = ({ daoName, daoAddress, network, confirmToken, unsubscribeToken }) => { + const confirmLink = confirmUrl(confirmToken); + const unsubLink = unsubscribeUrl(unsubscribeToken); + const daoLabel = daoName ? `${daoName} (${daoAddress})` : daoAddress; + const explanation = `You asked to receive Homebase alerts for the DAO ${daoLabel} on ${network}.`; + + const text = [ + explanation, + "", + `Confirm your subscription: ${confirmLink}`, + "", + "If you did not request this, ignore this email and nothing will be sent.", + `Unsubscribe: ${unsubLink}`, + ].join("\n"); + + const html = [ + `

${escapeHtml(explanation)}

`, + `

Confirm your subscription

`, + "

If you did not request this, ignore this email and nothing will be sent.

", + `

Unsubscribe

`, + ].join("\n"); + + return { + // Timestamp keeps each confirmation in its own mail thread; Gmail otherwise + // groups same-subject emails and shows the oldest (possibly dead) link first. + subject: `Confirm Homebase alerts for ${daoName || daoAddress} (${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC)`, + text, + html, + headers: { + "List-Unsubscribe": `<${unsubLink}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }; +}; + +/** + * POST /subscriptions + * Always answers 200 { ok: true } for well formed input so that the endpoint + * cannot be used to probe whether an address is already subscribed. + */ +const createSubscription = async (req, response) => { + const { email, daoAddress, network } = req.body || {}; + const daoName = cleanDaoName((req.body || {}).daoName); + + if (!isValidEmail(email)) { + return response.status(400).json({ message: "Invalid email address" }); + } + if (!isValidContractAddress(daoAddress)) { + return response.status(400).json({ message: "Invalid DAO contract address" }); + } + if (!isSupportedNetwork(network)) { + return response.status(400).json({ message: "Invalid network" }); + } + + const normalizedEmail = email.trim().toLowerCase(); + const normalizedNetwork = network.toLowerCase(); + + if (isRateLimited(normalizedEmail)) { + return response.status(429).json({ message: "Too many subscription requests" }); + } + + try { + let subscription = await SubscriptionModel.findOne({ + email: normalizedEmail, + daoAddress, + network: normalizedNetwork, + }); + + if (!subscription) { + subscription = await SubscriptionModel.create({ + email: normalizedEmail, + daoAddress, + network: normalizedNetwork, + confirmed: false, + confirmToken: generateToken(), + unsubscribeToken: generateToken(), + }); + } else if (!subscription.confirmed) { + // Keep the existing confirmation token: mail clients thread repeated + // confirmation emails together and often surface the older one, so a + // rotated token would turn the visible link into a dead one. + if (!subscription.confirmToken) { + subscription.confirmToken = generateToken(); + } + if (!subscription.unsubscribeToken) { + subscription.unsubscribeToken = generateToken(); + } + if (subscription.isModified()) { + await subscription.save(); + } + } + + if (!subscription.confirmed) { + const mail = buildConfirmationEmail({ + daoName, + daoAddress: subscription.daoAddress, + network: subscription.network, + confirmToken: subscription.confirmToken, + unsubscribeToken: subscription.unsubscribeToken, + }); + await sendMail({ to: subscription.email, ...mail }); + } + + return response.status(200).json({ ok: true }); + } catch (error) { + console.log("error: ", error); + return response.status(400).send({ + message: error.message, + }); + } +}; + +/** + * GET /subscriptions/confirm/:token + */ +const confirmSubscription = async (req, response) => { + const { token } = req.params; + + try { + const subscription = await SubscriptionModel.findOne({ confirmToken: token }); + + if (!subscription) { + return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`); + } + + if (!subscription.confirmed) { + subscription.confirmed = true; + subscription.confirmedAt = new Date(); + await subscription.save(); + } + + return response.redirect( + 302, + `${getAppUrl()}/explorer/dao/${subscription.daoAddress}?alerts=confirmed` + ); + } catch (error) { + console.log("error: ", error); + return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`); + } +}; + +/** + * GET /subscriptions/unsubscribe/:token + */ +const unsubscribeSubscription = async (req, response) => { + const { token } = req.params; + + try { + const subscription = await SubscriptionModel.findOneAndDelete({ + unsubscribeToken: token, + }); + + if (!subscription) { + return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`); + } + + return response.redirect( + 302, + `${getAppUrl()}/explorer/dao/${subscription.daoAddress}?alerts=unsubscribed` + ); + } catch (error) { + console.log("error: ", error); + return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`); + } +}; + +module.exports = { + createSubscription, + confirmSubscription, + unsubscribeSubscription, + SUPPORTED_NETWORKS, + unsubscribeUrl, + confirmUrl, + resetRateLimit, +}; diff --git a/config.env.example b/config.env.example index d013676..c535c42 100644 --- a/config.env.example +++ b/config.env.example @@ -1,3 +1,14 @@ ATLAS_URI=#YOUR_MONGODB_URI PORT=#YOUR_NONDEFAULT_APP_PORT -NFT_STORAGE_KEY=XXXXXX #TokenFrom Nft.Storage \ No newline at end of file +NFT_STORAGE_KEY=XXXXXX #TokenFrom Nft.Storage + +# Email alerts (DAO subscriptions). Leave SMTP_HOST empty to disable sending. +SMTP_HOST=#smtp.example.com +SMTP_PORT=587 #465 for implicit TLS, anything else uses STARTTLS +SMTP_USER=#YOUR_SMTP_USERNAME +SMTP_PASS=#YOUR_SMTP_PASSWORD +MAIL_FROM=Homebase Alerts + +# Public URLs used to build links inside outgoing mail +APP_URL=https://tezos-homebase.io +PUBLIC_API_URL=https://homebase-backend.netlify.app diff --git a/db/models/Poll.model.js b/db/models/Poll.model.js index ea2cb66..6f90083 100644 --- a/db/models/Poll.model.js +++ b/db/models/Poll.model.js @@ -60,6 +60,39 @@ const PollModelSchema = new Schema({ type: String, index: true, sparse: true, + }, + // Optional treasury transfer this poll is asking for. + fundingRequest: { + type: new Schema({ + recipient: { + type: String, + required: true, + }, + // Decimal string, at most 6 decimal places + amount: { + type: String, + required: true, + }, + }, { _id: false }), + default: undefined, + }, + // Set once the poll has been promoted to an on-chain DAO proposal. + onchainProposal: { + type: new Schema({ + daoAddress: { + type: String, + required: true, + }, + proposalKey: { + type: String, + required: true, + }, + network: { + type: String, + required: true, + }, + }, { _id: false }), + default: undefined, } },{ timestamps: true, diff --git a/db/models/Subscription.model.js b/db/models/Subscription.model.js new file mode 100644 index 0000000..51b68ed --- /dev/null +++ b/db/models/Subscription.model.js @@ -0,0 +1,56 @@ +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const SubscriptionModelSchema = new Schema({ + email: { + type: String, + required: true, + lowercase: true, + trim: true, + }, + daoAddress: { + type: String, + required: true, + }, + network: { + type: String, + required: true, + }, + confirmed: { + type: Boolean, + default: false, + }, + confirmToken: { + type: String, + index: true, + sparse: true, + }, + unsubscribeToken: { + type: String, + index: true, + sparse: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, + confirmedAt: { + type: Date, + default: null, + }, +}); + +// One subscription per (email, DAO, network) triple. +SubscriptionModelSchema.index( + { email: 1, daoAddress: 1, network: 1 }, + { unique: true } +); + +const SubscriptionModel = mongoose.model( + 'Subscription', + SubscriptionModelSchema, + 'Subscriptions' +); + +module.exports = SubscriptionModel; diff --git a/package-lock.json b/package-lock.json index 4e400cd..3b101f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "mongoose": "^8.5.2", "nanoid": "^3.3.7", "nft.storage": "^7.1.1", + "nodemailer": "^10.0.9", "persistent-cache": "^1.1.2", "serverless-http": "^3.2.0", "swagger-jsdoc": "^6.2.8", @@ -6860,6 +6861,15 @@ "node.extend": "1.0.8" } }, + "node_modules/nodemailer": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.9.tgz", + "integrity": "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", diff --git a/package.json b/package.json index e365f28..9c01703 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "mongoose": "^8.5.2", "nanoid": "^3.3.7", "nft.storage": "^7.1.1", + "nodemailer": "^10.0.9", "persistent-cache": "^1.1.2", "serverless-http": "^3.2.0", "swagger-jsdoc": "^6.2.8", diff --git a/routes/polls.js b/routes/polls.js index 22d0e83..06e89d2 100644 --- a/routes/polls.js +++ b/routes/polls.js @@ -6,7 +6,7 @@ const express = require("express"); const pollsRoutes = express.Router(); const { requireSignature } = require("../middlewares"); -const { getPollsById, getPollById, addPoll } = require("../components/polls"); +const { getPollsById, getPollById, addPoll, linkProposal } = require("../components/polls"); /** * @swagger @@ -71,5 +71,43 @@ pollsRoutes.route("/polls/:id/list").get(getPollsById); * description: Invalid signature payload */ pollsRoutes.route("/poll/add").all(requireSignature).post(addPoll); +/** + * @swagger + * /polls/{id}/link-proposal: + * post: + * summary: Link a poll to the on-chain proposal it was promoted to + * tags: [Polls] + * parameters: + * - in: path + * name: id + * required: true + * description: The ID of the poll + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * payloadBytes: + * type: string + * description: Signed payload holding daoAddress, proposalKey and network + * publicKey: + * type: string + * signature: + * type: string + * responses: + * 200: + * description: Proposal linked successfully + * 400: + * description: Invalid signature payload or proposal details + * 403: + * description: Signer is neither the poll author nor the community admin + * 404: + * description: Poll not found + */ +pollsRoutes.route("/polls/:id/link-proposal").all(requireSignature).post(linkProposal); module.exports = pollsRoutes; diff --git a/routes/polls.test.js b/routes/polls.test.js index 4300e58..8c90215 100644 --- a/routes/polls.test.js +++ b/routes/polls.test.js @@ -1,12 +1,41 @@ const request = require("supertest"); const express = require("express"); +const mongoose = require("mongoose"); +const { char2Bytes } = require("@taquito/utils"); + const pollsRoutes = require("./polls"); +const { linkProposal, validateFundingRequest } = require("../components/polls"); +const PollModel = require("../db/models/Poll.model"); +const DaoModel = require("../db/models/Dao.model"); +const { connectToMongoose } = require("../db/mongoose-connection"); +const { createTestDAO, createTestPoll } = require("../tests/fixtures/test-data"); const app = express(); app.use(express.json()); app.use("/", pollsRoutes); const id = 123; +// Real Tezos addresses, needed because validateAddress verifies the checksum. +const DAO_ADDRESS = "KT1T17GC91HrJ8ijZgnMaE9j4PZbojbVAn73"; +const RECIPIENT = "tz1burnburnburnburnburnburnburjAYjjX"; +const AUTHOR = "tz1TestUser1"; +const DAO_ADMIN = "tz1TestUser1"; + +const signedPayload = (data) => + char2Bytes( + `Tezos Signed Message: homebase.com ${new Date().toISOString()} ${JSON.stringify(data)}` + ); + +/** + * linkProposal is mounted behind requireSignature in the real router, which + * needs a genuine wallet signature. These tests exercise the component + * directly so the authorization rules can be checked without a signer, and + * the route level test below covers the signature gate itself. + */ +const linkProposalApp = express(); +linkProposalApp.use(express.json()); +linkProposalApp.post("/polls/:id/link-proposal", linkProposal); + describe("Polls Routes", () => { it("should not get a poll with an invalid id", async () => { await request(app) @@ -23,4 +52,238 @@ describe("Polls Routes", () => { it("should not add a poll with an invalid signature payload", async () => { await request(app).post(`/poll/add`).send("").expect(500); }); + it("should not link a proposal with an invalid signature payload", async () => { + await request(app).post(`/polls/${id}/link-proposal`).send("").expect(500); + }); +}); + +describe("validateFundingRequest", () => { + it("should return undefined when no funding request is present", () => { + expect(validateFundingRequest(undefined)).toBeUndefined(); + expect(validateFundingRequest(null)).toBeUndefined(); + }); + + it("should accept a valid recipient and amount", () => { + expect(validateFundingRequest({ recipient: RECIPIENT, amount: "12.5" })).toEqual({ + recipient: RECIPIENT, + amount: "12.5", + }); + }); + + it("should accept a KT1 recipient", () => { + expect(validateFundingRequest({ recipient: DAO_ADDRESS, amount: "1" })).toEqual({ + recipient: DAO_ADDRESS, + amount: "1", + }); + }); + + it("should accept exactly six decimals", () => { + expect(validateFundingRequest({ recipient: RECIPIENT, amount: "0.000001" })).toEqual({ + recipient: RECIPIENT, + amount: "0.000001", + }); + }); + + it("should reject an invalid recipient", () => { + expect(() => + validateFundingRequest({ recipient: "not-an-address", amount: "1" }) + ).toThrow("Invalid funding request recipient"); + }); + + it("should reject more than six decimals", () => { + expect(() => + validateFundingRequest({ recipient: RECIPIENT, amount: "0.0000001" }) + ).toThrow("Invalid funding request amount"); + }); + + it("should reject a zero or negative amount", () => { + expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: "0" })).toThrow( + "Invalid funding request amount" + ); + expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: "-5" })).toThrow( + "Invalid funding request amount" + ); + }); + + it("should reject a non string amount", () => { + expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: 5 })).toThrow( + "Invalid funding request amount" + ); + }); +}); + +describe("Poll funding request and proposal linking", () => { + // Jest workers share one in-memory Mongo, so this suite only removes the + // documents it created rather than emptying the collections. + const createdPollIds = []; + const createdDaoIds = []; + + const makeDao = async (overrides) => { + const dao = await DaoModel.create(createTestDAO(overrides)); + createdDaoIds.push(dao._id); + return dao; + }; + + const makePoll = async (daoId, overrides) => { + const poll = await PollModel.create(createTestPoll(daoId, overrides)); + createdPollIds.push(poll._id); + return poll; + }; + + beforeAll(async () => { + await connectToMongoose(); + }); + + afterAll(async () => { + await PollModel.deleteMany({ _id: { $in: createdPollIds } }); + await DaoModel.deleteMany({ _id: { $in: createdDaoIds } }); + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } + }); + + it("should persist a funding request on the poll document", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "100.5" } }); + + const saved = await PollModel.findById(poll._id).lean(); + expect(saved.fundingRequest).toEqual({ recipient: RECIPIENT, amount: "100.5" }); + }); + + it("should return the funding request from GET /polls/:id/polls", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "7" } }); + + const response = await request(app).get(`/polls/${poll._id}/polls`).expect(200); + expect(response.body.fundingRequest).toEqual({ recipient: RECIPIENT, amount: "7" }); + }); + + it("should return the funding request from GET /polls/:id/list", async () => { + const dao = await makeDao(); + await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "7" } }); + + const response = await request(app).get(`/polls/${dao._id}/list`).expect(200); + expect(response.body[0].fundingRequest).toEqual({ recipient: RECIPIENT, amount: "7" }); + }); + + it("should leave fundingRequest unset when no funding is requested", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id); + + const saved = await PollModel.findById(poll._id).lean(); + expect(saved.fundingRequest).toBeUndefined(); + }); + + describe("POST /polls/:id/link-proposal", () => { + const proposalPayload = (overrides = {}) => ({ + daoAddress: DAO_ADDRESS, + proposalKey: "0xproposalkey", + network: "mainnet", + ...overrides, + }); + + it("should link a proposal when the signer is the poll author", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id, { author: AUTHOR }); + + const payload = proposalPayload(); + const response = await request(linkProposalApp) + .post(`/polls/${poll._id}/link-proposal`) + .send({ + payloadBytes: signedPayload(payload), + publicKey: AUTHOR, + network: "etherlink-mainnet", + }) + .expect(200); + + expect(response.body).toEqual({ ok: true }); + + const saved = await PollModel.findById(poll._id).lean(); + expect(saved.onchainProposal).toEqual(payload); + }); + + it("should link a proposal when the signer is the community admin", async () => { + const dao = await makeDao({ members: [DAO_ADMIN, "tz1Other"] }); + const poll = await makePoll(dao._id, { author: "tz1SomebodyElse" }); + + await request(linkProposalApp) + .post(`/polls/${poll._id}/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload()), + publicKey: DAO_ADMIN, + network: "etherlink-mainnet", + }) + .expect(200); + + const saved = await PollModel.findById(poll._id).lean(); + expect(saved.onchainProposal.proposalKey).toBe("0xproposalkey"); + }); + + it("should return 403 when the signer is neither author nor admin", async () => { + const dao = await makeDao({ members: ["tz1Admin"] }); + const poll = await makePoll(dao._id, { author: "tz1SomebodyElse" }); + + await request(linkProposalApp) + .post(`/polls/${poll._id}/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload()), + publicKey: "tz1Intruder", + network: "etherlink-mainnet", + }) + .expect(403); + + const saved = await PollModel.findById(poll._id).lean(); + expect(saved.onchainProposal).toBeUndefined(); + }); + + it("should return 404 for an unknown poll", async () => { + await request(linkProposalApp) + .post(`/polls/${new mongoose.Types.ObjectId()}/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload()), + publicKey: AUTHOR, + network: "etherlink-mainnet", + }) + .expect(404); + }); + + it("should return 404 for a malformed poll id", async () => { + await request(linkProposalApp) + .post(`/polls/not-an-id/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload()), + publicKey: AUTHOR, + network: "etherlink-mainnet", + }) + .expect(404); + }); + + it("should return 400 for an invalid dao address", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id, { author: AUTHOR }); + + await request(linkProposalApp) + .post(`/polls/${poll._id}/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload({ daoAddress: "not-an-address" })), + publicKey: AUTHOR, + network: "etherlink-mainnet", + }) + .expect(400); + }); + + it("should return 400 for a missing proposal key", async () => { + const dao = await makeDao(); + const poll = await makePoll(dao._id, { author: AUTHOR }); + + await request(linkProposalApp) + .post(`/polls/${poll._id}/link-proposal`) + .send({ + payloadBytes: signedPayload(proposalPayload({ proposalKey: " " })), + publicKey: AUTHOR, + network: "etherlink-mainnet", + }) + .expect(400); + }); + }); }); diff --git a/routes/subscriptions.js b/routes/subscriptions.js new file mode 100644 index 0000000..140fee1 --- /dev/null +++ b/routes/subscriptions.js @@ -0,0 +1,86 @@ +const express = require("express"); + +const { + createSubscription, + confirmSubscription, + unsubscribeSubscription, +} = require("../components/subscriptions"); + +// recordRoutes is an instance of the express router. +// We use it to define our routes. +const subscriptionsRoutes = express.Router(); + +/** + * @swagger + * /subscriptions: + * post: + * summary: Subscribe an email address to alerts for a DAO + * tags: [Subscriptions] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, daoAddress, network] + * properties: + * email: + * type: string + * description: Email address to send alerts to + * daoAddress: + * type: string + * description: Tezos contract address of the DAO (KT1...) + * network: + * type: string + * description: Network the DAO lives on (mainnet, ghostnet, shadownet) + * responses: + * 200: + * description: Request accepted, a confirmation email was sent if needed + * 400: + * description: Invalid email, DAO address or network + * 429: + * description: Too many subscription requests for this email + */ +subscriptionsRoutes.route("/subscriptions").post(createSubscription); + +/** + * @swagger + * /subscriptions/confirm/{token}: + * get: + * summary: Confirm an email subscription + * tags: [Subscriptions] + * parameters: + * - in: path + * name: token + * required: true + * description: The confirmation token from the subscription email + * schema: + * type: string + * responses: + * 302: + * description: Redirect to the DAO page, or to the app root for an unknown token + */ +subscriptionsRoutes.route("/subscriptions/confirm/:token").get(confirmSubscription); + +/** + * @swagger + * /subscriptions/unsubscribe/{token}: + * get: + * summary: Remove an email subscription + * tags: [Subscriptions] + * parameters: + * - in: path + * name: token + * required: true + * description: The unsubscribe token from any alert email + * schema: + * type: string + * responses: + * 302: + * description: Redirect to the DAO page, or to the app root for an unknown token + */ +subscriptionsRoutes + .route("/subscriptions/unsubscribe/:token") + .get(unsubscribeSubscription); + +module.exports = subscriptionsRoutes; diff --git a/routes/subscriptions.test.js b/routes/subscriptions.test.js new file mode 100644 index 0000000..374e66a --- /dev/null +++ b/routes/subscriptions.test.js @@ -0,0 +1,217 @@ +const request = require("supertest"); +const express = require("express"); +const mongoose = require("mongoose"); + +jest.mock("../services/mailer.service", () => { + const actual = jest.requireActual("../services/mailer.service"); + return { + ...actual, + sendMail: jest.fn().mockResolvedValue({ skipped: true }), + }; +}); + +const { sendMail } = require("../services/mailer.service"); +const subscriptionsRoutes = require("./subscriptions"); +const { resetRateLimit } = require("../components/subscriptions"); +const SubscriptionModel = require("../db/models/Subscription.model"); +const { connectToMongoose } = require("../db/mongoose-connection"); + +const app = express(); +app.use(express.json()); +app.use("/", subscriptionsRoutes); + +// A real KT1 contract address, needed because validateAddress checks the checksum. +const DAO_ADDRESS = "KT1T17GC91HrJ8ijZgnMaE9j4PZbojbVAn73"; +const APP_URL = "https://tezos-homebase.io"; +const API_URL = "https://homebase-backend.netlify.app"; + +describe("Subscriptions Routes", () => { + beforeAll(async () => { + await connectToMongoose(); + }); + + beforeEach(async () => { + await SubscriptionModel.deleteMany({}); + resetRateLimit(); + sendMail.mockClear(); + }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } + }); + + describe("POST /subscriptions", () => { + it("should create an unconfirmed subscription and send a confirmation email", async () => { + const response = await request(app) + .post("/subscriptions") + .send({ email: "Alerts.User+dao@Example.com", daoAddress: DAO_ADDRESS, network: "mainnet" }) + .expect(200); + + expect(response.body).toEqual({ ok: true }); + + const saved = await SubscriptionModel.findOne({ daoAddress: DAO_ADDRESS }); + expect(saved).not.toBeNull(); + expect(saved.email).toBe("alerts.user+dao@example.com"); + expect(saved.network).toBe("mainnet"); + expect(saved.confirmed).toBe(false); + expect(saved.confirmToken).toHaveLength(64); + expect(saved.unsubscribeToken).toHaveLength(64); + + expect(sendMail).toHaveBeenCalledTimes(1); + const mail = sendMail.mock.calls[0][0]; + expect(mail.to).toBe("alerts.user+dao@example.com"); + expect(mail.text).toContain(`${API_URL}/subscriptions/confirm/${saved.confirmToken}`); + expect(mail.text).toContain(DAO_ADDRESS); + expect(mail.text).toContain("mainnet"); + expect(mail.text).toContain( + `${API_URL}/subscriptions/unsubscribe/${saved.unsubscribeToken}` + ); + expect(mail.headers["List-Unsubscribe"]).toBe( + `<${API_URL}/subscriptions/unsubscribe/${saved.unsubscribeToken}>` + ); + }); + + it("should accept shadownet as a network", async () => { + await request(app) + .post("/subscriptions") + .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "shadownet" }) + .expect(200); + + const saved = await SubscriptionModel.findOne({ network: "shadownet" }); + expect(saved).not.toBeNull(); + }); + + it("should not create a duplicate subscription for the same email, dao and network", async () => { + const body = { email: "user@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" }; + + await request(app).post("/subscriptions").send(body).expect(200); + await request(app).post("/subscriptions").send(body).expect(200); + + const count = await SubscriptionModel.countDocuments({ email: "user@example.com" }); + expect(count).toBe(1); + }); + + it("should not resend a confirmation email for an already confirmed subscription", async () => { + await SubscriptionModel.create({ + email: "user@example.com", + daoAddress: DAO_ADDRESS, + network: "mainnet", + confirmed: true, + confirmToken: "a".repeat(64), + unsubscribeToken: "b".repeat(64), + confirmedAt: new Date(), + }); + + const response = await request(app) + .post("/subscriptions") + .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" }) + .expect(200); + + expect(response.body).toEqual({ ok: true }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("should reject an invalid email address", async () => { + await request(app) + .post("/subscriptions") + .send({ email: "not-an-email", daoAddress: DAO_ADDRESS, network: "mainnet" }) + .expect(400); + + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("should reject a non KT1 dao address", async () => { + await request(app) + .post("/subscriptions") + .send({ + email: "user@example.com", + daoAddress: "tz1burnburnburnburnburnburnburjAYjjX", + network: "mainnet", + }) + .expect(400); + }); + + it("should reject an unsupported network", async () => { + await request(app) + .post("/subscriptions") + .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "notanetwork" }) + .expect(400); + }); + + it("should reject more than 5 requests per email per hour", async () => { + const body = { email: "spammer@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" }; + + for (let i = 0; i < 5; i += 1) { + await request(app).post("/subscriptions").send(body).expect(200); + } + + await request(app).post("/subscriptions").send(body).expect(429); + }); + }); + + describe("GET /subscriptions/confirm/:token", () => { + it("should confirm the subscription and redirect to the dao page", async () => { + const subscription = await SubscriptionModel.create({ + email: "user@example.com", + daoAddress: DAO_ADDRESS, + network: "mainnet", + confirmToken: "c".repeat(64), + unsubscribeToken: "d".repeat(64), + }); + + const response = await request(app) + .get(`/subscriptions/confirm/${subscription.confirmToken}`) + .expect(302); + + expect(response.headers.location).toBe( + `${APP_URL}/explorer/dao/${DAO_ADDRESS}?alerts=confirmed` + ); + + const saved = await SubscriptionModel.findById(subscription._id); + expect(saved.confirmed).toBe(true); + expect(saved.confirmedAt).toBeInstanceOf(Date); + }); + + it("should redirect to the app root for an unknown token", async () => { + const response = await request(app) + .get(`/subscriptions/confirm/${"e".repeat(64)}`) + .expect(302); + + expect(response.headers.location).toBe(`${APP_URL}/explorer/daos?alerts=invalid`); + }); + }); + + describe("GET /subscriptions/unsubscribe/:token", () => { + it("should delete the subscription and redirect to the dao page", async () => { + const subscription = await SubscriptionModel.create({ + email: "user@example.com", + daoAddress: DAO_ADDRESS, + network: "mainnet", + confirmed: true, + confirmToken: "f".repeat(64), + unsubscribeToken: "1".repeat(64), + }); + + const response = await request(app) + .get(`/subscriptions/unsubscribe/${subscription.unsubscribeToken}`) + .expect(302); + + expect(response.headers.location).toBe( + `${APP_URL}/explorer/dao/${DAO_ADDRESS}?alerts=unsubscribed` + ); + + const saved = await SubscriptionModel.findById(subscription._id); + expect(saved).toBeNull(); + }); + + it("should redirect to the app root for an unknown token", async () => { + const response = await request(app) + .get(`/subscriptions/unsubscribe/${"2".repeat(64)}`) + .expect(302); + + expect(response.headers.location).toBe(`${APP_URL}/explorer/daos?alerts=invalid`); + }); + }); +}); diff --git a/server.js b/server.js index c1c100e..f6f524d 100644 --- a/server.js +++ b/server.js @@ -54,6 +54,7 @@ app.use(require("./routes/tokens")); app.use(require("./routes/choices")); app.use(require("./routes/blocks")); app.use(require("./routes/aci")); +app.use(require("./routes/subscriptions")); // Global error handler to avoid crashing without logs // Place after routes to catch any unhandled errors diff --git a/services/mailer.service.js b/services/mailer.service.js new file mode 100644 index 0000000..faf9ec9 --- /dev/null +++ b/services/mailer.service.js @@ -0,0 +1,85 @@ +const nodemailer = require("nodemailer"); + +const DEFAULT_MAIL_FROM = "Homebase Alerts "; + +let cachedTransport = null; + +/** + * Public URLs used to build links inside outgoing mail. + * APP_URL points at the frontend, PUBLIC_API_URL at this backend. + */ +const getAppUrl = () => + (process.env.APP_URL || "https://tezos-homebase.io").replace(/\/+$/, ""); + +const getPublicApiUrl = () => + (process.env.PUBLIC_API_URL || "https://homebase-backend.netlify.app").replace( + /\/+$/, + "" + ); + +const isMailEnabled = () => Boolean(process.env.SMTP_HOST); + +const getTransport = () => { + if (cachedTransport) return cachedTransport; + + const port = Number(process.env.SMTP_PORT || 587); + const options = { + host: process.env.SMTP_HOST, + port, + // Port 465 is implicit TLS, everything else uses STARTTLS. + secure: port === 465, + }; + + if (process.env.SMTP_USER || process.env.SMTP_PASS) { + options.auth = { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }; + } + + cachedTransport = nodemailer.createTransport(options); + return cachedTransport; +}; + +/** + * Send one email. When SMTP_HOST is not configured this is a no-op so that + * local development and the test suite never try to open a real connection. + * + * @param {object} params + * @param {string} params.to Recipient address + * @param {string} params.subject Subject line + * @param {string} [params.text] Plain text body + * @param {string} [params.html] HTML body + * @param {object} [params.headers] Extra headers, e.g. List-Unsubscribe + * @returns {Promise<{skipped: boolean, messageId?: string, error?: string}>} + */ +const sendMail = async ({ to, subject, text, html, headers }) => { + if (!isMailEnabled()) { + console.log("[mailer:skipped]", { to, subject, reason: "SMTP_HOST is not set" }); + return { skipped: true }; + } + + try { + const info = await getTransport().sendMail({ + from: process.env.MAIL_FROM || DEFAULT_MAIL_FROM, + to, + subject, + text, + html, + headers, + }); + console.log("[mailer:sent]", { to, subject, messageId: info?.messageId }); + return { skipped: false, messageId: info?.messageId }; + } catch (error) { + // Mail delivery must never break the request that triggered it. + console.error("[mailer:error]", { to, subject, error: error?.message }); + return { skipped: false, error: error?.message }; + } +}; + +module.exports = { + sendMail, + isMailEnabled, + getAppUrl, + getPublicApiUrl, +};