package elinps import ( "crypto/subtle" "fmt" "html/template" "net/http" "net/url" "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") // 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 = r.RespondidoEm.Format("2006-01-02 15:04") } 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) + ")" } coment := "" if r.Justificativa != nil { coment = template.HTMLEscapeString(*r.Justificativa) } b.WriteString("") } b.WriteString("
DataNotaUsuárioComentário
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + coment + "
") // 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("
") 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)