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
15 changes: 15 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@
"PATH=/home/john/.nvm/versions/node/v22.15.0/bin:/usr/local/bin:/usr/bin:/bin yarn dev"
],
"port": 3000
},
{
"name": "prod",
"runtimeExecutable": "wsl.exe",
"runtimeArgs": [
"-d",
"Ubuntu",
"--cd",
"/home/john/Code/johnenderson.dev",
"--",
"bash",
"-c",
"PATH=/home/john/.nvm/versions/node/v22.15.0/bin:/usr/local/bin:/usr/bin:/bin yarn start"
],
"port": 3000
}
]
}
7 changes: 1 addition & 6 deletions app/api/likes/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
incrementLikes,
isRedisConfigured,
} from '@/lib/redis';
import { getClientIp } from '@/lib/request';

export const dynamic = 'force-dynamic';

Expand All @@ -21,12 +22,6 @@ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const isKnownArticle = (id: string) =>
ID_PATTERN.test(id) && getArticleLikesIds().has(id);

const getClientIp = (request: Request): string => {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return request.headers.get('x-real-ip') ?? 'unknown';
};

export async function GET(_request: Request, { params }: Params) {
const { id } = await params;

Expand Down
105 changes: 105 additions & 0 deletions app/api/reactions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { NextResponse } from 'next/server';

import {
getArticleLikesIds,
getArticleSectionSlugs,
} from '@/features/articles/lib/articles';
import { ArticleReactions, isReactionId } from '@/lib/reactions';
import {
checkReactionsRateLimit,
getReactionFields,
incrementReaction,
isRedisConfigured,
} from '@/lib/redis';
import { getClientIp } from '@/lib/request';

export const dynamic = 'force-dynamic';

type Params = { params: Promise<{ id: string }> };

const noStore = { headers: { 'Cache-Control': 'no-store' } };

const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;

// Whitelist: só artigos reais (mesma proteção da API de likes).
const isKnownArticle = (id: string) =>
ID_PATTERN.test(id) && getArticleLikesIds().has(id);

/** Converte o hash cru (`"<slug>:<reactionId>" → n`) no shape por seção. */
const toArticleReactions = (
fields: Record<string, number>,
): ArticleReactions => {
const reactions: ArticleReactions = {};

for (const [field, count] of Object.entries(fields)) {
const separator = field.lastIndexOf(':');
if (separator === -1) continue;

const slug = field.slice(0, separator);
const reaction = field.slice(separator + 1);
if (!isReactionId(reaction) || Number(count) <= 0) continue;

reactions[slug] = { ...reactions[slug], [reaction]: Number(count) };
}

return reactions;
};

export async function GET(_request: Request, { params }: Params) {
const { id } = await params;

if (!isKnownArticle(id)) {
return NextResponse.json({ reactions: null }, { ...noStore, status: 404 });
}

// Reações desativadas (sem Redis) — null faz o widget não renderizar.
if (!isRedisConfigured()) {
return NextResponse.json({ reactions: null }, noStore);
}

const fields = await getReactionFields(id);
return NextResponse.json({ reactions: toArticleReactions(fields) }, noStore);
}

export async function POST(request: Request, { params }: Params) {
const { id } = await params;

if (!isKnownArticle(id)) {
return NextResponse.json({ count: null }, { ...noStore, status: 404 });
}

if (!isRedisConfigured()) {
return NextResponse.json({ count: null }, noStore);
}

let body: { slug?: unknown; reaction?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ count: null }, { ...noStore, status: 400 });
}

const { slug, reaction } = body;

// Whitelist dupla: a reação precisa existir e o slug precisa ser um h2 real
// do artigo — bloqueia a criação de campos arbitrários no hash do Redis.
if (
typeof reaction !== 'string' ||
!isReactionId(reaction) ||
typeof slug !== 'string' ||
!getArticleSectionSlugs(id).has(slug)
) {
return NextResponse.json({ count: null }, { ...noStore, status: 400 });
}

const allowed = await checkReactionsRateLimit(getClientIp(request));
if (!allowed) {
return NextResponse.json(
{ count: null, error: 'rate_limited' },
{ ...noStore, status: 429 },
);
}

const count = await incrementReaction(id, `${slug}:${reaction}`);
return NextResponse.json({ count }, noStore);
}
84 changes: 50 additions & 34 deletions app/me/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ import {
faCode,
faGraduationCap,
faHandshake,
faHeadphones,
faIdCard,
faUser,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { Metadata } from 'next';

import { Card } from '@/base/components/Card';
import { PageTitle } from '@/base/components/PageTitle';
import { SectionHeader } from '@/base/components/SectionHeader';
import { TagList } from '@/features/about/components';
import { getGithubLanguages, getGithubUsername } from '@/lib/github';
import { getLastfmTopTags } from '@/lib/lastfm';
Expand Down Expand Up @@ -113,16 +117,18 @@ export default async function Page() {

<div className="h-px w-full bg-site-border-muted" />

<section
aria-labelledby="tldr-title"
className="flex w-full flex-col gap-6"
>
<h2
<section aria-labelledby="tldr-title" className="w-full">
<SectionHeader
icon={
<FontAwesomeIcon
icon={faIdCard}
aria-hidden="true"
className="size-4"
/>
}
id="tldr-title"
className="m-0 text-2xl font-bold text-site-foreground"
>
Em poucas palavras
</h2>
title="Em poucas palavras"
/>

<ul className="m-0 grid w-full list-none grid-cols-1 gap-3 p-0 sm:grid-cols-2 lg:grid-cols-4">
{tldrCards.map((card) => (
Expand Down Expand Up @@ -167,14 +173,19 @@ export default async function Page() {

<section
aria-labelledby="personally-title"
className="flex w-full max-w-3xl flex-col gap-6"
className="w-full max-w-3xl"
>
<h2
<SectionHeader
icon={
<FontAwesomeIcon
icon={faUser}
aria-hidden="true"
className="size-4"
/>
}
id="personally-title"
className="m-0 text-2xl font-bold text-site-foreground"
>
Pessoalmente
</h2>
title="Pessoalmente"
/>

<div className="flex flex-col gap-5 text-site-body-muted leading-relaxed">
<p className="m-0">Oi, eu sou o John!</p>
Expand Down Expand Up @@ -250,18 +261,20 @@ export default async function Page() {

<section
aria-labelledby="stacks-title"
className="flex w-full max-w-3xl flex-col gap-4"
className="w-full max-w-3xl"
>
<h2
<SectionHeader
icon={
<FontAwesomeIcon
icon={faCode}
aria-hidden="true"
className="size-4"
/>
}
id="stacks-title"
className="m-0 text-2xl font-bold text-site-foreground"
>
Stacks que eu codo
</h2>
<p className="m-0 text-sm text-site-body-muted">
As linguagens que mais aparecem nos meus repositórios
públicos.
</p>
title="Stacks que eu codo"
subtitle="As linguagens que mais aparecem nos meus repositórios públicos."
/>
<TagList
items={languages.map((language) => ({
label: language.name,
Expand All @@ -279,17 +292,20 @@ export default async function Page() {

<section
aria-labelledby="listening-title"
className="flex w-full max-w-3xl flex-col gap-4"
className="w-full max-w-3xl"
>
<h2
<SectionHeader
icon={
<FontAwesomeIcon
icon={faHeadphones}
aria-hidden="true"
className="size-4"
/>
}
id="listening-title"
className="m-0 text-2xl font-bold text-site-foreground"
>
O que ando curtindo
</h2>
<p className="m-0 text-sm text-site-body-muted">
Os gêneros e tags que dominam o que tenho ouvido no Last.fm.
</p>
title="O que ando curtindo"
subtitle="Os gêneros e tags que dominam o que tenho ouvido no Last.fm."
/>
<TagList
variant="neutral"
items={musicTags.map((tag, index) => ({
Expand Down
Loading
Loading