291 lines
8.1 KiB
Go
291 lines
8.1 KiB
Go
// Package pontomais é o adapter de saída para a API da PontoMais.
|
|
//
|
|
// Contratos confirmados no Modelo: POST /api/auth/sign_in devolve token e
|
|
// client_id; GET /api/time_cards/current/last_cached devolve os registros
|
|
// recentes. A API não informa a validade do token: a sessão fica só em
|
|
// memória e é renovada quando o histórico responde 401/403.
|
|
package pontomais
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"botpontos/internal/domain"
|
|
)
|
|
|
|
var (
|
|
// ErrAuthRejected: a PontoMais recusou o login (credencial inválida ou bloqueada).
|
|
ErrAuthRejected = errors.New("a PontoMais recusou a credencial")
|
|
)
|
|
|
|
// Decrypter decifra a senha guardada; satisfeito por *crypto.Keyring.
|
|
type Decrypter interface {
|
|
Decrypt(data []byte, version int16) (string, error)
|
|
}
|
|
|
|
type Client struct {
|
|
http *http.Client
|
|
authURL string
|
|
historyURL string
|
|
keys Decrypter
|
|
retryDelay time.Duration
|
|
|
|
mu sync.Mutex
|
|
sessions map[int64]cachedSession
|
|
}
|
|
|
|
type session struct {
|
|
client string
|
|
token string
|
|
uid string
|
|
}
|
|
|
|
type cachedSession struct {
|
|
session session
|
|
credentialsUpdated time.Time
|
|
}
|
|
|
|
func NewClient(authURL, historyURL string, timeout time.Duration, keys Decrypter) *Client {
|
|
return &Client{
|
|
http: &http.Client{Timeout: timeout},
|
|
authURL: authURL,
|
|
historyURL: historyURL,
|
|
keys: keys,
|
|
retryDelay: 3 * time.Second,
|
|
sessions: make(map[int64]cachedSession),
|
|
}
|
|
}
|
|
|
|
// History devolve os pontos recentes do usuário. Faz uma nova tentativa rápida
|
|
// em falhas transitórias (rede, timeout, 5xx) e refaz o login uma vez após 401/403.
|
|
func (c *Client) History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
|
points, err := c.historyOnce(ctx, cred, loc)
|
|
if err == nil || !isTransient(err) {
|
|
return points, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(c.retryDelay):
|
|
}
|
|
return c.historyOnce(ctx, cred, loc)
|
|
}
|
|
|
|
func (c *Client) historyOnce(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
|
s, ok := c.cached(cred)
|
|
if !ok {
|
|
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
|
return nil, err
|
|
}
|
|
c.store(cred, s)
|
|
}
|
|
|
|
points, err := c.fetchHistory(ctx, s, loc)
|
|
if !errors.Is(err, errUnauthorized) {
|
|
return points, err
|
|
}
|
|
|
|
// Token recusado: descarta a sessão e tenta uma vez com login novo.
|
|
c.Invalidate(cred.UserID)
|
|
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
|
return nil, err
|
|
}
|
|
c.store(cred, s)
|
|
points, err = c.fetchHistory(ctx, s, loc)
|
|
if errors.Is(err, errUnauthorized) {
|
|
return nil, fmt.Errorf("%w: token recusado mesmo após novo login", ErrAuthRejected)
|
|
}
|
|
return points, err
|
|
}
|
|
|
|
// Validate testa uma credencial ainda não gravada: login + uma leitura do histórico.
|
|
func (c *Client) Validate(ctx context.Context, email, password string, loc *time.Location) error {
|
|
s, err := c.signIn(ctx, email, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.fetchHistory(ctx, s, loc)
|
|
if errors.Is(err, errUnauthorized) {
|
|
return fmt.Errorf("%w: histórico recusou o token", ErrAuthRejected)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Invalidate descarta a sessão em memória (ex.: credencial alterada).
|
|
func (c *Client) Invalidate(userID int64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.sessions, userID)
|
|
}
|
|
|
|
func (c *Client) cached(cred domain.Credential) (session, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
item, ok := c.sessions[cred.UserID]
|
|
if !ok || !item.credentialsUpdated.Equal(cred.UpdatedAt) {
|
|
return session{}, false
|
|
}
|
|
return item.session, true
|
|
}
|
|
|
|
func (c *Client) store(cred domain.Credential, s session) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sessions[cred.UserID] = cachedSession{session: s, credentialsUpdated: cred.UpdatedAt}
|
|
}
|
|
|
|
type authRequest struct {
|
|
Login string `json:"login"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type authResponse struct {
|
|
Token string `json:"token"`
|
|
ClientID string `json:"client_id"`
|
|
Data struct {
|
|
Login string `json:"login"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func (c *Client) signIn(ctx context.Context, email, password string) (session, error) {
|
|
body, err := json.Marshal(authRequest{Login: email, Password: password})
|
|
if err != nil {
|
|
return session{}, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return session{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return session{}, networkError("login", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden ||
|
|
resp.StatusCode == http.StatusUnprocessableEntity:
|
|
return session{}, fmt.Errorf("%w (login HTTP %d)", ErrAuthRejected, resp.StatusCode)
|
|
case resp.StatusCode < 200 || resp.StatusCode > 299:
|
|
return session{}, &statusError{op: "login", code: resp.StatusCode}
|
|
}
|
|
|
|
var payload authResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return session{}, fmt.Errorf("resposta de login ilegível: %w", err)
|
|
}
|
|
if payload.Token == "" || payload.ClientID == "" {
|
|
return session{}, errors.New("resposta de login sem token ou client_id")
|
|
}
|
|
uid := payload.Data.Login
|
|
if uid == "" {
|
|
uid = email
|
|
}
|
|
return session{client: payload.ClientID, token: payload.Token, uid: uid}, nil
|
|
}
|
|
|
|
type historyResponse struct {
|
|
TimeCards []struct {
|
|
Date string `json:"date"`
|
|
Time string `json:"time"`
|
|
Disabled bool `json:"disabled"`
|
|
} `json:"time_cards"`
|
|
}
|
|
|
|
var errUnauthorized = errors.New("token não autorizado")
|
|
|
|
func (c *Client) fetchHistory(ctx context.Context, s session, loc *time.Location) ([]domain.Point, error) {
|
|
if _, err := url.Parse(c.historyURL); err != nil {
|
|
return nil, fmt.Errorf("URL do histórico inválida: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.historyURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("access-token", s.token)
|
|
req.Header.Set("client", s.client)
|
|
req.Header.Set("uid", s.uid)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, networkError("histórico", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return nil, errUnauthorized
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return nil, &statusError{op: "histórico", code: resp.StatusCode}
|
|
}
|
|
|
|
var payload historyResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return nil, fmt.Errorf("resposta de histórico ilegível: %w", err)
|
|
}
|
|
|
|
points := make([]domain.Point, 0, len(payload.TimeCards))
|
|
for _, raw := range payload.TimeCards {
|
|
if raw.Disabled {
|
|
continue
|
|
}
|
|
value := strings.TrimSpace(raw.Date) + " " + strings.TrimSpace(raw.Time)
|
|
occurredAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: horário %q", domain.ErrInvalidPointHistory, value)
|
|
}
|
|
points = append(points, domain.Point{OccurredAt: occurredAt})
|
|
}
|
|
return points, nil
|
|
}
|
|
|
|
type statusError struct {
|
|
op string
|
|
code int
|
|
}
|
|
|
|
func (e *statusError) Error() string { return fmt.Sprintf("PontoMais: %s retornou HTTP %d", e.op, e.code) }
|
|
|
|
type transientError struct{ err error }
|
|
|
|
func (e *transientError) Error() string { return e.err.Error() }
|
|
func (e *transientError) Unwrap() error { return e.err }
|
|
|
|
// networkError remove a URL do erro (por segurança e legibilidade) e marca como transitório.
|
|
func networkError(op string, err error) error {
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
err = urlErr.Err
|
|
}
|
|
return &transientError{err: fmt.Errorf("PontoMais: falha de rede no %s: %w", op, err)}
|
|
}
|
|
|
|
func isTransient(err error) bool {
|
|
var t *transientError
|
|
if errors.As(err, &t) {
|
|
return true
|
|
}
|
|
var s *statusError
|
|
return errors.As(err, &s) && (s.code >= 500 || s.code == http.StatusTooManyRequests)
|
|
}
|