feat(bimi): logo studio, certificate upload and a setup check in the console

The console took pasted SVG text and a URL. It now:

- takes a PNG or SVG and makes the logo BIMI accepts (SVG Tiny PS, square,
  at most 32 KB). An SVG has what the format forbids removed and is told what;
  a PNG is traced into vectors in the browser (imagetracerjs, public domain),
  walking down quality presets until it fits. The person positions it as in the
  photo editor: drag, zoom, background colour, preview in a circle or rounded
  square, and downloads the exact SVG to give a certificate authority
- holds the .pem the authority returns, validated against the logo and domain,
  and serves it at /.well-known/bimi/vmc.pem (routed by the webmail)
- shows the DNS record and runs the setup check (logo, DMARC, record, public
  URLs, certificate)
- a golden-file contract: the SVGs the studio produces are validated by the
  server's own validator (Go) and pinned by the studio's tests (TS)
- found by those tests: an element inside a link kept its onclick when the
  link was unwrapped; links are now unwrapped before cleaning
- the BIMI and photo texts are in Brazilian Portuguese with accents (they had
  been written without), and in Spanish with accents

A VMC cannot be generated from an image: it is issued by a certificate
authority. The README and the panel say so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-16 17:04:35 -03:00
1 parent 109b8360dd
commit dabd1568d1
15 files changed
+1583 -164

No files matched your search

+36 -12
View File
@@ -194,21 +194,45 @@ Gmail or Outlook shows beside a message comes from the *recipient's* own
contacts, or from the sender's Google or Microsoft account. A self-hosted contacts, or from the sender's Google or Microsoft account. A self-hosted
server cannot put an image there. server cannot put an image there.
**BIMI** is what external clients do read, and it is per domain — one mark for **BIMI** is what external clients do read, and it is per domain: one mark for
everyone who sends from it. Publishing one needs: everyone who sends from it. Publishing one needs three things, and the console
(`/admin/domains`, under the domain) handles each:
- DMARC at `p=quarantine` or `p=reject`, which this server already sets; 1. **The logo.** Upload a PNG or an SVG. The studio converts it to what BIMI
- a logo in SVG Tiny Portable/Secure — square viewBox, one `<title>`, nothing accepts, SVG Tiny Portable/Secure on a square canvas of at most 32 KB with
active or external. `/admin/domains` validates an upload against every rule nothing active or external in it, and lets you place it the way the profile
at once and names the ones it fails, because a receiver that rejects a mark photo editor does: drag, zoom, background colour, with a preview inside the
simply shows nothing; circle or rounded square clients draw. An SVG has what the format forbids
- a **Verified Mark Certificate** for Gmail and Outlook to render it. It is removed (scripts, links, embedded bitmaps, editor metadata) and says what; a
issued against a registered trademark, costs on the order of US$1000 a year, PNG is traced into vector paths, which loses some detail, so send the SVG
and without it the record is still valid — some clients use it, those two do when you have it. The server validates the result again against every rule
not. at once, because a receiver that rejects a mark simply shows nothing.
2. **DMARC at `p=quarantine` or `p=reject`** with `pct=100`, which this server
already sets.
3. **A certificate, for Gmail and Apple Mail to render it.** A Verified Mark
Certificate (VMC, for a registered trademark) or a Common Mark Certificate
(CMC, for a mark in use) is **issued by a certificate authority**
(DigiCert, Entrust, SSL.com and others) after it checks the organisation.
It cannot be generated here from an image, and nothing signed here would be
trusted. What the console does: gives you the exact SVG to send the
authority, takes the `.pem` it returns, and refuses one that would not work
(the logo inside it must be byte for byte the published one, it must cover
the domain, be in date, and never contain a private key). It then serves it
from `https://<mail host>/.well-known/bimi/vmc.pem` and points the `a=` tag
there. Without a certificate the record is still valid and some clients use
it; Gmail and Apple Mail do not.
"Verificar configuracao" in the panel looks at what a receiver would: the logo
file, DMARC, the BIMI record in DNS, the public URL of the logo, the
certificate and its public URL, and names the one that is wrong.
The logo is served from `https://<mail host>/.well-known/bimi/default.svg` and The logo is served from `https://<mail host>/.well-known/bimi/default.svg` and
the record is published with the rest of the zone. the record is published with the rest of the zone. Replacing the logo under a
held certificate breaks the certificate, and the console says so.
A changed profile photo shows up at once everywhere in the webmail: every
photo carries a version that is bumped on save, in this tab and the others. The
server lets a browser reuse one for a minute and then revalidates it.
### DANE ### DANE
@@ -2,6 +2,8 @@ package bimi
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -150,3 +152,26 @@ func TestValidate_ReportsEveryViolationAtOnce(t *testing.T) {
} }
} }
} }
// The webmail's logo studio (services/webmail/src/lib/bimiLogo.ts) produces the
// files in testdata/studio, and its tests keep them current. This is the other
// half of the contract: every one of them has to pass the validator that guards
// the upload. A studio that starts producing something the server refuses
// fails here, instead of in front of an operator who did everything right.
func TestWhatTheStudioProducesIsAccepted(t *testing.T) {
files, err := filepath.Glob("testdata/studio/*.svg")
if err != nil || len(files) == 0 {
t.Fatalf("no studio output to check (%v)", err)
}
for _, file := range files {
t.Run(filepath.Base(file), func(t *testing.T) {
svg, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if violations := Validate(svg); len(violations) != 0 {
t.Errorf("the validator refused what the studio made: %v", violations)
}
})
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512"><title>Example</title><rect width="512" height="512" fill="#ffffff"/><g transform="matrix(2.048 0 0 2.048 51.2 153.6)"><defs><linearGradient id="g"><stop offset="0" stop-color="#ff0000" stop-opacity="1"/><stop offset="1" stop-color="#0000ff"/></linearGradient></defs>
<rect x="10" y="10" width="80" height="80" fill="url(#g)" stroke="none"/>
<circle cx="150" cy="50" r="40" fill="#123456"/></g></svg>

After

Width:  |  Height:  |  Size: 542 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512"><title>Example brand</title><rect width="512" height="512" fill="#0b5c55"/><g transform="matrix(2.662 0 0 2.662 -51.2 148.48)"><defs><linearGradient id="g"><stop offset="0" stop-color="#ff0000" stop-opacity="1"/><stop offset="1" stop-color="#0000ff"/></linearGradient></defs>
<rect x="10" y="10" width="80" height="80" fill="url(#g)" stroke="none"/>
<circle cx="150" cy="50" r="40" fill="#123456"/></g></svg>

After

Width:  |  Height:  |  Size: 550 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512"><title>Traced</title><rect width="512" height="512" fill="#ffffff"/><g transform="matrix(6.4 0 0 6.4 51.2 51.2)"><path fill="#dc1e1e" d="M 17.5 19 L 27.5 19 L 35 25.5 Q 37.7 29.3 36 37.5 L 29.5 45 Q 25.7 47.7 17.5 46 L 10 39.5 Q 7.3 35.7 9 27.5 L 15.5 20 L 17.5 19 Z "/><path fill="#143cc8" d="M 40 24 L 58 24 L 58 42 L 40 42 L 40 24 Z "/></g></svg>

After

Width:  |  Height:  |  Size: 487 B

+68 -3
View File
@@ -115,14 +115,79 @@
"totpRemoved": "Second factor removed, with its recovery codes.", "totpRemoved": "Second factor removed, with its recovery codes.",
"tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)", "tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)",
"bimiTitle": "BIMI for {domain}", "bimiTitle": "BIMI for {domain}",
"bimiExplainer": "BIMI is the only mechanism that shows an image beside your mail in external clients. It is per domain, not per user. Gmail and Outlook render it only when the record points at a VMC.", "bimiExplainer": "BIMI is the only mechanism that shows an image next to your mail in outside clients. It is per domain, not per user. Upload the logo as a PNG or SVG and the system converts it to the BIMI format. Gmail and Apple Mail show it only with a certificate (VMC or CMC) issued by a certificate authority, which this system cannot create.",
"bimiConfigured": "Logo published", "bimiConfigured": "Logo published",
"bimiNotConfigured": "No logo", "bimiNotConfigured": "No logo",
"bimiNoVmc": "No VMC: Gmail and Outlook will not show this logo.", "bimiNoVmc": "No VMC: Gmail and Outlook will not show this logo.",
"bimiSvg": "Logo (SVG Tiny PS)",
"bimiVmc": "VMC URL (optional)", "bimiVmc": "VMC URL (optional)",
"bimiRejected": "The logo does not meet SVG Tiny Portable/Secure:", "bimiRejected": "The logo does not meet SVG Tiny Portable/Secure:",
"bimiSaved": "Logo saved." "bimiSaved": "Logo saved.",
"bimiNone": "No logo",
"bimiChoose": "Choose PNG or SVG",
"bimiReplace": "Replace logo",
"bimiFormatHint": "PNG or SVG. The result is a square SVG Tiny PS of at most 32 KB.",
"bimiDragHint": "Drag to position. Mail clients show the logo inside a circle or a rounded square.",
"bimiZoom": "Zoom",
"bimiBackground": "Background colour",
"bimiBrandTitle": "Brand name (SVG title)",
"bimiMask": "Preview",
"bimiMask_circle": "Circle",
"bimiMask_rounded": "Rounded square",
"bimiMask_none": "No mask",
"bimiPreset": "Conversion quality",
"bimiPreset_high": "High (most faithful)",
"bimiPreset_balanced": "Balanced",
"bimiPreset_compact": "Compact",
"bimiPreset_tiny": "Minimal",
"bimiTraced": "The PNG was converted to vector, which loses some detail. For the sharpest result, upload the original SVG.",
"bimiAutoLowered": "The quality was lowered automatically to fit in 32 KB.",
"bimiSize": "{size} of {max}",
"bimiTooBig": "The file is over 32 KB. Pick a lower quality or simplify the drawing.",
"bimiRemoved": "Removed from the file, because BIMI does not allow them: {items}.",
"bimiTextWarning": "The logo contains text, which uses the reader's font and may look different. Convert the text to outlines in your editor.",
"bimiDownload": "Download SVG",
"bimiSaveLogo": "Save logo",
"bimiCertTitle": "Certificate (VMC or CMC)",
"bimiCertExplainer": "Gmail and Apple Mail show the logo only with a certificate issued by a certificate authority such as DigiCert, Entrust or SSL.com: a VMC, for a registered trademark, or a CMC, for a mark in use. This system cannot create that certificate. Download the SVG above, send it to the authority, and put the .pem file it returns here. Gmail accepts both types; other clients may require a VMC.",
"bimiCertChoose": "Upload .pem file",
"bimiCertReplace": "Replace certificate",
"bimiCertPaste": "Or paste the .pem contents",
"bimiCertSave": "Save certificate",
"bimiCertValid": "Valid certificate",
"bimiCertInvalid": "Certificate has problems",
"bimiCertIssued": "Issued to {organization} by {issuer}",
"bimiCertValidUntil": "Valid until {date}",
"bimiCertDomains": "Domains: {names}",
"bimiCertMatches": "It carries exactly this logo.",
"bimiCertMismatch": "The logo inside the certificate is not the one published. Upload the SVG it was issued for.",
"bimiCertRemove": "Remove certificate",
"bimiCertSaved": "Certificate saved.",
"bimiCertRejected": "The certificate was refused:",
"bimiRecordTitle": "DNS record",
"bimiRecordHint": "This TXT record is created by the DNS reconciliation. Check it, or create it by hand at your provider.",
"bimiCopy": "Copy",
"bimiCopied": "Copied",
"bimiCheckTitle": "Check the setup",
"bimiCheckExplainer": "Looks at DNS and the public URLs the way a mail server would, and says which part is wrong.",
"bimiCheckRun": "Check now",
"bimiCheckRunning": "Checking...",
"bimiCheckReady": "Everything is in place for BIMI.",
"bimiCheckNotReady": "Some items need fixing.",
"bimiCheckStatus_ok": "OK",
"bimiCheckStatus_warn": "Notice",
"bimiCheckStatus_fail": "Failed",
"bimiCheckItem_logo": "Logo file",
"bimiCheckItem_dmarc": "DMARC policy",
"bimiCheckItem_record": "BIMI record in DNS",
"bimiCheckItem_logoUrl": "Public URL of the logo",
"bimiCheckItem_certificate": "Certificate",
"bimiCheckItem_certificateUrl": "Public URL of the certificate",
"bimiError_notXml": "That file is not a valid SVG.",
"bimiError_notSvg": "That file is not an SVG.",
"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."
}, },
"auth": { "auth": {
"adminLoginTitle": "Admin console", "adminLoginTitle": "Admin console",
+71 -6
View File
@@ -115,14 +115,79 @@
"totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.", "totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.",
"tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)", "tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)",
"bimiTitle": "BIMI de {domain}", "bimiTitle": "BIMI de {domain}",
"bimiExplainer": "BIMI es el unico mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Gmail y Outlook solo lo dibujan cuando el registro apunta a un VMC.", "bimiExplainer": "BIMI es el único mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Suba el logotipo en PNG o SVG y el sistema lo convierte al formato de BIMI. Gmail y Apple Mail solo lo muestran con un certificado (VMC o CMC) emitido por una autoridad certificadora, que este sistema no puede crear.",
"bimiConfigured": "Logotipo publicado", "bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sin logotipo", "bimiNotConfigured": "Sin logotipo",
"bimiNoVmc": "Sin VMC: Gmail y Outlook no mostraran este logotipo.", "bimiNoVmc": "Sin certificado: Gmail y Apple Mail no mostrarán este logotipo.",
"bimiSvg": "Logotipo (SVG Tiny PS)", "bimiVmc": "URL del certificado (opcional)",
"bimiVmc": "URL del VMC (opcional)",
"bimiRejected": "El logotipo no cumple SVG Tiny Portable/Secure:", "bimiRejected": "El logotipo no cumple SVG Tiny Portable/Secure:",
"bimiSaved": "Logotipo guardado." "bimiSaved": "Logotipo guardado.",
"bimiNone": "Sin logotipo",
"bimiChoose": "Elegir PNG o SVG",
"bimiReplace": "Cambiar logotipo",
"bimiFormatHint": "PNG o SVG. El resultado es un SVG Tiny PS cuadrado de como máximo 32 KB.",
"bimiDragHint": "Arrastre para colocar. Los clientes de correo muestran el logotipo dentro de un círculo o de un cuadrado redondeado.",
"bimiZoom": "Zoom",
"bimiBackground": "Color de fondo",
"bimiBrandTitle": "Nombre de la marca (título del SVG)",
"bimiMask": "Vista previa",
"bimiMask_circle": "Círculo",
"bimiMask_rounded": "Cuadrado redondeado",
"bimiMask_none": "Sin máscara",
"bimiPreset": "Calidad de la conversión",
"bimiPreset_high": "Alta (más fiel)",
"bimiPreset_balanced": "Equilibrada",
"bimiPreset_compact": "Compacta",
"bimiPreset_tiny": "Mínima",
"bimiTraced": "El PNG se convirtió a vector, lo que pierde algo de detalle. Para el resultado más nítido, suba el SVG original.",
"bimiAutoLowered": "La calidad se redujo automáticamente para caber en 32 KB.",
"bimiSize": "{size} de {max}",
"bimiTooBig": "El archivo supera 32 KB. Elija una calidad menor o simplifique el dibujo.",
"bimiRemoved": "Se quitaron del archivo, porque BIMI no los permite: {items}.",
"bimiTextWarning": "El logotipo contiene texto, que usa la fuente de quien lee el mensaje y puede verse distinto. Convierta el texto en curvas en su editor.",
"bimiDownload": "Descargar SVG",
"bimiSaveLogo": "Guardar logotipo",
"bimiCertTitle": "Certificado (VMC o CMC)",
"bimiCertExplainer": "Gmail y Apple Mail solo muestran el logotipo con un certificado emitido por una autoridad certificadora, como DigiCert, Entrust o SSL.com: VMC, para marca registrada, o CMC, para marca en uso. El sistema no crea ese certificado. Descargue el SVG de arriba, envíelo a la autoridad y ponga aquí el archivo .pem que le devuelva. Gmail acepta los dos tipos; otros clientes pueden exigir VMC.",
"bimiCertChoose": "Subir archivo .pem",
"bimiCertReplace": "Cambiar certificado",
"bimiCertPaste": "O pegue el contenido del .pem",
"bimiCertSave": "Guardar certificado",
"bimiCertValid": "Certificado válido",
"bimiCertInvalid": "Certificado con problemas",
"bimiCertIssued": "Emitido para {organization} por {issuer}",
"bimiCertValidUntil": "Válido hasta {date}",
"bimiCertDomains": "Dominios: {names}",
"bimiCertMatches": "Contiene exactamente este logotipo.",
"bimiCertMismatch": "El logotipo dentro del certificado no es el publicado. Suba el SVG para el que fue emitido.",
"bimiCertRemove": "Quitar certificado",
"bimiCertSaved": "Certificado guardado.",
"bimiCertRejected": "El certificado fue rechazado:",
"bimiRecordTitle": "Registro DNS",
"bimiRecordHint": "Este registro TXT lo crea la reconciliación del DNS. Revíselo, o créelo manualmente en su proveedor.",
"bimiCopy": "Copiar",
"bimiCopied": "Copiado",
"bimiCheckTitle": "Verificar la configuración",
"bimiCheckExplainer": "Consulta el DNS y las URL públicas como lo haría un servidor de correo, y dice qué parte está mal.",
"bimiCheckRun": "Verificar ahora",
"bimiCheckRunning": "Verificando...",
"bimiCheckReady": "Todo listo para BIMI.",
"bimiCheckNotReady": "Hay elementos por corregir.",
"bimiCheckStatus_ok": "OK",
"bimiCheckStatus_warn": "Aviso",
"bimiCheckStatus_fail": "Falló",
"bimiCheckItem_logo": "Archivo del logotipo",
"bimiCheckItem_dmarc": "Política DMARC",
"bimiCheckItem_record": "Registro BIMI en el DNS",
"bimiCheckItem_logoUrl": "URL pública del logotipo",
"bimiCheckItem_certificate": "Certificado",
"bimiCheckItem_certificateUrl": "URL pública del certificado",
"bimiError_notXml": "Ese archivo no es un SVG válido.",
"bimiError_notSvg": "Ese archivo no es un SVG.",
"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."
}, },
"auth": { "auth": {
"adminLoginTitle": "Consola de administración", "adminLoginTitle": "Consola de administración",
@@ -455,7 +520,7 @@
}, },
"avatar": { "avatar": {
"title": "Foto de perfil", "title": "Foto de perfil",
"explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.", "explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la página de dominios.",
"none": "Sin foto", "none": "Sin foto",
"choose": "Elegir foto", "choose": "Elegir foto",
"replace": "Cambiar foto", "replace": "Cambiar foto",
+73 -8
View File
@@ -115,14 +115,79 @@
"totpRemoved": "Segundo factor removido, com os codigos de recuperacao.", "totpRemoved": "Segundo factor removido, com os codigos de recuperacao.",
"tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)", "tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)",
"bimiTitle": "BIMI de {domain}", "bimiTitle": "BIMI de {domain}",
"bimiExplainer": "O BIMI e o unico mecanismo que mostra uma imagem ao lado do seu correio em clientes externos. E por dominio, nao por utilizador. O Gmail e o Outlook so o desenham quando o registo aponta para um VMC.", "bimiExplainer": "O BIMI é o único mecanismo que mostra uma imagem ao lado do seu e-mail em clientes externos. Ele vale por domínio, não por usuário. Envie o logotipo em PNG ou SVG e o sistema o converte para o formato do BIMI. O Gmail e o Apple Mail só o mostram com um certificado (VMC ou CMC) emitido por uma autoridade certificadora, que este sistema não consegue criar.",
"bimiConfigured": "Logotipo publicado", "bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sem logotipo", "bimiNotConfigured": "Sem logotipo",
"bimiNoVmc": "Sem VMC: o Gmail e o Outlook nao vao mostrar este logotipo.", "bimiNoVmc": "Sem certificado: o Gmail e o Apple Mail não vão mostrar este logotipo.",
"bimiSvg": "Logotipo (SVG Tiny PS)", "bimiVmc": "URL do certificado (opcional)",
"bimiVmc": "URL do VMC (opcional)", "bimiRejected": "O logotipo não cumpre o SVG Tiny Portable/Secure:",
"bimiRejected": "O logotipo nao cumpre o SVG Tiny Portable/Secure:", "bimiSaved": "Logotipo salvo.",
"bimiSaved": "Logotipo guardado." "bimiNone": "Sem logotipo",
"bimiChoose": "Escolher PNG ou SVG",
"bimiReplace": "Trocar logotipo",
"bimiFormatHint": "PNG ou SVG. O resultado é um SVG Tiny PS quadrado, com no máximo 32 KB.",
"bimiDragHint": "Arraste para posicionar. Os clientes de e-mail mostram o logotipo dentro de um círculo ou de um quadrado arredondado.",
"bimiZoom": "Zoom",
"bimiBackground": "Cor de fundo",
"bimiBrandTitle": "Nome da marca (título do SVG)",
"bimiMask": "Pré-visualização",
"bimiMask_circle": "Círculo",
"bimiMask_rounded": "Quadrado arredondado",
"bimiMask_none": "Sem máscara",
"bimiPreset": "Qualidade da conversão",
"bimiPreset_high": "Alta (mais fiel)",
"bimiPreset_balanced": "Equilibrada",
"bimiPreset_compact": "Compacta",
"bimiPreset_tiny": "Mínima",
"bimiTraced": "O PNG foi convertido em vetor, o que perde um pouco de detalhe. Para o resultado mais nítido, envie o SVG original.",
"bimiAutoLowered": "A qualidade foi reduzida automaticamente para caber em 32 KB.",
"bimiSize": "{size} de {max}",
"bimiTooBig": "O arquivo passa de 32 KB. Escolha uma qualidade menor ou simplifique o desenho.",
"bimiRemoved": "Foram removidos do arquivo, porque o BIMI não os permite: {items}.",
"bimiTextWarning": "O logotipo contém texto, que usa a fonte de quem lê a mensagem e pode mudar de aparência. Converta o texto em curvas no seu editor.",
"bimiDownload": "Baixar SVG",
"bimiSaveLogo": "Salvar logotipo",
"bimiCertTitle": "Certificado (VMC ou CMC)",
"bimiCertExplainer": "O Gmail e o Apple Mail só mostram o logotipo com um certificado emitido por uma autoridade certificadora, como DigiCert, Entrust ou SSL.com: VMC, para marca registrada, ou CMC, para marca em uso. O sistema não cria esse certificado. Baixe o SVG acima, envie-o à autoridade e coloque aqui o arquivo .pem que ela devolver. O Gmail aceita os dois tipos; outros clientes podem exigir VMC.",
"bimiCertChoose": "Enviar arquivo .pem",
"bimiCertReplace": "Trocar certificado",
"bimiCertPaste": "Ou cole o conteúdo do .pem",
"bimiCertSave": "Salvar certificado",
"bimiCertValid": "Certificado válido",
"bimiCertInvalid": "Certificado com problemas",
"bimiCertIssued": "Emitido para {organization} por {issuer}",
"bimiCertValidUntil": "Válido até {date}",
"bimiCertDomains": "Domínios: {names}",
"bimiCertMatches": "Contém exatamente este logotipo.",
"bimiCertMismatch": "O logotipo dentro do certificado não é o que está publicado. Envie o SVG para o qual ele foi emitido.",
"bimiCertRemove": "Remover certificado",
"bimiCertSaved": "Certificado salvo.",
"bimiCertRejected": "O certificado foi recusado:",
"bimiRecordTitle": "Registro DNS",
"bimiRecordHint": "Este registro TXT é criado pela reconciliação do DNS. Confira-o, ou crie-o manualmente no seu provedor.",
"bimiCopy": "Copiar",
"bimiCopied": "Copiado",
"bimiCheckTitle": "Verificar configuração",
"bimiCheckExplainer": "Consulta o DNS e as URLs públicas como um servidor de e-mail faria, e diz qual parte está errada.",
"bimiCheckRun": "Verificar agora",
"bimiCheckRunning": "Verificando...",
"bimiCheckReady": "Tudo certo para o BIMI.",
"bimiCheckNotReady": "Há itens a corrigir.",
"bimiCheckStatus_ok": "OK",
"bimiCheckStatus_warn": "Atenção",
"bimiCheckStatus_fail": "Falhou",
"bimiCheckItem_logo": "Arquivo do logotipo",
"bimiCheckItem_dmarc": "Política DMARC",
"bimiCheckItem_record": "Registro BIMI no DNS",
"bimiCheckItem_logoUrl": "URL pública do logotipo",
"bimiCheckItem_certificate": "Certificado",
"bimiCheckItem_certificateUrl": "URL pública do certificado",
"bimiError_notXml": "Esse arquivo não é um SVG válido.",
"bimiError_notSvg": "Esse arquivo não é um SVG.",
"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."
}, },
"auth": { "auth": {
"adminLoginTitle": "Console de administração", "adminLoginTitle": "Console de administração",
@@ -455,12 +520,12 @@
}, },
"avatar": { "avatar": {
"title": "Foto de perfil", "title": "Foto de perfil",
"explainer": "Esta foto aparece dentro do LambdaMail. Servicos externos como Gmail e Outlook nao a leem: para eles, veja BIMI na pagina de dominios.", "explainer": "Esta foto aparece dentro do LambdaMail. Serviços externos como Gmail e Outlook não a leem: para eles, veja o BIMI na página de domínios.",
"none": "Sem foto", "none": "Sem foto",
"choose": "Escolher foto", "choose": "Escolher foto",
"replace": "Trocar foto", "replace": "Trocar foto",
"zoom": "Zoom", "zoom": "Zoom",
"dragToPosition": "Arraste para posicionar", "dragToPosition": "Arraste para posicionar",
"notAnImage": "Nao foi possivel ler esse ficheiro como imagem." "notAnImage": "Não foi possível ler esse arquivo como imagem."
} }
} }
+7
View File
@@ -11,6 +11,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.14", "dompurify": "^3.4.14",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"imagetracerjs": "1.2.6",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"next": "15.5.27", "next": "15.5.27",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
@@ -2721,6 +2722,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/imagetracerjs": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/imagetracerjs/-/imagetracerjs-1.2.6.tgz",
"integrity": "sha512-LKJlnKmXFzDdh6IZtXTyBxXcCLTAkwgKYS+NMiPXiXVnlTLjQC8fq7U89laUSgHtypJB3TdMMDK4ecG5NI/Cgw==",
"license": "Unlicense"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.14", "dompurify": "^3.4.14",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"imagetracerjs": "1.2.6",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"next": "15.5.27", "next": "15.5.27",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
@@ -5,6 +5,7 @@ import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "l
import { useTranslations } from "../../../../i18n/provider"; import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge"; import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button"; import { Button } from "../../../../components/ui/Button";
import { BimiPanel } from "../../../../components/BimiPanel";
/** /**
* Domains, read from the database. * Domains, read from the database.
@@ -79,13 +80,6 @@ export default function AdminDomainsPage() {
const [reconciling, setReconciling] = useState(false); const [reconciling, setReconciling] = useState(false);
// The BIMI mark for the selected domain: the one thing an external mail
// client will ever render for this sender.
const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
const [bimiSvg, setBimiSvg] = useState("");
const [bimiVmc, setBimiVmc] = useState("");
const [bimiBusy, setBimiBusy] = useState(false);
const [bimiProblems, setBimiProblems] = useState<string[]>([]);
const [verification, setVerification] = useState<DnsVerification | null>(null); const [verification, setVerification] = useState<DnsVerification | null>(null);
// What the last reconciliation actually did, so the panel can say it rather // What the last reconciliation actually did, so the panel can say it rather
// than leaving the operator to infer it from the verification that follows. // than leaving the operator to infer it from the verification that follows.
@@ -126,73 +120,6 @@ export default function AdminDomainsPage() {
// Asks the protocols service to resolve every expected record and report // Asks the protocols service to resolve every expected record and report
// each one. It used to post to the auth service, which has no resolver and // each one. It used to post to the auth service, which has no resolver and
// could only re-read the status already in the database. // could only re-read the status already in the database.
// Reloaded whenever the selected domain changes: the mark belongs to the
// domain, not to the console.
useEffect(() => {
if (!selected) {
setBimi(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
const data = res.ok ? await res.json() : { configured: false };
if (cancelled) return;
setBimi(data);
setBimiVmc(data.vmc_url ?? "");
setBimiSvg("");
setBimiProblems([]);
} catch {
if (!cancelled) setBimi({ configured: false });
}
})();
return () => {
cancelled = true;
};
}, [selected?.name]);
const saveBimi = async () => {
if (!selected || !bimiSvg.trim()) return;
setBimiBusy(true);
setBimiProblems([]);
setError(null);
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
setBimiSvg("");
setNotice(t("admin.bimiSaved"));
} catch {
setError(t("errors.serverError"));
} finally {
setBimiBusy(false);
}
};
const removeBimi = async () => {
if (!selected) return;
setBimiBusy(true);
try {
await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
setBimi({ configured: false });
setBimiVmc("");
} finally {
setBimiBusy(false);
}
};
const handleReconcile = async () => { const handleReconcile = async () => {
if (!selected) return; if (!selected) return;
setReconciling(true); setReconciling(true);
@@ -523,67 +450,7 @@ export default function AdminDomainsPage() {
{/* BIMI: the only mark an external mail client ever renders for this {/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */} sender, and the reason a profile photo does not travel. */}
{activeTab === "domains" && selected && ( {activeTab === "domains" && selected && <BimiPanel domain={selected.name} onNotice={setNotice} />}
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{t("admin.bimiTitle", { domain: selected.name })}
</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{bimi?.configured && !bimi.vmc_url && (
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
)}
</div>
<div>
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
<textarea
id="bimi-svg"
value={bimiSvg}
onChange={(e) => setBimiSvg(e.target.value)}
rows={7}
spellCheck={false}
className={`${input} font-mono text-[12px]`}
/>
</div>
<div>
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
<input
id="bimi-vmc"
type="url"
value={bimiVmc}
onChange={(e) => setBimiVmc(e.target.value)}
placeholder="https://.../vmc.pem"
className={input}
/>
</div>
{bimiProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{bimiProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
{bimiBusy ? t("common.loading") : t("common.save")}
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
{t("common.delete")}
</Button>
)}
</div>
</section>
)}
{activeTab === "dkim" && ( {activeTab === "dkim" && (
<section className={`${panel} max-w-[720px]`}> <section className={`${panel} max-w-[720px]`}>
@@ -0,0 +1,741 @@
"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 { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { useTranslations } from "../i18n/provider";
import {
BimiLogoError,
DEFAULT_LAYOUT,
MAX_LOGO_BYTES,
TRACE_PRESETS,
buildLogoSvg,
byteLength,
clampLayout,
isHexColor,
sanitizeSvg,
traceRaster,
type Artwork,
type Layout,
type Raster,
type TracePreset,
} from "../lib/bimiLogo";
/** The frame the logo is positioned in. Square, like the file it produces. */
const VIEWPORT = 288;
/** Bitmaps are traced at this size at most: tracing cost grows with the pixels. */
const TRACE_MAX_SIDE = 512;
type Mask = "circle" | "rounded" | "none";
interface CertificateInfo {
present: boolean;
valid?: boolean;
violations?: string[];
subject?: string;
organization?: string;
issuer?: string;
not_after?: string;
dns_names?: string[];
kind?: string;
matches_logo?: boolean;
warnings?: string[];
}
interface BimiState {
configured: boolean;
svg?: string;
vmc_url?: string;
logo_url?: string;
certificate_url?: string;
certificate?: CertificateInfo;
}
interface CheckResult {
id: string;
status: "ok" | "warn" | "fail";
detail: string;
}
interface BimiPanelProps {
domain: string;
onNotice: (message: string) => void;
}
const panelClass = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
const inputClass =
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
const labelClass = "mb-1.5 block text-xs text-slate-400";
const subPanel = "flex flex-col gap-3 rounded-xl bg-dark-card/60 p-4 shadow-edge";
function dataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
function maskClass(mask: Mask): string {
if (mask === "circle") return "rounded-full";
if (mask === "rounded") return "rounded-[22%]";
return "";
}
function formatBytes(n: number): string {
return `${(n / 1024).toFixed(1)} KB`;
}
/** Reads a bitmap file into pixels, scaled down so tracing stays quick. */
async function readRaster(file: File): Promise<Raster> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new BimiLogoError("empty"));
el.src = url;
});
const ratio = Math.min(1, TRACE_MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight));
const width = Math.max(1, Math.round(img.naturalWidth * ratio));
const height = Math.max(1, Math.round(img.naturalHeight * ratio));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new BimiLogoError("empty");
ctx.drawImage(img, 0, 0, width, height);
const { data } = ctx.getImageData(0, 0, width, height);
return { width, height, data };
} finally {
URL.revokeObjectURL(url);
}
}
export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
const t = useTranslations();
const [bimi, setBimi] = useState<BimiState | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The studio: what was chosen, and how it is placed
const [art, setArt] = useState<Artwork | null>(null);
const [raster, setRaster] = useState<Raster | null>(null);
const [preset, setPreset] = useState<TracePreset>("balanced");
const [loweredAuto, setLoweredAuto] = useState(false);
const [layout, setLayout] = useState<Layout>(DEFAULT_LAYOUT);
const [background, setBackground] = useState("#ffffff");
const [title, setTitle] = useState(domain);
const [mask, setMask] = useState<Mask>("circle");
const [problems, setProblems] = useState<string[]>([]);
const [vmcUrl, setVmcUrl] = useState("");
const [pem, setPem] = useState("");
const [certProblems, setCertProblems] = useState<string[]>([]);
const [checks, setChecks] = useState<{ ready: boolean; checks: CheckResult[] } | null>(null);
const [checking, setChecking] = useState(false);
const [copied, setCopied] = useState(false);
const fileRef = useRef<HTMLInputElement | null>(null);
const pemRef = useRef<HTMLInputElement | null>(null);
const dragRef = useRef<{ x: number; y: number; from: Layout } | null>(null);
const base = `/api/v1/admin/bimi`;
const q = `domain=${encodeURIComponent(domain)}`;
const load = useCallback(async () => {
try {
const res = await fetch(`${base}?${q}`);
const data: BimiState = res.ok ? await res.json() : { configured: false };
setBimi(data);
setVmcUrl(data.vmc_url ?? "");
} catch {
setBimi({ configured: false });
}
}, [base, q]);
// The mark belongs to the domain: a different domain starts from scratch
useEffect(() => {
setArt(null);
setRaster(null);
setProblems([]);
setError(null);
setChecks(null);
setPem("");
setCertProblems([]);
setTitle(domain);
setBimi(null);
void load();
}, [domain, load]);
// ------------------------------------------------------------ the studio
/** Traces pixels, walking down the presets until the file fits the limit. */
const traceFitting = useCallback(
(pixels: Raster, from: TracePreset, current: { layout: Layout; title: string; background: string }) => {
const tracer = import("imagetracerjs").then((m) => m.default);
return tracer.then((engine) => {
const start = TRACE_PRESETS.indexOf(from);
for (let i = start; i < TRACE_PRESETS.length; i++) {
const traced = traceRaster(pixels, TRACE_PRESETS[i], engine);
const size = byteLength(buildLogoSvg(traced, current.layout, current.title, current.background));
if (size <= MAX_LOGO_BYTES || i === TRACE_PRESETS.length - 1) {
return { art: traced, preset: TRACE_PRESETS[i], lowered: i > start };
}
}
throw new BimiLogoError("tooComplex");
});
},
[],
);
const openFile = async (file: File) => {
setError(null);
setProblems([]);
setBusy(true);
try {
setLayout(DEFAULT_LAYOUT);
if (file.type === "image/svg+xml" || /\.svg$/i.test(file.name)) {
setRaster(null);
setLoweredAuto(false);
setArt(sanitizeSvg(await file.text()));
} else if (/^image\/(png|jpeg|webp)$/.test(file.type)) {
const pixels = await readRaster(file);
const result = await traceFitting(pixels, "balanced", { layout: DEFAULT_LAYOUT, title, background });
setRaster(pixels);
setArt(result.art);
setPreset(result.preset);
setLoweredAuto(result.lowered);
} else {
throw new BimiLogoError("empty");
}
} catch (err) {
setArt(null);
const code = err instanceof BimiLogoError ? err.code : "notImage";
setError(t(`admin.bimiError_${code}`));
} finally {
setBusy(false);
}
};
const changePreset = async (next: TracePreset) => {
if (!raster) return;
setPreset(next);
setLoweredAuto(false);
setBusy(true);
try {
const engine = (await import("imagetracerjs")).default;
setArt(traceRaster(raster, next, engine));
} catch {
setError(t("admin.bimiError_empty"));
} finally {
setBusy(false);
}
};
const finalSvg = useMemo(() => (art ? buildLogoSvg(art, layout, title, background) : ""), [art, layout, title, background]);
const size = finalSvg ? byteLength(finalSvg) : 0;
const tooBig = size > MAX_LOGO_BYTES;
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!art) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { x: e.clientX, y: e.clientY, from: layout };
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
setLayout(
clampLayout({
zoom: drag.from.zoom,
dx: drag.from.dx + (e.clientX - drag.x) / VIEWPORT,
dy: drag.from.dy + (e.clientY - drag.y) / VIEWPORT,
}),
);
};
const endDrag = () => {
dragRef.current = null;
};
const download = () => {
const url = URL.createObjectURL(new Blob([finalSvg], { type: "image/svg+xml" }));
const a = document.createElement("a");
a.href = url;
a.download = `${domain}-bimi.svg`;
a.click();
URL.revokeObjectURL(url);
};
const saveLogo = async () => {
if (!finalSvg || tooBig) return;
setBusy(true);
setProblems([]);
setError(null);
try {
const res = await fetch(`${base}?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: finalSvg, vmc_url: vmcUrl.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setArt(null);
setRaster(null);
onNotice(t("admin.bimiSaved"));
await load();
} catch {
setError(t("errors.serverError"));
} finally {
setBusy(false);
}
};
const saveVmcUrl = async () => {
if (!bimi?.svg) return;
setBusy(true);
try {
await fetch(`${base}?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimi.svg, vmc_url: vmcUrl.trim() }),
});
await load();
} finally {
setBusy(false);
}
};
const removeLogo = async () => {
setBusy(true);
try {
await fetch(`${base}?${q}`, { method: "DELETE" });
setChecks(null);
await load();
} finally {
setBusy(false);
}
};
// --------------------------------------------------------- certificate
const sendPem = async (text: string) => {
if (!text.trim()) return;
setBusy(true);
setCertProblems([]);
try {
const res = await fetch(`${base}/certificate?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pem: text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setCertProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setPem("");
onNotice(t("admin.bimiCertSaved"));
await load();
} catch {
setCertProblems([t("errors.serverError")]);
} finally {
setBusy(false);
}
};
const removeCertificate = async () => {
setBusy(true);
try {
await fetch(`${base}/certificate?${q}`, { method: "DELETE" });
setChecks(null);
await load();
} finally {
setBusy(false);
}
};
// --------------------------------------------------------------- check
const runCheck = async () => {
setChecking(true);
try {
const res = await fetch(`${base}/check?${q}`);
const data = await res.json();
setChecks(res.ok ? { ready: Boolean(data.ready), checks: data.checks ?? [] } : null);
} catch {
setChecks(null);
} finally {
setChecking(false);
}
};
const record = bimi?.configured
? `v=BIMI1; l=${bimi.logo_url};${bimi.certificate?.present ? ` a=${bimi.certificate_url};` : vmcUrl.trim() ? ` a=${vmcUrl.trim()};` : ""}`
: "";
const copyRecord = async () => {
try {
await navigator.clipboard.writeText(record);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* the text is selectable on screen */
}
};
const cert = bimi?.certificate;
const hasCertificate = Boolean(cert?.present);
const noVmc = bimi?.configured && !hasCertificate && !vmcUrl.trim();
const statusBadge = (status: CheckResult["status"]) =>
status === "ok" ? "success" : status === "warn" ? "warning" : "danger";
return (
<section className={panelClass}>
<div>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.bimiTitle", { domain })}</h2>
<p className="mt-1 text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{noVmc && <span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>}
</div>
{/* ---------------------------------------------------------- logo */}
<div className={subPanel}>
{!art ? (
<div className="flex flex-wrap items-center gap-4">
<span className="flex h-[88px] w-[88px] flex-none items-center justify-center overflow-hidden rounded-full bg-dark-card shadow-edge">
{bimi?.svg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={dataUrl(bimi.svg)} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-[11px] text-slate-500">{t("admin.bimiNone")}</span>
)}
</span>
<div className="flex min-w-[220px] flex-1 flex-col gap-2">
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiFormatHint")}</p>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
<Upload className="h-3.5 w-3.5" />
<span>{bimi?.configured ? t("admin.bimiReplace") : t("admin.bimiChoose")}</span>
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeLogo()} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("common.delete")}</span>
</Button>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-wrap items-start gap-6">
<div className="flex flex-col items-center gap-3">
<div
role="img"
aria-label={t("admin.bimiDragHint")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`relative cursor-grab touch-none overflow-hidden shadow-edge active:cursor-grabbing ${maskClass(mask)}`}
style={{ width: VIEWPORT, height: VIEWPORT, background: "repeating-conic-gradient(#1e293b 0% 25%, #0f172a 0% 50%) 0 0 / 16px 16px" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dataUrl(finalSvg)} alt="" draggable={false} className="pointer-events-none h-full w-full select-none" />
</div>
<div className="flex w-full max-w-[288px] items-center gap-2.5">
<ZoomOut className="h-4 w-4 flex-none text-slate-400" />
<input
type="range"
min={0.2}
max={4}
step={0.01}
value={layout.zoom}
onChange={(e) => setLayout((l) => clampLayout({ ...l, zoom: Number(e.target.value) }))}
aria-label={t("admin.bimiZoom")}
className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-dark-card accent-indigo-500"
/>
<ZoomIn className="h-4 w-4 flex-none text-slate-400" />
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5" role="group" aria-label={t("admin.bimiMask")}>
{(["circle", "rounded", "none"] as Mask[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMask(m)}
aria-pressed={mask === m}
className={`rounded-md px-2.5 py-1 text-[11px] ${mask === m ? "bg-indigo-800 text-indigo-100" : "bg-slate-800 text-slate-300"}`}
>
{t(`admin.bimiMask_${m}`)}
</button>
))}
</div>
<p className="max-w-[288px] text-center text-[11.5px] text-slate-500">{t("admin.bimiDragHint")}</p>
</div>
<div className="flex min-w-[240px] flex-1 flex-col gap-3">
<div>
<label htmlFor="bimi-title" className={labelClass}>{t("admin.bimiBrandTitle")}</label>
<input id="bimi-title" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={100} className={inputClass} />
</div>
<div>
<label htmlFor="bimi-bg" className={labelClass}>{t("admin.bimiBackground")}</label>
<div className="flex items-center gap-2">
<input
id="bimi-bg"
type="color"
value={isHexColor(background) ? background : "#ffffff"}
onChange={(e) => setBackground(e.target.value)}
className="h-9 w-12 cursor-pointer rounded-md bg-dark-card p-1 shadow-edge"
/>
<span className="font-mono text-[12px] text-slate-400">{background}</span>
</div>
</div>
{raster && (
<div>
<label htmlFor="bimi-preset" className={labelClass}>{t("admin.bimiPreset")}</label>
<select
id="bimi-preset"
value={preset}
onChange={(e) => void changePreset(e.target.value as TracePreset)}
disabled={busy}
className={inputClass}
>
{TRACE_PRESETS.map((p) => (
<option key={p} value={p}>{t(`admin.bimiPreset_${p}`)}</option>
))}
</select>
<p className="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">{t("admin.bimiTraced")}</p>
{loweredAuto && <p className="mt-1 text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiAutoLowered")}</p>}
</div>
)}
<div className={`text-[12.5px] ${tooBig ? "text-rose-300" : "text-slate-400"}`}>
{t("admin.bimiSize", { size: formatBytes(size), max: formatBytes(MAX_LOGO_BYTES) })}
{tooBig && <span className="mt-1 block text-[11.5px]">{t("admin.bimiTooBig")}</span>}
</div>
{art.removed.length > 0 && (
<p className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiRemoved", { items: art.removed.join(", ") })}</p>
)}
{art.warnings.includes("text") && (
<p className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiTextWarning")}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => { setArt(null); setRaster(null); }} disabled={busy}>
{t("common.cancel")}
</Button>
<Button variant="secondary" size="sm" onClick={download}>
<Download className="h-3.5 w-3.5" />
<span>{t("admin.bimiDownload")}</span>
</Button>
<Button variant="primary" size="sm" onClick={() => void saveLogo()} disabled={busy || tooBig}>
{busy ? t("common.loading") : t("admin.bimiSaveLogo")}
</Button>
</div>
</div>
</div>
)}
{error && <div className="rounded-xl bg-rose-900/60 px-3.5 py-2.5 text-[12.5px] text-rose-200 shadow-edge">{error}</div>}
{problems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{problems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,.svg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void openFile(file);
// Cleared so choosing the same file twice in a row still fires
e.target.value = "";
}}
/>
</div>
{/* ---------------------------------------------------- certificate */}
{bimi?.configured && (
<div className={subPanel}>
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiCertTitle")}</h3>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiCertExplainer")}</p>
{hasCertificate && cert ? (
<div className="flex flex-col gap-2 rounded-lg bg-dark-panel px-3.5 py-3 shadow-edge">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={cert.valid ? "success" : "danger"}>
{cert.valid ? t("admin.bimiCertValid") : t("admin.bimiCertInvalid")}
</Badge>
{cert.kind && cert.kind !== "unknown" && <Badge variant="info">{cert.kind.toUpperCase()}</Badge>}
</div>
{cert.organization && (
<span className="text-[12.5px] text-slate-200">
{t("admin.bimiCertIssued", { organization: cert.organization, issuer: cert.issuer ?? "" })}
</span>
)}
{cert.not_after && (
<span className="text-[12px] text-slate-400">
{t("admin.bimiCertValidUntil", { date: new Date(cert.not_after).toLocaleDateString() })}
</span>
)}
{cert.dns_names && cert.dns_names.length > 0 && (
<span className="break-all text-[11.5px] text-slate-500">{t("admin.bimiCertDomains", { names: cert.dns_names.join(", ") })}</span>
)}
<span className={`text-[12px] ${cert.matches_logo ? "text-slate-300" : "text-rose-300"}`}>
{cert.matches_logo ? t("admin.bimiCertMatches") : t("admin.bimiCertMismatch")}
</span>
{(cert.violations ?? []).map((v) => (
<span key={v} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{v}</span>
))}
{(cert.warnings ?? []).map((w) => (
<span key={w} className="text-[11.5px] leading-relaxed text-amber-400">{w}</span>
))}
<span className="break-all font-mono text-[11px] text-slate-500">{bimi.certificate_url}</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => pemRef.current?.click()} disabled={busy}>
<Upload className="h-3.5 w-3.5" />
<span>{hasCertificate ? t("admin.bimiCertReplace") : t("admin.bimiCertChoose")}</span>
</Button>
{hasCertificate && (
<Button variant="ghost" size="sm" onClick={() => void removeCertificate()} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("admin.bimiCertRemove")}</span>
</Button>
)}
</div>
<div>
<label htmlFor="bimi-pem" className={labelClass}>{t("admin.bimiCertPaste")}</label>
<textarea
id="bimi-pem"
value={pem}
onChange={(e) => setPem(e.target.value)}
rows={4}
spellCheck={false}
placeholder="-----BEGIN CERTIFICATE-----"
className={`${inputClass} font-mono text-[12px]`}
/>
{pem.trim() && (
<div className="mt-2">
<Button variant="primary" size="sm" onClick={() => void sendPem(pem)} disabled={busy}>
{busy ? t("common.loading") : t("admin.bimiCertSave")}
</Button>
</div>
)}
</div>
{certProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiCertRejected")}</span>
{certProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
{!hasCertificate && (
<div>
<label htmlFor="bimi-vmc" className={labelClass}>{t("admin.bimiVmc")}</label>
<div className="flex flex-wrap gap-2">
<input
id="bimi-vmc"
type="url"
value={vmcUrl}
onChange={(e) => setVmcUrl(e.target.value)}
placeholder="https://.../vmc.pem"
className={`${inputClass} min-w-[220px] flex-1`}
/>
<Button variant="secondary" size="sm" onClick={() => void saveVmcUrl()} disabled={busy || vmcUrl === (bimi?.vmc_url ?? "")}>
{t("common.save")}
</Button>
</div>
</div>
)}
<input
ref={pemRef}
type="file"
accept=".pem,.crt,.cer,application/x-pem-file,application/pem-certificate-chain,text/plain"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) await sendPem(await file.text());
e.target.value = "";
}}
/>
</div>
)}
{/* ------------------------------------------------------- DNS + check */}
{bimi?.configured && (
<div className={subPanel}>
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiRecordTitle")}</h3>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiRecordHint")}</p>
<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>
<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>
</div>
</div>
<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}>
<Play className="h-3.5 w-3.5" />
<span>{checking ? t("admin.bimiCheckRunning") : t("admin.bimiCheckRun")}</span>
</Button>
</div>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiCheckExplainer")}</p>
{checks && (
<div className="flex flex-col gap-2">
<div className={`flex items-center gap-2 text-[13px] ${checks.ready ? "text-indigo-200" : "text-amber-300"}`}>
{checks.ready ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
{checks.ready ? t("admin.bimiCheckReady") : t("admin.bimiCheckNotReady")}
</div>
{checks.checks.map((c) => (
<div key={c.id} className="flex flex-col gap-1 rounded-lg bg-dark-panel px-3.5 py-2.5 shadow-edge">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusBadge(c.status)}>{t(`admin.bimiCheckStatus_${c.status}`)}</Badge>
<span className="text-[12.5px] text-slate-200">{t(`admin.bimiCheckItem_${c.id}`)}</span>
</div>
<span className="break-words text-[11.5px] leading-relaxed text-slate-400">{c.detail}</span>
</div>
))}
</div>
)}
</div>
)}
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
/** imagetracerjs ships no types; only the call the studio makes is described. */
declare module "imagetracerjs" {
const ImageTracer: {
imagedataToSVG(
raster: { width: number; height: number; data: Uint8ClampedArray },
options?: Record<string, number | string | boolean>,
): string;
};
export default ImageTracer;
}
+228
View File
@@ -0,0 +1,228 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ImageTracer from "imagetracerjs";
import {
BimiLogoError,
DEFAULT_LAYOUT,
MAX_LOGO_BYTES,
TRACE_PRESETS,
buildLogoSvg,
byteLength,
clampLayout,
sanitizeSvg,
traceRaster,
type Raster,
} from "./bimiLogo";
/** A drawing from a design tool: namespaces, a style attribute, a link and a script. */
const FROM_A_DESIGN_TOOL = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="100" viewBox="0 0 200 100" onload="alert(1)">
<sodipodi:namedview id="base"/>
<metadata>made with a tool</metadata>
<script>alert(1)</script>
<!-- a comment -->
<defs><linearGradient id="g"><stop offset="0" style="stop-color:#ff0000;stop-opacity:1"/><stop offset="1" stop-color="#0000ff"/></linearGradient></defs>
<rect x="10" y="10" width="80" height="80" style="fill:url(#g);stroke:none;filter:blur(2px)" class="big" inkscape:label="box"/>
<a xlink:href="https://evil.test/"><circle cx="150" cy="50" r="40" fill="#123456" onclick="steal()"/></a>
<image xlink:href="data:image/png;base64,AAAA" width="10" height="10"/>
<use xlink:href="#g"/>
</svg>`;
describe("sanitizeSvg", () => {
it("keeps the drawing and drops everything the format forbids", () => {
const art = sanitizeSvg(FROM_A_DESIGN_TOOL);
expect(art.width).toBe(200);
expect(art.height).toBe(100);
expect(art.inner).toContain("<rect");
expect(art.inner).toContain("<circle");
expect(art.inner).toContain("<linearGradient");
for (const forbidden of ["script", "onclick", "onload", "<image", "<use", "<a ", "evil.test", "sodipodi", "inkscape", "class=", "style=", "<!--", "metadata"]) {
expect(art.inner, forbidden).not.toContain(forbidden);
}
// What was lost that the person should know about
expect(art.removed).toEqual(expect.arrayContaining(["script", "image", "use", "a (link)"]));
// Editor noise is not worth mentioning
expect(art.removed).not.toContain("metadata");
});
it("turns style declarations into attributes, and refuses ones that fetch", () => {
const art = sanitizeSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="5" height="5" style="fill:#ff0000;opacity:.5;background:url(https://x.test/a.png)"/></svg>`,
);
expect(art.inner).toContain('fill="#ff0000"');
expect(art.inner).toContain('opacity=".5"');
expect(art.inner).not.toContain("x.test");
});
it("keeps a gradient reference and drops an external one", () => {
const art = sanitizeSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="5" height="5" fill="url(#g)"/><rect width="5" height="5" fill="url(https://x.test/g)"/></svg>`,
);
expect(art.inner).toContain('fill="url(#g)"');
expect(art.inner).not.toContain("x.test");
});
it("reads the size from width and height when there is no viewBox", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" width="64px" height="32"><rect width="64" height="32"/></svg>`);
expect([art.minX, art.minY, art.width, art.height]).toEqual([0, 0, 64, 32]);
});
it("keeps the origin of a viewBox that does not start at zero", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -20 100 50"><rect x="-10" y="-20" width="100" height="50"/></svg>`);
expect([art.minX, art.minY, art.width, art.height]).toEqual([-10, -20, 100, 50]);
});
it("warns that text depends on fonts", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><text x="1" y="5">Hi</text></svg>`);
expect(art.warnings).toEqual(["text"]);
});
it.each([
["not XML", "this is not xml <<<", "notXml"],
["not SVG", `<html xmlns="http://www.w3.org/1999/xhtml"><body/></html>`, "notSvg"],
["no size", `<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>`, "noSize"],
["nothing left", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script>1</script></svg>`, "empty"],
])("refuses a file that is %s", (_name, source, code) => {
expect(() => sanitizeSvg(source)).toThrowError(BimiLogoError);
try {
sanitizeSvg(source);
} catch (error) {
expect((error as BimiLogoError).code).toBe(code);
}
});
});
describe("buildLogoSvg", () => {
const art = sanitizeSvg(FROM_A_DESIGN_TOOL);
it("is SVG Tiny PS on a square canvas, with one title", () => {
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
expect(svg).toContain('version="1.2"');
expect(svg).toContain('baseProfile="tiny-ps"');
expect(svg).toContain('viewBox="0 0 512 512"');
expect(svg.match(/<title>/g)).toHaveLength(1);
expect(svg).not.toMatch(/<svg[^>]*\s(x|y)=/);
});
it("centres a wide drawing and fills 80% of the square", () => {
// 200x100 scaled to 409.6 wide: 2.048, centred vertically
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
expect(svg).toContain("matrix(2.048 0 0 2.048 51.2 153.6)");
});
it("moves and zooms the way the person positioned it", () => {
const base = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
const moved = buildLogoSvg(art, { zoom: 1, dx: 0.1, dy: -0.1 }, "Example", "#ffffff");
expect(moved).toContain("matrix(2.048 0 0 2.048 102.4 102.4)");
expect(moved).not.toBe(base);
expect(buildLogoSvg(art, { zoom: 0.5, dx: 0, dy: 0 }, "Example", "#ffffff")).toContain("matrix(1.024 0 0 1.024");
});
it("accounts for a viewBox that does not start at zero", () => {
const offset = { ...art, minX: -10, minY: -20 };
const plain = buildLogoSvg(art, DEFAULT_LAYOUT, "x", "#fff");
const shifted = buildLogoSvg(offset, DEFAULT_LAYOUT, "x", "#fff");
expect(shifted).not.toBe(plain);
// 10 units at 2.048 is 20.48 further right
expect(shifted).toContain("matrix(2.048 0 0 2.048 71.68 194.56)");
});
it("escapes the title and falls back for a bad colour or an empty title", () => {
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, `A & <B> "C"`, "red; evil");
expect(svg).toContain("<title>A &amp; &lt;B&gt; &quot;C&quot;</title>");
expect(svg).toContain('fill="#ffffff"');
expect(buildLogoSvg(art, DEFAULT_LAYOUT, " ", "#000000")).toContain("<title>Logo</title>");
});
it("keeps the layout inside sane limits", () => {
expect(clampLayout({ zoom: 99, dx: 9, dy: -9 })).toEqual({ zoom: 4, dx: 0.5, dy: -0.5 });
expect(clampLayout({ zoom: 0, dx: 0, dy: 0 }).zoom).toBe(0.2);
});
it("counts bytes, not characters", () => {
expect(byteLength("a")).toBe(1);
expect(byteLength("é")).toBe(2);
});
});
/** 64x64: a red disc on a transparent background, and a blue square. Deterministic. */
function syntheticLogo(): Raster {
const size = 64;
const data = new Uint8ClampedArray(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const inDisc = (x - 22) ** 2 + (y - 32) ** 2 < 14 ** 2;
const inSquare = x >= 40 && x < 58 && y >= 24 && y < 42;
if (inDisc) data.set([220, 30, 30, 255], i);
else if (inSquare) data.set([20, 60, 200, 255], i);
// else: fully transparent
}
}
return { width: size, height: size, data };
}
describe("traceRaster", () => {
const tracer = ImageTracer as unknown as Parameters<typeof traceRaster>[2];
it("vectorises pixels into paths and drops the transparent background", () => {
const art = traceRaster(syntheticLogo(), "balanced", tracer);
expect(art.width).toBe(64);
expect(art.inner).toContain("<path");
expect(art.inner).not.toContain("rgb(");
expect(art.inner).toMatch(/fill="#[0-9a-f]{6}"/);
// The tracer's own wrapper and comment are gone
expect(art.inner).not.toContain("<svg");
expect(art.inner).not.toContain("imagetracer");
// Both colours made it, nothing is left of the empty background
expect(art.inner.toLowerCase()).toMatch(/#dc1e1e|#d[0-9a-f]1[0-9a-f]1[0-9a-f]/);
expect(art.inner).not.toMatch(/opacity="0"/);
});
it("gets smaller as the preset gets coarser", () => {
const sizes = TRACE_PRESETS.map((preset) => byteLength(traceRaster(syntheticLogo(), preset, tracer).inner));
for (let i = 1; i < sizes.length; i++) expect(sizes[i]).toBeLessThanOrEqual(sizes[i - 1]);
expect(sizes[sizes.length - 1]).toBeLessThan(MAX_LOGO_BYTES);
});
it("refuses an image with nothing in it", () => {
const blank: Raster = { width: 8, height: 8, data: new Uint8ClampedArray(8 * 8 * 4) };
expect(() => traceRaster(blank, "balanced", tracer)).toThrowError(BimiLogoError);
});
});
// The server validates what the studio produces (protocols/internal/domain/bimi).
// Both sides read the same files, so a studio that starts producing something the
// server refuses fails here or there. Regenerate with UPDATE_GOLDEN=1 after
// changing the output on purpose.
describe("what the studio produces is what the server accepts", () => {
const dir = path.resolve(__dirname, "../../../protocols/internal/domain/bimi/testdata/studio");
const tracer = ImageTracer as unknown as Parameters<typeof traceRaster>[2];
const cases: Record<string, () => string> = {
"from-a-design-tool.svg": () => buildLogoSvg(sanitizeSvg(FROM_A_DESIGN_TOOL), DEFAULT_LAYOUT, "Example", "#ffffff"),
"positioned-and-zoomed.svg": () =>
buildLogoSvg(sanitizeSvg(FROM_A_DESIGN_TOOL), { zoom: 1.3, dx: -0.08, dy: 0.05 }, "Example brand", "#0b5c55"),
"traced-from-a-png.svg": () => buildLogoSvg(traceRaster(syntheticLogo(), "balanced", tracer), DEFAULT_LAYOUT, "Traced", "#ffffff"),
};
for (const [name, produce] of Object.entries(cases)) {
it(name, () => {
const output = produce();
const file = path.join(dir, name);
if (process.env.UPDATE_GOLDEN || !existsSync(file)) {
mkdirSync(dir, { recursive: true });
writeFileSync(file, output);
}
expect(output).toBe(readFileSync(file, "utf8"));
expect(byteLength(output)).toBeLessThanOrEqual(MAX_LOGO_BYTES);
});
}
});
+311
View File
@@ -0,0 +1,311 @@
/**
* Turns an uploaded PNG or SVG into the logo BIMI publishes.
*
* BIMI accepts one kind of file: SVG Tiny Portable/Secure, square, at most
* 32 KB, with no script, no raster image inside and nothing fetched from
* elsewhere. A receiver that is handed anything else shows no logo and says
* nothing, so the conversion is done here, before upload, and the server
* checks the result again (protocols/internal/domain/bimi).
*
* Three steps, each a pure function:
* - sanitizeSvg: an uploaded SVG, reduced to what the format allows
* - traceRaster: a PNG, vectorised (a logo cannot carry a bitmap)
* - buildLogoSvg: either one, placed on a square canvas the way the person
* positioned it, as the final document
*/
/** The ceiling the specification sets for the file. */
export const MAX_LOGO_BYTES = 32 * 1024;
/** The square the logo is drawn on. Only the ratio matters to a receiver. */
export const CANVAS = 512;
/** How much of the square the logo fills when it is first placed. */
const FILL = 0.8;
export type LogoErrorCode = "notXml" | "notSvg" | "noSize" | "empty" | "tooComplex";
export class BimiLogoError extends Error {
constructor(readonly code: LogoErrorCode) {
super(code);
}
}
/** Artwork reduced to drawing elements, with the size it was drawn at. */
export interface Artwork {
/** The drawing elements, serialised, without any <svg> wrapper. */
inner: string;
/** Where the drawing starts and how large it is, in its own units. */
minX: number;
minY: number;
width: number;
height: number;
/** Things that were dropped, for the person to know about. */
removed: string[];
/** Things kept that may not look the same everywhere ("text": it depends on fonts). */
warnings: string[];
}
// ------------------------------------------------------------ sanitizing
/** Elements kept: shapes, groups and what gradients need. */
const ALLOWED_ELEMENTS = new Set([
"g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
"defs", "lineargradient", "radialgradient", "stop", "text", "tspan", "title", "desc",
]);
/** Dropped without comment: editor noise that draws nothing. */
const SILENT_ELEMENTS = new Set(["metadata", "namedview", "title", "desc"]);
/** Style declarations that become presentation attributes; the rest are dropped. */
const STYLE_PROPERTIES = new Set([
"fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity",
"stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "opacity", "stop-color", "stop-opacity",
]);
const KEPT_ATTRIBUTES = new Set([
"d", "x", "y", "cx", "cy", "r", "rx", "ry", "x1", "y1", "x2", "y2", "width", "height", "points",
"transform", "id", "offset", "gradientunits", "gradienttransform", "spreadmethod", "fx", "fy",
"font-family", "font-size", "font-weight", "text-anchor", "dx", "dy",
...STYLE_PROPERTIES,
]);
function localName(node: Element): string {
return node.localName.toLowerCase();
}
/** Reads "fill:#000;stroke:none" into presentation attributes. */
function applyStyle(el: Element): void {
const style = el.getAttribute("style");
el.removeAttribute("style");
if (!style) return;
for (const declaration of style.split(";")) {
const [rawName, ...rest] = declaration.split(":");
const name = rawName?.trim().toLowerCase();
const value = rest.join(":").trim();
// A url() in a style value would fetch something; only plain values pass
if (name && STYLE_PROPERTIES.has(name) && value && !/url\s*\(\s*(?!#)/i.test(value)) {
el.setAttribute(name, value);
}
}
}
function cleanElement(el: Element, removed: Set<string>): void {
applyStyle(el);
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
// Event handlers, classes, editor namespaces (inkscape:, sodipodi:), links
// out of the document: none of them draw anything, and some are active
if (!KEPT_ATTRIBUTES.has(name)) {
el.removeAttribute(attr.name);
continue;
}
// fill="url(#id)" is how a gradient is used; anything else with url() is out
if (/url\s*\(\s*(?!#)/i.test(attr.value)) el.removeAttribute(attr.name);
}
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 1) {
const element = child as Element;
const name = localName(element);
if (!ALLOWED_ELEMENTS.has(name)) {
if (!SILENT_ELEMENTS.has(name) && !name.includes(":")) removed.add(name);
el.removeChild(element);
continue;
}
// Titles and descriptions inside the drawing are dropped; the logo gets one title of its own
if (name === "title" || name === "desc") {
el.removeChild(element);
continue;
}
cleanElement(element, removed);
} else if (child.nodeType !== 3) {
// Comments, processing instructions, CDATA
el.removeChild(child);
}
}
}
/**
* An anchor wraps drawing: keep what is inside it, lose the link. Done before
* the cleaning pass, so what comes out of an anchor is cleaned like the rest.
*/
function unwrapAnchors(root: Element, removed: Set<string>): void {
for (const anchor of Array.from(root.getElementsByTagName("a"))) {
removed.add("a (link)");
const parent = anchor.parentNode;
if (!parent) continue;
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
parent.removeChild(anchor);
}
}
function numberOf(value: string | null): number | null {
if (!value) return null;
const n = parseFloat(value);
return Number.isFinite(n) && n > 0 ? n : null;
}
/**
* Reduces an uploaded SVG to its drawing. Throws BimiLogoError when it is not
* something that can be placed (not XML, not SVG, no size to place it by).
*/
export function sanitizeSvg(source: string): Artwork {
const doc = new DOMParser().parseFromString(source, "image/svg+xml");
if (doc.getElementsByTagName("parsererror").length > 0) throw new BimiLogoError("notXml");
const root = doc.documentElement;
if (!root || localName(root) !== "svg") throw new BimiLogoError("notSvg");
let minX = 0;
let minY = 0;
let width: number | null = null;
let height: number | null = null;
const viewBox = (root.getAttribute("viewBox") ?? "")
.trim()
.split(/[\s,]+/)
.map(Number);
if (viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
[minX, minY, width, height] = viewBox;
} else {
width = numberOf(root.getAttribute("width"));
height = numberOf(root.getAttribute("height"));
}
if (!width || !height) throw new BimiLogoError("noSize");
const removed = new Set<string>();
unwrapAnchors(root, removed);
cleanElement(root, removed);
// Serialised whole and unwrapped, so the children carry no stray namespace
const serialised = new XMLSerializer().serializeToString(root);
const inner = serialised
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "")
.replace(/\s+xmlns(:\w+)?="[^"]*"/g, "")
.trim();
if (!inner || !/<(path|rect|circle|ellipse|line|polyline|polygon|text)\b/i.test(inner)) {
throw new BimiLogoError("empty");
}
// Text is drawn with whatever font the receiver has, not the one it was made in
const warnings = /<text\b/i.test(inner) ? ["text"] : [];
return { inner, minX, minY, width, height, removed: Array.from(removed).sort(), warnings };
}
// ------------------------------------------------------------- tracing
export type TracePreset = "high" | "balanced" | "compact" | "tiny";
/** From most faithful to smallest. A logo that does not fit the limit walks down this list. */
export const TRACE_PRESETS: TracePreset[] = ["high", "balanced", "compact", "tiny"];
const PRESET_OPTIONS: Record<TracePreset, Record<string, number>> = {
high: { numberofcolors: 24, pathomit: 4, ltres: 1, qtres: 1, roundcoords: 1, colorsampling: 2 },
balanced: { numberofcolors: 12, pathomit: 8, ltres: 1, qtres: 1, roundcoords: 1, colorsampling: 2 },
compact: { numberofcolors: 6, pathomit: 16, ltres: 1.5, qtres: 1.5, roundcoords: 0, colorsampling: 2 },
tiny: { numberofcolors: 3, pathomit: 32, ltres: 2, qtres: 2, roundcoords: 0, colorsampling: 2 },
};
/** Pixels, the same shape ImageData has, so tests need no canvas. */
export interface Raster {
width: number;
height: number;
data: Uint8ClampedArray;
}
interface Tracer {
imagedataToSVG(raster: Raster, options: Record<string, number>): string;
}
/** rgb(1, 2, 3) to #010203, which is shorter and what the validator likes best. */
function hex(rgb: string): string {
const m = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i.exec(rgb);
if (!m) return rgb;
return "#" + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("");
}
/**
* Vectorises pixels. The result is the drawing only: the tracer's own wrapper
* (and the comment it adds) is discarded, and fully transparent paths, which
* are the empty background, are dropped.
*/
export function traceRaster(raster: Raster, preset: TracePreset, tracer: Tracer): Artwork {
const svg = tracer.imagedataToSVG(raster, { ...PRESET_OPTIONS[preset], strokewidth: 0, viewbox: 0, scale: 1 });
const paths: string[] = [];
for (const match of svg.matchAll(/<path\b[^>]*\/>/g)) {
const tag = match[0];
const opacity = /\bopacity="([\d.]+)"/.exec(tag)?.[1];
if (opacity !== undefined && Number(opacity) === 0) continue;
const fill = /\bfill="([^"]+)"/.exec(tag)?.[1] ?? "#000000";
const d = /\bd="([^"]+)"/.exec(tag)?.[1];
if (!d) continue;
const alpha = opacity !== undefined && Number(opacity) < 1 ? ` fill-opacity="${opacity}"` : "";
paths.push(`<path fill="${hex(fill)}"${alpha} d="${d}"/>`);
}
if (paths.length === 0) throw new BimiLogoError("empty");
return { inner: paths.join(""), minX: 0, minY: 0, width: raster.width, height: raster.height, removed: [], warnings: [] };
}
// ------------------------------------------------------------- building
export interface Layout {
/** 1 fills 80% of the square; larger zooms in, smaller leaves more margin. */
zoom: number;
/** Offset from centred, as a fraction of the square. */
dx: number;
dy: number;
}
export const DEFAULT_LAYOUT: Layout = { zoom: 1, dx: 0, dy: 0 };
export function clampLayout(layout: Layout): Layout {
const zoom = Math.min(4, Math.max(0.2, layout.zoom));
// The logo may be pushed until its centre reaches an edge, no further
return { zoom, dx: Math.min(0.5, Math.max(-0.5, layout.dx)), dy: Math.min(0.5, Math.max(-0.5, layout.dy)) };
}
const round = (n: number) => Math.round(n * 1000) / 1000;
function escapeXml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export function isHexColor(value: string): boolean {
return /^#[0-9a-f]{6}$/i.test(value);
}
/**
* The final document: the artwork on a solid square, placed as laid out.
*
* The background is solid because a receiver draws the logo on its own
* coloured surface and in its own mask (a circle in Gmail), and a transparent
* mark disappears into half of them.
*/
export function buildLogoSvg(art: Artwork, layout: Layout, title: string, background: string): string {
const { zoom, dx, dy } = clampLayout(layout);
const fill = isHexColor(background) ? background.toLowerCase() : "#ffffff";
const scale = ((CANVAS * FILL) / Math.max(art.width, art.height)) * zoom;
const tx = (CANVAS - art.width * scale) / 2 + dx * CANVAS - art.minX * scale;
const ty = (CANVAS - art.height * scale) / 2 + dy * CANVAS - art.minY * scale;
return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 ${CANVAS} ${CANVAS}">` +
`<title>${escapeXml(title.trim() || "Logo")}</title>` +
`<rect width="${CANVAS}" height="${CANVAS}" fill="${fill}"/>` +
`<g transform="matrix(${round(scale)} 0 0 ${round(scale)} ${round(tx)} ${round(ty)})">${art.inner}</g>` +
`</svg>\n`
);
}
/** Bytes the file will occupy, as the server counts them. */
export function byteLength(svg: string): number {
return new TextEncoder().encode(svg).length;
}