Skip to content
Draft
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
51 changes: 51 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,63 @@
/** @type {import('next').NextConfig} */

const productionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL;

const securityHeaders = [
// Prevent MIME-type sniffing
{ key: "X-Content-Type-Options", value: "nosniff" },
// Block site from being framed (clickjacking protection)
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
// Enable XSS filter in older browsers
{ key: "X-XSS-Protection", value: "1; mode=block" },
// Control referrer information
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// Restrict browser features
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(self), interest-cohort=()",
},
// Force HTTPS in production
...(productionUrl
? [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
]
: []),
// Content Security Policy
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://res.cloudinary.com https://lh3.googleusercontent.com https://avatars.githubusercontent.com",
"connect-src 'self' https://www.google-analytics.com https://api.mixpanel.com",
"frame-src 'self' https://www.youtube.com https://platform.twitter.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join("; "),
},
];

const nextConfig = {
env: {
NEXT_PUBLIC_SITE_URL:
process.env.NEXT_PUBLIC_SITE_URL ||
(productionUrl ? `https://${productionUrl}` : "http://localhost:3000"),
},
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};

export default nextConfig;
14 changes: 14 additions & 0 deletions src/app/api/auth/send-verification/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ import { NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { sendEmail } from "@/src/lib/send-email";
import { getSettings } from "@/src/lib/queries/settings";
import { getClientIp, rateLimit } from "@/src/lib/rate-limit";

export async function POST(req: NextRequest) {
// Rate limit: 3 verification emails per IP per 10 minutes
const ip = getClientIp(req);
const rl = rateLimit(`send-verification:${ip}`, { limit: 3, windowSecs: 600 });
if (!rl.success) {
return NextResponse.json(
{
success: false,
message: "Too many requests. Please try again later.",
},
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}

const { email } = await req.json();

if (!email) {
Expand Down
41 changes: 40 additions & 1 deletion src/app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { createUser } from "@/src/lib/queries/create-user";
import { getUser } from "@/src/lib/queries/get-user";
import { getClientIp, rateLimit } from "@/src/lib/rate-limit";

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const MAX_NAME_LENGTH = 100;
const MAX_EMAIL_LENGTH = 255;

export async function POST(req: NextRequest) {
// Rate limit: 5 signup attempts per IP per 10 minutes
const ip = getClientIp(req);
const rl = rateLimit(`signup:${ip}`, { limit: 5, windowSecs: 600 });
if (!rl.success) {
return NextResponse.json(
{ data: null, message: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}

try {
const { name, email, password } = await req.json();

if (!name || !email || !password) {
return NextResponse.json(
{ data: null, message: "Missing required fields" },
{ status: 400 }
);
}

if (typeof name !== "string" || name.trim().length === 0 || name.length > MAX_NAME_LENGTH) {
return NextResponse.json(
{ data: null, message: `Name must be between 1 and ${MAX_NAME_LENGTH} characters` },
{ status: 400 }
);
}

if (typeof email !== "string" || email.length > MAX_EMAIL_LENGTH || !EMAIL_REGEX.test(email)) {
return NextResponse.json(
{ data: null, message: "Invalid email address" },
{ status: 400 }
);
}

if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
return NextResponse.json(
{ data: null, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
{ status: 400 }
);
}

const username = email.split("@")[0];
const existingUser = await getUser(email);

Expand All @@ -20,7 +59,7 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
const user = await createUser({ name, email, password, username });
const user = await createUser({ name: name.trim(), email, password, username });
return NextResponse.json({
data: user,
message: "User created successfully",
Expand Down
30 changes: 29 additions & 1 deletion src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { db } from "@/src/db";
import { contactMessages } from "@/src/db/schemas/contact.sql";
import { getClientIp, rateLimit } from "@/src/lib/rate-limit";
import { sanitizeAndEncodeHtml } from "@/src/utils";
import { NextRequest, NextResponse } from "next/server";

const MAX_MESSAGE_LENGTH = 2000;
const MAX_NAME_LENGTH = 100;

export async function POST(request: NextRequest) {
// Rate limit: 3 contact submissions per IP per 15 minutes
const ip = getClientIp(request);
const rl = rateLimit(`contact:${ip}`, { limit: 3, windowSecs: 900 });
if (!rl.success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}

try {
const { name, email, message } = await request.json();

Expand All @@ -22,8 +36,22 @@ export async function POST(request: NextRequest) {
);
}

if (typeof name !== "string" || name.trim().length === 0 || name.length > MAX_NAME_LENGTH) {
return NextResponse.json(
{ error: `Name must be between 1 and ${MAX_NAME_LENGTH} characters` },
{ status: 400 }
);
}

if (typeof message !== "string" || message.trim().length === 0 || message.length > MAX_MESSAGE_LENGTH) {
return NextResponse.json(
{ error: `Message must be between 1 and ${MAX_MESSAGE_LENGTH} characters` },
{ status: 400 }
);
}

await db.insert(contactMessages).values({
name,
name: sanitizeAndEncodeHtml(name.trim()),
email,
message: sanitizeAndEncodeHtml(message),
});
Expand Down
83 changes: 83 additions & 0 deletions src/app/api/posts/[slugOrPostId]/revisions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { db } from "@/src/db";
import { posts, postRevisions } from "@/src/db/schemas";
import { checkPermission } from "@/src/lib/auth/check-permission";
import { desc, eq, or, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";

export const dynamic = "force-dynamic";

export async function GET(
req: NextRequest,
{ params }: { params: { slugOrPostId: string } }
) {
const { slugOrPostId } = params;
return await checkPermission(
{ requiredPermission: "posts:edit" },
async () => {
try {
const post = await db.query.posts.findFirst({
where: or(
eq(posts.slug, slugOrPostId),
eq(posts.post_id, slugOrPostId)
),
columns: { id: true },
});

if (!post) {
return NextResponse.json(
{ data: null, message: "Post not found" },
{ status: 404 }
);
}

const { searchParams } = new URL(req.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const limit = Math.min(
50,
Math.max(1, Number(searchParams.get("limit")) || 10)
);
const offset = (page - 1) * limit;

const [totalResult, revisions] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(postRevisions)
.where(eq(postRevisions.post_id, post.id)),
db.query.postRevisions.findMany({
where: eq(postRevisions.post_id, post.id),
orderBy: [desc(postRevisions.revision_number)],
columns: {
id: true,
post_id: true,
title: true,
summary: true,
revision_number: true,
revised_by: true,
created_at: true,
},
limit,
offset,
}),
]);

const total = Number(totalResult[0].count);

return NextResponse.json({
data: revisions,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
message: "Post revisions fetched successfully",
});
} catch (error) {
return NextResponse.json(
{ data: null, error: "Failed to fetch revisions" },
{ status: 500 }
);
}
}
);
}
47 changes: 29 additions & 18 deletions src/app/api/posts/[slugOrPostId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from "@/src/db";
import { posts } from "@/src/db/schemas";
import { posts, postRevisions } from "@/src/db/schemas";
import { checkPermission } from "@/src/lib/auth/check-permission";
import { getSession } from "@/src/lib/auth/next-auth";
import {
Expand Down Expand Up @@ -68,23 +68,34 @@ export async function PUT(
{ status: 404 }
);

await db
.update(posts)
.set({
...body,
scheduled_at: body.scheduled_at
? new Date(body.scheduled_at)
: null,
reading_time: body?.content
? calculateReadingTime(
stripHtml(decodeAndSanitizeHtml(body?.content || ""))
)
: oldPost?.reading_time,
updated_at: new Date(),
})
.where(
or(eq(posts.slug, slugOrPostId), eq(posts.post_id, slugOrPostId))
);
await db.transaction(async (tx) => {
// Snapshot the current post state as a revision before overwriting
await tx.insert(postRevisions).values({
post_id: oldPost.id,
title: oldPost.title,
content: oldPost.content,
summary: oldPost.summary,
revised_by: session?.user?.id ?? oldPost.author_id,
});

await tx
.update(posts)
.set({
...body,
scheduled_at: body.scheduled_at
? new Date(body.scheduled_at)
: null,
reading_time: body?.content
? calculateReadingTime(
stripHtml(decodeAndSanitizeHtml(body?.content || ""))
)
: oldPost?.reading_time,
updated_at: new Date(),
})
.where(
or(eq(posts.slug, slugOrPostId), eq(posts.post_id, slugOrPostId))
);
});
const post = await getPostForEditing(slugOrPostId);
revalidateTag("getPostWithCache");
revalidateTag("getPlainPostWithCache");
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/taxonomies/categories/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export async function PUT(
}
await db
.update(categories)
.set({ name: body.name, slug: body.slug })
.set({ name: body.name, slug: body.slug, description: body.description })
.where(eq(categories.id, id));
revalidateTag("queryCategoriesWithFilters");
return NextResponse.json({
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/taxonomies/categories/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
{ requiredPermission: "posts:create" },
async () => {
try {
const { name, slug } = await request.json();
const { name, slug, description } = await request.json();

if (!name || !slug) {
return NextResponse.json(
Expand All @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {

const newCategory = await db
.insert(categories)
.values({ name, slug })
.values({ name, slug, description })
.onDuplicateKeyUpdate({ set: { name: sql`name`, slug: sql`slug` } });
revalidateTag("queryCategoriesWithFilters");

Expand Down
4 changes: 2 additions & 2 deletions src/app/api/taxonomies/tags/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
{ requiredPermission: "posts:create" },
async () => {
try {
const { name, slug } = await request.json();
const { name, slug, description } = await request.json();

if (!name || !slug) {
return NextResponse.json(
Expand All @@ -52,7 +52,7 @@ export async function POST(request: NextRequest) {

const newCategory = await db
.insert(tags)
.values({ name, slug })
.values({ name, slug, description })
.onDuplicateKeyUpdate({ set: { name: sql`name`, slug: sql`slug` } });
revalidateTag("queryTagsWithFilters");

Expand Down
Loading