Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ LEMONSQUEEZY_PLUS_VARIANT_ID=
PLUS_PRICE_LABEL="$5 / month"
# Platform fee on paid-post sales for Plus creators (free creators pay PLATFORM_FEE_PERCENT)
PLUS_FEE_PERCENT=10

# Contact page: messages are always saved in the database. Set this to also get them by email
# (needs RESEND_API_KEY above).
CONTACT_EMAIL=
3 changes: 3 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const schema = z.object({
LEMONSQUEEZY_PLUS_VARIANT_ID: z.string().optional(),
// Shown on the Plus page. The real price lives in Lemon Squeezy; keep this text in sync.
PLUS_PRICE_LABEL: z.string().optional().transform((v) => v?.trim() || "$5 / month"),
// Where messages from the Contact page are emailed (needs RESEND_API_KEY). They are always
// saved in the database too.
CONTACT_EMAIL: z.string().optional().transform((v) => v?.trim() || undefined),
// "true" blocks posting, liking, commenting and following until the email is verified.
REQUIRE_VERIFIED_EMAIL: z
.enum(["true", "false"])
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/controllers/contactController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Request, Response } from "express";
import { env } from "../config/env.js";
import { logger } from "../config/logger.js";
import { ContactMessage } from "../models/ContactMessage.js";
import { sendMail } from "../services/mailService.js";

const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

export async function send(req: Request, res: Response) {
const { name, email, message, website } = req.body as {
name: string;
email: string;
message: string;
website?: string;
};

// Honeypot filled in: pretend it worked so bots learn nothing.
if (website) return void res.status(201).json({ ok: true });

await ContactMessage.create({ name, email, message });

if (env.CONTACT_EMAIL) {
// Best effort: the message is already saved, so a mail failure must not fail the request.
try {
await sendMail({
to: env.CONTACT_EMAIL,
subject: `Commune contact: ${name}`.slice(0, 120),
text: `From: ${name} <${email}>\n\n${message}`,
html: `<p><strong>${escapeHtml(name)}</strong> &lt;${escapeHtml(email)}&gt;</p><p>${escapeHtml(message).replace(/\n/g, "<br>")}</p>`,
});
} catch (err) {
logger.error({ err: String(err) }, "Could not email contact message");
}
}
res.status(201).json({ ok: true });
}
189 changes: 189 additions & 0 deletions apps/api/src/controllers/messageController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type { Request, Response } from "express";
import mongoose from "mongoose";
import type { ConversationDTO, MessageDTO, MessagePage } from "@commune/shared";
import { Conversation } from "../models/Conversation.js";
import { Message } from "../models/Message.js";
import { User } from "../models/User.js";
import { badRequest, forbidden, notFound } from "../utils/errors.js";
import { isPlusActive } from "../utils/plan.js";
import { findByUsername } from "./userController.js";

const PREVIEW_LENGTH = 120;
const USER_FIELDS = "username bio avatarUrl plusUntil banned";

interface LeanUser {
_id: unknown;
username: string;
bio?: string;
avatarUrl?: string | null;
plusUntil?: Date | null;
}
interface LeanConversation {
_id: unknown;
participants: unknown[];
lastMessageText?: string;
lastMessageSender?: unknown;
lastMessageAt: Date;
unread?: Record<string, number> | null;
}
interface LeanMessage {
_id: unknown;
conversation: unknown;
sender: unknown;
text: string;
createdAt: Date;
}

const str = (v: unknown) => String(v);
const pairKey = (a: string, b: string) => [a, b].sort().join(":");

function toMessageDTO(m: LeanMessage): MessageDTO {
return {
id: str(m._id),
conversationId: str(m.conversation),
senderId: str(m.sender),
text: m.text,
createdAt: m.createdAt.toISOString(),
};
}

function toConversationDTO(c: LeanConversation, me: string, other: LeanUser): ConversationDTO {
const unread = c.unread && typeof c.unread === "object" ? Number(c.unread[me] ?? 0) : 0;
return {
id: str(c._id),
other: {
id: str(other._id),
username: other.username,
bio: other.bio ?? "",
avatarUrl: other.avatarUrl ?? null,
plus: isPlusActive(other.plusUntil),
},
lastMessage: c.lastMessageText
? {
text: c.lastMessageText,
senderId: str(c.lastMessageSender),
createdAt: c.lastMessageAt.toISOString(),
}
: null,
lastMessageAt: c.lastMessageAt.toISOString(),
unread,
};
}

/** Loads a conversation and makes sure the caller is one of its two participants. */
async function loadConversation(id: string | undefined, me: string) {
if (!id || !mongoose.isValidObjectId(id)) throw notFound("Conversation not found");
const convo = (await Conversation.findById(id).lean()) as LeanConversation | null;
// Same answer for "doesn't exist" and "not yours" so ids can't be probed.
if (!convo || !convo.participants.some((p) => str(p) === me)) throw notFound("Conversation not found");
const otherId = convo.participants.map(str).find((p) => p !== me);
return { convo, otherId: otherId ?? me };
}

async function saveMessage(convoId: string, me: string, otherId: string, text: string) {
const message = await Message.create({ conversation: convoId, sender: me, text });
await Conversation.updateOne(
{ _id: convoId },
{
$set: {
lastMessageText: text.slice(0, PREVIEW_LENGTH),
lastMessageSender: me,
lastMessageAt: message.createdAt,
},
$inc: { [`unread.${otherId}`]: 1 },
},
);
return toMessageDTO(message.toObject() as unknown as LeanMessage);
}

/** GET /messages: your conversations, most recent first. */
export async function listConversations(req: Request, res: Response) {
const me = req.userId!;
const convos = (await Conversation.find({ participants: me })
.sort({ lastMessageAt: -1 })
.limit(50)
.lean()) as unknown as LeanConversation[];

const otherIds = convos.map((c) => c.participants.map(str).find((p) => p !== me) ?? me);
const users = (await User.find({ _id: { $in: otherIds } })
.select(USER_FIELDS)
.lean()) as unknown as (LeanUser & { banned?: boolean })[];
const byId = new Map(users.map((u) => [str(u._id), u]));

const out: ConversationDTO[] = [];
convos.forEach((c, i) => {
const other = byId.get(otherIds[i]!);
if (other && !other.banned) out.push(toConversationDTO(c, me, other));
});
res.json(out);
}

/** GET /messages/unread-count: how many conversations have unread messages. */
export async function unreadCount(req: Request, res: Response) {
const me = req.userId!;
const count = await Conversation.countDocuments({
participants: me,
[`unread.${me}`]: { $gt: 0 },
});
res.json({ count });
}

/** GET /messages/:id: a page of messages (newest page first request; older via cursor). */
export async function getMessages(req: Request, res: Response) {
const me = req.userId!;
const { convo, otherId } = await loadConversation(req.params.id, me);
const { cursor, limit } = res.locals.query as { cursor?: string; limit: number };

const rows = (await Message.find({
conversation: str(convo._id),
...(cursor ? { createdAt: { $lt: new Date(cursor) } } : {}),
})
.sort({ createdAt: -1 })
.limit(limit + 1)
.lean()) as unknown as LeanMessage[];

const hasMore = rows.length > limit;
const page = rows.slice(0, limit); // newest first
const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null;

// Opening the latest page counts as reading the conversation.
if (!cursor && Number(convo.unread?.[me] ?? 0) > 0) {
await Conversation.updateOne({ _id: str(convo._id) }, { $set: { [`unread.${me}`]: 0 } });
}

const other = (await User.findById(otherId).select(USER_FIELDS).lean()) as unknown as LeanUser | null;
const result: MessagePage & { conversation?: ConversationDTO } = {
items: page.reverse().map(toMessageDTO), // oldest first for display
nextCursor,
};
if (!cursor && other) result.conversation = toConversationDTO(convo, me, other);
res.json(result);
}

/** POST /messages/:id: reply in an existing conversation. */
export async function send(req: Request, res: Response) {
const me = req.userId!;
const { convo, otherId } = await loadConversation(req.params.id, me);
const other = (await User.findById(otherId).select("banned").lean()) as { banned?: boolean } | null;
if (!other || other.banned) throw forbidden("You can't message this account");
res.status(201).json(await saveMessage(str(convo._id), me, otherId, req.body.text));
}

/** POST /messages/with/:username: send to a person, creating the conversation if needed. */
export async function start(req: Request, res: Response) {
const me = req.userId!;
const target = await findByUsername(req.params.username);
const targetId = str(target._id);
if (targetId === me) throw badRequest("You can't message yourself");
if (target.banned) throw forbidden("You can't message this account");

const key = pairKey(me, targetId);
const convo = (await Conversation.findOneAndUpdate(
{ key },
{ $setOnInsert: { key, participants: [me, targetId], lastMessageAt: new Date() } },
{ upsert: true, new: true },
).lean()) as unknown as LeanConversation;

const message = await saveMessage(str(convo._id), me, targetId, req.body.text);
res.status(201).json({ conversationId: str(convo._id), message });
}
18 changes: 18 additions & 0 deletions apps/api/src/controllers/newsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Request, Response } from "express";
import type { NewsTopic } from "@commune/shared";
import { logger } from "../config/logger.js";
import { getNews } from "../services/newsService.js";
import { AppError } from "../utils/errors.js";

/** Public. The CDN may keep a copy for 10 minutes, so most visitors never reach this code. */
export async function list(_req: Request, res: Response) {
const { topic } = res.locals.query as { topic: NewsTopic };
try {
const items = await getNews(topic);
res.set("Cache-Control", "public, max-age=60, s-maxage=600, stale-while-revalidate=3600");
res.json(items);
} catch (err) {
logger.error({ err: String(err), topic }, "News unavailable");
throw new AppError(503, "News is unavailable right now, try again in a few minutes.");
}
}
29 changes: 29 additions & 0 deletions apps/api/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,32 @@ export const communityLimiter = rateLimit({
skip,
message: { error: "You're creating communities too fast, try again later." },
});

/** Sending direct messages. */
export const messageLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
limit: 60,
standardHeaders: "draft-7",
legacyHeaders: false,
skip,
message: { error: "You're sending messages too fast, slow down." },
});

/** Starting conversations with new people (the main spam vector for direct messages). */
export const newChatLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 20,
standardHeaders: "draft-7",
legacyHeaders: false,
skip,
message: { error: "You've started a lot of conversations, try again later." },
});

export const contactLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: "draft-7",
legacyHeaders: false,
skip,
message: { error: "Too many messages, try again later." },
});
10 changes: 10 additions & 0 deletions apps/api/src/models/ContactMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Schema, model } from "mongoose";

const contactMessageSchema = new Schema({
name: { type: String, required: true, maxlength: 60 },
email: { type: String, required: true, maxlength: 120 },
message: { type: String, required: true, maxlength: 2000 },
createdAt: { type: Date, default: Date.now },
});

export const ContactMessage = model("ContactMessage", contactMessageSchema);
19 changes: 19 additions & 0 deletions apps/api/src/models/Conversation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Schema, model } from "mongoose";

/**
* A one-to-one chat. `key` is the two user ids, sorted and joined, so a pair of people can
* only ever have one conversation (and concurrent "start chat" requests cannot create two).
*/
const conversationSchema = new Schema({
key: { type: String, required: true, unique: true },
participants: { type: [{ type: Schema.Types.ObjectId, ref: "User" }], required: true },
lastMessageText: { type: String, default: "" },
lastMessageSender: { type: Schema.Types.ObjectId, ref: "User", default: null },
lastMessageAt: { type: Date, default: Date.now },
// Unread count per participant, keyed by user id.
unread: { type: Map, of: Number, default: {} },
});

conversationSchema.index({ participants: 1, lastMessageAt: -1 });

export const Conversation = model("Conversation", conversationSchema);
12 changes: 12 additions & 0 deletions apps/api/src/models/Message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Schema, model } from "mongoose";

const messageSchema = new Schema({
conversation: { type: Schema.Types.ObjectId, ref: "Conversation", required: true },
sender: { type: Schema.Types.ObjectId, ref: "User", required: true },
text: { type: String, required: true, maxlength: 1000 },
createdAt: { type: Date, default: Date.now },
});

messageSchema.index({ conversation: 1, createdAt: -1 });

export const Message = model("Message", messageSchema);
Loading
Loading