package elinps import ( "crypto/subtle" "fmt" "html/template" "net/http" "net/url" "regexp" "strings" "time" "e-li.nps/internal/contratos" "e-li.nps/internal/db" "github.com/jackc/pgx/v5" ) // Proteção simples do painel administrativo. // // Objetivo: bloquear acesso ao painel com uma senha definida no .env. // Implementação: cookie assinado de forma simples (token aleatório por boot). // // Observação: é propositalmente simples (sem banco) para manter o projeto leve. // Se precisar evoluir depois, podemos trocar por JWT ou sessão persistida. type AuthPainel struct { Senha string Token string } func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) { if !a.habilitado() { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("painel desabilitado")) return } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } senha := r.FormValue("senha") if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("senha invalida")) return } http.SetCookie(w, &http.Cookie{ Name: a.cookieName(), Value: a.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, // Secure deve ser true em produção com HTTPS. Secure: false, // Expira em 24h (relogin simples). Expires: time.Now().Add(24 * time.Hour), }) http.Redirect(w, r, "/painel", http.StatusFound) } type NPSMensal = contratos.NPSMensal type RespostaPainel = contratos.RespostaPainel type PainelDados = contratos.PainelDados func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) { ctx := r.Context() // Query params produto := r.URL.Query().Get("produto") pagina := 1 if p := r.URL.Query().Get("pagina"); p != "" { // best-effort parse _, _ = fmt.Sscanf(p, "%d", &pagina) if pagina <= 0 { pagina = 1 } } somenteBaixas := r.URL.Query().Get("baixas") == "1" produtos, err := store.ListarProdutos(ctx) if err != nil { a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao listar produtos"}) return } if produto == "" && len(produtos) > 0 { produto = produtos[0] } dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas} if produto == "" { a.renderPainelHTML(w, dados) return } // tabela segura prodNorm, err := db.NormalizeProduto(produto) if err != nil { dados.MsgErro = "produto inválido" a.renderPainelHTML(w, dados) return } tabela := db.TableNameForProduto(prodNorm) if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil { dados.MsgErro = "erro ao garantir tabela" a.renderPainelHTML(w, dados) return } meses, err := store.NPSMesAMes(ctx, tabela, 12) if err != nil { dados.MsgErro = "erro ao calcular NPS" a.renderPainelHTML(w, dados) return } dados.Meses = meses respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50}) if err != nil { // Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar. if err == pgx.ErrNoRows { respostas = []contratos.RespostaPainel{} } else { dados.MsgErro = "erro ao listar respostas" } } dados.Respostas = respostas a.renderPainelHTML(w, dados) } func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { w.Header().Set("Content-Type", "text/html; charset=utf-8") formatarDataPainel := func(t time.Time) string { // O banco armazena timestamptz (UTC normalizado). O requisito do painel é // exibir no fuso de Brasília. loc, err := time.LoadLocation("America/Sao_Paulo") if err != nil { // Fallback best-effort (sem regras de DST antigas, mas suficiente para // ambientes sem tzdata). loc = time.FixedZone("America/Sao_Paulo", -3*60*60) } return t.In(loc).Format("02/01/2006 15:04") } soDigitos := func(s string) string { // Normalização best-effort para criar links tel/WhatsApp. // Mantemos apenas dígitos e descartamos qualquer outro caractere. re := regexp.MustCompile(`\D`) return re.ReplaceAllString(s, "") } linkEmail := func(email string) string { email = strings.TrimSpace(email) if email == "" { return "-" } // mailto aceita percent-encoding; usar PathEscape evita injeção no href. href := "mailto:" + url.PathEscape(email) txt := template.HTMLEscapeString(email) return "" + txt + "" } linkTelefone := func(telOriginal string) (hrefTel string, hrefWA string, htmlTel string) { telOriginal = strings.TrimSpace(telOriginal) if telOriginal == "" { return "", "", "-" } dig := soDigitos(telOriginal) if dig == "" { // Se não há dígitos, exibimos apenas como texto. return "", "", template.HTMLEscapeString(telOriginal) } // tel: — preferimos com '+' quando possível. hrefTel = "tel:+" + dig // WhatsApp wa.me exige número em formato internacional com dígitos. waNum := dig // Heurística: se parece número BR (10/11 dígitos) e não tem DDI, prefixa 55. if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") { waNum = "55" + waNum } hrefWA = "https://wa.me/" + waNum // Exibição do número: mantém o original (mais amigável), mas escapado. htmlTel = "" + template.HTMLEscapeString(telOriginal) + "" return hrefTel, hrefWA, htmlTel } // HTML propositalmente simples (sem template engine) para manter isolado. // Se quiser evoluir, dá pra migrar para templates. var b strings.Builder b.WriteString("") b.WriteString("e-li.nps • Painel") b.WriteString(``) b.WriteString("
") b.WriteString("

e-li.nps • Painel

") if d.MsgErro != "" { b.WriteString("

" + template.HTMLEscapeString(d.MsgErro) + "

") } b.WriteString("
") b.WriteString("") b.WriteString("") chk := "" if d.SomenteBaixas { chk = "checked" } b.WriteString("") b.WriteString("") b.WriteString("
") b.WriteString("
") // NPS mês a mês b.WriteString("

NPS mês a mês

") b.WriteString("") for _, m := range d.Meses { b.WriteString(fmt.Sprintf("", template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS)) } b.WriteString("
MêsDetratoresNeutrosPromotoresTotalNPS
%s%d%d%d%d%d
") // Respostas b.WriteString("

Respostas

") b.WriteString("") for _, r := range d.Respostas { data := "-" if r.RespondidoEm != nil { data = formatarDataPainel(*r.RespondidoEm) } else { // fallback: apesar do painel listar "respondido", mantemos robustez. data = formatarDataPainel(r.PedidoCriadoEm) } nota := "-" if r.Nota != nil { nota = fmt.Sprintf("%d", *r.Nota) } usuario := template.HTMLEscapeString(r.UsuarioNome) if r.UsuarioCodigo != nil { usuario += " (" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")" } inquilino := template.HTMLEscapeString(r.InquilinoNome) + " (" + template.HTMLEscapeString(r.InquilinoCodigo) + ")" emailHTML := "-" emailHref := "" if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" { emailHTML = linkEmail(*r.UsuarioEmail) emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail)) } telHref := "" waHref := "" telefoneHTML := "-" if r.UsuarioTelefone != nil && strings.TrimSpace(*r.UsuarioTelefone) != "" { telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone) } // Ícones (inline SVG) — simples e sem dependências. iconMail := `` iconPhone := `` iconWA := `` acoes := "" if emailHref != "" { acoes += "" + iconMail + "" } if waHref != "" { acoes += "" + iconWA + "" } if telHref != "" { acoes += "" + iconPhone + "" } if acoes == "" { acoes = "-" } coment := "" if r.Justificativa != nil { coment = template.HTMLEscapeString(*r.Justificativa) } b.WriteString("") } b.WriteString("
DataNotaUsuárioInquilinoEmailTelefoneComentárioAções
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + coment + "" + acoes + "
") // Navegação base := "/painel?produto=" + url.QueryEscape(d.Produto) if d.SomenteBaixas { base += "&baixas=1" } prev := d.Pagina - 1 if prev < 1 { prev = 1 } next := d.Pagina + 1 b.WriteString("
") b.WriteString("Anterior") b.WriteString("Página " + fmt.Sprintf("%d", d.Pagina) + "") b.WriteString("Próxima") b.WriteString("
") b.WriteString("
") // JS do painel: apenas bootstrap para executar a lógica no WASM. // Regras (.agent): sem dependências externas. A lógica fica no WASM. b.WriteString(``) b.WriteString("") w.Write([]byte(b.String())) } func (a AuthPainel) habilitado() bool { return a.Senha != "" && a.Token != "" } func (a AuthPainel) cookieName() string { return "eli_nps_painel" } func (a AuthPainel) isAutenticado(r *http.Request) bool { c, err := r.Cookie(a.cookieName()) if err != nil { return false } return subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.Token)) == 1 } func (a AuthPainel) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !a.habilitado() { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("painel desabilitado")) return } if a.isAutenticado(r) { next.ServeHTTP(w, r) return } http.Redirect(w, r, "/painel/login", http.StatusFound) }) } func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) { // HTML mínimo para evitar dependências. w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` e-li.nps • Painel

e-li.nps • Painel

Acesso protegido por senha (SENHA_PAINEL).

`)) } // (handlerLoginPost duplicado removido)