feat: initpage com sync Go + auth + frontend em web/

This commit is contained in:
2026-10-21 18:06:25 -03:00
commit 17b81a808a
24 changed files with 4807 additions and 0 deletions

65
internal/auth/password.go Normal file
View File

@@ -0,0 +1,65 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
func HashPassword(password string) ([]byte, error) {
// Argon2id params simples/seguros
const (
time = 1
memory = 64 * 1024
threads = 4
keyLen = 32
)
salt := []byte{} // em produção use salt aleatório; simplificado aqui? vamos melhorar
// Melhor: gerar salt
return argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen), nil
}
// versão melhor com salt
func HashPasswordSafe(password string) (string, error) {
if password == "" {
return "", errors.New("senha vazia")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("$argon2id$v=19$m=65536,t=1,p=4$%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) < 6 {
return false
}
// partes: ["", "argon2id", "v=19", "m=65536,t=1,p=4", salt, hash]
saltB, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
hashB, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
cmp := argon2.IDKey([]byte(password), saltB, 1, 64*1024, 4, uint32(len(hashB)))
if len(cmp) != len(hashB) {
return false
}
for i := range cmp {
if cmp[i] != hashB[i] {
return false
}
}
return true
}

34
internal/config/config.go Normal file
View File

@@ -0,0 +1,34 @@
package config
import (
"os"
)
type Config struct {
Port string
BasePath string
AppOrigin string
SessionSecret string
TurnstileSecret string
TurnstileSiteKey string
DBPath string
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
BasePath: getEnv("BASE_PATH", "/initpage"),
AppOrigin: getEnv("APP_ORIGIN", "https://apps.weltonmoura.com.br"),
SessionSecret: getEnv("SESSION_SECRET", "dev-secret-change-me"),
TurnstileSecret: getEnv("TURNSTILE_SECRET", ""),
TurnstileSiteKey: getEnv("TURNSTILE_SITEKEY", ""),
DBPath: getEnv("DB_PATH", "./data/initpage.db"),
}
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

41
internal/db/db.go Normal file
View File

@@ -0,0 +1,41 @@
package db
import (
"database/sql"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func Init(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
if _, err := db.Exec(migrations); err != nil {
return nil, err
}
return db, nil
}
const migrations = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email_verified BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME
);
CREATE TABLE IF NOT EXISTS user_sync (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
version INTEGER DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`

121
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,121 @@
package handlers
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/alexedwards/scs/v2"
"github.com/weltonmoura/initpage/internal/auth"
"github.com/weltonmoura/initpage/internal/config"
)
type Auth struct {
DB *sql.DB
SM *scs.SessionManager
Cfg *config.Config
}
type authReq struct {
Email string `json:"email"`
Password string `json:"password"`
Honeypot string `json:"website"`
Token string `json:"cf_turnstile_response"`
}
func (h *Auth) Register(w http.ResponseWriter, r *http.Request) {
var req authReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"dados inválidos"}`))
return
}
if req.Honeypot != "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"rejeitado"}`))
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if !strings.Contains(email, "@") || len(req.Password) < 6 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"email ou senha inválidos"}`))
return
}
// Turnstile opcional
if h.Cfg.TurnstileSecret != "" && req.Token != "" {
// validação simples - implementar se necessário
}
hash, err := auth.HashPasswordSafe(req.Password)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"erro ao criar conta"}`))
return
}
id := randHex(16)
_, err = h.DB.Exec(`INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, id, email, hash, time.Now())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"email já cadastrado ou inválido"}`))
return
}
h.SM.Put(r.Context(), "user_id", id)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (h *Auth) Login(w http.ResponseWriter, r *http.Request) {
var req authReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"dados inválidos"}`))
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
var id, ph string
err := h.DB.QueryRow(`SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&id, &ph)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"email ou senha inválidos"}`))
return
}
if !auth.VerifyPassword(ph, req.Password) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"email ou senha inválidos"}`))
return
}
_, _ = h.DB.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, time.Now(), id)
h.SM.Put(r.Context(), "user_id", id)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) {
h.SM.Destroy(r.Context())
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (h *Auth) Me(w http.ResponseWriter, r *http.Request) {
if !h.SM.Exists(r.Context(), "user_id") {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"logged":false}`))
return
}
uid := h.SM.Get(r.Context(), "user_id").(string)
var email string
_ = h.DB.QueryRow(`SELECT email FROM users WHERE id=?`, uid).Scan(&email)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"logged": true, "email": email})
}
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,45 @@
package middleware
import (
"context"
"net/http"
"github.com/alexedwards/scs/v2"
)
type ctxKey string
const UserIDKey ctxKey = "user_id"
func AuthRequired(sm *scs.SessionManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !sm.Exists(r.Context(), "user_id") {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"não autenticado"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
func LoadUserID(sm *scs.SessionManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := sm.Get(r.Context(), "user_id").(string)
if ok {
r = r.WithContext(context.WithValue(r.Context(), UserIDKey, uid))
}
next.ServeHTTP(w, r)
})
}
}
func GetUserID(ctx context.Context) string {
v := ctx.Value(UserIDKey)
if s, ok := v.(string); ok {
return s
}
return ""
}

View File

@@ -0,0 +1,66 @@
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
type IPRateLimiter struct {
mu sync.RWMutex
limiters map[string]*rate.Limiter
r rate.Limit
b int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
limiters: make(map[string]*rate.Limiter),
r: r,
b: b,
}
}
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
if lim, ok := i.limiters[ip]; ok {
return lim
}
lim := rate.NewLimiter(i.r, i.b)
i.limiters[ip] = lim
return lim
}
func (i *IPRateLimiter) Cleanup() {
ticker := time.NewTicker(time.Minute)
go func() {
for range ticker.C {
i.mu.Lock()
for k := range i.limiters {
delete(i.limiters, k)
}
i.mu.Unlock()
}
}()
}
func RateLimit(l *IPRateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
// simples, suficiente pra começar
ip = xf
}
if !l.GetLimiter(ip).Allow() {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"muitas tentativas, tente novamente mais tarde"}`))
return
}
next.ServeHTTP(w, r)
})
}
}

91
internal/sync/sync.go Normal file
View File

@@ -0,0 +1,91 @@
package sync
import (
"database/sql"
"encoding/json"
"net/http"
"time"
mw "github.com/weltonmoura/initpage/internal/middleware"
)
type Sync struct {
DB *sql.DB
}
type syncPayload struct {
Data json.RawMessage `json:"data"`
Version int `json:"version"`
}
func (s *Sync) Get(w http.ResponseWriter, r *http.Request) {
uid := mw.GetUserID(r.Context())
if uid == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var data string
var version int
var updatedAt string
_ = s.DB.QueryRow(`SELECT data, version, updated_at FROM user_sync WHERE user_id=?`, uid).Scan(&data, &version, &updatedAt)
if data == "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":null,"version":0}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":` + data + `,"version":` + itoa(version) + `}`))
}
func (s *Sync) Put(w http.ResponseWriter, r *http.Request) {
uid := mw.GetUserID(r.Context())
if uid == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var p syncPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"json inválido"}`))
return
}
if len(p.Data) > 256*1024 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"dados muito grandes"}`))
return
}
// valida JSON básico
if !json.Valid(p.Data) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"json inválido"}`))
return
}
_, err := s.DB.Exec(`INSERT INTO user_sync(user_id,data,version,updated_at) VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET data=excluded.data, version=excluded.version, updated_at=excluded.updated_at`,
uid, string(p.Data), p.Version, time.Now())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func itoa(i int) string {
if i == 0 {
return "0"
}
sign := ""
if i < 0 {
sign = "-"
i = -i
}
b := [12]byte{}
n := len(b)
for i > 0 {
n--
b[n] = byte('0' + i%10)
i /= 10
}
return sign + string(b[n:])
}