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: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Public site URL used for canonical metadata, sitemap, and robots.
NEXT_PUBLIC_SITE_URL=https://anusbutt.com
NEXT_PUBLIC_SITE_URL=https://www.anasbutt.site

# Resend API key. Keep this server-only and configure it in Vercel/local .env.local.
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxx

# Verified sender and destination addresses for the fixed portfolio contact workflow.
CONTACT_FROM_EMAIL=portfolio@anusbutt.com
CONTACT_FROM_EMAIL=portfolio@anasbutt.site
CONTACT_TO_EMAIL=your-email@example.com
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ Thumbs.db
# IDE
.vscode/
.idea/
.vercel
# Local Spec-Driven Development and agent workspace
.agents/
.claude/
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing

1. Read the feature specification and plan under specs/portfolio-architecture-refactor/ before making architectural changes.
1. Read README.md and the relevant source files before making a change.
2. Keep portfolio content in src/content and avoid duplicating identity values in components.
3. Run npm run check and npm run test:e2e before opening a pull request.
4. Preserve accessible keyboard navigation, reduced-motion behavior, responsive layout, and the existing visual identity.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Personal portfolio for Anus Butt, an AI engineer and full-stack engineer building auditable agent systems, developer tools, and production web applications.

Live site: https://anusbutt.com
Live site: https://www.anasbutt.site

## Architecture

Expand Down Expand Up @@ -62,7 +62,7 @@ npm run check

## Deployment

Deploy the repository to Vercel with the four environment variables configured in the project settings. Pushes to the main branch can use the included GitHub Actions checks as the merge gate. The contact limiter is intentionally an in-memory, per-instance guard suitable for low-volume portfolio traffic; a managed limiter would be the next step if abuse volume grows.
Deploy the repository to Vercel with the four environment variables configured in the project settings. Pushes to the default branch can use the included GitHub Actions checks as the merge gate. The contact limiter is intentionally an in-memory, per-instance guard suitable for low-volume portfolio traffic; a managed limiter would be the next step if abuse volume grows.

## Contributing and security

Expand Down
4 changes: 2 additions & 2 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 changes: 5 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { profile } from "@/content/profile";
import { siteUrl } from "@/content/site";
import Navbar from "@/components/layout/Navbar";
import Hero from "@/components/sections/hero/Hero";
import About from "@/components/sections/about/About";
Expand All @@ -14,14 +15,17 @@ const personJsonLd = {
jobTitle: profile.title,
address: { "@type": "PostalAddress", addressCountry: "Pakistan" },
sameAs: profile.sameAs,
url: siteUrl.toString(),
};

export default function Home() {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
dangerouslySetInnerHTML={{
__html: JSON.stringify(personJsonLd).replace(/</g, "\\u003c"),
}}
/>
<Navbar />
<Hero />
Expand Down
42 changes: 34 additions & 8 deletions src/components/sections/contact/ContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, FormEvent } from "react";
import { motion } from "framer-motion";
import { CONTACT_FIELD_LIMITS, isValidContactEmail } from "@/shared/contact";

interface FormErrors {
name?: string;
Expand All @@ -13,22 +14,28 @@ export default function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [website, setWebsite] = useState("");
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [statusMessage, setStatusMessage] = useState("");

function validate(): FormErrors {
const errs: FormErrors = {};
if (!name.trim()) errs.name = "Name is required";
else if (name.length > 100) errs.name = "Name must be 100 characters or less";
const trimmedName = name.trim();
const trimmedEmail = email.trim();
const trimmedMessage = message.trim();

if (!email.trim()) errs.email = "Email is required";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errs.email = "Please enter a valid email";
if (!trimmedName) errs.name = "Name is required";
else if (trimmedName.length > CONTACT_FIELD_LIMITS.name)
errs.name = "Name must be 100 characters or less";

if (!message.trim()) errs.message = "Message is required";
else if (message.length > 2000) errs.message = "Message must be 2000 characters or less";
if (!trimmedEmail) errs.email = "Email is required";
else if (!isValidContactEmail(trimmedEmail)) errs.email = "Please enter a valid email";

if (!trimmedMessage) errs.message = "Message is required";
else if (trimmedMessage.length > CONTACT_FIELD_LIMITS.message)
errs.message = "Message must be 2000 characters or less";

return errs;
}
Expand All @@ -50,7 +57,12 @@ export default function ContactForm() {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), email: email.trim(), message: message.trim() }),
body: JSON.stringify({
name: name.trim(),
email: email.trim(),
message: message.trim(),
website,
}),
});

const data = await res.json();
Expand All @@ -61,6 +73,7 @@ export default function ContactForm() {
setName("");
setEmail("");
setMessage("");
setWebsite("");
} else {
setStatus("error");
setStatusMessage(data.message || "Failed to send message. Please try again.");
Expand Down Expand Up @@ -167,6 +180,19 @@ export default function ContactForm() {
)}
</div>

<div aria-hidden="true" className="sr-only">
<label htmlFor="website">Website</label>
<input
id="website"
name="website"
type="text"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</div>

<button
type="submit"
disabled={isSubmitting}
Expand Down
4 changes: 3 additions & 1 deletion src/content/profile.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const DEFAULT_SITE_URL = "https://www.anasbutt.site";

export interface Profile {
name: string;
title: string;
Expand All @@ -17,7 +19,7 @@ export const profile: Profile = {
"AI engineer and full-stack engineer building auditable agent systems, developer tools, and production web applications with TypeScript, Python, FastAPI, and PostgreSQL.",
location: "Karachi, Pakistan",
availability: "Open to AI & full-stack roles",
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "https://anusbutt.com",
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? DEFAULT_SITE_URL,
sameAs: [
"https://github.com/anusbutt",
"https://x.com/iamanusbutt",
Expand Down
2 changes: 1 addition & 1 deletion src/server/contact/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getResend } from "@/server/email/resend";
import type { ContactInput } from "@/server/contact/validation";
import type { ContactInput } from "@/shared/contact";

interface ContactConfig {
from: string;
Expand Down
17 changes: 8 additions & 9 deletions src/server/contact/validation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export interface ContactInput {
name: string;
email: string;
message: string;
}
import {
CONTACT_FIELD_LIMITS,
isValidContactEmail,
type ContactInput,
} from "@/shared/contact";

interface ContactPayload {
name?: unknown;
Expand All @@ -12,7 +12,6 @@ interface ContactPayload {
}

const allowedKeys = new Set(["name", "email", "message", "website"]);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand Down Expand Up @@ -47,19 +46,19 @@ export function parseContactInput(value: unknown):
if (input.name.length === 0) {
return { ok: false, message: "Name is required." };
}
if (input.name.length > 100) {
if (input.name.length > CONTACT_FIELD_LIMITS.name) {
return { ok: false, message: "Name must be 100 characters or less." };
}
if (input.email.length === 0) {
return { ok: false, message: "Email is required." };
}
if (input.email.length > 254 || !emailPattern.test(input.email)) {
if (!isValidContactEmail(input.email)) {
return { ok: false, message: "Please enter a valid email address." };
}
if (input.message.length === 0) {
return { ok: false, message: "Message is required." };
}
if (input.message.length > 2000) {
if (input.message.length > CONTACT_FIELD_LIMITS.message) {
return { ok: false, message: "Message must be 2000 characters or less." };
}

Expand Down
20 changes: 20 additions & 0 deletions src/shared/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ContactInput {
name: string;
email: string;
message: string;
}

export const CONTACT_FIELD_LIMITS = {
name: 100,
email: 254,
message: 2000,
} as const;

export const CONTACT_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function isValidContactEmail(email: string): boolean {
return (
email.length <= CONTACT_FIELD_LIMITS.email &&
CONTACT_EMAIL_PATTERN.test(email)
);
}
40 changes: 40 additions & 0 deletions tests/contact.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ test.describe("contact API boundary", () => {
await expect(response.json()).resolves.toMatchObject({ success: false });
});

test("rejects populated honeypot input", async ({ request }) => {
const response = await request.post("/api/contact", {
data: {
name: "Automated visitor",
email: "visitor@example.com",
message: "This should not be delivered.",
website: "https://bot.example/",
},
});
expect(response.status()).toBe(400);
await expect(response.json()).resolves.toEqual({ success: false, message: "Unable to process this request." });
});

test("reports missing email configuration without exposing server details", async ({ request }) => {
const response = await request.post("/api/contact", {
data: {
name: "Test visitor",
email: "visitor@example.com",
message: "Hello from an automated acceptance test.",
website: "",
},
});
expect(response.status()).toBe(503);
Expand All @@ -32,4 +46,30 @@ test.describe("contact API boundary", () => {
message: "Contact service is temporarily unavailable.",
});
});
});

test("submits the contact form with an empty honeypot", async ({ page }) => {
let requestBody: Record<string, unknown> | undefined;
await page.route("/api/contact", async (route) => {
requestBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, message: "Message sent successfully." }),
});
});

await page.goto("/");
await page.locator("#name").fill("Test visitor");
await page.locator("#email").fill("visitor@example.com");
await page.locator("#message").fill("Hello from an automated acceptance test.");
await page.getByRole("button", { name: "Send message" }).click();

await expect(page.getByRole("status")).toHaveText("Message sent successfully! I'll get back to you soon.");
expect(requestBody).toMatchObject({
name: "Test visitor",
email: "visitor@example.com",
message: "Hello from an automated acceptance test.",
website: "",
});
});
3 changes: 3 additions & 0 deletions tests/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ test("homepage positioning and responsive layout", async ({ page }, testInfo) =>
await contactHeading.scrollIntoViewIfNeeded();
await expect(contactHeading).toBeVisible();
await expect(page.getByText("Founder of Omniveer.", { exact: true })).toBeVisible();
await expect(page.locator("#website")).toBeAttached();
await expect(page.locator("#website").locator("..")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#website")).toHaveAttribute("tabindex", "-1");
await expect(
page.locator("#contact").getByText("Duct Lead Qualifier", { exact: false }),
).toHaveCount(0);
Expand Down
5 changes: 3 additions & 2 deletions tests/seo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import { expect, test } from "@playwright/test";
test("metadata and SEO routes are available", async ({ request }) => {
const robots = await request.get("/robots.txt");
expect(robots.ok()).toBe(true);
expect(await robots.text()).toContain("Sitemap:");
expect(await robots.text()).toContain("Sitemap: https://www.anasbutt.site/sitemap.xml");

const sitemap = await request.get("/sitemap.xml");
expect(sitemap.ok()).toBe(true);
expect(await sitemap.text()).toContain("<loc>");
expect(await sitemap.text()).toContain("<loc>https://www.anasbutt.site/</loc>");

const response = await request.get("/");
expect(response.ok()).toBe(true);
const html = await response.text();
expect(html).toContain('rel="canonical"');
expect(html).toContain("https://www.anasbutt.site");
expect(html).toContain('property="og:title"');
});
Loading