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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ PLATFORM_FEE_PERCENT=20
# Set to true to require a verified email before posting, liking, commenting or following.
REQUIRE_VERIFIED_EMAIL=false

# Optional: "Sign in with Google". Create a Web OAuth client in Google Cloud Console and paste
# its client ID here AND in VITE_GOOGLE_CLIENT_ID below (the same value; it is public).
GOOGLE_CLIENT_ID=

# ---- Web ----
# Only needed for local development; production builds use "/api" automatically.
VITE_API_URL=http://localhost:4000/api

# Optional: Google sign-in button and AdSense ads (both are hidden when blank).
VITE_GOOGLE_CLIENT_ID=
# AdSense publisher id (ca-pub-...) and the ad unit id for the Explore feed. Production only.
VITE_ADSENSE_CLIENT=
VITE_ADSENSE_FEED_SLOT=
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Log out and back in, and an **Admin** link appears in the header.
- [x] Moderation tools (reports, admin queue, post removal, account suspension)
- [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

## License

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const schema = z.object({
.string()
.optional()
.transform((v) => v?.trim() || "Commune <onboarding@resend.dev>"),
// "Sign in with Google": the OAuth Web client ID (looks like 123-abc.apps.googleusercontent.com).
GOOGLE_CLIENT_ID: z.string().optional(),
// Paid posts, through Lemon Squeezy (https://lemonsqueezy.com). All four are needed for real
// checkouts. Outside production, with none set, purchases are simulated so you can test.
LEMONSQUEEZY_API_KEY: z.string().optional(),
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ export async function logout(req: Request, res: Response) {
await authService.logout(presented, res);
res.status(204).end();
}

export async function google(req: Request, res: Response) {
res.json(await authService.loginWithGoogle(req.body.credential, res));
}
31 changes: 31 additions & 0 deletions apps/api/src/controllers/publicController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import mongoose from "mongoose";
import type { Request, Response } from "express";
import type { Page, PostDTO } from "@commune/shared";
import { Post } from "../models/Post.js";
import { badRequest } from "../utils/errors.js";
import { toPostDTO, type PopulatedPost } from "../utils/serializers.js";

/**
* Latest posts for logged-out visitors. Paid posts are excluded entirely (not even teasers),
* and nothing viewer-specific (likes) is included, so the response is the same for everyone.
*/
export async function listPosts(_req: Request, res: Response) {
const { cursor, limit } = (res.locals as { query: { cursor?: string; limit: number } }).query;
if (cursor && !mongoose.isValidObjectId(cursor)) throw badRequest("Invalid cursor");

const filter = cursor ? { priceCents: null, _id: { $lt: cursor } } : { priceCents: null };
const rows = (await Post.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.populate("author", "username avatarUrl")
.lean()) as unknown as PopulatedPost[];

const hasMore = rows.length > limit;
const posts = hasMore ? rows.slice(0, limit) : rows;
const body: Page<PostDTO> = {
items: posts.map((p) => toPostDTO(p, false, true)),
nextCursor: hasMore ? (posts[posts.length - 1]?._id.toString() ?? null) : null,
};
res.set("Cache-Control", "public, max-age=30");
res.json(body);
}
10 changes: 10 additions & 0 deletions apps/api/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,13 @@ export const searchLimiter = rateLimit({
skip,
message: { error: "Searching too fast, try again in a moment." },
});

/** Unauthenticated read-only endpoints (the public Explore page). */
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 60,
standardHeaders: "draft-7",
legacyHeaders: false,
skip,
message: { error: "Too many requests, slow down." },
});
2 changes: 2 additions & 0 deletions apps/api/src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const userSchema = new Schema(
avatarUrl: { type: String, default: null, index: true, sparse: true },
role: { type: String, enum: ["user", "admin"], default: "user" },
banned: { type: Boolean, default: false },
// Google account id ("sub"). Unset for password-only accounts, so the unique index is sparse.
googleId: { type: String, unique: true, sparse: true },
emailVerified: { type: Boolean, default: false },
},
{ timestamps: true },
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createPostSchema,
createReportSchema,
forgotPasswordSchema,
googleLoginSchema,
loginSchema,
paginationSchema,
registerSchema,
Expand All @@ -22,11 +23,12 @@ import * as payments from "../controllers/paymentController.js";
import * as notifications from "../controllers/notificationController.js";
import * as posts from "../controllers/postController.js";
import * as reports from "../controllers/reportController.js";
import * as publicApi from "../controllers/publicController.js";
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, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js";
import { authLimiter, checkoutLimiter, 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 All @@ -47,6 +49,7 @@ router.get("/health", (_req, res) => {
// Auth
router.post("/auth/register", authLimiter, validate(registerSchema), asyncHandler(auth.register));
router.post("/auth/login", authLimiter, validate(loginSchema), asyncHandler(auth.login));
router.post("/auth/google", authLimiter, validate(googleLoginSchema), asyncHandler(auth.google));
router.post("/auth/refresh", authLimiter, asyncHandler(auth.refresh));
router.post("/auth/logout", asyncHandler(auth.logout));

Expand Down Expand Up @@ -78,6 +81,15 @@ router.post(
asyncHandler(account.resendVerification),
);

// Public, read-only (no login): powers the Explore page that search engines and ad
// reviewers can see. Only free posts are ever returned.
router.get(
"/public/posts",
publicLimiter,
validate(paginationSchema, "query"),
asyncHandler(publicApi.listPosts),
);

// Uploads (auth and rate limit run before the file is parsed)
router.post(
"/uploads",
Expand Down
57 changes: 56 additions & 1 deletion apps/api/src/services/authService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import type { Response } from "express";
import type { AuthResponse, LoginInput, RegisterInput } from "@commune/shared";
import { env, isProd } from "../config/env.js";
import { RefreshToken } from "../models/RefreshToken.js";
import { User } from "../models/User.js";
import { conflict, forbidden, unauthorized } from "../utils/errors.js";
import { AppError, conflict, forbidden, unauthorized } from "../utils/errors.js";
import { toPublicUser } from "../utils/serializers.js";
import { verifyGoogleIdToken, type GoogleProfile } from "./googleAuth.js";
import { sendVerificationEmailInBackground } from "./accountService.js";
import { generateRefreshToken, hashToken, signAccessToken } from "../utils/tokens.js";

Expand Down Expand Up @@ -89,3 +91,56 @@ export async function logout(presented: string | undefined, res: Response) {
if (presented) await RefreshToken.deleteOne({ tokenHash: hashToken(presented) });
clearRefreshCookie(res);
}

/** Accounts created with Google get a random password nobody knows, so password login is off. */
const randomPasswordHash = () => bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);

async function uniqueUsername(profile: GoogleProfile): Promise<string> {
const raw = profile.name ?? profile.email.split("@")[0] ?? "";
let base = raw.normalize("NFKD").replace(/[^a-zA-Z0-9_]/g, "").slice(0, 16);
if (base.length < 3) base = "user";
for (let attempt = 0; attempt < 6; attempt++) {
const candidate =
attempt === 0 ? base : `${base}${crypto.randomInt(0, 10000).toString().padStart(4, "0")}`;
const taken = await User.exists({ username: candidate }).collation({
locale: "en",
strength: 2,
});
if (!taken) return candidate;
}
return `user${crypto.randomBytes(5).toString("hex")}`;
}

/** Sign in, or sign up, with a Google ID token. */
export async function loginWithGoogle(credential: string, res: Response) {
if (!env.GOOGLE_CLIENT_ID) throw new AppError(503, "Google sign-in is not set up");
const google = await verifyGoogleIdToken(credential);

let user = await User.findOne({ googleId: google.sub });
if (!user) {
const existing = await User.findOne({ email: google.email });
if (existing) {
existing.googleId = google.sub;
if (!existing.emailVerified) {
// Someone may have registered this address with a password before its real owner
// arrived. Google just proved ownership, so cancel any password and sessions that
// were set up without that proof.
existing.passwordHash = await randomPasswordHash();
existing.emailVerified = true;
await RefreshToken.deleteMany({ user: existing._id });
}
await existing.save();
user = existing;
} else {
user = await User.create({
username: await uniqueUsername(google),
email: google.email,
passwordHash: await randomPasswordHash(),
googleId: google.sub,
emailVerified: true,
});
}
}
if (user.banned) throw forbidden("This account has been suspended");
return issueSession(res, user);
}
103 changes: 103 additions & 0 deletions apps/api/src/services/googleAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import crypto from "node:crypto";
import { env } from "../config/env.js";
import { AppError, unauthorized } from "../utils/errors.js";

/**
* Verifies a Google ID token (the "credential" from Sign in with Google) without any extra
* dependency: the RS256 signature is checked against Google's published public keys, then the
* claims are checked. Nothing in the token is trusted before the signature is valid.
*/
const CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
const ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
const CLOCK_SKEW_MS = 60_000;

interface KeyCache {
keys: Map<string, crypto.KeyObject>;
fetchedAt: number;
expiresAt: number;
}
let cache: KeyCache | null = null;

async function loadKeys(): Promise<KeyCache> {
let res: Response;
try {
res = await fetch(CERTS_URL);
} catch {
throw new AppError(502, "Google sign-in is unavailable right now");
}
if (!res.ok) throw new AppError(502, "Google sign-in is unavailable right now");

const body = (await res.json()) as { keys?: (crypto.JsonWebKey & { kid?: string })[] };
const keys = new Map<string, crypto.KeyObject>();
for (const jwk of body.keys ?? []) {
if (jwk.kid && jwk.kty === "RSA") {
keys.set(jwk.kid, crypto.createPublicKey({ key: jwk, format: "jwk" }));
}
}
const maxAge = /max-age=(\d+)/.exec(res.headers.get("cache-control") ?? "")?.[1];
const now = Date.now();
cache = { keys, fetchedAt: now, expiresAt: now + (maxAge ? Number(maxAge) : 3600) * 1000 };
return cache;
}

async function findKey(kid: string): Promise<crypto.KeyObject | undefined> {
const now = Date.now();
if (cache && now < cache.expiresAt && cache.keys.has(kid)) return cache.keys.get(kid);
// Unknown key id: Google may have rotated keys. Refetch, but at most once a minute.
if (!cache || now >= cache.expiresAt || now - cache.fetchedAt > 60_000) await loadKeys();
return cache?.keys.get(kid);
}

const decode = (part: string) => Buffer.from(part, "base64url");

export interface GoogleProfile {
sub: string;
email: string;
name: string | null;
}

export async function verifyGoogleIdToken(token: string): Promise<GoogleProfile> {
const fail = () => unauthorized("Google sign-in failed");
if (!env.GOOGLE_CLIENT_ID) throw new AppError(503, "Google sign-in is not set up");

const parts = token.split(".");
if (parts.length !== 3) throw fail();
const [headerPart, payloadPart, signaturePart] = parts as [string, string, string];

let header: { alg?: string; kid?: string };
let claims: {
iss?: string;
aud?: string;
exp?: number;
sub?: string;
email?: string;
email_verified?: boolean | string;
name?: string;
};
try {
header = JSON.parse(decode(headerPart).toString("utf8"));
claims = JSON.parse(decode(payloadPart).toString("utf8"));
} catch {
throw fail();
}

if (header.alg !== "RS256" || !header.kid) throw fail();
const key = await findKey(header.kid);
if (!key) throw fail();
const valid = crypto.verify(
"RSA-SHA256",
Buffer.from(`${headerPart}.${payloadPart}`),
key,
decode(signaturePart),
);
if (!valid) throw fail();

if (!claims.iss || !ISSUERS.includes(claims.iss)) throw fail();
if (claims.aud !== env.GOOGLE_CLIENT_ID) throw fail();
if (typeof claims.exp !== "number" || claims.exp * 1000 + CLOCK_SKEW_MS < Date.now()) throw fail();
if (!claims.sub || !claims.email) throw fail();
// Only trust an email address Google itself has verified.
if (claims.email_verified !== true && claims.email_verified !== "true") throw fail();

return { sub: claims.sub, email: claims.email.toLowerCase(), name: claims.name ?? null };
}
Loading
Loading