diff --git a/.claude/launch.json b/.claude/launch.json index 197235e..e15a68e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } diff --git a/app/api/likes/[id]/route.ts b/app/api/likes/[id]/route.ts index 34df7dc..bd7e9be 100644 --- a/app/api/likes/[id]/route.ts +++ b/app/api/likes/[id]/route.ts @@ -7,6 +7,7 @@ import { incrementLikes, isRedisConfigured, } from '@/lib/redis'; +import { getClientIp } from '@/lib/request'; export const dynamic = 'force-dynamic'; @@ -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; diff --git a/app/api/reactions/[id]/route.ts b/app/api/reactions/[id]/route.ts new file mode 100644 index 0000000..45ebd54 --- /dev/null +++ b/app/api/reactions/[id]/route.ts @@ -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 (`":" → n`) no shape por seção. */ +const toArticleReactions = ( + fields: Record, +): 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); +} diff --git a/app/me/page.tsx b/app/me/page.tsx index fef5a56..2abd1e6 100644 --- a/app/me/page.tsx +++ b/app/me/page.tsx @@ -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'; @@ -113,16 +117,18 @@ export default async function Page() {
-
-

+

+ title="Em poucas palavras" + />
    {tldrCards.map((card) => ( @@ -167,14 +173,19 @@ export default async function Page() {
    - + title="Pessoalmente" + />

    Oi, eu sou o John!

    @@ -250,18 +261,20 @@ export default async function Page() {
    - -

    - As linguagens que mais aparecem nos meus repositórios - públicos. -

    + title="Stacks que eu codo" + subtitle="As linguagens que mais aparecem nos meus repositórios públicos." + /> ({ label: language.name, @@ -279,17 +292,20 @@ export default async function Page() {
    - -

    - Os gêneros e tags que dominam o que tenho ouvido no Last.fm. -

    + title="O que ando curtindo" + subtitle="Os gêneros e tags que dominam o que tenho ouvido no Last.fm." + /> ({ diff --git a/app/now/page.tsx b/app/now/page.tsx index 8b5c3e8..02b71ce 100644 --- a/app/now/page.tsx +++ b/app/now/page.tsx @@ -12,6 +12,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { Metadata } from 'next'; import { PageTitle } from '@/base/components/PageTitle'; +import { + SECTION_ACTION_CLASS, + SectionHeader, +} from '@/base/components/SectionHeader'; import { ActivityFeed, ArtistCard, @@ -25,7 +29,6 @@ import { LolRankedCard, RadarCard, RecentTrack, - SectionIcon, StarredRepos, } from '@/features/now/components'; import { getGithubDev, getGithubStarred } from '@/lib/github'; @@ -85,7 +88,7 @@ const HeadphonesIcon = () => (
    -

    - Radar atual -

    -

    - {RADAR_DESCRIPTION} -

    -
    - +
    )} @@ -281,36 +274,34 @@ export default async function NowPage() { aria-labelledby="listening-title" className="border-b border-site-border-subtle py-16" > -
    - - - -
    -

    } + id="listening-title" + title="No repeat do mês" + subtitle={LISTENING_DESCRIPTION} + action={ + - No repeat da semana -

    -

    - {LISTENING_DESCRIPTION} -

    -
    -
    + Ver no Last.fm → + + } + />
    -
    -

    - Trilha da semana -

    -
    +

    + Trilha do mês +

    {featuredTrack ? (
    @@ -318,14 +309,12 @@ export default async function NowPage() {
    ) : null} -
    -

    - Mais recentes -

    -
    +

    + Mais recentes +

    {recentTrackList.length > 0 ? (
      {recentTrackList.slice(0, 4).map((track, index) => ( @@ -348,14 +337,12 @@ export default async function NowPage() {
    -
    -

    - Companhia da semana -

    -
    +

    + Companhia do mês +

    {artists.length > 0 ? ( <>
    @@ -367,34 +354,19 @@ export default async function NowPage() { /> ))}
    -
    -

    - - Imagens via Spotify -

    - - Ver no Last.fm -
    +

    + + Imagens via Spotify +

    ) : (

    - Sem artistas da semana para mostrar. + Sem artistas do mês para mostrar.

    )}
    @@ -402,22 +374,18 @@ export default async function NowPage() {
    -
    - - - -
    -

    - Provável recaída -

    -

    - {PLAYING_DESCRIPTION} -

    -
    -
    +