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
+623
-41
No files matched your search
@@ -226,9 +226,23 @@ everyone who sends from it. Publishing one needs three things, and the console
|
||||
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 record is published with the rest of the zone. Replacing the logo under a
|
||||
held certificate breaks the certificate, and the console says so.
|
||||
The logo is served from `https://<mail host>/.well-known/bimi/default.svg`.
|
||||
|
||||
**The DNS record follows the picture.** With a Cloudflare token configured, the
|
||||
`default._bimi.<domain>` TXT record is part of what the DNS reconciler
|
||||
publishes, and saving or removing the logo or the certificate in the console
|
||||
triggers that reconciliation by itself ("Publicar no DNS agora" does the same
|
||||
on demand). The record is created when a logo exists, updated when the
|
||||
certificate is added, and removed when the logo is removed, but only a record
|
||||
this reconciler published: one made by hand is left alone.
|
||||
|
||||
A held certificate is advertised (`a=`) only while it is valid for the logo
|
||||
being served. The certificate vouches for one exact SVG, so replacing the logo
|
||||
ends it, and a record that kept naming it would make every receiver reject BIMI,
|
||||
including those that would have shown the logo without a certificate. The record
|
||||
then goes back to `l=` alone, and the console says why the certificate is held
|
||||
back. The panel, the setup check and the reconciler all take that decision from
|
||||
the same function (`bimi.Publish`), so they cannot disagree about it.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/bimi"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||
)
|
||||
|
||||
// bimiPublication is what a domain's BIMI record should say, for the sync, the
|
||||
// background sweep and the console alike. Read each time, so a logo saved a
|
||||
// moment ago is what gets published and a removed one stops being published.
|
||||
func bimiPublication(ctx context.Context, repo *postgres.BimiRepository, cfg config, domain string) bimi.Publication {
|
||||
if repo == nil {
|
||||
return bimi.Publication{}
|
||||
}
|
||||
logo, err := repo.Load(ctx, domain)
|
||||
if err != nil {
|
||||
// Unknown is not "no logo": returning an empty publication here would
|
||||
// have the sync delete the record over a database hiccup.
|
||||
log.Printf("BIMI: could not read the logo for %s, leaving its record alone: %v", domain, err)
|
||||
return bimi.Publication{LogoURL: httppresentation.BimiLogoURL(cfg.PrimaryMailHost)}
|
||||
}
|
||||
if logo == nil {
|
||||
return bimi.Publication{}
|
||||
}
|
||||
return bimi.Publish(
|
||||
logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
||||
httppresentation.BimiLogoURL(cfg.PrimaryMailHost),
|
||||
httppresentation.BimiVmcURL(cfg.PrimaryMailHost),
|
||||
time.Now(),
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func (d domainReconciler) ReconcileDomain(
|
||||
Created: out.CreatedCount,
|
||||
Updated: out.UpdatedCount,
|
||||
Unchanged: out.UnchangedCount,
|
||||
Deleted: out.DeletedCount,
|
||||
Conflicts: conflicts,
|
||||
Errors: out.Errors,
|
||||
}, nil
|
||||
@@ -54,7 +55,7 @@ func (d domainReconciler) ReconcileDomain(
|
||||
// halfway through each other's zone updates.
|
||||
func reconcileDomainOnDemand(
|
||||
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
|
||||
dkimRepo *postgres.DkimRepository, domain string,
|
||||
dkimRepo *postgres.DkimRepository, bimiRepo *postgres.BimiRepository, domain string,
|
||||
) (*usecase.SyncDnsRecordsOutput, error) {
|
||||
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
|
||||
syncUC.SetVerifier(netdns.NewPublicVerifier())
|
||||
@@ -67,6 +68,8 @@ func reconcileDomainOnDemand(
|
||||
DaneEnabled: cfg.OutboundDane,
|
||||
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
|
||||
}
|
||||
publication := bimiPublication(ctx, bimiRepo, cfg, domain)
|
||||
input.BimiLogoURL, input.BimiVmcURL = publication.LogoURL, publication.CertificateURL
|
||||
|
||||
// A domain with no DKIM key of its own would publish a selector pointing
|
||||
// at nothing, so the keys are provisioned first, exactly as the timer does.
|
||||
|
||||
@@ -187,7 +187,7 @@ func run(cfg config) {
|
||||
// ------------------------------------------------------- background jobs
|
||||
go runDeliveryWorker(ctx, outboundWorker)
|
||||
if cfg.CloudflareToken != "" {
|
||||
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool))
|
||||
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool), postgres.NewBimiRepository(pool))
|
||||
} else {
|
||||
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
|
||||
}
|
||||
@@ -215,9 +215,19 @@ func run(cfg config) {
|
||||
// provider to talk to, so the route can say it is unavailable instead of
|
||||
// reaching Cloudflare with an empty token and reporting a refusal.
|
||||
if cfg.CloudflareToken != "" {
|
||||
bimiRepo := postgres.NewBimiRepository(pool)
|
||||
router.SetDnsReconciler(domainReconciler{run: func(c context.Context, domain string) (*usecase.SyncDnsRecordsOutput, error) {
|
||||
return reconcileDomainOnDemand(c, cfg, aliasRepo, dkimRepo, domain)
|
||||
return reconcileDomainOnDemand(c, cfg, aliasRepo, dkimRepo, bimiRepo, domain)
|
||||
}})
|
||||
// Saving or removing a logo or a certificate publishes the change: the
|
||||
// record in the zone follows the picture, with no button to remember.
|
||||
router.SetBimiPublisher(func(domain string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := reconcileDomainOnDemand(ctx, cfg, aliasRepo, dkimRepo, bimiRepo, domain); err != nil {
|
||||
log.Printf("BIMI: publishing the record for %s after a change failed: %v", domain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
router.SetFolderAdmin(usecase.NewManageFoldersUseCase(
|
||||
postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool)))
|
||||
@@ -578,6 +588,7 @@ func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCas
|
||||
func runDnsReconciler(
|
||||
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
|
||||
dkimRepo *postgres.DkimRepository, domainRepo *postgres.DomainRepository,
|
||||
bimiRepo *postgres.BimiRepository,
|
||||
) {
|
||||
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
|
||||
syncUC.SetVerifier(netdns.NewPublicVerifier())
|
||||
@@ -600,6 +611,9 @@ func runDnsReconciler(
|
||||
// (PLAN.md section 10.4).
|
||||
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
|
||||
}
|
||||
// The logo and certificate a domain publishes through BIMI
|
||||
publication := bimiPublication(ctx, bimiRepo, cfg, domain)
|
||||
input.BimiLogoURL, input.BimiVmcURL = publication.LogoURL, publication.CertificateURL
|
||||
|
||||
if provisioner != nil {
|
||||
keys, err := provisioner.Execute(ctx, domain)
|
||||
@@ -717,19 +731,12 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e
|
||||
RelaySpfInclude: relayConfig(s.cfg).SpfInclude(),
|
||||
}
|
||||
|
||||
// The BIMI record is published only when a mark is actually stored, so
|
||||
// the panel never advertises a logo that would 404.
|
||||
// The same decision the sync makes (bimi.Publish), so the panel expects
|
||||
// exactly what the reconciler writes.
|
||||
if s.bimi != nil {
|
||||
if logo, err := s.bimi.Load(ctx, domain); err == nil && logo != nil {
|
||||
spec.BimiLogoURL = httppresentation.BimiLogoURL(s.cfg.PrimaryMailHost)
|
||||
// The certificate held here wins: it is the one verified against the
|
||||
// logo being served. An external URL is the fallback.
|
||||
if len(logo.VmcPEM) > 0 {
|
||||
spec.BimiVmcURL = httppresentation.BimiVmcURL(s.cfg.PrimaryMailHost)
|
||||
} else {
|
||||
spec.BimiVmcURL = logo.VmcURL
|
||||
}
|
||||
}
|
||||
p := bimiPublication(ctx, s.bimi, s.cfg, domain)
|
||||
spec.BimiLogoURL = p.LogoURL
|
||||
spec.BimiVmcURL = p.CertificateURL
|
||||
}
|
||||
|
||||
// Read rather than provisioned: verifying must not create keys as a side
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
func bimiInput(logo, cert string) SyncDnsRecordsInput {
|
||||
return SyncDnsRecordsInput{
|
||||
DomainName: "example.test",
|
||||
MailHost: "mail.example.test",
|
||||
ServerIPv4: "192.0.2.1",
|
||||
BimiLogoURL: logo,
|
||||
BimiVmcURL: cert,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
bimiName = "default._bimi.example.test"
|
||||
bimiLogo = "https://mail.example.test/.well-known/bimi/default.svg"
|
||||
bimiCert = "https://mail.example.test/.well-known/bimi/vmc.pem"
|
||||
)
|
||||
|
||||
func bimiRecords(dns *fakeDnsProvider) []entity.DnsRecord { return dns.find("TXT", bimiName) }
|
||||
|
||||
// The gap this closes: the console knew the BIMI record, the reconciler did
|
||||
// not, so storing a logo never put anything in the zone.
|
||||
func TestSyncPublishesTheBimiRecord(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, &fakeSystemAliasRepository{})
|
||||
|
||||
out, err := uc.Execute(context.Background(), bimiInput(bimiLogo, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bimiRecords(dns)
|
||||
if len(got) != 1 || got[0].Value != "v=BIMI1; l="+bimiLogo+";" {
|
||||
t.Fatalf("records = %+v", got)
|
||||
}
|
||||
if !strings.HasPrefix(got[0].Comment, "LambdaMail") {
|
||||
t.Errorf("the record must carry the ownership comment, got %q", got[0].Comment)
|
||||
}
|
||||
if out.CreatedCount == 0 {
|
||||
t.Error("nothing reported as created")
|
||||
}
|
||||
|
||||
// Idempotent
|
||||
out2, _ := uc.Execute(context.Background(), bimiInput(bimiLogo, ""))
|
||||
if out2.CreatedCount != 0 || out2.UpdatedCount != 0 || len(bimiRecords(dns)) != 1 {
|
||||
t.Errorf("second run changed something: created=%d updated=%d", out2.CreatedCount, out2.UpdatedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUpdatesTheBimiRecordWhenACertificateIsAdded(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, &fakeSystemAliasRepository{})
|
||||
_, _ = uc.Execute(context.Background(), bimiInput(bimiLogo, ""))
|
||||
|
||||
out, err := uc.Execute(context.Background(), bimiInput(bimiLogo, bimiCert))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bimiRecords(dns)
|
||||
if len(got) != 1 || got[0].Value != "v=BIMI1; l="+bimiLogo+"; a="+bimiCert+";" {
|
||||
t.Fatalf("records = %+v", got)
|
||||
}
|
||||
if out.UpdatedCount != 1 {
|
||||
t.Errorf("updated = %d, want 1 (an update, not a second record)", out.UpdatedCount)
|
||||
}
|
||||
|
||||
// And back, when the certificate is withheld
|
||||
_, _ = uc.Execute(context.Background(), bimiInput(bimiLogo, ""))
|
||||
if got := bimiRecords(dns); len(got) != 1 || strings.Contains(got[0].Value, "a=") {
|
||||
t.Fatalf("the certificate stayed advertised: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRemovesTheBimiRecordItPublishedWhenTheLogoGoes(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, &fakeSystemAliasRepository{})
|
||||
_, _ = uc.Execute(context.Background(), bimiInput(bimiLogo, ""))
|
||||
|
||||
out, err := uc.Execute(context.Background(), bimiInput("", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bimiRecords(dns); len(got) != 0 {
|
||||
t.Fatalf("the record survived the logo: %+v", got)
|
||||
}
|
||||
if out.DeletedCount != 1 {
|
||||
t.Errorf("deleted = %d", out.DeletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A record the operator made by hand is theirs, even if it looks like ours.
|
||||
func TestSyncLeavesAHandMadeBimiRecordAlone(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
dns.seed(entity.DnsRecord{Type: "TXT", Name: bimiName, Value: "v=BIMI1; l=https://cdn.example.test/logo.svg;", TTL: 1, Comment: "made by hand"})
|
||||
uc := NewSyncDnsRecordsUseCase(dns, &fakeSystemAliasRepository{})
|
||||
|
||||
out, err := uc.Execute(context.Background(), bimiInput("", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bimiRecords(dns); len(got) != 1 || out.DeletedCount != 0 {
|
||||
t.Fatalf("a record this reconciler did not publish was touched: %+v deleted=%d", got, out.DeletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPublishesNothingForADomainWithoutALogo(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, &fakeSystemAliasRepository{})
|
||||
if _, err := uc.Execute(context.Background(), bimiInput("", "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bimiRecords(dns); len(got) != 0 {
|
||||
t.Fatalf("records = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ type SyncDnsRecordsInput struct {
|
||||
DaneEnabled bool
|
||||
RelaySpfInclude string
|
||||
AdminEmailAddress string
|
||||
// BimiLogoURL and BimiVmcURL are what the BIMI record should say (see
|
||||
// bimi.Publish). With no logo URL the domain has no BIMI record, and one
|
||||
// this reconciler published earlier is removed.
|
||||
BimiLogoURL string
|
||||
BimiVmcURL string
|
||||
}
|
||||
|
||||
// SyncConflict describes a record that could not be reconciled because a
|
||||
@@ -59,6 +64,9 @@ type SyncDnsRecordsOutput struct {
|
||||
CreatedCount int
|
||||
UpdatedCount int
|
||||
UnchangedCount int
|
||||
// DeletedCount is the records retired because they are no longer wanted
|
||||
// (a BIMI record whose logo was removed).
|
||||
DeletedCount int
|
||||
ConflictCount int
|
||||
Conflicts []SyncConflict
|
||||
Errors []string
|
||||
@@ -108,6 +116,8 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
|
||||
TlsaHashes: tlsaHashes,
|
||||
DaneEnabled: input.DaneEnabled,
|
||||
RelaySpfInclude: input.RelaySpfInclude,
|
||||
BimiLogoURL: input.BimiLogoURL,
|
||||
BimiVmcURL: input.BimiVmcURL,
|
||||
})
|
||||
|
||||
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
|
||||
@@ -165,6 +175,8 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
|
||||
}
|
||||
}
|
||||
|
||||
uc.retireBimi(ctx, zoneID, input, existingByKey, output)
|
||||
|
||||
if input.AdminEmailAddress != "" && uc.aliasRepo != nil {
|
||||
if err := uc.aliasRepo.EnsureSystemAliases(ctx, input.DomainName, input.AdminEmailAddress); err != nil {
|
||||
return nil, fmt.Errorf("ensure system aliases: %w", err)
|
||||
@@ -187,6 +199,31 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// retireBimi removes the BIMI record this reconciler published once the domain
|
||||
// no longer has a logo: left in place it advertises a logo URL that answers 404.
|
||||
//
|
||||
// Only a record carrying this reconciler's comment is touched. One the
|
||||
// operator created by hand, even with the same v=BIMI1 tag, is theirs.
|
||||
func (uc *SyncDnsRecordsUseCase) retireBimi(
|
||||
ctx context.Context, zoneID string, input SyncDnsRecordsInput,
|
||||
existingByKey map[string][]entity.DnsRecord, output *SyncDnsRecordsOutput,
|
||||
) {
|
||||
if input.BimiLogoURL != "" {
|
||||
return
|
||||
}
|
||||
name := "default._bimi." + input.DomainName
|
||||
for _, rec := range existingByKey[recordKey("TXT", name)] {
|
||||
if !strings.HasPrefix(rec.Comment, ownedRecordCommentPrefix) {
|
||||
continue
|
||||
}
|
||||
if err := uc.dnsProvider.DeleteRecord(ctx, zoneID, rec.ID); err != nil {
|
||||
output.Errors = append(output.Errors, fmt.Sprintf("delete TXT %s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
output.DeletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// verifyPublished re-reads every desired record through public resolvers and
|
||||
// returns the ones that did not check out.
|
||||
func (uc *SyncDnsRecordsUseCase) verifyPublished(ctx context.Context, specs []entity.DnsRecord) []string {
|
||||
|
||||
@@ -58,6 +58,8 @@ type Inputs struct {
|
||||
// the one the operator keeps elsewhere, used only when none is held.
|
||||
CertificatePEM []byte
|
||||
ExternalCertificateURL string
|
||||
// Withheld is why a held certificate is not in the record (see Publish).
|
||||
Withheld string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
@@ -214,7 +216,12 @@ func checkCertificate(ctx context.Context, in Inputs, fetch Fetcher) []Check {
|
||||
for _, v := range violations {
|
||||
reasons = append(reasons, v.String())
|
||||
}
|
||||
return append([]Check{{ID: "certificate", Status: StatusFail, Detail: strings.Join(reasons, "; ")}}, out...)
|
||||
detail := strings.Join(reasons, "; ")
|
||||
if in.Withheld != "" {
|
||||
// The record stays valid for receivers that do not need a certificate
|
||||
detail = "held here but NOT published in the DNS record, because it does not apply: " + detail
|
||||
}
|
||||
return append([]Check{{ID: "certificate", Status: StatusFail, Detail: detail}}, out...)
|
||||
}
|
||||
if len(cert.Warnings) > 0 {
|
||||
return append([]Check{{ID: "certificate", Status: StatusWarn, Detail: strings.Join(cert.Warnings, "; ")}}, out...)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package bimi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Publication is what the domain's BIMI DNS record should say right now.
|
||||
//
|
||||
// One decision, taken in one place, because three things have to agree on it:
|
||||
// the record the reconciler writes to the zone, the one the console expects to
|
||||
// find there, and the setup check. They used to compute it separately, and the
|
||||
// reconciler did not compute it at all, so a logo that was stored was never
|
||||
// published.
|
||||
type Publication struct {
|
||||
// LogoURL is empty when the domain has no logo: then there is no record.
|
||||
LogoURL string
|
||||
// CertificateURL is the a= tag, empty when none should be published.
|
||||
CertificateURL string
|
||||
// Withheld says why a certificate that is held here is not being published.
|
||||
Withheld string
|
||||
}
|
||||
|
||||
// Publish decides the record for a domain.
|
||||
//
|
||||
// A certificate held here is published only while it is valid for the logo
|
||||
// being served. The certificate vouches for one exact SVG, so the moment the
|
||||
// logo is replaced it stops applying, and a record that still names it makes
|
||||
// receivers reject BIMI outright, including the ones that would have shown the
|
||||
// logo without a certificate. Withholding it keeps those working and leaves the
|
||||
// reason for the console to show.
|
||||
func Publish(svg, pem []byte, externalURL, domain, logoURL, hostedCertificateURL string, now time.Time) Publication {
|
||||
if len(svg) == 0 {
|
||||
return Publication{}
|
||||
}
|
||||
p := Publication{LogoURL: logoURL, CertificateURL: strings.TrimSpace(externalURL)}
|
||||
|
||||
if len(pem) == 0 {
|
||||
return p
|
||||
}
|
||||
|
||||
if _, violations := InspectCertificate(pem, domain, svg, now); len(violations) > 0 {
|
||||
rules := make([]string, 0, len(violations))
|
||||
for _, v := range violations {
|
||||
rules = append(rules, v.Rule)
|
||||
}
|
||||
p.Withheld = strings.Join(rules, ", ")
|
||||
return p
|
||||
}
|
||||
p.CertificateURL = hostedCertificateURL
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package bimi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pubLogo = "https://mail.ebay.com/.well-known/bimi/default.svg"
|
||||
pubCert = "https://mail.ebay.com/.well-known/bimi/vmc.pem"
|
||||
)
|
||||
|
||||
func TestPublishNothingWithoutALogo(t *testing.T) {
|
||||
if got := Publish(nil, nil, "https://x.test/v.pem", "ebay.com", pubLogo, pubCert, fixtureNow); got != (Publication{}) {
|
||||
t.Fatalf("a domain with no logo must publish nothing, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLogoOnly(t *testing.T) {
|
||||
got := Publish(read(t, "ebay.svg"), nil, "", "ebay.com", pubLogo, pubCert, fixtureNow)
|
||||
if got.LogoURL != pubLogo || got.CertificateURL != "" || got.Withheld != "" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishAnExternalCertificateURL(t *testing.T) {
|
||||
got := Publish(read(t, "ebay.svg"), nil, " https://vmc.example.test/x.pem ", "ebay.com", pubLogo, pubCert, fixtureNow)
|
||||
if got.CertificateURL != "https://vmc.example.test/x.pem" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishTheHeldCertificateWhileItIsValidForTheLogo(t *testing.T) {
|
||||
got := Publish(read(t, "ebay.svg"), read(t, "ebay.pem"), "https://old.test/x.pem", "ebay.com", pubLogo, pubCert, fixtureNow)
|
||||
// The held one wins over an external URL: it is the one checked against this logo
|
||||
if got.CertificateURL != pubCert || got.Withheld != "" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this exists: replacing the logo under a held certificate must not
|
||||
// leave a record that points receivers at a certificate for another picture.
|
||||
func TestPublishWithholdsACertificateThatNoLongerMatchesTheLogo(t *testing.T) {
|
||||
got := Publish(read(t, "cnn.svg"), read(t, "ebay.pem"), "", "ebay.com", pubLogo, pubCert, fixtureNow)
|
||||
if got.LogoURL != pubLogo {
|
||||
t.Errorf("the logo must still be published: %+v", got)
|
||||
}
|
||||
if got.CertificateURL != "" || got.Withheld != "logoMismatch" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishWithholdsAnExpiredCertificate(t *testing.T) {
|
||||
got := Publish(read(t, "ebay.svg"), read(t, "ebay.pem"), "", "ebay.com", pubLogo, pubCert, time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if got.CertificateURL != "" || got.Withheld != "validity" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFallsBackToTheExternalURLWhenTheHeldCertificateIsWithheld(t *testing.T) {
|
||||
got := Publish(read(t, "cnn.svg"), read(t, "ebay.pem"), "https://vmc.example.test/x.pem", "ebay.com", pubLogo, pubCert, fixtureNow)
|
||||
if got.CertificateURL != "https://vmc.example.test/x.pem" || got.Withheld == "" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -207,14 +207,10 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
|
||||
|
||||
// BIMI, published only when there is a logo to point at.
|
||||
if spec.BimiLogoURL != "" {
|
||||
value := fmt.Sprintf("v=BIMI1; l=%s;", spec.BimiLogoURL)
|
||||
if spec.BimiVmcURL != "" {
|
||||
value = fmt.Sprintf("v=BIMI1; l=%s; a=%s;", spec.BimiLogoURL, spec.BimiVmcURL)
|
||||
}
|
||||
records = append(records, DnsRecord{
|
||||
Type: "TXT",
|
||||
Name: fmt.Sprintf("default._bimi.%s", domainName),
|
||||
Value: value,
|
||||
Value: BimiRecordValue(spec.BimiLogoURL, spec.BimiVmcURL),
|
||||
TTL: 1,
|
||||
Comment: "LambdaMail BIMI Record",
|
||||
})
|
||||
@@ -316,3 +312,15 @@ func hostInsideDomain(host, domain string) bool {
|
||||
// inside "example.test".
|
||||
return host == domain || strings.HasSuffix(host, "."+domain)
|
||||
}
|
||||
|
||||
// BimiRecordValue is the text of a domain's BIMI TXT record. The reconciler
|
||||
// writes it and the console shows it, so both come from here.
|
||||
func BimiRecordValue(logoURL, certificateURL string) string {
|
||||
if logoURL == "" {
|
||||
return ""
|
||||
}
|
||||
if certificateURL != "" {
|
||||
return fmt.Sprintf("v=BIMI1; l=%s; a=%s;", logoURL, certificateURL)
|
||||
}
|
||||
return fmt.Sprintf("v=BIMI1; l=%s;", logoURL)
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type ReconcileResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
// Deleted counts records retired because they are no longer wanted.
|
||||
Deleted int `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/bimi"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// BimiStore holds the mark a domain publishes, and the certificate that
|
||||
@@ -47,6 +48,25 @@ type bimiAPI struct {
|
||||
now func() time.Time
|
||||
dns bimi.Resolver
|
||||
fetch bimi.Fetcher
|
||||
// publish is told when a domain's logo or certificate changed, so the DNS
|
||||
// record can follow. Nil when there is no DNS provider to publish to.
|
||||
publish func(domain string)
|
||||
}
|
||||
|
||||
// SetBimiPublisher wires what happens after a logo or certificate changes.
|
||||
func (r *Router) SetBimiPublisher(publish func(domain string)) {
|
||||
if r.bimi != nil {
|
||||
r.bimi.publish = publish
|
||||
}
|
||||
}
|
||||
|
||||
// changed tells the publisher, in the background: a change is not slowed
|
||||
// down by a round trip to the DNS provider, and its outcome is the
|
||||
// reconciliation result the console shows, not part of the save.
|
||||
func (a *bimiAPI) changed(domain string) {
|
||||
if a.publish != nil {
|
||||
go a.publish(domain)
|
||||
}
|
||||
}
|
||||
|
||||
// SetBimiAPI wires the BIMI logo store.
|
||||
@@ -168,6 +188,7 @@ func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the logo")
|
||||
return
|
||||
}
|
||||
r.bimi.changed(domain)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET, PUT or DELETE")
|
||||
@@ -228,6 +249,7 @@ func (r *Router) handleBimiCertificateAdmin(w http.ResponseWriter, req *http.Req
|
||||
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the certificate")
|
||||
return
|
||||
}
|
||||
r.bimi.changed(domain)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "PUT or DELETE")
|
||||
@@ -259,21 +281,21 @@ func (r *Router) handleBimiCheck(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// What the record should say is decided once (bimi.Publish), exactly as the
|
||||
// sync writes it, so "the record is wrong" means it differs from what the
|
||||
// reconciler would publish.
|
||||
publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
||||
BimiLogoURL(r.bimi.mailHost), BimiVmcURL(r.bimi.mailHost), r.bimi.now())
|
||||
in := bimi.Inputs{
|
||||
Domain: domain,
|
||||
LogoURL: BimiLogoURL(r.bimi.mailHost),
|
||||
LogoURL: publication.LogoURL,
|
||||
CertificateURL: publication.CertificateURL,
|
||||
SVG: logo.SVG,
|
||||
CertificatePEM: logo.VmcPEM,
|
||||
ExternalCertificateURL: logo.VmcURL,
|
||||
Withheld: publication.Withheld,
|
||||
Now: r.bimi.now(),
|
||||
}
|
||||
// The record must name the certificate that is actually served
|
||||
switch {
|
||||
case len(logo.VmcPEM) > 0:
|
||||
in.CertificateURL = BimiVmcURL(r.bimi.mailHost)
|
||||
default:
|
||||
in.CertificateURL = logo.VmcURL
|
||||
}
|
||||
|
||||
results := bimi.RunChecks(ctx, in, r.bimi.dns, r.bimi.fetch)
|
||||
checks := make([]map[string]string, 0, len(results))
|
||||
@@ -298,9 +320,16 @@ func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
|
||||
return
|
||||
}
|
||||
publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
||||
BimiLogoURL(a.mailHost), BimiVmcURL(a.mailHost), a.now())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"configured": true,
|
||||
"etag": logo.ETag,
|
||||
// The record exactly as the reconciler writes it, and the reason a held
|
||||
// certificate is left out of it, if it is
|
||||
"dns_record": entity.BimiRecordValue(publication.LogoURL, publication.CertificateURL),
|
||||
"certificate_withheld": publication.Withheld,
|
||||
"dns_publishing": a.publish != nil,
|
||||
"vmc_url": logo.VmcURL,
|
||||
"bytes": len(logo.SVG),
|
||||
"svg": string(logo.SVG),
|
||||
@@ -391,8 +420,10 @@ func (a *bimiAPI) putCertificate(w http.ResponseWriter, req *http.Request, domai
|
||||
writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first")
|
||||
return
|
||||
}
|
||||
a.changed(domain)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"dns_publishing": a.publish != nil,
|
||||
"certificate": a.describeCertificate(domain, &BimiLogo{SVG: logo.SVG, VmcPEM: pem}),
|
||||
})
|
||||
}
|
||||
@@ -446,7 +477,8 @@ func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
|
||||
}
|
||||
// A certificate vouches for one exact SVG. Replacing the logo under a held
|
||||
// certificate breaks it, which receivers do not report; say so now.
|
||||
response := map[string]any{"ok": true, "etag": etag}
|
||||
a.changed(domain)
|
||||
response := map[string]any{"ok": true, "etag": etag, "dns_publishing": a.publish != nil}
|
||||
if held, err := a.store.Load(req.Context(), domain); err == nil && held != nil && len(held.VmcPEM) > 0 {
|
||||
if cert, _ := bimi.InspectCertificate(held.VmcPEM, domain, held.SVG, a.now()); cert != nil && !cert.MatchesLogo {
|
||||
response["certificate_mismatch"] = true
|
||||
|
||||
@@ -335,3 +335,123 @@ func TestFetcherRefusesPrivateAndPlainHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publishRecorder collects the domains the console asked to have published.
|
||||
type publishRecorder struct{ domains chan string }
|
||||
|
||||
func newPublishRecorder() *publishRecorder { return &publishRecorder{domains: make(chan string, 8)} }
|
||||
|
||||
func (p *publishRecorder) record(domain string) { p.domains <- domain }
|
||||
|
||||
func (p *publishRecorder) expect(t *testing.T, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-p.domains:
|
||||
if got != "ebay.com" {
|
||||
t.Errorf("%s published %q", what, got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s did not ask for the DNS record to be published", what)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *publishRecorder) expectNothing(t *testing.T, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-p.domains:
|
||||
t.Fatalf("%s published %q though it changed nothing", what, got)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// The record in the zone follows the picture: every change to the logo or the
|
||||
// certificate asks for the DNS to be reconciled, and nothing else does.
|
||||
func TestBimiChangesAreHandedToThePublisher(t *testing.T) {
|
||||
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
|
||||
r := newBimiRouter(store)
|
||||
rec := newPublishRecorder()
|
||||
r.bimi.publish = rec.record
|
||||
|
||||
putLogo(t, r, fixture(t, "ebay.svg"))
|
||||
rec.expect(t, "saving the logo")
|
||||
|
||||
putCertificate(r, fixture(t, "ebay.pem"))
|
||||
rec.expect(t, "saving the certificate")
|
||||
|
||||
// Reads and refused uploads change nothing and publish nothing
|
||||
bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true)
|
||||
putCertificate(r, []byte("-----BEGIN PRIVATE KEY-----\nQUJD\n-----END PRIVATE KEY-----\n"))
|
||||
putLogo(t, r, []byte("<svg/>"))
|
||||
rec.expectNothing(t, "a read or a refused upload")
|
||||
|
||||
bimiCall(r, http.MethodDelete, "/api/v1/admin/bimi/certificate?domain=ebay.com", "", true)
|
||||
rec.expect(t, "removing the certificate")
|
||||
|
||||
bimiCall(r, http.MethodDelete, "/api/v1/admin/bimi?domain=ebay.com", "", true)
|
||||
rec.expect(t, "removing the logo")
|
||||
}
|
||||
|
||||
func TestBimiWithoutAPublisherStillWorks(t *testing.T) {
|
||||
r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{}})
|
||||
resp := jsonBody(t, putLogo(t, r, fixture(t, "ebay.svg")))
|
||||
if resp["ok"] != true || resp["dns_publishing"] != false {
|
||||
t.Fatalf("%v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The check expects what the reconciler writes: with a certificate that no
|
||||
// longer matches the logo that is a record without a=, and that is correct.
|
||||
func TestBimiCheckExpectsWhatTheReconcilerPublishes(t *testing.T) {
|
||||
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
|
||||
r := newBimiRouter(store)
|
||||
putLogo(t, r, fixture(t, "cnn.svg"))
|
||||
store.logos["ebay.com"].VmcPEM = fixture(t, "ebay.pem") // a certificate for another picture
|
||||
|
||||
r.bimi.dns = fakeBimiDNS{
|
||||
"_dmarc.ebay.com": {"v=DMARC1; p=reject"},
|
||||
"default._bimi.ebay.com": {"v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg;"},
|
||||
}
|
||||
body := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=ebay.com", "", true))
|
||||
|
||||
status := map[string]string{}
|
||||
detail := map[string]string{}
|
||||
for _, c := range body["checks"].([]any) {
|
||||
m := c.(map[string]any)
|
||||
status[m["id"].(string)] = m["status"].(string)
|
||||
detail[m["id"].(string)] = m["detail"].(string)
|
||||
}
|
||||
if status["record"] != "ok" {
|
||||
t.Errorf("the record without a= is what the reconciler writes here, but the check said %s: %s", status["record"], detail["record"])
|
||||
}
|
||||
if status["certificate"] != "fail" || !strings.Contains(detail["certificate"], "NOT published") {
|
||||
t.Errorf("the certificate check must say it is held back: %s %s", status["certificate"], detail["certificate"])
|
||||
}
|
||||
}
|
||||
|
||||
// The console shows the record the reconciler writes, taken from the server
|
||||
// rather than put together again in the browser.
|
||||
func TestBimiGetReportsTheRecordItWouldPublish(t *testing.T) {
|
||||
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
|
||||
r := newBimiRouter(store)
|
||||
putLogo(t, r, fixture(t, "ebay.svg"))
|
||||
read := func() map[string]any {
|
||||
return jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true))
|
||||
}
|
||||
|
||||
got := read()
|
||||
if got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg;" || got["certificate_withheld"] != "" {
|
||||
t.Fatalf("logo only: %v", got)
|
||||
}
|
||||
|
||||
putCertificate(r, fixture(t, "ebay.pem"))
|
||||
if got := read(); got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg; a=https://mail.ebay.com/.well-known/bimi/vmc.pem;" {
|
||||
t.Fatalf("with its certificate: %v", got["dns_record"])
|
||||
}
|
||||
|
||||
// The logo is replaced: the certificate is held but no longer advertised
|
||||
putLogo(t, r, fixture(t, "cnn.svg"))
|
||||
got = read()
|
||||
if got["certificate_withheld"] != "logoMismatch" || strings.Contains(got["dns_record"].(string), "a=") {
|
||||
t.Fatalf("after replacing the logo: %v / %v", got["dns_record"], got["certificate_withheld"])
|
||||
}
|
||||
}
|
||||
@@ -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