feat(logo): migrar token Logo.dev para runtime server-side

NEXT_PUBLIC_LOGO_DEV_TOKEN renomeado para LOGO_DEV_TOKEN — lido apenas
em runtime no servidor. URL construída nos endpoints /api/logo/mapping e
/api/logo/search; cliente nunca recebe o token. Novo server.ts com
isLogoDevEnabled() e buildLogoDevUrl(). LogoDevProvider (Context) propaga
flag `enabled` para Client Components. Build arg removido do Dockerfile
e do workflow docker-publish.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:52:24 +00:00
parent 6d81ff8b53
commit e005add233
12 changed files with 147 additions and 91 deletions

View File

@@ -58,7 +58,9 @@ OPENROUTER_API_KEY=
# === Logo.dev (Opcional) === # === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev # Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
# NEXT_PUBLIC_LOGO_DEV_TOKEN — token público (aparece no frontend, ok por design) # Ambas as variáveis são lidas em runtime pelo servidor — basta configurá-las
# LOGO_DEV_SECRET_KEY — chave secreta (apenas server-side, nunca expor) # no host (Coolify, Railway, Docker Compose etc.), sem mexer no CI.
NEXT_PUBLIC_LOGO_DEV_TOKEN= # LOGO_DEV_TOKEN — token público usado pelo servidor para montar a URL da imagem
# LOGO_DEV_SECRET_KEY — chave secreta para a Brand Search API (picker de logo)
LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY= LOGO_DEV_SECRET_KEY=

View File

@@ -85,8 +85,6 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
- name: Image digest - name: Image digest
run: echo ${{ steps.meta.outputs.digest }} run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -40,9 +40,9 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Token público do Logo.dev — injetado em build time (NEXT_PUBLIC_* é inlined pelo Next.js) # Nota: a integração Logo.dev não precisa mais de build args. O token
ARG NEXT_PUBLIC_LOGO_DEV_TOKEN # `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no
ENV NEXT_PUBLIC_LOGO_DEV_TOKEN=$NEXT_PUBLIC_LOGO_DEV_TOKEN # host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`.
# Build da aplicação Next.js # Build da aplicação Next.js
RUN pnpm build RUN pnpm build

View File

@@ -1,9 +1,10 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -13,33 +14,25 @@ export default async function DashboardLayout({
await connection(); await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const navbarData = await fetchDashboardNavbarData(session.user.id);
const logoDevEnabled = isLogoDevEnabled();
return ( return (
<PrivacyProvider> <LogoDevProvider enabled={logoDevEnabled}>
<AppNavbar <PrivacyProvider>
user={{ ...session.user, image: session.user.image ?? null }} <AppNavbar
pagadorAvatarUrl={navbarData.pagadorAvatarUrl} user={{ ...session.user, image: session.user.image ?? null }}
preLancamentosCount={navbarData.preLancamentosCount} pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
notificationsSnapshot={navbarData.notificationsSnapshot} preLancamentosCount={navbarData.preLancamentosCount}
/> notificationsSnapshot={navbarData.notificationsSnapshot}
<div className="relative flex flex-1 flex-col pt-16"> />
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36"> <div className="relative flex flex-1 flex-col pt-16">
<DotPattern <div className="@container/main flex flex-1 flex-col gap-2">
width={20} <div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
height={20} {children}
cx={1.25} </div>
cy={1.25}
cr={1.25}
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
</div>
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
{children}
</div> </div>
</div> </div>
</div> </PrivacyProvider>
</PrivacyProvider> </LogoDevProvider>
); );
} }

View File

@@ -1,26 +1,29 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries"; import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
/** /**
* GET /api/logo/mapping?name={name} * GET /api/logo/mapping?name={name}
* *
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento. * Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento,
* Usado pelo EstablishmentLogo para hidratar o domain salvo no banco. * junto com a `logoUrl` final (construída server-side com o token). O
* cliente usa `logoUrl` diretamente — sem precisar conhecer o token.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await getOptionalUserSession(); const session = await getOptionalUserSession();
if (!session) { if (!session) {
return NextResponse.json({ domain: null }, { status: 200 }); return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const name = searchParams.get("name")?.trim(); const name = searchParams.get("name")?.trim();
if (!name) { if (!name) {
return NextResponse.json({ domain: null }, { status: 200 }); return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
} }
const domain = await fetchEstablishmentLogoDomain(session.user.id, name); const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
return NextResponse.json({ domain }); const logoUrl = buildLogoDevUrl(domain);
return NextResponse.json({ domain, logoUrl });
} }

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search"; const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
@@ -8,6 +9,10 @@ interface LogoResult {
domain: string; domain: string;
} }
interface LogoResultWithUrl extends LogoResult {
logoUrl: string | null;
}
async function searchByStrategy( async function searchByStrategy(
q: string, q: string,
strategy: "match" | "typeahead", strategy: "match" | "typeahead",
@@ -66,12 +71,14 @@ export async function GET(request: Request) {
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade) // Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
const seen = new Set<string>(); const seen = new Set<string>();
const merged: LogoResult[] = []; const merged: LogoResultWithUrl[] = [];
for (const result of [...matchResults, ...typeaheadResults]) { for (const result of [...matchResults, ...typeaheadResults]) {
if (!seen.has(result.domain)) { if (!seen.has(result.domain)) {
seen.add(result.domain); seen.add(result.domain);
merged.push(result); // logoUrl é construída server-side com o token — o cliente nunca
// precisa conhecer LOGO_DEV_TOKEN para renderizar a imagem.
merged.push({ ...result, logoUrl: buildLogoDevUrl(result.domain) });
if (merged.length >= 20) break; if (merged.length >= 20) break;
} }
} }

View File

@@ -13,7 +13,7 @@ const sizeVariants = {
sm: { sm: {
container: "size-8", container: "size-8",
icon: "size-4", icon: "size-4",
text: "text-[10px]", text: "text-xs",
}, },
md: { md: {
container: "size-9", container: "size-9",

View File

@@ -9,7 +9,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/shared/components/ui/popover"; } from "@/shared/components/ui/popover";
import { Spinner } from "@/shared/components/ui/spinner"; import { Spinner } from "@/shared/components/ui/spinner";
import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo"; import { logoQueryKeys, toNameKey } from "@/shared/lib/logo";
import { import {
removeEstablishmentLogoAction, removeEstablishmentLogoAction,
saveEstablishmentLogoAction, saveEstablishmentLogoAction,
@@ -24,6 +24,8 @@ import { cn } from "@/shared/utils/ui";
interface LogoResult { interface LogoResult {
name: string; name: string;
domain: string; domain: string;
/** URL da imagem construída server-side — cliente usa direto sem token. */
logoUrl: string | null;
} }
async function fetchLogoResults(query: string): Promise<LogoResult[]> { async function fetchLogoResults(query: string): Promise<LogoResult[]> {
@@ -77,13 +79,14 @@ export function EstablishmentLogoPicker({
staleTime: 1000 * 60 * 60, staleTime: 1000 * 60 * 60,
}); });
function handleSelect(domain: string) { function handleSelect(result: LogoResult) {
startTransition(async () => { startTransition(async () => {
await saveEstablishmentLogoAction(name, domain); await saveEstablishmentLogoAction(name, result.domain);
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
domain, domain: result.domain,
logoUrl: result.logoUrl,
}); });
onSelect(domain); onSelect(result.domain);
}); });
} }
@@ -92,6 +95,7 @@ export function EstablishmentLogoPicker({
await removeEstablishmentLogoAction(name); await removeEstablishmentLogoAction(name);
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
domain: null, domain: null,
logoUrl: null,
}); });
onSelect(null); onSelect(null);
}); });
@@ -143,7 +147,7 @@ export function EstablishmentLogoPicker({
> >
{buildInitials(name)} {buildInitials(name)}
</div> </div>
<span className="w-full truncate text-[10px] leading-tight text-muted-foreground"> <span className="w-full truncate text-xs leading-tight text-muted-foreground">
Iniciais Iniciais
</span> </span>
</button> </button>
@@ -153,7 +157,7 @@ export function EstablishmentLogoPicker({
key={r.domain} key={r.domain}
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => handleSelect(r.domain)} onClick={() => handleSelect(r)}
className={cn( className={cn(
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50", "flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
resolvedDomain === r.domain && resolvedDomain === r.domain &&
@@ -161,9 +165,8 @@ export function EstablishmentLogoPicker({
)} )}
title={r.name} title={r.name}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={buildLogoDevUrl(r.domain) ?? ""} src={r.logoUrl ?? ""}
alt={r.name} alt={r.name}
width={36} width={36}
height={36} height={36}
@@ -173,7 +176,7 @@ export function EstablishmentLogoPicker({
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
}} }}
/> />
<span className="w-full truncate text-[10px] leading-tight"> <span className="w-full truncate text-xs leading-tight">
{r.name} {r.name}
</span> </span>
</button> </button>

View File

@@ -2,13 +2,9 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useState } from "react";
import { import { useLogoDevEnabled } from "@/shared/components/providers/logo-dev-provider";
buildLogoDevUrl, import { logoQueryKeys, toNameKey } from "@/shared/lib/logo";
LOGO_DEV_TOKEN,
logoQueryKeys,
toNameKey,
} from "@/shared/lib/logo";
import { import {
buildInitials, buildInitials,
getCategoryBgColorFromName, getCategoryBgColorFromName,
@@ -17,11 +13,14 @@ import {
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import { EstablishmentLogoPicker } from "./establishment-logo-picker"; import { EstablishmentLogoPicker } from "./establishment-logo-picker";
async function fetchLogoMapping( interface LogoMappingResponse {
name: string, domain: string | null;
): Promise<{ domain: string | null }> { logoUrl: string | null;
}
async function fetchLogoMapping(name: string): Promise<LogoMappingResponse> {
const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`); const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`);
if (!res.ok) return { domain: null }; if (!res.ok) return { domain: null, logoUrl: null };
return res.json(); return res.json();
} }
@@ -29,6 +28,8 @@ interface EstablishmentLogoProps {
name: string; name: string;
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */ /** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
domain?: string | null; domain?: string | null;
/** URL pré-construída no servidor — evita flicker no primeiro render. */
logoUrl?: string | null;
size?: number; size?: number;
className?: string; className?: string;
} }
@@ -36,31 +37,33 @@ interface EstablishmentLogoProps {
export function EstablishmentLogo({ export function EstablishmentLogo({
name, name,
domain: initialDomain, domain: initialDomain,
logoUrl: initialLogoUrl,
size = 32, size = 32,
className, className,
}: EstablishmentLogoProps) { }: EstablishmentLogoProps) {
const logoDevEnabled = useLogoDevEnabled();
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const hasPlaceholder =
initialDomain !== undefined || initialLogoUrl !== undefined;
const { data: mappingData } = useQuery({ const { data: mappingData } = useQuery({
queryKey: logoQueryKeys.mapping(toNameKey(name)), queryKey: logoQueryKeys.mapping(toNameKey(name)),
queryFn: () => fetchLogoMapping(name), queryFn: () => fetchLogoMapping(name),
placeholderData: placeholderData: hasPlaceholder
initialDomain !== undefined ? {
? { domain: initialDomain ?? null } domain: initialDomain ?? null,
: undefined, logoUrl: initialLogoUrl ?? null,
}
: undefined,
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "", enabled: logoDevEnabled,
}); });
const resolvedDomain = mappingData?.domain ?? null; const resolvedDomain = mappingData?.domain ?? null;
const logoUrl = mappingData?.logoUrl ?? null;
// biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar
useEffect(() => {
setImgError(false);
}, [resolvedDomain]);
const logoUrl = buildLogoDevUrl(resolvedDomain);
const showLogo = Boolean(logoUrl) && !imgError; const showLogo = Boolean(logoUrl) && !imgError;
const initials = buildInitials(name); const initials = buildInitials(name);
@@ -85,7 +88,6 @@ export function EstablishmentLogo({
const logoImage = const logoImage =
showLogo && logoUrl ? ( showLogo && logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={logoUrl} src={logoUrl}
alt={name} alt={name}
@@ -99,7 +101,7 @@ export function EstablishmentLogo({
initialsAvatar initialsAvatar
); );
if (!LOGO_DEV_TOKEN) { if (!logoDevEnabled) {
return ( return (
<div className={cn("shrink-0", className)} aria-hidden> <div className={cn("shrink-0", className)} aria-hidden>
{initialsAvatar} {initialsAvatar}

View File

@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
/**
* Expõe, para Client Components, se a integração Logo.dev está configurada.
*
* O valor é determinado server-side (lendo `LOGO_DEV_TOKEN` via
* `isLogoDevEnabled()` em `@/shared/lib/logo/server`) e passado como prop
* a partir do layout do dashboard — evitando que o cliente precise do token.
*/
const LogoDevContext = createContext<boolean>(false);
interface LogoDevProviderProps {
enabled: boolean;
children: React.ReactNode;
}
export function LogoDevProvider({ enabled, children }: LogoDevProviderProps) {
return (
<LogoDevContext.Provider value={enabled}>
{children}
</LogoDevContext.Provider>
);
}
export function useLogoDevEnabled(): boolean {
return useContext(LogoDevContext);
}

View File

@@ -1,14 +1,5 @@
/**
* Logo utilities
*
* Consolidated from:
* - /lib/logo.ts (utility functions)
*/
/** /**
* Normalizes logo path to get just the filename * Normalizes logo path to get just the filename
* @param logo - Logo path or URL
* @returns Filename only
*/ */
export const normalizeLogo = (logo?: string | null) => export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? ""; logo?.split("/").filter(Boolean).pop() ?? "";
@@ -45,13 +36,11 @@ export const deriveNameFromLogo = (logo?: string | null) => {
export const toNameKey = (name: string): string => name.trim().toLowerCase(); export const toNameKey = (name: string): string => name.trim().toLowerCase();
// === Logo.dev === // === Logo.dev ===
//
export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN; // A construção de URLs e a leitura do token acontecem server-side em
// `./server.ts`. O cliente consome `logoUrl` pré-construída a partir das
export function buildLogoDevUrl(domain?: string | null): string | null { // API routes (`/api/logo/mapping` e `/api/logo/search`) e usa o
if (!LOGO_DEV_TOKEN || !domain) return null; // `LogoDevProvider` para saber se a integração está habilitada.
return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`;
}
export const logoQueryKeys = { export const logoQueryKeys = {
mapping: (nameKey: string) => ["logo-mapping", nameKey] as const, mapping: (nameKey: string) => ["logo-mapping", nameKey] as const,

View File

@@ -0,0 +1,30 @@
/**
* Helpers server-only para Logo.dev.
*
* IMPORTANTE: este módulo lê `process.env.LOGO_DEV_TOKEN`, que não existe
* no bundle do cliente. Nunca importe este arquivo de Client Components
* — use o `LogoDevProvider` para propagar o estado `enabled` e consuma
* `logoUrl` a partir das respostas das API routes.
*/
function getLogoDevToken(): string | undefined {
const token = process.env.LOGO_DEV_TOKEN;
return token && token.length > 0 ? token : undefined;
}
/**
* Indica se a integração Logo.dev está configurada.
* Usado para habilitar o picker e a exibição de logos na UI.
*/
export function isLogoDevEnabled(): boolean {
return getLogoDevToken() !== undefined;
}
/**
* Constrói a URL final da imagem Logo.dev com o token aplicado server-side.
* Retorna null se o token não estiver configurado ou se o domínio for vazio.
*/
export function buildLogoDevUrl(domain?: string | null): string | null {
const token = getLogoDevToken();
if (!token || !domain) return null;
return `https://img.logo.dev/${domain}?token=${token}&size=64&format=png`;
}