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:
1 parent
0ff9028927
commit
6e49ca00e2
17 files changed
+641
-59
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user