feat(bimi): reconcile the BIMI record to Cloudflare and keep it in step with the logo

The reconciler never wrote the BIMI record: only the console's verification
knew about it, so a stored logo was never published to the zone.

- SyncDnsRecordsInput carries the BIMI logo and certificate URLs; the record is
  created, updated (a= added or dropped) and, when the logo is removed,
  deleted. Only a record carrying this reconciler's comment is retired: one
  made by hand is left alone
- bimi.Publish decides the record in one place for the timer sweep, the
  console's button and the panel's verification. A held certificate is
  advertised only while it is valid for the logo being served: replacing the
  logo ends the certificate, and a record that kept naming it makes every
  receiver reject BIMI, including those that need no certificate
- saving or removing the logo or the certificate triggers the reconciliation in
  the background; the panel shows the record as the server writes it, says when
  a certificate is held back and why, and has a publish-now button
- a read error is not 'no logo': the sync leaves the record alone instead of
  deleting it over a database hiccup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-16 18:17:07 -03:00
1 parent 0ff9028927
commit 6e49ca00e2
17 files changed
+641 -59

No files matched your search

+7 -1
View File
@@ -187,7 +187,13 @@
"bimiError_noSize": "The SVG has no size (viewBox, or width and height), so it cannot be positioned.",
"bimiError_empty": "No drawing could be found in that file.",
"bimiError_notImage": "Choose a PNG or SVG file.",
"bimiError_tooComplex": "The drawing is too complex to fit in 32 KB."
"bimiError_tooComplex": "The drawing is too complex to fit in 32 KB.",
"bimiPublishNow": "Publish to DNS now",
"bimiPublishing": "Publishing...",
"bimiPublished": "Published to DNS: {created} created, {updated} updated, {deleted} removed.",
"bimiPublishUnavailable": "Publishing to DNS is not set up in this environment (the Cloudflare token is missing).",
"bimiAutoPublish": "When you save or remove the logo or the certificate, the record is published to DNS automatically.",
"bimiWithheld": "The stored certificate is not being advertised in DNS because it no longer applies to this logo ({reason}). Without it the record stays valid for providers that do not require a certificate."
},
"auth": {
"adminLoginTitle": "Admin console",
+7 -1
View File
@@ -187,7 +187,13 @@
"bimiError_noSize": "El SVG no tiene tamaño (viewBox, o width y height), así que no se puede colocar.",
"bimiError_empty": "No se encontró ningún dibujo en ese archivo.",
"bimiError_notImage": "Elija un archivo PNG o SVG.",
"bimiError_tooComplex": "El dibujo es demasiado complejo para caber en 32 KB."
"bimiError_tooComplex": "El dibujo es demasiado complejo para caber en 32 KB.",
"bimiPublishNow": "Publicar en el DNS ahora",
"bimiPublishing": "Publicando...",
"bimiPublished": "Publicado en el DNS: {created} creado(s), {updated} actualizado(s), {deleted} eliminado(s).",
"bimiPublishUnavailable": "La publicación en el DNS no está configurada en este entorno (falta el token de Cloudflare).",
"bimiAutoPublish": "Al guardar o quitar el logotipo o el certificado, el registro se publica en el DNS automáticamente.",
"bimiWithheld": "El certificado guardado no se está anunciando en el DNS porque ya no corresponde a este logotipo ({reason}). Sin él, el registro sigue siendo válido para los proveedores que no exigen certificado."
},
"auth": {
"adminLoginTitle": "Consola de administración",
+7 -1
View File
@@ -187,7 +187,13 @@
"bimiError_noSize": "O SVG não tem tamanho (viewBox, ou width e height), por isso não dá para posicioná-lo.",
"bimiError_empty": "Não foi possível encontrar um desenho nesse arquivo.",
"bimiError_notImage": "Escolha um arquivo PNG ou SVG.",
"bimiError_tooComplex": "O desenho é complexo demais para caber em 32 KB."
"bimiError_tooComplex": "O desenho é complexo demais para caber em 32 KB.",
"bimiPublishNow": "Publicar no DNS agora",
"bimiPublishing": "Publicando...",
"bimiPublished": "Publicado no DNS: {created} criado(s), {updated} atualizado(s), {deleted} removido(s).",
"bimiPublishUnavailable": "A publicação no DNS não está configurada neste ambiente (falta o token do Cloudflare).",
"bimiAutoPublish": "Ao salvar ou remover o logotipo ou o certificado, o registro é publicado no DNS automaticamente.",
"bimiWithheld": "O certificado guardado não está sendo anunciado no DNS porque não vale mais para este logotipo ({reason}). Sem ele, o registro continua válido para os provedores que não exigem certificado."
},
"auth": {
"adminLoginTitle": "Console de administração",
+64 -5
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Upload, ZoomIn, ZoomOut, Download, Trash2, CheckCircle2, AlertTriangle, Copy, Play } from "lucide-react";
import { Upload, ZoomIn, ZoomOut, Download, Trash2, CheckCircle2, AlertTriangle, Copy, Play, CloudUpload } from "lucide-react";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { useTranslations } from "../i18n/provider";
@@ -50,6 +50,17 @@ interface BimiState {
logo_url?: string;
certificate_url?: string;
certificate?: CertificateInfo;
/** The TXT record as the reconciler writes it. */
dns_record?: string;
/** Why a certificate that is held is left out of that record, if it is. */
certificate_withheld?: string;
/** Whether saving a change publishes the record by itself. */
dns_publishing?: boolean;
}
interface PublishOutcome {
ok: boolean;
message: string;
}
interface CheckResult {
@@ -131,6 +142,9 @@ export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
const [pem, setPem] = useState("");
const [certProblems, setCertProblems] = useState<string[]>([]);
const [publishing, setPublishing] = useState(false);
const [published, setPublished] = useState<PublishOutcome | null>(null);
const [checks, setChecks] = useState<{ ready: boolean; checks: CheckResult[] } | null>(null);
const [checking, setChecking] = useState(false);
const [copied, setCopied] = useState(false);
@@ -160,6 +174,7 @@ export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
setProblems([]);
setError(null);
setChecks(null);
setPublished(null);
setPem("");
setCertProblems([]);
setTitle(domain);
@@ -359,6 +374,36 @@ export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
}
};
// -------------------------------------------------------------- publish
/** Writes the domain's records to the DNS provider now, the same sync that runs on a timer. */
const publishNow = async () => {
setPublishing(true);
setPublished(null);
try {
const res = await fetch(`/api/v1/admin/dns/reconcile?${q}`, { method: "POST" });
const data = await res.json().catch(() => ({}));
if (res.status === 503) {
setPublished({ ok: false, message: t("admin.bimiPublishUnavailable") });
} else if (!res.ok) {
setPublished({ ok: false, message: data.message ?? t("errors.serverError") });
} else {
const problems = [...(data.errors ?? []), ...(data.conflicts ?? [])];
setPublished({
ok: problems.length === 0,
message:
t("admin.bimiPublished", { created: data.created ?? 0, updated: data.updated ?? 0, deleted: data.deleted ?? 0 }) +
(problems.length ? ` ${problems.join("; ")}` : ""),
});
setChecks(null);
}
} catch {
setPublished({ ok: false, message: t("errors.serverError") });
} finally {
setPublishing(false);
}
};
// --------------------------------------------------------------- check
const runCheck = async () => {
@@ -374,9 +419,7 @@ export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
}
};
const record = bimi?.configured
? `v=BIMI1; l=${bimi.logo_url};${bimi.certificate?.present ? ` a=${bimi.certificate_url};` : vmcUrl.trim() ? ` a=${vmcUrl.trim()};` : ""}`
: "";
const record = bimi?.dns_record ?? "";
const copyRecord = async () => {
try {
@@ -699,14 +742,30 @@ export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
<div className="flex flex-col gap-1.5 rounded-lg bg-dark-panel px-3.5 py-3 shadow-edge">
<span className="font-mono text-[11.5px] text-slate-500">default._bimi.{domain} TXT</span>
<span className="break-all font-mono text-[12px] text-slate-200">{record}</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => void copyRecord()}>
<Copy className="h-3.5 w-3.5" />
<span>{copied ? t("admin.bimiCopied") : t("admin.bimiCopy")}</span>
</Button>
<Button variant="secondary" size="sm" onClick={() => void publishNow()} disabled={publishing}>
<CloudUpload className="h-3.5 w-3.5" />
<span>{publishing ? t("admin.bimiPublishing") : t("admin.bimiPublishNow")}</span>
</Button>
</div>
</div>
{bimi?.dns_publishing && <p className="text-[11.5px] leading-relaxed text-slate-500">{t("admin.bimiAutoPublish")}</p>}
{bimi?.certificate_withheld && (
<p className="text-[11.5px] leading-relaxed text-amber-400">
{t("admin.bimiWithheld", { reason: bimi.certificate_withheld })}
</p>
)}
{published && (
<p role="status" className={`text-[12px] leading-relaxed ${published.ok ? "text-slate-300" : "text-rose-300"}`}>
{published.message}
</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-3">
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiCheckTitle")}</h3>
<Button variant="secondary" size="sm" onClick={() => void runCheck()} disabled={checking}>