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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Log out and back in, and an **Admin** link appears in the header.
- [x] Email verification and password reset (Resend)
- [x] Paid posts (pay to unlock, Lemon Squeezy checkout, creator earnings ledger)
- [x] Sign in with Google; public Explore page with optional AdSense ads
- [x] Communities (create, join, post inside them; owners can remove posts)

## License

Expand Down
105 changes: 105 additions & 0 deletions apps/api/src/controllers/communityController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Request, Response } from "express";
import { communitySlugSchema } from "@commune/shared";
import type { CommunityDTO } from "@commune/shared";
import { Community } from "../models/Community.js";
import { Membership } from "../models/Membership.js";
import { badRequest, conflict, forbidden, notFound } from "../utils/errors.js";
import { escapeRegex } from "../utils/regex.js";
import { toCommunityDTO } from "../utils/serializers.js";
import { sendPostPage } from "./postController.js";

const MAX_COMMUNITIES_PER_USER = 5;
const LIST_LIMIT = 30;

async function findBySlug(raw: string | undefined) {
const parsed = communitySlugSchema.safeParse(raw);
if (!parsed.success) throw notFound("Community not found");
const community = await Community.findOne({ slug: parsed.data }).lean();
if (!community) throw notFound("Community not found");
return community;
}

const isDuplicate = (err: unknown) => (err as { code?: number }).code === 11000;

export async function create(req: Request, res: Response) {
const { slug, name, description } = req.body as { slug: string; name: string; description: string };

// A cap keeps one account from claiming every good name.
const owned = await Community.countDocuments({ creator: req.userId });
if (owned >= MAX_COMMUNITIES_PER_USER) {
throw forbidden(`You can create up to ${MAX_COMMUNITIES_PER_USER} communities`);
}

let community;
try {
community = await Community.create({
slug,
name,
description,
creator: req.userId,
memberCount: 1,
});
} catch (err) {
if (isDuplicate(err)) throw conflict("That community address is already taken");
throw err;
}
await Membership.create({ user: req.userId, community: community._id, role: "owner" });
res.status(201).json(toCommunityDTO(community, "owner"));
}

/** Most popular communities, optionally filtered by text, or only the ones I belong to. */
export async function list(req: Request, res: Response) {
const { q, mine } = (res.locals as { query: { q?: string; mine?: "1" } }).query;

const memberships = await Membership.find({ user: req.userId }).select("community role").lean();
const roles = new Map(memberships.map((m) => [m.community.toString(), m.role]));

const filter: Record<string, unknown> = {};
if (mine) filter._id = { $in: memberships.map((m) => m.community) };
if (q) {
const pattern = { $regex: escapeRegex(q), $options: "i" };
filter.$or = [{ slug: pattern }, { name: pattern }];
}

const rows = await Community.find(filter).sort({ memberCount: -1, _id: -1 }).limit(LIST_LIMIT).lean();
const items: CommunityDTO[] = rows.map((c) =>
toCommunityDTO(c, (roles.get(c._id.toString()) as "owner" | "member" | undefined) ?? null),
);
res.json(items);
}

export async function get(req: Request, res: Response) {
const community = await findBySlug(req.params.slug);
const membership = await Membership.findOne({ user: req.userId, community: community._id })
.select("role")
.lean();
res.json(toCommunityDTO(community, membership?.role ?? null));
}

export async function join(req: Request, res: Response) {
const community = await findBySlug(req.params.slug);
try {
await Membership.create({ user: req.userId, community: community._id, role: "member" });
await Community.updateOne({ _id: community._id }, { $inc: { memberCount: 1 } });
} catch (err) {
// Already a member (a double click): treat as success.
if (!isDuplicate(err)) throw err;
}
res.status(204).end();
}

export async function leave(req: Request, res: Response) {
const community = await findBySlug(req.params.slug);
const existing = await Membership.findOne({ user: req.userId, community: community._id })
.select("role")
.lean();
if (existing?.role === "owner") throw badRequest("Owners can't leave their own community");
const removed = await Membership.findOneAndDelete({ user: req.userId, community: community._id });
if (removed) await Community.updateOne({ _id: community._id }, { $inc: { memberCount: -1 } });
res.status(204).end();
}

export async function posts(req: Request, res: Response) {
const community = await findBySlug(req.params.slug);
await sendPostPage(req, res, { community: community._id });
}
30 changes: 25 additions & 5 deletions apps/api/src/controllers/postController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { Request, Response } from "express";
import type { Page, PostDTO } from "@commune/shared";
import { Comment } from "../models/Comment.js";
import { Follow } from "../models/Follow.js";
import { Community } from "../models/Community.js";
import { Like } from "../models/Like.js";
import { Membership } from "../models/Membership.js";
import { Post } from "../models/Post.js";
import { notify } from "../services/notificationService.js";
import { unlockedPostIds } from "../services/purchaseService.js";
Expand All @@ -17,6 +19,7 @@ import {
} from "../utils/serializers.js";

const AUTHOR_FIELDS = "username avatarUrl";
const COMMUNITY_FIELDS = "slug name";

const param = (req: Request, name: string): string => {
const value = req.params[name];
Expand All @@ -34,6 +37,7 @@ async function likedSet(userId: string, postIds: mongoose.Types.ObjectId[]) {
async function loadPost(id: string): Promise<PopulatedPost> {
const post = (await Post.findById(id)
.populate("author", AUTHOR_FIELDS)
.populate("community", COMMUNITY_FIELDS)
.lean()) as unknown as PopulatedPost | null;
if (!post) throw notFound("Post not found");
return post;
Expand Down Expand Up @@ -78,6 +82,7 @@ export async function sendPostPage(
.sort({ _id: -1 })
.limit(limit + 1)
.populate("author", AUTHOR_FIELDS)
.populate("community", COMMUNITY_FIELDS)
.lean()) as unknown as PopulatedPost[];

const hasMore = rows.length > limit;
Expand Down Expand Up @@ -117,8 +122,19 @@ export async function getPost(req: Request, res: Response) {
}

export async function createPost(req: Request, res: Response) {
let communityId: string | null = null;
if (req.body.community) {
const community = await Community.findOne({ slug: req.body.community }).select("_id").lean();
if (!community) throw notFound("Community not found");
// Only members can post in a community.
if (!(await Membership.exists({ user: req.userId, community: community._id }))) {
throw forbidden("Join this community to post in it");
}
communityId = community._id.toString();
}
const created = await Post.create({
author: req.userId,
community: communityId,
content: req.body.content,
imageUrl: req.body.imageUrl ?? null,
priceCents: req.body.priceCents ?? null,
Expand All @@ -131,12 +147,16 @@ export async function createPost(req: Request, res: Response) {

export async function deletePost(req: Request, res: Response) {
const id = param(req, "id");
const post = await Post.findById(id).select("author").lean();
const post = await Post.findById(id).select("author community").lean();
if (!post) throw notFound("Post not found");
// Authors can delete their own posts; admins can remove anyone's.
if (post.author.toString() !== req.userId && req.userRole !== "admin") {
throw forbidden("You can only delete your own posts");
}
// Authors can delete their own posts; admins can remove anyone's; so can the owner of the
// community the post was made in.
const allowed =
post.author.toString() === req.userId ||
req.userRole === "admin" ||
(post.community != null &&
Boolean(await Membership.exists({ user: req.userId, community: post.community, role: "owner" })));
if (!allowed) throw forbidden("You can only delete your own posts");

await removePost(id, req.userId);
res.status(204).end();
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/controllers/publicController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export async function listPosts(_req: Request, res: Response) {
.sort({ _id: -1 })
.limit(limit + 1)
.populate("author", "username avatarUrl")
.populate("community", "slug name")
.lean()) as unknown as PopulatedPost[];

const hasMore = rows.length > limit;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/controllers/searchController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export async function posts(req: Request, res: Response) {
.skip((page - 1) * SEARCH_PAGE_SIZE)
.limit(SEARCH_PAGE_SIZE + 1)
.populate("author", "username avatarUrl")
.populate("community", "slug name")
.lean()) as unknown as PopulatedPost[];

const { items, nextCursor } = paginate(rows, page);
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,12 @@ export const publicLimiter = rateLimit({
skip,
message: { error: "Too many requests, slow down." },
});

export const communityLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 10,
standardHeaders: "draft-7",
legacyHeaders: false,
skip,
message: { error: "You're creating communities too fast, try again later." },
});
19 changes: 19 additions & 0 deletions apps/api/src/models/Community.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Schema, model } from "mongoose";

const communitySchema = new Schema(
{
// URL-safe, lowercase, unique: /c/<slug>
slug: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: { type: String, required: true, trim: true, maxlength: 40 },
description: { type: String, default: "", maxlength: 280 },
creator: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
// Kept in step with Membership rows (like likeCount on posts) so lists need no counting.
memberCount: { type: Number, default: 0, min: 0 },
},
{ timestamps: true },
);

// "Most popular first" lists.
communitySchema.index({ memberCount: -1, _id: -1 });

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

const membershipSchema = new Schema(
{
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
community: { type: Schema.Types.ObjectId, ref: "Community", required: true },
role: { type: String, enum: ["owner", "member"], default: "member" },
},
{ timestamps: true },
);

// One membership per person per community; also makes joins safe under double clicks.
membershipSchema.index({ user: 1, community: 1 }, { unique: true });
membershipSchema.index({ community: 1 });

export const Membership = model("Membership", membershipSchema);
2 changes: 2 additions & 0 deletions apps/api/src/models/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const postSchema = new Schema(
author: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
content: { type: String, trim: true, maxlength: 500, default: "" },
imageUrl: { type: String, default: null, index: true, sparse: true },
// Set when the post was made inside a community.
community: { type: Schema.Types.ObjectId, ref: "Community", default: null, index: true },
// Paid posts: price in US cents and the content that is hidden until purchase.
priceCents: { type: Number, default: null, min: 100 },
premiumContent: { type: String, default: null, maxlength: 5000 },
Expand Down
30 changes: 29 additions & 1 deletion apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Router } from "express";
import {
banUserSchema,
communityListSchema,
createCommentSchema,
createCommunitySchema,
createPostSchema,
createReportSchema,
forgotPasswordSchema,
Expand All @@ -20,6 +22,7 @@ import * as account from "../controllers/accountController.js";
import * as admin from "../controllers/adminController.js";
import * as auth from "../controllers/authController.js";
import * as payments from "../controllers/paymentController.js";
import * as communities from "../controllers/communityController.js";
import * as notifications from "../controllers/notificationController.js";
import * as posts from "../controllers/postController.js";
import * as reports from "../controllers/reportController.js";
Expand All @@ -28,7 +31,7 @@ import * as search from "../controllers/searchController.js";
import * as uploads from "../controllers/uploadController.js";
import * as users from "../controllers/userController.js";
import { requireActive, requireAdmin, requireAuth } from "../middleware/auth.js";
import { authLimiter, checkoutLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js";
import { authLimiter, checkoutLimiter, communityLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js";
import { parseImageUpload } from "../middleware/upload.js";
import { validate } from "../middleware/validate.js";
import { asyncHandler } from "../utils/errors.js";
Expand Down Expand Up @@ -140,6 +143,31 @@ router.get(
asyncHandler(search.posts),
);

// Communities
router.get(
"/communities",
requireAuth,
validate(communityListSchema, "query"),
asyncHandler(communities.list),
);
router.post(
"/communities",
requireAuth,
communityLimiter,
validate(createCommunitySchema),
requireActive,
asyncHandler(communities.create),
);
router.get("/communities/:slug", requireAuth, asyncHandler(communities.get));
router.get(
"/communities/:slug/posts",
requireAuth,
validate(paginationSchema, "query"),
asyncHandler(communities.posts),
);
router.post("/communities/:slug/join", requireAuth, requireActive, asyncHandler(communities.join));
router.delete("/communities/:slug/join", requireAuth, requireActive, asyncHandler(communities.leave));

// Posts
router.get("/posts", requireAuth, validate(paginationSchema, "query"), asyncHandler(posts.listPosts));
router.post(
Expand Down
Loading
Loading