Pular para o conteúdo
Node.js

Node.js: moderando imagens com TensorFlow

Paloma Macetko
Ilustração colorida de um unicórnio guardião diante de um portal em forma de moldura de foto, com uma coruja de óculos examinando fotografias flutuantes com uma lupa e cinco etiquetas de aprovação ao lado, castelo com velas ao fundo

Olá meus Unicórnios! 🦄✨

Todo site que aceita upload de imagem tem, mais cedo ou mais tarde, o mesmo problema: alguém vai mandar aquilo que você não quer hospedar. 😬 E moderar na mão não escala — nem que seja um site pequeno, ninguém quer abrir a pasta de uploads de manhã com medo do que vai ver.

A solução óbvia hoje é mandar a imagem para uma API de moderação de alguma nuvem. Funciona muito bem — e cobra por imagem, e leva a foto dos seus usuários para fora. Eu queria o contrário: rodar a classificação na minha própria máquina, sem enviar nada para lugar nenhum, sem conta em nuvem e sem contador girando.

Foi aí que montei esta API: um servidor Express que recebe a URL de uma imagem e devolve o que ela é, usando TensorFlow.js puro com o modelo do nsfwjs. E este artigo é a história dela — incluindo as duas coisas que quase me fizeram desistir na primeira tarde. 😅

Exemplos_ModerarImagens no GitHubA API completa, com página de demonstração para você colar uma URL e ver as cinco classes.github.com

🧠 Por que TensorFlow.js "puro" e não o tfjs-node

Essa foi a primeira decisão, e ela define o resto do projeto.

O TensorFlow.js no Node tem duas encarnações. O @tensorflow/tfjs-node é o rápido: usa o TensorFlow nativo em C++, com aceleração de verdade. O @tensorflow/tfjs é o "puro" — JavaScript do começo ao fim, o mesmo que roda no navegador.

O rápido parece a escolha óbvia. Só que ele é um módulo nativo, e módulo nativo no Windows significa a saga de sempre: Python, Visual Studio Build Tools, uma compilação que falha com um erro de 400 linhas e você lendo issue de 2019 no GitHub. 🙃

Eu escolhi o puro de propósito, e a lista de pré-requisitos do projeto virou isto aqui:

node --version   # v18 ou superior. So isso.

Sem Python. Sem compilador. Sem build tools. Um npm install e roda — na minha máquina, no servidor, no Docker, em qualquer lugar. As dependências inteiras cabem em cinco linhas:

{
  "dependencies": {
    "@tensorflow/tfjs": "^4.22.0",
    "dotenv": "^16.4.5",
    "express": "^4.21.2",
    "nsfwjs": "^4.2.1",
    "sharp": "^0.33.5"
  }
}

O preço? Ele avisa, toda vez que sobe. Literalmente:

============================
Hi, looks like you are running TensorFlow.js in Node.js. To speed things up
dramatically, install our node backend, visit
https://github.com/tensorflow/tfjs-node for more details.
============================

"Speed things up dramatically". 😅 Dá até medo. Mas segura essa informação, porque lá na frente eu meço quanto custa de verdade — e a resposta me surpreendeu.

😤 A primeira mentira: "é só carregar o modelo do disco"

Essa foi a hora em que eu achei que ia ser rápido e não foi.

O nsfwjs tem uma função load() que, na documentação, resolve tudo. No navegador você aponta para uma URL e pronto. No Node, com o tfjs-node instalado, você aponta para file://caminho/do/modelo e também pronto.

Eu não tenho o tfjs-node. E aí descobri o detalhe cruel: o TensorFlow.js puro não tem IOHandler de sistema de arquivos. 😳

Faz todo sentido quando você para para pensar — o tfjs "puro" é o build do navegador, e navegador não lê disco. Quem implementa o file:// é justamente o pacote nativo que eu recusei. Só que a mensagem de erro não te conta isso; ela reclama de um handler que não existe e te deixa achando que errou o caminho do arquivo.

A saída é ler os arquivos você mesma e montar o handler na memória. O tfjs tem exatamente a peça para isso, o tf.io.fromMemory() — ele só quer os três pedaços que compõem um modelo: a topologia, as especificações dos pesos e os bytes dos pesos.

const fs = require('fs/promises');
const tf = require('@tensorflow/tfjs');

// Monta um IOHandler em memoria a partir do model.json + shards no disco.
async function fileSystemHandler(modelDir) {
  const json = JSON.parse(await fs.readFile(`${modelDir}/model.json`, 'utf8'));
  const specs = [];
  const partes = [];

  for (const grupo of json.weightsManifest) {
    specs.push(...grupo.weights);
    for (const arquivo of grupo.paths) {
      const buf = await fs.readFile(`${modelDir}/${arquivo}`);
      // Recorta so a regiao deste Buffer (ele pode compartilhar um pool maior).
      partes.push(new Uint8Array(buf));
    }
  }

  return tf.io.fromMemory({
    modelTopology: json.modelTopology,
    weightSpecs: specs,
    weightData: Buffer.concat(partes).buffer,
  });
}

Aquele comentário do meio é o bug que me pegou, e vale o parágrafo. 🙏

Um Buffer do Node não é dono do seu próprio ArrayBuffer: para buffers pequenos, o Node os aloca dentro de um pool compartilhado maior. Se você passar buf.buffer direto, entrega ao TensorFlow o pool inteiro — que contém os seus pesos e mais um monte de bytes de outra coisa qualquer, com os seus começando num offset arbitrário. O modelo carrega, não dá erro nenhum, e classifica com pesos lixo.

Por isso o new Uint8Array(buf): ele enxerga exatamente os bytes daquele Buffer — do offset dele, no comprimento dele — e o Buffer.concat() depois junta tudo numa área nova e contínua. É uma linha que parece paranoia e é a diferença entre um modelo que funciona e um que devolve números aleatórios com toda a confiança do mundo. 😅

Com o handler pronto, o resto é o que a documentação prometia — e repare que o modelo é carregado uma vez só, guardado num módulo:

const MODEL_SIZE = 224;   // MobileNetV2 espera 224x224
let model = null;

async function loadModel() {
  if (!model) {
    const handler = await fileSystemHandler(MODEL_DIR);
    const instance = new NSFWJS(handler, { size: MODEL_SIZE, type: 'layers' });
    await instance.load();
    model = instance;
  }
  return model;
}

Carregar o modelo a cada requisição seria um desastre — são 2,5 MB de pesos e quase um segundo de trabalho. O servidor carrega antes de abrir a porta, e é por isso que o /health devolve um modelLoaded: para o balanceador não mandar tráfego para um processo que ainda está acordando.

async function start() {
  console.log('Loading nsfwjs model...');
  await loadModel();          // primeiro carrega...
  console.log('Model loaded.');
  const app = createApp();
  app.listen(config.PORT);    // ...depois escuta.
}

📦 De onde vem o modelo (e por que ele está no repositório)

O nsfwjs traz o MobileNetV2 embutido no próprio pacote npm, mas num formato dele — minificado, com o binário dos pesos em base64 dentro de um arquivo .js. Não dá para apontar o TensorFlow para aquilo.

Então tem um script que roda uma vez só e extrai o modelo para o formato padrão do TensorFlow.js (model.json + shard .bin). O trecho que eu mais gosto dele é a conferência:

const shardBuf = Buffer.from(shardStr, 'base64');

// Quantos bytes cada peso ocupa: quantizado usa 1 ou 2, float32 usa 4.
function bytesDoPeso(w) {
  const itens = w.shape.reduce((a, b) => a * b, 1);
  if (w.quantization) {
    return itens * (w.quantization.dtype === 'uint8' ? 1 : 2);
  }
  return itens * (w.dtype === 'float32' ? 4 : 1);
}

// Confere o tamanho decodificado contra o que o manifesto declara.
const esperado = modelJson.weightsManifest
  .flatMap((grupo) => grupo.weights)
  .reduce((total, w) => total + bytesDoPeso(w), 0);

if (shardBuf.length !== esperado) {
  throw new Error(`Tamanho do shard nao bate: ${shardBuf.length}, esperado ${esperado}`);
}

Ele soma, peso a peso, quantos bytes o manifesto diz que existem e compara com quantos bytes realmente saíram do base64. Se não bater, ele explode ali — na extração, num script que eu rodo consciente — em vez de deixar o erro aparecer em produção como uma classificação estranha. Repare no w.quantization: o modelo é quantizado, então cada peso ocupa 1 ou 2 bytes em vez de 4, e quem esquecer disso calcula o tamanho errado.

O resultado fica versionado no repositório, 2,5 MB:

model/
  group1-shard1of1.bin    2.619.461 bytes
  model.json                117.932 bytes

São 2,5 MB que não mudam nunca, e o melhor: eles saem do próprio pacote que o npm install já baixou. Nenhum download de modelo de lugar nenhum — a API sobe offline, e a pasta modelo/ se regenera com um comando.

🖼️ O sharp faz mais do que decodificar

O modelo não come JPEG — ele come um tensor de números. Alguém precisa transformar os bytes da imagem numa matriz altura × largura × 3 canais, e esse alguém é o sharp.

Só que essa função tem quatro chamadas encadeadas, e nenhuma delas é decoração:

async function decodeToTensor(buffer) {
  const cru = await sharp(buffer, { animated: false })
    .rotate()              // aplica a orientacao EXIF
    .toColourspace('srgb') // normaliza o espaco de cor
    .removeAlpha()         // 4 canais viram 3
    .raw()
    .toBuffer({ resolveWithObject: true })
    .catch((err) => {
      throw new Error(`Formato de imagem invalido ou corrompido: ${err.message}`);
    });

  const { width, height, channels } = cru.info;
  if (channels !== 3) {
    throw new Error(`Esperava 3 canais apos decodificar, veio ${channels}`);
  }

  const tensor = tf.tensor3d(new Uint8Array(cru.data), [height, width, 3], 'int32');
  return { tensor, width, height };
}

Vamos por partes, porque cada linha dessas evita um bug específico:

.rotate() sem argumento nenhum é a joia escondida. 💎 Chamado assim, vazio, ele não rotaciona um ângulo fixo: ele lê a orientação EXIF do arquivo e endireita a foto. Sem essa linha, foto de iPhone chega deitada no modelo — porque o iPhone grava a imagem na horizontal e anexa um "girar 90°" nos metadados. O seu olho nunca vê isso (o visualizador obedece o EXIF), mas o tensor cru, não. E um modelo de visão computacional classificando uma pessoa deitada de lado erra bem mais.

.removeAlpha() resolve o PNG. PNG tem 4 canais (RGB + transparência) e o MobileNetV2 quer 3. Sem isso, o tf.tensor3d recebe uma contagem de bytes que não fecha com a forma declarada e quebra — com uma mensagem sobre tamanho de tensor que não menciona transparência em momento algum. 🙄

A conferência channels !== 3 é o cinto de segurança: se alguma imagem exótica escapar (escala de cinza, CMYK), eu prefiro um erro claro a um tensor com a forma errada.

E de brinde vem o suporte a formato. Como quem decodifica é o sharp, a API aceita tudo que o sharp lê: JPEG, PNG, WebP, GIF, TIFF, SVG e — o que mais importa na prática — HEIC/HEIF, que é o formato que o iPhone manda por padrão.

Testei o caminho do erro com um arquivo .jpg que na verdade era texto. A parte final da mensagem vem do próprio sharp:

{
  "error": "Formato de imagem invalido ou corrompido: Input buffer contains unsupported image format",
  "elapsedMs": 64
}

64 ms para recusar. O sharp olha os primeiros bytes, não reconhece assinatura de formato nenhum e desiste — sem nunca acordar o TensorFlow.

🧹 O tensor que precisa ser liberado na mão

Esse é o tipo de coisa que só aparece depois que o serviço está no ar há um tempo. E é curto:

async function classify(buffer) {
  const { tensor, width, height } = await decodeToTensor(buffer);
  try {
    const predictions = await model.classify(tensor);
    return { predictions: sortPredictions(predictions), width, height };
  } finally {
    tensor.dispose();   // <-- este finally nao e opcional
  }
}

Tensor do TensorFlow.js não é coletado pelo garbage collector do JavaScript. A memória dele vive fora do heap do V8, e o tf.tensor3d reserva um bloco que só é devolvido quando você chama dispose(). Esqueceu? Vazamento — e um dos silenciosos, porque o processo cresce devagarinho enquanto o heapUsed jura que está tudo bem.

Repare que o dispose() está num finally, não depois do classify. É de propósito: se a classificação lançar exceção, o tensor ainda precisa ser liberado. Um dispose() na linha de baixo do return vaza exatamente nos casos de erro — que são justamente os que se repetem quando alguma coisa vai mal em produção. 😅

Esse detalhe também explica uma escolha do log. Para medir memória, o projeto usa rss, e não heapUsed:

// RSS — e nao heapUsed — porque os tensores e o buffer da imagem vivem em
// memoria nativa, fora do heap do V8. O delta e sinalizado (+/-) porque o GC
// pode rodar no meio da requisicao e deixar o processo menor do que comecou.
function formatMem(startRss) {
  const rss = process.memoryUsage().rss;
  const delta = rss - startRss;
  const sign = delta >= 0 ? '+' : '-';
  return `mem=${mb(rss)}MB (${sign}${mb(Math.abs(delta))}MB)`;
}

Medir o heap aqui te daria uma linha reta bonita e mentirosa. 📈

🎯 As cinco classes — e o que elas realmente medem

Agora a parte que eu achei mais interessante do projeto todo, e que ninguém escreve na documentação.

O nsfwjs devolve cinco classes com as probabilidades somando 1: Neutral, Drawing, Sexy, Hentai e Porn. E a decisão de moderação sai de somas, não de uma classe só:

function isPorn(predictions, threshold) {
  return probOf(predictions, 'Porn') + probOf(predictions, 'Hentai') >= threshold;
}

function isNSFW(predictions, threshold) {
  return (
    probOf(predictions, 'Porn') +
      probOf(predictions, 'Hentai') +
      probOf(predictions, 'Sexy') >=
    threshold
  );
}

Somar é essencial, e é fácil errar aqui. Uma imagem pode dar 35% Porn e 40% Hentai — nenhuma das duas passa de 60% sozinha, e olhando classe por classe você aprovaria uma imagem que o modelo está 75% convencido de que é pornografia. 😰 Por isso são dois critérios separados: isPorn (explícito) e isNSFW, que soma também o Sexy para quem quer ser mais conservador.

A descoberta: as classes medem o traço, não o assunto

Rodei a API contra as capas deste blog — ilustrações — e contra prints e fotos dos artigos antigos. Repare no que aconteceu.

Capa de um artigo sobre nobreak, ilustração de unicórnio:

{
  "predictions": [
    { "className": "Drawing", "probability": 0.9929920434951782 },
    { "className": "Neutral", "probability": 0.003550437744706869 },
    { "className": "Hentai",  "probability": 0.003429210279136896 },
    { "className": "Porn",    "probability": 0.00002208616024290677 },
    { "className": "Sexy",    "probability": 0.00000626325572739006 }
  ],
  "dominant": { "className": "Drawing", "probability": 0.9929920434951782 },
  "isPorn": false,
  "isNSFW": false,
  "image": { "width": 1280, "height": 853 },
  "elapsedMs": 1533
}

Achou "Drawing" com 99,3% — perfeito. Mas olha quem está em terceiro lugar: Hentai, acima de Porn e de Sexy. Numa ilustração de unicórnio ao lado de um nobreak. 🤨

Agora a mesma API num print de tela do AWS CloudWatch:

{
  "predictions": [
    { "className": "Neutral", "probability": 0.9333747029304504 },
    { "className": "Drawing", "probability": 0.064621202647686 },
    { "className": "Hentai",  "probability": 0.0018375966465100646 },
    { "className": "Porn",    "probability": 0.0000995250593405217 },
    { "className": "Sexy",    "probability": 0.00006696546188322827 }
  ],
  "dominant": { "className": "Neutral", "probability": 0.9333747029304504 },
  "isPorn": false, "isNSFW": false,
  "image": { "width": 1920, "height": 1280 }, "elapsedMs": 1098
}

E numa foto real, de um Arduino numa protoboard:

{
  "predictions": [
    { "className": "Neutral", "probability": 0.9167398810386658 },
    { "className": "Drawing", "probability": 0.07601407170295715 },
    { "className": "Sexy",    "probability": 0.0067705027759075165 },
    { "className": "Porn",    "probability": 0.00030024044099263847 },
    { "className": "Hentai",  "probability": 0.0001752842654241249 }
  ],
  "dominant": { "className": "Neutral", "probability": 0.9167398810386658 },
  "isPorn": false, "isNSFW": false,
  "image": { "width": 1920, "height": 1280 }, "elapsedMs": 1115
}

Viu a inversão? Na foto do Arduino, o Hentai despencou para último lugar (0,017%) e quem subiu foi o Sexy (0,68%) — quarenta vezes mais alto que na ilustração.

As classes de trás não estão medindo o quanto a imagem é imprópria. Estão medindo o meio. Para o modelo, Hentai é "desenho impróprio" e Porn/Sexy são "foto imprópria" — então uma ilustração inocente distribui sua incerteza entre as classes de desenho, e uma foto inocente distribui entre as classes de foto. O ruído segue o traço, não o conteúdo.

Isso tem uma consequência bem prática: não monte alarme em cima de uma classe isolada. Se você logar "imagens com Hentai acima de zero", vai encher o relatório de capas de blog e ilustração de gatinho. É exatamente por isso que a decisão é uma soma com limiar — e por isso o limiar padrão é 0,6, não 0,01.

🔐 A guarda que eu desliguei — e o comentário que deixei no lugar

Aqui tem uma decisão que vai contra o manual, e eu quero contar direito porque é o tipo de coisa que a gente encontra num código alheio e não entende. 😅

Uma API que recebe URL do usuário e vai buscar é um convite ao SSRF: eu mando http://169.254.169.254/latest/meta-data/ (o endereço mágico de metadados da AWS) e o seu servidor busca por mim, de dentro da sua rede, e me conta o que achou. Por isso o projeto nasceu com um ssrfGuard.js completo, que bloqueia faixas privadas de IPv4 e IPv6.

Só que o meu caso de uso precisava classificar imagens de um serviço interno, em localhost. E aí a guarda foi desligada — com o motivo escrito no código:

const host = parsed.hostname;
// SECURITY: private/loopback/reserved IP rejection is DELIBERATELY DISABLED.
// The service owner explicitly opted to allow internal/localhost image hosts
// (e.g. http://localhost:4000/...) and accepted the SSRF risk. This includes
// reachability of internal services and cloud metadata (169.254.169.254). Do
// NOT re-enable range checks here without the owner's consent; if a safer
// posture is wanted, restore isPrivateIP() gated behind an env flag/allowlist.
if (net.isIP(host)) {
  return [host];
}

Repare que a função isPrivateIP() continua ali, inteira e testada — com testes cobrindo faixa privada de IPv4, loopback, link-local, ULA de IPv6 e endereço IPv4 mapeado em IPv6. Ela só não é chamada.

Isso foi de propósito, e é o conselho que eu levo para outros projetos: quando desligar uma proteção, desligue a chamada, não apague o código. Quem chegar depois encontra a peça pronta, o comentário dizendo quem decidiu e o que foi aceito em troca, e os testes provando que ela funciona. Apagar teria transformado uma decisão consciente num buraco silencioso — e daqui a um ano ninguém saberia dizer se aquilo foi escolha ou esquecimento. 🙈

O que continua ligado, porque não custa nada:

if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
  throw new SsrfError('Image URL must use http or https');
}
if (parsed.username || parsed.password) {
  throw new SsrfError('Image URL must not contain credentials');
}

O bloqueio de credencial na URL vale a linha: https://usuario:senha@host/x.jpg é uma forma clássica de fazer o servidor autenticar em algum lugar por você.

As duas guardas que sobreviveram inteiras

Essas duas eu não desligaria por nada, e são as mais espertas do projeto.

Um: redirecionamento não é seguido.

response = await fetch(url, { signal: controller.signal, redirect: 'manual' });
// ...
if (response.status >= 300 && response.status < 400) {
  throw new Error('Image URL must not redirect');
}

Porque validar a URL de entrada não serve de nada se ela responder 302 apontando para outro lugar. Eu passo https://meusite.com/foto.jpg, você valida e aprova, e o meu site responde "vai para http://169.254.169.254/". Todo o cuidado da validação evaporou. Recusar o 3xx corta esse caminho inteiro.

E olha que engraçado: essa guarda me pegou no meu próprio teste. Quando tentei classificar uma imagem apontando para o blog em produção:

{
  "error": "Image URL must not redirect",
  "elapsedMs": 169
}

Não era ataque nenhum — era o redirecionamento normal de canonicalização do site. 😄 Levei alguns segundos até cair a ficha, e achei um ótimo lembrete de que essa guarda é agressiva mesmo: se o seu servidor de imagens redireciona (de http para https, ou de domínio para CDN), você precisa passar a URL final.

Dois: o tamanho é contado enquanto baixa.

const chunks = [];
let total = 0;
for await (const chunk of response.body) {
  total += chunk.byteLength;
  if (total > maxBytes) {
    controller.abort();
    throw new Error(`Image too large: > ${maxBytes} bytes`);
  }
  chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);

Tem uma checagem do Content-Length antes, que rejeita cedo quando o servidor é honesto. Mas o Content-Length é informação que o outro lado escolhe mandar — pode vir ausente, pode vir mentindo "2 MB" e despejar 8 GB. Contar byte a byte e abortar no meio é a única versão que funciona contra um servidor hostil.

⏱️ Quanto custa: os números reais

Agora aquele aviso do começo, o "speed things up dramatically". Vamos medir. 🧪

Primeiro a subida do processo:

require(): 142ms
loadModel(): 849ms
RSS depois de carregar: 181.3MB

Menos de um segundo para ler 2,5 MB de pesos e montar o modelo. E as classificações, direto do log do servidor (formato: imagem, veredito, tempo, memória):

[predict] .../nodejs-lendo-e-controlando-o-nobreak-nhs... -> Drawing 99.3% | isPorn=false | 1533ms | mem=171.6MB (+66.9MB)
[analyze] .../jt-express-api-rastreando-encomendas...     -> Drawing 99.9% | isPorn=false |  900ms | mem=169.9MB (+3.0MB)
[predict] .../home-assistant-gerenciando-tomadas-tuya...  -> Drawing 99.3% | isPorn=false |  887ms | mem=174.0MB (+8.4MB)
[predict] .../aws-cloudwatch-etapa-2-criando-graficos...  -> Neutral 93.3% | isPorn=false | 1098ms | mem=211.8MB (+41.1MB)
[predict] .../arduino-ligacao-com-potenciometro...        -> Neutral 91.7% | isPorn=false | 1115ms | mem=212.1MB (+68.1MB)

Três coisas que esse bloco conta:

A primeira requisição é a mais cara — 1533 ms contra ~890 ms das seguintes com imagem do mesmo tamanho. É o aquecimento: o tfjs aloca os buffers internos das operações na primeira passada e reaproveita depois. Não meça desempenho na primeira chamada; ela mente para pior. 📊

Imagem maior custa mais: as de 1920×1280 ficaram na casa de 1100 ms, as de 1280×853 em ~890 ms. Todas são reduzidas para 224×224 antes do modelo, então essa diferença é quase toda decodificação — trabalho do sharp, não do TensorFlow. Se o seu gargalo for esse, reduzir a imagem antes de mandar resolve mais que trocar de backend.

O delta de memória pula bastante (+3 MB a +68 MB) e o RSS total sobe devagar, de 163 MB para 212 MB. Isso é normal e é o motivo do sinal +/- no formatador: o coletor de lixo roda quando quer, então uma requisição pode terminar com o processo menor do que começou. O que importa acompanhar é a tendência ao longo de horas, não o número de uma requisição.

E o veredito sobre o tfjs puro: cerca de 1 segundo por imagem em CPU, sem nenhum binário nativo. Para moderar upload de usuário isso é folgado — o gargalo real vai ser a rede, não o modelo. Se você precisa classificar milhares de imagens por minuto, aí sim o tfjs-node paga a saga da instalação. Para o resto de nós, o aviso do console é mais assustador do que o problema. 😌

🚪 A API pronta

Juntando tudo, o servidor é pequeno:

function createApp() {
  const app = express();
  app.use(express.json({ limit: '1mb' }));

  app.get('/health', (req, res) => {
    res.json({ status: 'ok', modelLoaded: isModelLoaded() });
  });

  const auth = makeAuth(config.AUTH_TOKEN);
  app.post('/analyze', auth, analyzeHandler);
  app.post('/predict', auth, predictHandler);

  return app;
}

O /health fica fora da autenticação de propósito — monitoramento precisa alcançar ele sem segredo. Os dois endpoints de classificação exigem token, e a comparação é feita em tempo constante:

function safeEqual(a, b) {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

Um === comum sai fora no primeiro caractere diferente, e essa diferença de tempo — minúscula, mas mensurável — permite descobrir o token caractere por caractere. O timingSafeEqual sempre percorre tudo. (A conferência de tamanho antes é obrigatória: o timingSafeEqual lança exceção se os buffers tiverem comprimentos diferentes.)

Usando na prática:

curl -X POST http://localhost:3000/analyze \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"imageUrl":"https://exemplo.com/foto.jpg"}'

E os erros, todos capturados de verdade nos meus testes:

Sem token, o corpo é só isto, com HTTP 401:

{
  "error": "Unauthorized"
}

Com token válido mas requisição malformada, vem HTTP 400 — e repare que o elapsedMs é zero, porque nada foi baixado:

{
  "error": "Body must include a string \"imageUrl\"",
  "elapsedMs": 0
}

E o mesmo 400 para um esquema que a guarda recusa:

{
  "error": "Image URL must use http or https",
  "elapsedMs": 0
}

O mapa de status é a parte que eu mais recomendo copiar, porque separa culpa de quem:

// SsrfError e problemas de formato da URL sao erro do cliente (400);
// o resto (timeout, HTTP da origem, imagem grande demais) e erro de gateway.
if (err instanceof SsrfError || /https|invalid image url|credential/i.test(err.message)) {
  err.status = 400;
} else {
  err.status = 502;
}

400 quando quem chamou mandou algo errado. 502 quando o problema foi buscar a imagem lá fora. 500 só quando a classificação em si falhou. Isso importa de verdade quando você está olhando o painel às duas da manhã: um pico de 400 é cliente mal-configurado, um pico de 502 é a internet ou a origem das imagens caindo, e um pico de 500 é você. 😅

📝 O log que serve para duas coisas

Cada requisição escreve uma linha no console e um registro estruturado em logs/AAAA-MM-DD.jsonl. E o detalhe importante está no comentário:

// Melhor esforco: nunca lancar dentro do caminho da requisicao — uma falha de
// log nao pode transformar uma analise bem-sucedida em erro.
function append(record) {
  const now = new Date();
  const entry = { timestamp: now.toISOString(), ...record };
  fs.mkdir(LOG_DIR, { recursive: true }, (mkErr) => {
    if (mkErr) {
      console.error(`[analysisLog] could not create ${LOG_DIR}: ${mkErr.message}`);
      return;
    }
    fs.appendFile(logFilePath(now), JSON.stringify(entry) + '\n', (err) => {
      if (err) console.error(`[analysisLog] could not write log: ${err.message}`);
    });
  });
}

Tudo assíncrono e nenhum erro sobe. Disco cheio, permissão errada, pasta apagada — o log reclama no console e a requisição segue feliz. Seria muito bobo devolver 500 numa classificação que deu certo só porque não deu para anotar. 🤦‍♀️

O formato .jsonl (um JSON por linha) é escolha prática: dá para acompanhar com tail -f e processar depois sem carregar tudo na memória. É o formato para responder "quantas imagens foram bloqueadas semana passada?" — e para recalibrar o limiar, já que o registro guarda as cinco probabilidades e não só o veredito. Mudou de 0,6 para 0,5? Você consegue reprocessar o histórico e ver o que teria mudado, sem reclassificar imagem nenhuma.

🧪 Os testes rodam sem rede e sem modelo

São 26 testes, no runner nativo do Node (node --test, sem Jest, sem Mocha):

✔ rejects wrong token (0.2392ms)
✔ accepts correct Bearer token and calls next (0.1429ms)
✔ sortPredictions orders by probability desc (1.3251ms)
✔ sortPredictions does not mutate input (0.2644ms)
✔ isPorn true exactly at the boundary (0.1363ms)
✔ isPrivateIP flags IPv6 loopback/link-local/ULA/mapped (3.483ms)
✔ assertSafeUrl rejects credentials in url (0.1868ms)
✔ assertSafeUrl allows literal private IP host (SSRF range check disabled) (0.729ms)
ℹ tests 26
ℹ pass 26
ℹ fail 0
ℹ duration_ms 150.5487

150 milissegundos para a suíte inteira. Isso é possível porque nenhum teste carrega o modelo nem acessa a rede — eles cobrem a autenticação, a matemática dos limiares e a guarda de URL, que são justamente as partes onde um erro é silencioso.

Dois deles merecem menção. O isPorn true exactly at the boundary testa o valor exato do limiar — porque a diferença entre > e >= é o tipo de bug que passa despercebido para sempre. E o sortPredictions does not mutate input existe porque a função copia antes de ordenar:

function sortPredictions(predictions) {
  return [...predictions].sort((a, b) => b.probability - a.probability);
}

O .sort() do JavaScript ordena no lugar. Sem aquele [...], ordenar para exibir bagunçaria o array que o isPorn vai ler depois. Funcionaria mesmo assim (a soma não liga para ordem), mas é uma armadilha esperando o próximo que mexer no código — e o teste é o que garante que ela não volte.

E o último da lista é meu favorito pelo nome: assertSafeUrl allows literal private IP host (SSRF range check disabled). O teste documenta a decisão. Se alguém religar a guarda sem ler o comentário, esse teste quebra e conta a história inteira. 🎯

✅ O que eu levo deste projeto

Se você for construir moderação de imagem em Node, o resumo é esse:

  • O tfjs puro é suficiente para a maioria dos casos — ~1 s por imagem em CPU, zero dor de instalação. Só troque pelo nativo com número na mão.
  • Sem tfjs-node não existe carregar do disco: leia os arquivos e monte o handler com tf.io.fromMemory() — recortando o ArrayBuffer com slice().
  • sharp().rotate() vazio conserta as fotos de celular deitadas, e .removeAlpha() conserta os PNGs.
  • tensor.dispose() dentro de um finally, sempre. E monitore RSS, não heap.
  • Decida por soma de classes com limiar, nunca por uma classe isolada — as classes de trás medem o traço da imagem, não o quanto ela é imprópria.
  • Ao desligar uma proteção, deixe o código, o comentário e o teste. Decisão documentada não vira buraco esquecido.

E o melhor: nenhuma imagem sai da minha máquina, nenhuma fatura por requisição, e o modelo inteiro cabe em 2,5 MB. Para um problema que parecia exigir nuvem, ficou bem doméstico. 🏡✨

💾 O repositório

Está tudo lá, com uma página de demonstração — cole a URL de uma imagem e veja as cinco classes com as barras. O extrair-modelo.js gera os 2,5 MB a partir do pacote que o npm install já baixou:

Exemplos_ModerarImagens no GitHubnpm install, node extrair-modelo.js, node servidor.js. Só precisa do Node 18+.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também