Revert "revert: foto de perfil e BIMI, para restaurar o servico"

Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site
estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o
502 continuou depois de reverter.

A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify
a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit
anterior, com expose 3000.

As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por
isso o codigo reposto encontra as tabelas que espera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 22:29:12 -03:00
1 parent e93fa19bdd
commit c59548e311
26 files changed
+1899 -21

No files matched your search

@@ -0,0 +1,217 @@
package httppresentation
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"lambdamail/protocols/internal/domain/bimi"
)
// BimiStore holds the mark a domain publishes.
type BimiStore interface {
Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error)
Load(ctx context.Context, domain string) (*BimiLogo, error)
Delete(ctx context.Context, domain string) error
}
// BimiLogo is a stored mark as this layer sees it.
type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
}
type bimiAPI struct {
store BimiStore
sessions *WebSessionVerifier
// domainOf maps the host a request arrived on to the mail domain whose
// logo should answer, so one deployment can serve several.
defaultDomain string
}
// SetBimiAPI wires the BIMI logo store.
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain string) {
if store == nil {
return
}
r.bimi = &bimiAPI{store: store, defaultDomain: defaultDomain}
if sessionSecret != "" {
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
}
}
// handlePublicLogo serves the mark itself.
//
// Deliberately open: this is fetched by receiving mail providers evaluating a
// message, which hold no session here and never will. It is also the only
// thing in this service that is meant to be read by strangers, so it answers
// nothing but the stored bytes.
func (r *Router) handleBimiLogo(w http.ResponseWriter, req *http.Request) {
if r.bimi == nil {
http.NotFound(w, req)
return
}
if req.Method != http.MethodGet && req.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
return
}
domain := r.bimi.domainFor(req)
logo, err := r.bimi.store.Load(req.Context(), domain)
if err != nil || logo == nil {
http.NotFound(w, req)
return
}
if req.Header.Get("If-None-Match") == `"`+logo.ETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("ETag", `"`+logo.ETag+`"`)
// Public and long: receivers fetch this for every message they evaluate,
// and the mark changes about as often as a company rebrands.
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
// The document is served from the same origin as the console, and an SVG
// is a document a browser will happily execute. Nothing in a validated
// Tiny PS mark is active, but the header costs nothing and covers the case
// where validation is one day loosened.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(logo.SVG)
}
func (a *bimiAPI) domainFor(req *http.Request) string {
host := extractHost(req.Host)
// mail.example.test serves example.test's mark; anything else falls back
// to the domain this deployment was configured with.
if trimmed := strings.TrimPrefix(host, "mail."); trimmed != host && trimmed != "" {
return trimmed
}
return a.defaultDomain
}
// handleBimiAdmin uploads, inspects or removes a domain's mark.
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
if r.bimi == nil || r.bimi.sessions == nil {
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
"The BIMI panel needs JWT_SECRET and a database")
return
}
token := bearerOrCookie(req, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
return
}
switch req.Method {
case http.MethodGet:
r.bimi.get(w, req, domain)
case http.MethodPut:
r.bimi.put(w, req, domain)
case http.MethodDelete:
if err := r.bimi.store.Delete(req.Context(), domain); err != nil {
log.Printf("bimi: could not delete the logo for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the logo")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET, PUT or DELETE")
}
}
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
logo, err := a.store.Load(req.Context(), domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
return
}
if logo == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"etag": logo.ETag,
"vmc_url": logo.VmcURL,
"bytes": len(logo.SVG),
"svg": string(logo.SVG),
})
}
func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
// A shade over the specification's ceiling, so an oversized upload is
// refused by the validator with a reason rather than truncated here.
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxLogoBytes*2))
if err != nil {
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
return
}
var payload struct {
SVG string `json:"svg"`
VmcURL string `json:"vmc_url"`
}
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with an svg field")
return
}
// Every reason at once. Fixing one rule at a time against a receiver that
// silently shows nothing is a miserable way to learn this format.
if violations := bimi.Validate([]byte(payload.SVG)); len(violations) > 0 {
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "INVALID_LOGO",
"message": "The logo is not SVG Tiny Portable/Secure",
"violations": reasons,
})
return
}
vmc := strings.TrimSpace(payload.VmcURL)
if vmc != "" && !strings.HasPrefix(vmc, "https://") {
// A certificate fetched over plain HTTP is not evidence of anything,
// and every receiver refuses it.
writeError(w, http.StatusBadRequest, "INSECURE_VMC", "The certificate URL must be https")
return
}
etag, err := a.store.Save(req.Context(), domain, []byte(payload.SVG), vmc)
if err != nil {
log.Printf("bimi: could not save the logo for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "etag": etag})
}
// BimiLogoURL is where a domain's mark is served from.
func BimiLogoURL(mailHost string) string {
return fmt.Sprintf("https://%s/.well-known/bimi/default.svg", mailHost)
}
@@ -40,6 +40,7 @@ type Router struct {
// dnsReconciler is held here so it survives being set before dns exists.
dnsReconciler DomainReconciler
dnsStatus DnsStatusWriter
bimi *bimiAPI
// degradedFunc reports a condition that leaves the service running but
// not fit for production, the clearest case being a self-signed
// certificate standing in for one Traefik never issued
@@ -78,6 +79,8 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/health", r.handleHealth)
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo)
r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin)
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)