diff --git a/package-lock.json b/package-lock.json index 6379e40..2445519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "open-graph-scraper-lite": "2.0.0", "piscina": "4.7.0", "pm2": "5.3.0", + "postmark": "4.0.5", "preact": "10.18.1", "request-filtering-agent": "2.0.1", "rss-parser": "3.13.0", @@ -3208,6 +3209,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/b4a": { "version": "1.6.4", "license": "ISC" @@ -5247,14 +5258,15 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.4", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -8416,6 +8428,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postmark": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-4.0.5.tgz", + "integrity": "sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==", + "dependencies": { + "axios": "^1.7.4" + } + }, "node_modules/preact": { "version": "10.18.1", "license": "MIT", diff --git a/package.json b/package.json index b8d1346..b21def0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "connect": "ssh root@91.107.210.214", "deploy": "npm run build && npm run rsync", "rsync": "rsync -avz . root@91.107.210.214:./kiwistand --exclude=anonlocal --exclude=cache --exclude=docs --exclude=anon --exclude \"node_modules\" --exclude=\".git\" --exclude=bootstrap --exclude=data", - "dev": "cross-env DEBUG=\"*@attestate/*,-@attestate/delegator2*\" concurrently \"npm run watch:web\" \"npm run start\"", + "dev": "cross-env DEBUG=\"*@attestate/*,-@attestate/delegator2*,-@attestate/crawler*,-@attestate/extraction-worker*\" concurrently \"npm run watch:web\" \"npm run start\"", "start": "node -r dotenv/config ./src/launch.mjs", "watch": "nodemon --watch src/views --exec 'npm run dev:anon'", "reconcile": "NODE_ENV=reconcile npm run dev:anon", @@ -93,6 +93,7 @@ "open-graph-scraper-lite": "2.0.0", "piscina": "4.7.0", "pm2": "5.3.0", + "postmark": "4.0.5", "preact": "10.18.1", "request-filtering-agent": "2.0.1", "rss-parser": "3.13.0", diff --git a/src/email.mjs b/src/email.mjs new file mode 100644 index 0000000..6c1dadd --- /dev/null +++ b/src/email.mjs @@ -0,0 +1,98 @@ +import { env } from "process"; +import path from "path"; +import { randomBytes } from "crypto"; + +import Database from "better-sqlite3"; +import postmark from "postmark"; +import { eligible } from "@attestate/delegator2"; + +import { EIP712_MESSAGE } from "./constants.mjs"; +import * as id from "./id.mjs"; +import * as registry from "./chainstate/registry.mjs"; +import log from "./logger.mjs"; + +const DATA_DIR = env.DATA_DIR; +const DB_FILE = path.join(DATA_DIR, "email_subscriptions.db"); + +const db = new Database(DB_FILE); +const client = new postmark.ServerClient(env.POSTMARK_API_KEY); + +db.exec(` + CREATE TABLE IF NOT EXISTS email_subscriptions ( + address TEXT PRIMARY KEY, + email TEXT NOT NULL, + secret TEXT NOT NULL UNIQUE + ) + `); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_email_secret + ON email_subscriptions (email, secret) + `); + +db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_secret + ON email_subscriptions (secret) + `); + +export async function validate(message) { + const signer = id.ecrecover(message, EIP712_MESSAGE); + const allowlist = await registry.allowlist(); + const delegations = await registry.delegations(); + const identity = eligible(allowlist, delegations, signer); + + if (!identity) { + throw new Error( + "Body must include a validly signed message from an eligible signer.", + ); + } + + return identity; +} + +export async function addSubscription(address, email) { + const secret = randomBytes(32).toString("hex"); + const stmt = db.prepare(` + INSERT OR REPLACE INTO email_subscriptions + (address, email, secret) VALUES (?, ?, ?) + `); + stmt.run(address, email, secret); + return secret; +} + +export async function byAddress(address) { + const stmt = db.prepare( + "SELECT email, secret FROM email_subscriptions WHERE address = ?", + ); + const result = stmt.get(address); + return result ? { email: result.email, secret: result.secret } : null; +} + +export async function send(receiver, { sender, title, message, data }) { + if (env.NODE_ENV !== "production") return; + + const { email, secret } = await byAddress(receiver); + if (!email || !secret) return; + + const unsubscribeUrl = `https://news.kiwistand.com/unsubscribe/${secret}`; + + await client.sendEmail({ + From: `${sender} `, + To: email, + Subject: title, + TextBody: `${message}\n\nView comment: ${data.url}\n\n\n\nUnsubscribe: ${unsubscribeUrl}`, + MessageStream: "outbound", + }); +} + +export async function unsubscribe(secret) { + const stmt = db.prepare( + "SELECT address FROM email_subscriptions WHERE secret = ?", + ); + const exists = stmt.get(secret); + if (!exists) return false; + + const remove = db.prepare("DELETE FROM email_subscriptions WHERE secret = ?"); + remove.run(secret); + return true; +} diff --git a/src/http.mjs b/src/http.mjs index 73a022f..c68e001 100644 --- a/src/http.mjs +++ b/src/http.mjs @@ -68,6 +68,7 @@ import * as karma from "./karma.mjs"; import * as frame from "./frame.mjs"; import * as subscriptions from "./subscriptions.mjs"; import * as telegram from "./telegram.mjs"; +import * as email from "./email.mjs"; import * as price from "./price.mjs"; import { getRandomIndex, @@ -307,6 +308,63 @@ export async function launch(trie, libp2p) { reply.send(weeklySales); } }); + app.get("/unsubscribe/:secret", async (req, res) => { + const { secret } = req.params; + + try { + const success = await email.unsubscribe(secret); + if (!success) { + return res.status(404).send("Invalid or expired unsubscribe link"); + } + + res.send(` + + +

Unsubscribed

+

You have been successfully unsubscribed from notifications.

+ + + `); + } catch (err) { + console.error("Unsubscribe error:", err); + res.status(500).send("Error processing unsubscribe request"); + } + }); + + app.post("/api/v1/email-notifications", async (request, reply) => { + const message = request.body; + const testExpr = /.+@.+\..+/; + + if (!message || message.type !== "EMAILAUTH") { + return sendError(reply, 400, "Bad Request", "Invalid message type"); + } + + if (message.title === message.href && !testExpr.test(message.title)) { + return sendError( + reply, + 400, + "Bad Request", + "Title and href must be emails and the same", + ); + } + const userEmail = message.title; + + try { + const identity = await email.validate(message); + await email.addSubscription(identity, userEmail); + + const code = 200; + const httpMessage = "OK"; + const details = "Successfully subscribed to email notifications"; + return sendStatus(reply, code, httpMessage, details); + } catch (err) { + const code = 500; + const httpMessage = "Internal Server Error"; + const details = err.toString(); + return sendError(reply, code, httpMessage, details); + } + }); + app.post("/api/v1/telegram", async (request, reply) => { const message = request.body; // NOTE: The message here is ALMOST a compliant Kiwi News amplify or diff --git a/src/subscriptions.mjs b/src/subscriptions.mjs index ff16362..3424be5 100644 --- a/src/subscriptions.mjs +++ b/src/subscriptions.mjs @@ -8,16 +8,17 @@ import Database from "better-sqlite3"; import log from "./logger.mjs"; import { getSubmission } from "./cache.mjs"; import { resolve } from "./ens.mjs"; +import * as email from "./email.mjs"; import { truncateComment } from "./views/activity.mjs"; if (env.NODE_ENV == "production") webpush.setVapidDetails( "mailto:tim@daubenschuetz.de", - process.env.VAPID_PUBLIC_KEY, - process.env.VAPID_PRIVATE_KEY, + env.VAPID_PUBLIC_KEY, + env.VAPID_PRIVATE_KEY, ); -const DATA_DIR = process.env.DATA_DIR; +const DATA_DIR = env.DATA_DIR; const DB_FILE = path.join(DATA_DIR, "web_push_subscriptions.db"); const db = new Database(DB_FILE); @@ -62,17 +63,26 @@ export async function triggerNotification(message) { const uniqueReceivers = Array.from(new Set(receivers)); const maxChars = 140; + const url = + `https://news.kiwistand.com/stories?index=0x${submission.index}` + + `&cachebuster=0x${message.index}#0x${message.index}`; + await Promise.allSettled( - uniqueReceivers.map( - async (receiver) => - await send(receiver, { - title: `${ensData.displayName} replied`, - message: truncateComment(message.title, maxChars), - data: { - url: `https://news.kiwistand.com/stories?index=0x${submission.index}&cachebuster=0x${message.index}#0x${message.index}`, - }, - }), - ), + uniqueReceivers.map(async (receiver) => { + await send(receiver, { + title: `${ensData.displayName} replied`, + message: truncateComment(message.title, maxChars), + data: { + url: `https://news.kiwistand.com/stories?index=0x${submission.index}&cachebuster=0x${message.index}#0x${message.index}`, + }, + }); + await email.send(receiver, { + sender: ensData.displayName, + title: `New comment in: ${submission.title}`, + message: message.title, + data: { url }, + }); + }), ); } @@ -92,6 +102,8 @@ export function get(address) { } export async function send(address, payload) { + if (env.NODE_ENV !== "production") return; + const subscriptions = get(address); if (!subscriptions || subscriptions.length === 0) return; diff --git a/src/web/src/CommentSection.jsx b/src/web/src/CommentSection.jsx index aa2cc58..c55c11c 100644 --- a/src/web/src/CommentSection.jsx +++ b/src/web/src/CommentSection.jsx @@ -1,10 +1,14 @@ import React, { useRef, useState, useEffect } from "react"; import { formatDistanceToNowStrict } from "date-fns"; import Linkify from "linkify-react"; +import { useAccount, WagmiConfig } from "wagmi"; +import { RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { Wallet } from "@ethersproject/wallet"; +import { useProvider, client, chains } from "./client.mjs"; import CommentInput from "./CommentInput.jsx"; -import { fetchStory } from "./API.mjs"; -import { isIOS } from "./session.mjs"; +import * as API from "./API.mjs"; +import { getLocalAccount, isIOS, isRunningPWA } from "./session.mjs"; function ShareIcon(style) { return ( @@ -51,6 +55,124 @@ function truncateName(name) { return name; return name.slice(0, maxLength) + "..."; } +function NotificationOptIn(props) { + return ( +
+

+ Get email notifications when someone replies to your comments or + submissions +

+ + + + + + +
+ ); +} + +const EmailNotificationLink = (props) => { + const [status, setStatus] = useState(""); + const [email, setEmail] = useState(""); + const account = useAccount(); + const localAccount = getLocalAccount(account.address, props.allowlist); + const provider = useProvider(); + + const handleSubmit = async (e) => { + e.preventDefault(); + console.log("Submit triggered"); + setStatus("sending"); + + const value = API.messageFab(email, email, "EMAILAUTH"); + + let signature; + try { + const signer = new Wallet(localAccount.privateKey, provider); + signature = await signer._signTypedData( + API.EIP712_DOMAIN, + API.EIP712_TYPES, + value, + ); + } catch (err) { + console.error("Signing failed:", err); + setStatus("error"); + return; + } + + const wait = null; + const endpoint = "/api/v1/email-notifications"; + const port = window.location.port; + + try { + const response = await API.send(value, signature, wait, endpoint, port); + if (response.status === "success") { + setStatus("success"); + setEmail(""); + } else { + console.error("API error:", response.details); + setStatus("error"); + } + } catch (err) { + console.error("Network request failed:", err); + setStatus("error"); + } + }; + + return ( +
+ setEmail(e.target.value)} + placeholder="Enter your email" + style={{ + flex: 1, + padding: "6px 8px", + border: "var(--border-thin)", + borderRadius: "2px", + fontSize: "10pt", + }} + required + /> + +
+ ); +}; const Comment = React.forwardRef(({ comment, storyIndex }, ref) => { const [isTargeted, setIsTargeted] = useState( @@ -218,7 +340,7 @@ const CommentsSection = (props) => { (async () => { if (commentCount === 0) return; - const story = await fetchStory(storyIndex, commentCount); + const story = await API.fetchStory(storyIndex, commentCount); if (story && story.comments) setComments(story.comments); })(); }, [storyIndex]); @@ -246,6 +368,7 @@ const CommentsSection = (props) => { fontSize: "1rem", }} > + {comments.length > 0 && comments.map((comment, index) => (