Projeto bot pontos
This commit is contained in:
152
backend/cmd/botpontos/admin.go
Normal file
152
backend/cmd/botpontos/admin.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"botpontos/internal/app"
|
||||
"botpontos/internal/bootstrap"
|
||||
"botpontos/internal/config"
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
const adminUsage = `uso: botpontos admin <comando>
|
||||
|
||||
comandos:
|
||||
create-owner --username NOME cria a primeira conta owner do painel
|
||||
reset-password --username NOME redefine a senha de uma conta do painel
|
||||
bot-admin --user-id ID --enable|--disable
|
||||
liga ou desliga o admin do bot no Telegram
|
||||
set-credentials --user-id ID --email E grava e valida a credencial da PontoMais
|
||||
rotate-key recifra as credenciais com a chave atual
|
||||
|
||||
As senhas são lidas do terminal, sem eco. Execute com:
|
||||
docker compose run --rm api admin <comando> ...
|
||||
`
|
||||
|
||||
func runAdmin(ctx context.Context, cfg config.Config, logger *slog.Logger, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(os.Stderr, adminUsage)
|
||||
return errors.New("comando administrativo não informado")
|
||||
}
|
||||
built, err := bootstrap.Build(ctx, cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer built.Pool.Close()
|
||||
svc := built.Service
|
||||
|
||||
fs := flag.NewFlagSet("admin "+args[0], flag.ContinueOnError)
|
||||
username := fs.String("username", "", "nome da conta do painel")
|
||||
userID := fs.Int64("user-id", 0, "ID do usuário do Telegram (veja /usuarios)")
|
||||
email := fs.String("email", "", "e-mail de login na PontoMais")
|
||||
enable := fs.Bool("enable", false, "liga")
|
||||
disable := fs.Bool("disable", false, "desliga")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "create-owner":
|
||||
if *username == "" {
|
||||
return errors.New("informe --username")
|
||||
}
|
||||
password, err := readNewPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := svc.CreateAccount(ctx, app.CLIActor, app.AccountInput{
|
||||
Username: *username, Password: password, Role: domain.RoleOwner,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Conta owner %q criada (ID %d).\n", account.Username, account.ID)
|
||||
|
||||
case "reset-password":
|
||||
if *username == "" {
|
||||
return errors.New("informe --username")
|
||||
}
|
||||
password, err := readNewPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.ResetPasswordByUsername(ctx, app.CLIActor, *username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Senha redefinida. As sessões abertas dessa conta foram encerradas.")
|
||||
|
||||
case "bot-admin":
|
||||
if *userID <= 0 || *enable == *disable {
|
||||
return errors.New("informe --user-id e exatamente um entre --enable e --disable")
|
||||
}
|
||||
if err := svc.SetBotAdmin(ctx, app.CLIActor, *userID, *enable); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Admin do bot atualizado.")
|
||||
|
||||
case "set-credentials":
|
||||
if *userID <= 0 || *email == "" {
|
||||
return errors.New("informe --user-id e --email")
|
||||
}
|
||||
password, err := readPassword("Senha da PontoMais: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.SetCredentials(ctx, app.CLIActor, *userID, *email, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Credencial validada na PontoMais e gravada cifrada.")
|
||||
|
||||
case "rotate-key":
|
||||
count, err := svc.RotateKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d credencial(is) recifrada(s) com a chave versão %d.\n", count, cfg.Crypto.KeyVersion)
|
||||
|
||||
default:
|
||||
fmt.Fprint(os.Stderr, adminUsage)
|
||||
return fmt.Errorf("comando administrativo desconhecido: %s", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readNewPassword() (string, error) {
|
||||
first, err := readPassword("Nova senha (mínimo 12 caracteres): ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
second, err := readPassword("Repita a senha: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if first != second {
|
||||
return "", errors.New("as senhas não conferem")
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
|
||||
// readPassword lê sem eco quando há terminal; senão lê uma linha da entrada padrão.
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
fd := int(os.Stdin.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
raw, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return string(raw), err
|
||||
}
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
116
backend/cmd/botpontos/main.go
Normal file
116
backend/cmd/botpontos/main.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Comando botpontos: um único binário com os papéis api, worker, migrate e admin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
_ "time/tzdata" // a imagem distroless não tem /usr/share/zoneinfo
|
||||
|
||||
"botpontos/internal/adapters/postgres"
|
||||
"botpontos/internal/config"
|
||||
"botpontos/internal/httpapi"
|
||||
"botpontos/internal/worker"
|
||||
)
|
||||
|
||||
// version é preenchida no build com -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
const usage = `uso: botpontos <comando>
|
||||
|
||||
comandos:
|
||||
api inicia a API REST
|
||||
worker inicia o worker (bot do Telegram e jobs)
|
||||
migrate [up|status|down] aplica ou consulta as migrations (padrão: up)
|
||||
admin <comando> tarefas administrativas (botpontos admin para ver a lista)
|
||||
healthcheck <url> verifica um endpoint de saúde (usado pelo Docker)
|
||||
version mostra a versão
|
||||
`
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "erro:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
return errors.New("comando não informado")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
case "healthcheck":
|
||||
if len(args) != 2 {
|
||||
return errors.New("uso: botpontos healthcheck <url>")
|
||||
}
|
||||
return healthcheck(ctx, args[1])
|
||||
case "api", "worker", "migrate", "admin":
|
||||
// precisam de configuração; seguem abaixo
|
||||
default:
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
return fmt.Errorf("comando desconhecido: %s", args[0])
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuração inválida:\n%w", err)
|
||||
}
|
||||
logger := newLogger(cfg.LogLevel).With("component", args[0], "version", version)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
switch args[0] {
|
||||
case "api":
|
||||
return httpapi.Run(ctx, cfg, logger)
|
||||
case "worker":
|
||||
return worker.Run(ctx, cfg, logger)
|
||||
case "admin":
|
||||
return runAdmin(ctx, cfg, logger, args[1:])
|
||||
default: // migrate
|
||||
command := "up"
|
||||
if len(args) > 1 {
|
||||
command = args[1]
|
||||
}
|
||||
return postgres.Migrate(ctx, cfg.DB, command, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
_ = lvl.UnmarshalText([]byte(level)) // já validado em config.Load
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
|
||||
// healthcheck existe porque a imagem distroless não tem curl nem wget.
|
||||
func healthcheck(ctx context.Context, url string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user