diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index d87a032..53722ea 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -102,8 +102,6 @@ jobs: set -euo pipefail ./scripts/test_seed_sqlite.sh ./scripts/test_alpine_image_static.sh - ./scripts/smoke_compose.sh - ./scripts/check_production_domain.sh ./scripts/test_coolify_deploy.sh ./scripts/test_health_lifecycle.sh @@ -135,7 +133,7 @@ jobs: - name: Ambiente isolado do smoke A/B run: | cat > "$RUNNER_TEMP/tabuamare-ci.env" <<'ENV' - POSTGRESQL_CONN_STR=postgresql://postgres:postgres@postgres:5432/tabuamare_ci + POSTGRESQL_CONN_STR=postgresql://postgres:postgres@127.0.0.1:5432/tabuamare_ci GOOGLE_CLIENT_ID=ci-client GOOGLE_CLIENT_SECRET=ci-secret GOOGLE_REDIRECT_URI=https://tabuamare.api.br/auth/google/callback @@ -146,15 +144,27 @@ jobs: ENV - name: Smoke da imagem exata em A/B - env: - COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml - TABUAMARE_IMAGE: ${{ env.LOCAL_IMAGE }} - TABUAMARE_ENV_FILE: ${{ runner.temp }}/tabuamare-ci.env - COMPOSE_TEST_ENV_FILE: ${{ runner.temp }}/tabuamare-ci.env - COMPOSE_TEST_ALLOW_DB_MUTATIONS: "yes" - COMPOSE_RUNTIME: "1" - COMPOSE_BUILD: "0" - run: ./scripts/smoke_compose.sh + run: | + set -euo pipefail + env_file="$RUNNER_TEMP/tabuamare-ci.env" + a_name="tabuamare-ci-a-${GITHUB_RUN_ID}" + b_name="tabuamare-ci-b-${GITHUB_RUN_ID}" + a_volume="tabuamare-ci-a-${GITHUB_RUN_ID}" + b_volume="tabuamare-ci-b-${GITHUB_RUN_ID}" + cleanup() { docker rm -f "$a_name" "$b_name" >/dev/null 2>&1 || true; docker volume rm "$a_volume" "$b_volume" >/dev/null 2>&1 || true; } + trap cleanup EXIT + docker volume create "$a_volume" >/dev/null + docker volume create "$b_volume" >/dev/null + wait_http() { local url="$1" expected="$2" code=000; for _ in $(seq 1 180); do code="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"; [[ "$code" == "$expected" ]] && return 0; sleep 1; done; echo "$url: recebido $code, esperado $expected" >&2; docker logs "$a_name" >&2 || true; docker logs "$b_name" >&2 || true; return 1; } + docker run -d --name "$a_name" --network host --env-file "$env_file" -e PORT=3330 -v "$a_volume:/app/data" "$LOCAL_IMAGE" 3330 + wait_http http://127.0.0.1:3330/health/ready 204 + docker run -d --name "$b_name" --network host --env-file "$env_file" -e PORT=3340 -v "$b_volume:/app/data" "$LOCAL_IMAGE" 3340 + wait_http http://127.0.0.1:3340/health/ready 204 + wait_http http://127.0.0.1:3330/api/v2/states 200 + wait_http http://127.0.0.1:3340/api/v2/states 200 + [[ "$(docker inspect --format '{{.State.Status}}' "$a_name")" == running ]] + [[ "$(docker inspect --format '{{.State.Status}}' "$b_name")" == running ]] + echo 'PASS: smoke direto A/B, readiness e API v2' - name: Publicar tag imutavel if: github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 9c2e793..09a9251 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ bin/ /.qodo /.trae /.superpowers/ + +# SSH operacional local (nunca versionar) +/run_ssh.sh +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 813a277..f048c80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ v test tests/find_nearested_harbor_test.v # Docker production build (Alpine, uma app por container) docker build --platform linux/amd64 -t tabua-mare-api:local . -# Docker Compose local A/B -docker compose up -d --build +# Validar imagem Alpine +docker build --platform linux/amd64 -t tabua-mare-api:local . ``` ## Environment Setup @@ -163,8 +163,7 @@ tests/ — integration tests (_test.v files, require DB) ### Production Deployment - **Root `Dockerfile`** — Alpine 3.22 multi-stage, uma instância V por container na porta `3330`, UID 10001 e volume `/app/data`. -- **`docker-compose.yml`** — somente validação local A/B; volumes `sqlite-a` e `sqlite-b` separados. -- **Produção** — duas aplicações regulares Coolify usando `ghcr.io/ddiidev/tabua-mare-api:sha-`, balanceadas pelo Traefik. +- **Produção** — duas aplicações regulares Coolify usando `ghcr.io/ddiidev/tabua-mare-api:sha-`, balanceadas pelo Traefik. O repositório não usa Compose para produção nem CI. - **Fluxo público** — Cloudflare proxy → Traefik/Coolify → A ou B. Sem nginx, Cloudflare Tunnel, Swarm ou Compose de produção. - **Operação** — scripts e runbook em `ops/`; deploy manual sequencial em `.github/workflows/deploy-production.yml`. diff --git a/README.md b/README.md index 21df7b9..76f3c39 100644 --- a/README.md +++ b/README.md @@ -156,19 +156,6 @@ docker run --rm -p 3330:3330 \ - O seed SQLite é validado e atualizado atomicamente em `/app/data/taubinha.sqlite`. - Health checks: `/health/live` e `/health/ready`. -### Validação local A/B - -1. Copie o arquivo `.env.template` para `.env` e ajuste variáveis conforme necessário. -2. Construa e suba os serviços: - -``` -docker compose up -d --build -``` - -- O Compose local sobe somente `tabuamare-a` e `tabuamare-b`. -- Cada instância usa volume SQLite exclusivo. -- Portas locais: `3330` e `3340`. - ### Produção Coolify Produção usa duas aplicações regulares Coolify baseadas na mesma imagem GHCR imutável, atrás de Cloudflare e Traefik. Não usa nginx, Cloudflare Tunnel, Swarm ou Compose de produção. diff --git a/api.v b/api.v index 63e0d03..87e3c5f 100644 --- a/api.v +++ b/api.v @@ -81,4 +81,4 @@ pub fn (mut api APIController) get_nearest_harbor_by_state(mut ctx web_ctx.WsCtx @['/nearest-harbor-independent-state/:lat_lng'] pub fn (mut api APIController) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_lng string) veb.Result { return gone_v1(mut ctx) -} \ No newline at end of file +} diff --git a/api_v2.v b/api_v2.v index 4053b31..483d975 100644 --- a/api_v2.v +++ b/api_v2.v @@ -2,16 +2,14 @@ module main import veb import pool -import db.pg import shareds.types import shareds.web_ctx import shareds.conf_env import shareds.rate_limit import repository.habor_mare as repo_habor_mare import repository.tabua_mare as repo_tabua_mare -import repository.auth as repo_auth -import repository.auth.dto import repository.rate_limit as rl +import shareds.infradb_pg // APIController Controller da API endpoint base: /api/v2 pub struct APIControllerV2 { @@ -19,6 +17,7 @@ pub struct APIControllerV2 { env conf_env.EnvConfig mut: pool_conn &pool.ConnectionPool + pg_holder &infradb_pg.PgHolder } // init_cors inicializa o middleware CORS para o APIController @@ -39,9 +38,10 @@ fn (mut api APIControllerV2) init_cors() { // init_rate_limit aplica o middleware de rate-limit (por IP/api_key, minuto + mes) // usando a conexao PostgreSQL (contadores e creditos persistidos). -fn (mut api APIControllerV2) init_rate_limit(env conf_env.EnvConfig) { +fn (mut api APIControllerV2) init_rate_limit(env conf_env.EnvConfig, pg_holder &infradb_pg.PgHolder) { api.use(rate_limit.rate_limit_middleware(rate_limit.RateLimitOpts{ - env: env + env: env + pg_holder: pg_holder })) } @@ -87,8 +87,8 @@ pub fn (mut api APIControllerV2) get_harbors_by_ids(mut ctx web_ctx.WsCtx, harbo // get_tabua_mare Retorna o tábua (tabela) da mare de um porto específico para um mês e dias específicos. @['/tabua-mare/:harbor/:month/:days'] pub fn (mut api APIControllerV2) get_tabua_mare(mut ctx web_ctx.WsCtx, harbor_id string, month int, days string) veb.Result { - result := repo_tabua_mare.get_tabua_mare_by_month_days(mut api.pool_conn, harbor_id, - month, types.IntRangeArr(days).ints()) or { + result := repo_tabua_mare.get_tabua_mare_by_month_days(mut api.pool_conn, harbor_id, month, + types.IntRangeArr(days).ints()) or { ctx.res.set_status(.bad_request) return ctx.json(types.failure[string](400, 'error: ${err}')) } @@ -107,7 +107,8 @@ pub fn (mut api APIControllerV2) get_nearested_tabua_mare(mut ctx web_ctx.WsCtx, nearest_harbor := repo_habor_mare.find_nearest_harbor_within_same_state_v2(mut api.pool_conn, lat, lng, state) or { ctx.res.set_status(.not_found) - return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.')) + return ctx.json(types.failure[string](404, + 'Nenhum porto encontrado perto das coordenadas fornecidas.')) } // TODO: CORRIGIR @@ -130,7 +131,8 @@ pub fn (mut api APIControllerV2) get_nearest_harbor_by_state(mut ctx web_ctx.WsC nearest_harbor := repo_habor_mare.find_nearest_harbor_within_same_state_v2(mut api.pool_conn, lat, lng, state) or { ctx.res.set_status(.not_found) - return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.')) + return ctx.json(types.failure[string](404, + 'Nenhum porto encontrado perto das coordenadas fornecidas.')) } return ctx.json(types.success([nearest_harbor])) @@ -145,7 +147,8 @@ pub fn (mut api APIControllerV2) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_l nearest_harbor := repo_habor_mare.find_nearest_harbor_v2(mut api.pool_conn, lat, lng) or { ctx.res.set_status(.not_found) - return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.')) + return ctx.json(types.failure[string](404, + 'Nenhum porto encontrado perto das coordenadas fornecidas.')) } return ctx.json(types.success([nearest_harbor])) @@ -155,54 +158,49 @@ pub fn (mut api APIControllerV2) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_l // Autenticacao via header Authorization: Bearer ou X-Api-Key. @['/usage'; get] pub fn (mut api APIControllerV2) usage(mut ctx web_ctx.WsCtx) veb.Result { - connstr := api.env.postgresql_conn_str - if connstr == '' { - ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'banco indisponivel')) - } - api_key := rate_limit.extract_api_key(mut ctx) if api_key == '' { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'api_key ausente')) } - - mut db := pg.connect_with_conninfo(connstr) or { - ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) - } - defer { - db.close() or {} + if !api.pg_holder.available() { + ctx.res.set_status(.service_unavailable) + return ctx.json(types.failure[string](503, 'banco indisponivel')) } - mut key_found := true - key := repo_auth.find_by_key(mut db, api_key) or { key_found = false; dto.ApiKey{} } - if !key_found || key.revoked { + mut db := api.pg_holder.db() + + identity := rate_limit.resolve_api_key_identity(mut db, api_key) or { + ctx.res.set_status(.service_unavailable) + return ctx.json(types.failure[string](503, 'banco indisponivel')) + } + if !identity.found { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'api_key invalida ou revogada')) } - user_plan := repo_auth.find_plan_by_id(mut db, key.user_id) or { 'free' } - mut effective_plan := key.plan - if !rate_limit.is_plan_allowed(key.plan, user_plan) { - effective_plan = user_plan - } - - bucket := 'key:${key.key_value}' + effective_plan := identity.plan + bucket := identity.bucket limit_rpm, limit_monthly := rate_limit.plan_limits(api.env, effective_plan) used_rpm := rl.get_count(mut db, bucket, 'minute', rl.window_key_minute()) or { 0 } - monthly := rl.get_current_month_usage(mut db, bucket) or { - rl.CreditCheck{used: 0, remaining: limit_monthly, lim: limit_monthly} - } - - return ctx.json(types.success([{ - 'plan': effective_plan - 'limit_rpm': limit_rpm.str() - 'used_rpm': used_rpm.str() - 'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() } - 'limit_monthly': limit_monthly.str() - 'used_monthly': monthly.used.str() - 'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() } - }])) + monthly := rl.get_current_month_usage(mut db, bucket, limit_monthly) or { + rl.CreditCheck{ + used: 0 + remaining: limit_monthly + lim: limit_monthly + } + } + + return ctx.json(types.success([ + { + 'plan': effective_plan + 'limit_rpm': limit_rpm.str() + 'used_rpm': used_rpm.str() + 'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() } + 'limit_monthly': limit_monthly.str() + 'used_monthly': monthly.used.str() + 'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() } + }, + ])) } diff --git a/auth_controller.v b/auth_controller.v index d6498ec..530b0e6 100644 --- a/auth_controller.v +++ b/auth_controller.v @@ -6,31 +6,127 @@ import net.http import json import shareds.web_ctx import shareds.conf_env +import shareds.infradb_pg import shareds.types import shareds.rate_limit import repository.rate_limit as rl import domain.auth_user import repository.auth as repo_auth -import repository.auth.dto import v_stripe.stripe // AuthController trata das rotas de autenticacao (login Google, logout, /me, avatar). pub struct AuthController { veb.Controller veb.Middleware[web_ctx.WsCtx] - pub mut: +pub mut: env conf_env.EnvConfig + pg_holder &infradb_pg.PgHolder avatar_cache &auth_user.AvatarCache = unsafe { nil } } -// db_conn abre uma conexao PG nova a partir da connection string. -// Cada handler cria sua propria conexao e fecha no defer, evitando o bug -// de captura de &pg.DB em closures do V 0.5.1. +// db_conn retorna o pool PostgreSQL compartilhado da aplicacao. +// O holder e inicializado uma vez no startup e permanece vivo ate o shutdown. fn (ac &AuthController) db_conn() !&pg.DB { - if ac.env.postgresql_conn_str == '' { - return error('POSTGRESQL_CONN_STR nao configurado') + if !ac.pg_holder.available() { + return error('PostgreSQL indisponivel') + } + return ac.pg_holder.db() +} + +// safe_redirect_path permite apenas destinos locais do proprio aplicativo. +// Isso deixa o callback OAuth legivel e impede que `next` vire um redirect externo. +fn safe_redirect_path(next string) string { + if next == '' || !next.starts_with('/') || next.starts_with('//') { + return '/' + } + return next +} + +// stripe_price_id concentra a tabela de planos em um unico ponto do checkout. +fn stripe_price_id(env conf_env.EnvConfig, plan string) !string { + price_id := match plan { + 'plan5' { env.stripe_price_plan5 } + 'plan10' { env.stripe_price_plan10 } + 'planannual' { env.stripe_price_planannual } + else { return error('plano invalido') } + } + + if price_id == '' { + return error('price_id nao configurado') + } + return price_id +} + +// new_stripe_client evita repetir a validacao e a construcao do cliente em cada rota. +fn (ac &AuthController) new_stripe_client() !stripe.Client { + if ac.env.stripe_secret_key == '' { + return error('Stripe nao configurado') + } + return stripe.new_client(stripe.ClientConfig{ + secret_key: ac.env.stripe_secret_key + }) +} + +// require_user_id e a pre-condicao comum das rotas privadas do painel. +fn (mut ac AuthController) require_user_id(mut ctx web_ctx.WsCtx) !int { + uid := ac.current_user_id(mut ctx) + if uid == 0 { + return error('nao autenticado') + } + return uid +} + +// session_claims e a unica leitura/validacao do cookie JWT dentro do controller. +fn (ac &AuthController) session_claims(mut ctx web_ctx.WsCtx) !auth_user.JwtClaims { + if ac.env.session_secret == '' { + return error('sessao nao configurada') + } + token := ctx.get_cookie(ac.env.session_cookie_name) or { return error('nao autenticado') } + if !auth_user.verify(ac.env.session_secret, token) { + return error('token invalido ou expirado') + } + return auth_user.decode(token) or { error('token invalido') } +} + +fn (mut ac AuthController) current_user_id(mut ctx web_ctx.WsCtx) int { + claims := ac.session_claims(mut ctx) or { return 0 } + return claims.sub +} + +fn (ac &AuthController) google_config() auth_user.GoogleConfig { + return auth_user.GoogleConfig{ + client_id: ac.env.google_client_id + client_secret: ac.env.google_client_secret + redirect_uri: ac.env.google_redirect_uri + auth_url: ac.env.google_auth_url + token_url: ac.env.google_token_url + userinfo_url: ac.env.google_userinfo_url + scope: ac.env.google_scope + } +} + +struct RateLimitSubject { + bucket string + plan string +} + +// rate_limit_subject resolve somente identidade da medicao. A consulta dos +// contadores fica no handler, deixando explicita a separacao entre identidade, +// limites e uso. +fn (ac &AuthController) rate_limit_subject(mut ctx web_ctx.WsCtx, mut db pg.DB) RateLimitSubject { + subject := RateLimitSubject{ + bucket: 'ip:${ctx.ip()}' + plan: 'anon' + } + api_key := rate_limit.extract_api_key(mut ctx) + identity := rate_limit.resolve_api_key_identity(mut db, api_key) or { return subject } + if !identity.found { + return subject + } + return RateLimitSubject{ + bucket: identity.bucket + plan: identity.plan } - return pg.connect_with_conninfo(ac.env.postgresql_conn_str) } // google_login inicia o fluxo OAuth do Google: gera state, seta cookie efemero e @@ -62,6 +158,7 @@ pub fn (mut ac AuthController) google_login(mut ctx web_ctx.WsCtx) veb.Result { // de sessao HttpOnly. Redireciona para / (ou ?next=). @['/google/callback'; get] pub fn (mut ac AuthController) google_callback(mut ctx web_ctx.WsCtx) veb.Result { + eprintln('[oauth] callback started') code := ctx.query['code'] or { ctx.res.set_status(.bad_request) return ctx.text('code ausente') @@ -83,29 +180,34 @@ pub fn (mut ac AuthController) google_callback(mut ctx web_ctx.WsCtx) veb.Result cfg := ac.google_config() + eprintln('[oauth] exchanging authorization code') access_token := auth_user.exchange_code(cfg, code) or { + eprintln('[oauth] token exchange failed: ${err}') ctx.res.set_status(.unauthorized) return ctx.text('falha ao trocar code: ${err}') } + eprintln('[oauth] fetching userinfo') user_info := auth_user.fetch_userinfo(cfg, access_token) or { + eprintln('[oauth] userinfo failed: ${err}') ctx.res.set_status(.unauthorized) return ctx.text('falha ao obter userinfo: ${err}') } raw_json := json.encode(user_info) + eprintln('[oauth] connecting postgres') mut db := ac.db_conn() or { + eprintln('[oauth] postgres connection failed: ${err}') ctx.res.set_status(.internal_server_error) return ctx.text('banco de dados indisponivel: ${err}') } - defer { - db.close() or {} - } - upsert := repo_auth.upsert_by_provider(mut db, 'google', user_info.sub, - user_info.email, user_info.name, user_info.picture, raw_json) or { + eprintln('[oauth] upserting user') + upsert := repo_auth.upsert_by_provider(mut db, 'google', user_info.sub, user_info.email, + user_info.name, user_info.picture, raw_json) or { ctx.res.set_status(.internal_server_error) + eprintln('[oauth] user upsert failed: ${err}') return ctx.text('falha ao criar/atualizar usuario: ${err}') } @@ -134,7 +236,8 @@ pub fn (mut ac AuthController) google_callback(mut ctx web_ctx.WsCtx) veb.Result max_age: -1 }) - next := ctx.query['next'] or { '/' } + eprintln('[oauth] callback completed') + next := safe_redirect_path(ctx.query['next'] or { '/' }) return ctx.redirect(next, veb.RedirectParams{ typ: .found }) } @@ -154,19 +257,9 @@ pub fn (mut ac AuthController) logout(mut ctx web_ctx.WsCtx) veb.Result { // me retorna o usuario corrente (lendo o cookie de sessao JWT) como ResultAPI. @['/me'; get] pub fn (mut ac AuthController) me(mut ctx web_ctx.WsCtx) veb.Result { - token := ctx.get_cookie(ac.env.session_cookie_name) or { - ctx.res.set_status(.unauthorized) - return ctx.json(types.failure[string](401, 'nao autenticado')) - } - - if !auth_user.verify(ac.env.session_secret, token) { + claims := ac.session_claims(mut ctx) or { ctx.res.set_status(.unauthorized) - return ctx.json(types.failure[string](401, 'token invalido ou expirado')) - } - - claims := auth_user.decode(token) or { - ctx.res.set_status(.unauthorized) - return ctx.json(types.failure[string](401, 'token invalido')) + return ctx.json(types.failure[string](401, err.msg())) } // busca o plano atual do banco (pode ter mudado via webhooks/cancelamento) @@ -174,9 +267,6 @@ pub fn (mut ac AuthController) me(mut ctx web_ctx.WsCtx) veb.Result { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } plan := repo_auth.find_plan_by_id(mut db, claims.sub) or { claims.plan } user := auth_user.UserData{ @@ -204,9 +294,6 @@ pub fn (mut ac AuthController) avatar(mut ctx web_ctx.WsCtx, user_id string) veb ctx.res.set_status(.internal_server_error) return ctx.text('banco indisponivel: ${err}') } - defer { - db.close() or {} - } user := repo_auth.find_by_id(mut db, uid) or { ctx.res.set_status(.not_found) @@ -247,8 +334,7 @@ pub fn (mut ac AuthController) avatar(mut ctx web_ctx.WsCtx, user_id string) veb // api_keys_list lista as api_keys do usuario corrente. @['/api-keys'; get] pub fn (mut ac AuthController) api_keys_list(mut ctx web_ctx.WsCtx) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } @@ -256,9 +342,6 @@ pub fn (mut ac AuthController) api_keys_list(mut ctx web_ctx.WsCtx) veb.Result { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } keys := repo_auth.list_by_user(mut db, uid) or { ctx.res.set_status(.internal_server_error) @@ -271,8 +354,7 @@ pub fn (mut ac AuthController) api_keys_list(mut ctx web_ctx.WsCtx) veb.Result { // Body JSON: {"label": "...", "plan": "free|plan5|plan10"} @['/api-keys'; post] pub fn (mut ac AuthController) api_keys_create(mut ctx web_ctx.WsCtx) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } @@ -295,9 +377,6 @@ pub fn (mut ac AuthController) api_keys_create(mut ctx web_ctx.WsCtx) veb.Result ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } user_plan := repo_auth.find_plan_by_id(mut db, uid) or { 'free' } if !rate_limit.is_plan_allowed(parsed.plan, user_plan) { @@ -310,7 +389,13 @@ pub fn (mut ac AuthController) api_keys_create(mut ctx web_ctx.WsCtx) veb.Result return ctx.json(types.failure[string](500, 'erro ao criar api_key: ${err}')) } - return ctx.json(types.success([{'key_value': key_value, 'label': parsed.label, 'plan': parsed.plan}])) + return ctx.json(types.success([ + { + 'key_value': key_value + 'label': parsed.label + 'plan': parsed.plan + }, + ])) } struct ApiKeyCreatePayload { @@ -329,8 +414,7 @@ struct CheckoutPayload { // O usuario deve estar autenticado (cookie JWT). Retorna a checkout_url para redirecionamento. @['/checkout'; post] pub fn (mut ac AuthController) checkout(mut ctx web_ctx.WsCtx) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } @@ -340,25 +424,14 @@ pub fn (mut ac AuthController) checkout(mut ctx web_ctx.WsCtx) veb.Result { return ctx.json(types.failure[string](400, 'JSON invalido: ${err}')) } - // resolve o price_id do plano - price_id := match parsed.plan { - 'plan5' { ac.env.stripe_price_plan5 } - 'plan10' { ac.env.stripe_price_plan10 } - 'planannual' { ac.env.stripe_price_planannual } - else { + price_id := stripe_price_id(ac.env, parsed.plan) or { + if err.msg() == 'plano invalido' { ctx.res.set_status(.bad_request) return ctx.json(types.failure[string](400, 'plano invalido: ${parsed.plan}')) } - } - - if price_id == '' { ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'price_id nao configurado para o plano ${parsed.plan}')) - } - - if ac.env.stripe_secret_key == '' { - ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'Stripe nao configurado')) + return ctx.json(types.failure[string](500, + 'price_id nao configurado para o plano ${parsed.plan}')) } // busca o usuario no DB para ter email @@ -366,24 +439,20 @@ pub fn (mut ac AuthController) checkout(mut ctx web_ctx.WsCtx) veb.Result { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } user := repo_auth.find_by_id(mut db, uid) or { ctx.res.set_status(.not_found) return ctx.json(types.failure[string](404, 'usuario nao encontrado')) } // cria o Stripe client - mut stripe_client := stripe.new_client(stripe.ClientConfig{ - secret_key: ac.env.stripe_secret_key - }) or { + mut stripe_client := ac.new_stripe_client() or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'stripe client init failed: ${err}')) } // busca ou cria o Stripe Customer pelo email - stripe_customer_id := resolve_stripe_customer(mut stripe_client, uid, user.email) or { + stripe_customer_id := resolve_stripe_customer(mut stripe_client, user.stripe_customer_id, uid, + user.email) or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'falha ao resolver customer: ${err}')) } @@ -401,17 +470,17 @@ pub fn (mut ac AuthController) checkout(mut ctx web_ctx.WsCtx) veb.Result { 'plan_code': parsed.plan } session := stripe_client.create_checkout_session(stripe.CheckoutSessionCreateParams{ - mode: stripe.checkout_mode_subscription - line_items: [ + mode: stripe.checkout_mode_subscription + line_items: [ stripe.CheckoutLineItem{ price: price_id quantity: 1 }, ] - customer: stripe_customer_id - success_url: parsed.success_url - cancel_url: parsed.cancel_url - metadata: metadata + customer: stripe_customer_id + success_url: parsed.success_url + cancel_url: parsed.cancel_url + metadata: metadata subscription_data: stripe.CheckoutSubscriptionData{ metadata: metadata } @@ -420,31 +489,27 @@ pub fn (mut ac AuthController) checkout(mut ctx web_ctx.WsCtx) veb.Result { return ctx.json(types.failure[string](500, 'falha ao criar checkout: ${err}')) } - return ctx.json(types.success([{'checkout_url': session.url, 'session_id': session.id}])) + return ctx.json(types.success([ + { + 'checkout_url': session.url + 'session_id': session.id + }, + ])) } // billing_portal cria uma sessao do Stripe Customer Portal e retorna a URL // para o frontend redirecionar o usuario a gerenciar/cancelar a assinatura. @['/billing-portal'; post] pub fn (mut ac AuthController) billing_portal(mut ctx web_ctx.WsCtx) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } - if ac.env.stripe_secret_key == '' { - ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'Stripe nao configurado')) - } - mut db := ac.db_conn() or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } user := repo_auth.find_by_id(mut db, uid) or { ctx.res.set_status(.not_found) @@ -456,9 +521,7 @@ pub fn (mut ac AuthController) billing_portal(mut ctx web_ctx.WsCtx) veb.Result return ctx.json(types.failure[string](400, 'usuario sem customer Stripe')) } - mut stripe_client := stripe.new_client(stripe.ClientConfig{ - secret_key: ac.env.stripe_secret_key - }) or { + mut stripe_client := ac.new_stripe_client() or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'stripe client init failed: ${err}')) } @@ -471,31 +534,24 @@ pub fn (mut ac AuthController) billing_portal(mut ctx web_ctx.WsCtx) veb.Result return ctx.json(types.failure[string](500, 'falha ao criar portal: ${err}')) } - return ctx.json(types.success([{'url': portal.url}])) + return ctx.json(types.success([{ + 'url': portal.url + }])) } // cancel_subscription cancela a assinatura ativa do usuario no Stripe e // downgrade o plano para free no banco. @['/cancel-subscription'; post] pub fn (mut ac AuthController) cancel_subscription(mut ctx web_ctx.WsCtx) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } - if ac.env.stripe_secret_key == '' { - ctx.res.set_status(.internal_server_error) - return ctx.json(types.failure[string](500, 'Stripe nao configurado')) - } - mut db := ac.db_conn() or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } user := repo_auth.find_by_id(mut db, uid) or { ctx.res.set_status(.not_found) @@ -508,12 +564,12 @@ pub fn (mut ac AuthController) cancel_subscription(mut ctx web_ctx.WsCtx) veb.Re ctx.res.set_status(.bad_request) return ctx.json(types.failure[string](400, 'sem assinatura ativa')) } - return ctx.json(types.success([{'portal_required': true}])) + return ctx.json(types.success([{ + 'portal_required': true + }])) } - mut stripe_client := stripe.new_client(stripe.ClientConfig{ - secret_key: ac.env.stripe_secret_key - }) or { + mut stripe_client := ac.new_stripe_client() or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'stripe client init failed: ${err}')) } @@ -528,11 +584,16 @@ pub fn (mut ac AuthController) cancel_subscription(mut ctx web_ctx.WsCtx) veb.Re return ctx.json(types.failure[string](500, 'falha ao atualizar plano: ${err}')) } - return ctx.json(types.success([{'cancelled': true}])) + return ctx.json(types.success([{ + 'cancelled': true + }])) } // resolve_stripe_customer busca um customer existente pelo email ou cria um novo. -fn resolve_stripe_customer(mut stripe_client stripe.Client, user_id int, email string) !string { +fn resolve_stripe_customer(mut stripe_client stripe.Client, existing_customer_id string, user_id int, email string) !string { + if existing_customer_id != '' { + return existing_customer_id + } if email == '' { return error('email do usuario vazio') } @@ -571,42 +632,41 @@ pub fn (mut ac AuthController) stripe_webhook(mut ctx web_ctx.WsCtx) veb.Result return ctx.text('assinatura invalida: ${err}') } + ac.process_stripe_event(event) or { + eprintln('stripe webhook ${event.type_} failed: ${err}') + ctx.res.set_status(.internal_server_error) + return ctx.text('falha ao processar webhook') + } + + return ctx.json({ + 'received': 'true' + }) +} + +// process_stripe_event concentra o despacho dos eventos aceitos pelo produto. +// O endpoint so confirma recebimento depois que o handler termina sem erro. +fn (mut ac AuthController) process_stripe_event(event stripe.Event) ! { match event.type_ { 'checkout.session.completed' { - handle_stripe_checkout_completed(mut ac, event) or { - eprintln('stripe webhook checkout.session.completed failed: ${err}') - } + handle_stripe_checkout_completed(mut ac, event)! } 'customer.subscription.created', 'customer.subscription.updated' { - handle_stripe_subscription_updated(mut ac, event) or { - eprintln('stripe webhook ${event.type_} failed: ${err}') - } + handle_stripe_subscription_updated(mut ac, event)! } 'customer.subscription.deleted' { - handle_stripe_subscription_deleted(mut ac, event) or { - eprintln('stripe webhook customer.subscription.deleted failed: ${err}') - } + handle_stripe_subscription_deleted(mut ac, event)! } 'invoice.payment_failed' { - handle_stripe_invoice_payment_failed(mut ac, event) or { - eprintln('stripe webhook invoice.payment_failed failed: ${err}') - } + handle_stripe_invoice_payment_failed(mut ac, event)! } else {} } - - return ctx.json({'received': 'true'}) } // handle_stripe_checkout_completed processa o evento checkout.session.completed: // le o user_id dos metadados, atualiza o plano e salva subscription_id. fn handle_stripe_checkout_completed(mut ac AuthController, event stripe.Event) ! { - if ac.env.stripe_secret_key == '' { - return error('stripe secret key nao configurado') - } - mut stripe_client := stripe.new_client(stripe.ClientConfig{ - secret_key: ac.env.stripe_secret_key - })! + mut stripe_client := ac.new_stripe_client()! session := stripe_client.get_checkout_session(event.data_object_id)! @@ -625,9 +685,6 @@ fn handle_stripe_checkout_completed(mut ac AuthController, event stripe.Event) ! } mut db := ac.db_conn() or { return err } - defer { - db.close() or {} - } repo_auth.update_plan(mut db, uid, plan_code)! @@ -645,14 +702,9 @@ fn handle_stripe_checkout_completed(mut ac AuthController, event stripe.Event) ! // extrai customer_id e metadata (plan_code) do raw_body do evento. fn handle_stripe_subscription_updated(mut ac AuthController, event stripe.Event) ! { mut db := ac.db_conn() or { return err } - defer { - db.close() or {} - } // extrai customer_id, status e plan_code do raw_body - parsed := json.decode(StripeWebhookEvent, event.raw_body) or { - return error('falha ao parse raw_body: ${err}') - } + parsed := decode_stripe_event(event)! customer_id := parsed.data.object.customer if customer_id == '' { return error('customer_id ausente no evento') @@ -695,17 +747,18 @@ struct StripeWebhookEventObject { metadata map[string]string } +fn decode_stripe_event(event stripe.Event) !StripeWebhookEvent { + return json.decode(StripeWebhookEvent, event.raw_body) or { + error('falha ao parse raw_body: ${err}') + } +} + // handle_stripe_subscription_deleted processa customer.subscription.deleted (cancelamento). fn handle_stripe_subscription_deleted(mut ac AuthController, event stripe.Event) ! { mut db := ac.db_conn() or { return err } - defer { - db.close() or {} - } // extrai customer_id do raw_body para evitar chamar a API Stripe - wrapper := json.decode(StripeWebhookEvent, event.raw_body) or { - return error('falha ao parse raw_body: ${err}') - } + wrapper := decode_stripe_event(event)! customer_id := wrapper.data.object.customer if customer_id == '' { return error('customer_id ausente no evento') @@ -719,25 +772,15 @@ fn handle_stripe_subscription_deleted(mut ac AuthController, event stripe.Event) // handle_stripe_invoice_payment_failed processa invoice.payment_failed. // Se o usuario tiver outra subscription ativa, nao faz nada; senao, downgrade para free. fn handle_stripe_invoice_payment_failed(mut ac AuthController, event stripe.Event) ! { - if ac.env.stripe_secret_key == '' { - return error('stripe secret key nao configurado') - } - mut stripe_client := stripe.new_client(stripe.ClientConfig{ - secret_key: ac.env.stripe_secret_key - })! + mut stripe_client := ac.new_stripe_client()! - invoice := json.decode(StripeWebhookEvent, event.raw_body) or { - return error('falha ao parse raw_body: ${err}') - } + invoice := decode_stripe_event(event)! customer_id := invoice.data.object.customer if customer_id == '' { return error('customer_id ausente no evento') } mut db := ac.db_conn() or { return err } - defer { - db.close() or {} - } uid := repo_auth.find_id_by_stripe_customer(mut db, customer_id)! @@ -762,56 +805,36 @@ pub fn (mut ac AuthController) rate_limit_status(mut ctx web_ctx.WsCtx) veb.Resu ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } - // Defaults: anon por IP - ip := ctx.ip() - mut bucket := 'ip:${ip}' - mut plan := 'anon' + subject := ac.rate_limit_subject(mut ctx, mut db) + limit_rpm, limit_monthly := rate_limit.plan_limits(ac.env, subject.plan) - // JWT de login nao identifica chamadas da API. Sem api_key, permanece anonimo por IP. - // Se tem api_key valida, ela define plano e bucket isolado. - api_key := rate_limit.extract_api_key(mut ctx) - if api_key != '' { - mut key_found := true - key := repo_auth.find_by_key(mut db, api_key) or { key_found = false; dto.ApiKey{} } - if key_found && !key.revoked { - mut effective_plan := key.plan - user_plan := repo_auth.find_plan_by_id(mut db, key.user_id) or { '' } - if !rate_limit.is_plan_allowed(key.plan, user_plan) { - effective_plan = user_plan - } - bucket = 'key:${key.key_value}' - plan = effective_plan + used_rpm := rl.get_count(mut db, subject.bucket, 'minute', rl.window_key_minute()) or { 0 } + monthly := rl.get_current_month_usage(mut db, subject.bucket, limit_monthly) or { + rl.CreditCheck{ + used: 0 + remaining: limit_monthly + lim: limit_monthly } } - limit_rpm, limit_monthly := rate_limit.plan_limits(ac.env, plan) - - minute_key := rl.window_key_minute() - used_rpm := rl.get_count(mut db, bucket, 'minute', minute_key) or { 0 } - monthly := rl.get_current_month_usage(mut db, bucket) or { - rl.CreditCheck{used: 0, remaining: limit_monthly, lim: limit_monthly} - } - - return ctx.json(types.success([{ - 'plan': plan - 'limit_rpm': limit_rpm.str() - 'used_rpm': used_rpm.str() - 'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() } - 'limit_monthly': limit_monthly.str() - 'used_monthly': monthly.used.str() - 'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() } - }])) + return ctx.json(types.success([ + { + 'plan': subject.plan + 'limit_rpm': limit_rpm.str() + 'used_rpm': used_rpm.str() + 'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() } + 'limit_monthly': limit_monthly.str() + 'used_monthly': monthly.used.str() + 'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() } + }, + ])) } // api_keys_revoke revoga uma api_key do usuario corrente. @['/api-keys/:id'; delete] pub fn (mut ac AuthController) api_keys_revoke(mut ctx web_ctx.WsCtx, id string) veb.Result { - uid := ac.current_user_id(mut ctx) - if uid == 0 { + uid := ac.require_user_id(mut ctx) or { ctx.res.set_status(.unauthorized) return ctx.json(types.failure[string](401, 'nao autenticado')) } @@ -825,38 +848,12 @@ pub fn (mut ac AuthController) api_keys_revoke(mut ctx web_ctx.WsCtx, id string) ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}')) } - defer { - db.close() or {} - } repo_auth.revoke(mut db, uid, key_id) or { ctx.res.set_status(.internal_server_error) return ctx.json(types.failure[string](500, 'erro ao revogar: ${err}')) } - return ctx.json(types.success([{'revoked': true}])) -} - -// current_user_id extrai o user_id do cookie JWT, ou 0 se nao autenticado. -fn (mut ac AuthController) current_user_id(mut ctx web_ctx.WsCtx) int { - if ac.env.session_secret == '' { - return 0 - } - token := ctx.get_cookie(ac.env.session_cookie_name) or { return 0 } - if !auth_user.verify(ac.env.session_secret, token) { - return 0 - } - claims := auth_user.decode(token) or { return 0 } - return claims.sub -} - -fn (ac &AuthController) google_config() auth_user.GoogleConfig { - return auth_user.GoogleConfig{ - client_id: ac.env.google_client_id - client_secret: ac.env.google_client_secret - redirect_uri: ac.env.google_redirect_uri - auth_url: ac.env.google_auth_url - token_url: ac.env.google_token_url - userinfo_url: ac.env.google_userinfo_url - scope: ac.env.google_scope - } + return ctx.json(types.success([{ + 'revoked': true + }])) } diff --git a/auth_readability_test.v b/auth_readability_test.v new file mode 100644 index 0000000..20e29ea --- /dev/null +++ b/auth_readability_test.v @@ -0,0 +1,24 @@ +module main + +import shareds.conf_env + +fn test_safe_redirect_path_accepts_only_local_paths() { + assert safe_redirect_path('/dashboard') == '/dashboard' + assert safe_redirect_path('/docs?from=oauth') == '/docs?from=oauth' + assert safe_redirect_path('') == '/' + assert safe_redirect_path('dashboard') == '/' + assert safe_redirect_path('https://evil.example/') == '/' + assert safe_redirect_path('//evil.example/') == '/' +} + +fn test_stripe_price_id_is_selected_from_plan() { + env := conf_env.EnvConfig{ + stripe_price_plan5: 'price_plan5' + stripe_price_plan10: 'price_plan10' + stripe_price_planannual: 'price_annual' + } + assert stripe_price_id(env, 'plan5') or { '' } == 'price_plan5' + assert stripe_price_id(env, 'plan10') or { '' } == 'price_plan10' + assert stripe_price_id(env, 'planannual') or { '' } == 'price_annual' + assert stripe_price_id(env, 'free') or { '' } == '' +} diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml deleted file mode 100644 index 2f3ba45..0000000 --- a/docker-compose.ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: tabuamare_ci - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d tabuamare_ci"] - interval: 2s - timeout: 3s - retries: 30 - tmpfs: - - /var/lib/postgresql/data - - tabuamare-a: - image: ${TABUAMARE_IMAGE:?TABUAMARE_IMAGE obrigatoria} - depends_on: - postgres: - condition: service_healthy - - tabuamare-b: - image: ${TABUAMARE_IMAGE:?TABUAMARE_IMAGE obrigatoria} - depends_on: - postgres: - condition: service_healthy - tabuamare-a: - condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 170714f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: tabuamare-local - -x-app: &app - image: tabua-mare-api:local - build: - context: . - dockerfile: Dockerfile - restart: unless-stopped - env_file: - - path: ${TABUAMARE_ENV_FILE:-.env} - required: false - environment: - PORT: "3330" - DB_SQLITE_PATH: /app/data/taubinha.sqlite - healthcheck: - test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:3330/health/ready"] - interval: 10s - timeout: 3s - start_period: 30s - retries: 3 - stop_grace_period: 30s - cpus: 2.0 - mem_limit: 512m - mem_reservation: 256m - -services: - tabuamare-a: - <<: *app - ports: - - "127.0.0.1:3330:3330" - volumes: - - sqlite-a:/app/data - - tabuamare-b: - <<: *app - depends_on: - tabuamare-a: - condition: service_healthy - ports: - - "127.0.0.1:3340:3330" - volumes: - - sqlite-b:/app/data - -volumes: - sqlite-a: - sqlite-b: diff --git a/dockerfiles/entrypoint-alpine.sh b/dockerfiles/entrypoint-alpine.sh index 621cf53..f3d9b2c 100755 --- a/dockerfiles/entrypoint-alpine.sh +++ b/dockerfiles/entrypoint-alpine.sh @@ -42,6 +42,12 @@ quick_check() { [ "${result}" = 'ok' ] } +has_required_schema() { + database="$1" + result="$(${SQLITE3_BIN} "${database}" "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'data_mare' LIMIT 1;" 2>/dev/null)" || return 1 + [ "${result}" = '1' ] +} + [ -r "${SQLITE_SEED_PATH}" ] || fail "seed SQLite ausente: ${SQLITE_SEED_PATH}" [ -r "${SQLITE_SEED_CHECKSUM_PATH}" ] || fail "checksum do seed ausente: ${SQLITE_SEED_CHECKSUM_PATH}" [ -x "${APP_BINARY}" ] || fail "binario da aplicacao nao executavel: ${APP_BINARY}" @@ -54,6 +60,7 @@ printf '%s\n' "${EXPECTED_CHECKSUM}" | grep -Eq '^[0-9a-fA-F]{64}$' || \ ACTUAL_CHECKSUM="$(sha256sum "${SQLITE_SEED_PATH}" | awk '{ print $1 }')" [ "${ACTUAL_CHECKSUM}" = "${EXPECTED_CHECKSUM}" ] || fail 'checksum do seed SQLite nao confere' quick_check "${SQLITE_SEED_PATH}" || fail 'seed SQLite falhou no PRAGMA quick_check' +has_required_schema "${SQLITE_SEED_PATH}" || fail 'seed SQLite sem tabela data_mare' mkdir -p "${DATA_DIR}" "${DB_DIR}" "${MARKER_DIR}" chown "${APP_UID}:${APP_GID}" "${DATA_DIR}" "${DB_DIR}" "${MARKER_DIR}" @@ -63,12 +70,13 @@ if [ -r "${SQLITE_SEED_MARKER_PATH}" ]; then INSTALLED_CHECKSUM="$(awk 'NR == 1 { print $1 }' "${SQLITE_SEED_MARKER_PATH}")" fi -if [ ! -f "${DB_SQLITE_PATH}" ] || [ "${INSTALLED_CHECKSUM}" != "${EXPECTED_CHECKSUM}" ]; then +if [ ! -f "${DB_SQLITE_PATH}" ] || [ "${INSTALLED_CHECKSUM}" != "${EXPECTED_CHECKSUM}" ] || ! has_required_schema "${DB_SQLITE_PATH}"; then printf '[entrypoint] instalando seed SQLite %s\n' "${EXPECTED_CHECKSUM}" TEMP_DB="$(mktemp "${DB_SQLITE_PATH}.seed.XXXXXX")" TEMP_MARKER="$(mktemp "${SQLITE_SEED_MARKER_PATH}.tmp.XXXXXX")" cp "${SQLITE_SEED_PATH}" "${TEMP_DB}" quick_check "${TEMP_DB}" || fail 'copia temporaria do seed falhou no PRAGMA quick_check' + has_required_schema "${TEMP_DB}" || fail 'copia temporaria do seed sem tabela data_mare' printf '%s\n' "${EXPECTED_CHECKSUM}" > "${TEMP_MARKER}" chown "${APP_UID}:${APP_GID}" "${TEMP_DB}" "${TEMP_MARKER}" @@ -80,6 +88,7 @@ if [ ! -f "${DB_SQLITE_PATH}" ] || [ "${INSTALLED_CHECKSUM}" != "${EXPECTED_CHEC fi quick_check "${DB_SQLITE_PATH}" || fail 'banco SQLite instalado falhou no PRAGMA quick_check' +has_required_schema "${DB_SQLITE_PATH}" || fail 'banco SQLite instalado sem tabela data_mare' chown "${APP_UID}:${APP_GID}" "${DB_SQLITE_PATH}" "${SQLITE_SEED_MARKER_PATH}" trap - EXIT HUP INT TERM diff --git a/docs/superpowers/wip/2026-07-12-{mare-api}-rate-limit-probe.MD b/docs/superpowers/wip/2026-07-12-{mare-api}-rate-limit-probe.MD deleted file mode 100644 index 35bc798..0000000 --- a/docs/superpowers/wip/2026-07-12-{mare-api}-rate-limit-probe.MD +++ /dev/null @@ -1,40 +0,0 @@ -# API - Probe de rate-limit por IP - -Gerado em: 2026-07-12 -Sessao/contexto: mare-api-rate-limit-probe -Tipo de registro: validação operacional - -## Pedido Do Usuario - -Executar 5 requisições `curl` simultâneas do IP atual para verificar bloqueio por IP e confirmar liberação na janela seguinte. Criar também um script reproduzível. - -## Validacao Manual - -Alvo configurado: `http://localhost:3330/api/v2/states` (`URL_ENV` local). - -Burst inicial com 5 `curl` simultâneos: - -- 4 respostas `200 OK`. -- 1 resposta `429 Too Many Requests`. -- Resposta bloqueada informou `Retry-After: 60` e `Limite por minuto excedido`. - -Após a janela de 60 segundos, nova requisição retornou `200 OK` com JSON dos 17 estados. - -## Script - -- Criado [rate_limit_probe.py](../../../scripts/rate_limit_probe.py). -- Usa somente biblioteca padrão Python. -- Executa burst concorrente configurável (`--requests`). -- Exibe status, tempo, `Retry-After` e resumo JSON. -- `--wait-after` aguarda a janela indicada e faz uma nova requisição. - -Exemplo: - -```bash -python3 scripts/rate_limit_probe.py --url http://localhost:3330/api/v2/states --requests 5 --wait-after -``` - -## Escopo - -- Nenhuma alteração em API/backend; somente ferramenta de validação local. -- Nenhum teste unitário de interface criado. diff --git a/domain/auth_user/google_auth.v b/domain/auth_user/google_auth.v index b498610..6434bc3 100644 --- a/domain/auth_user/google_auth.v +++ b/domain/auth_user/google_auth.v @@ -5,6 +5,7 @@ import net.urllib import json import crypto.rand import encoding.base64 +import time pub struct GoogleUserInfo { pub: @@ -48,6 +49,7 @@ pub fn build_auth_url(cfg GoogleConfig, state string) string { // exchange_code troca o code por tokens e retorna o access_token. pub fn exchange_code(cfg GoogleConfig, code string) !string { + eprintln('[oauth] token exchange started') data := http.url_encode_form_data({ 'code': code 'client_id': cfg.client_id @@ -57,13 +59,24 @@ pub fn exchange_code(cfg GoogleConfig, code string) !string { }) resp := http.fetch(http.FetchConfig{ - method: .post - header: http.new_header(http.HeaderConfig{ .content_type, 'application/x-www-form-urlencoded' }) - url: cfg.token_url - data: data - })! + method: .post + header: http.new_header(http.HeaderConfig{.content_type, 'application/x-www-form-urlencoded'}) + url: cfg.token_url + data: data + read_timeout: 10 * time.second + write_timeout: 10 * time.second + max_retries: 1 + disable_connection_reuse: true + }) or { return error('token exchange request failed: ${err}') } + if resp.status_code < 200 || resp.status_code >= 300 { + return error('token exchange returned HTTP ${resp.status_code}') + } tokens := json.decode(TokenResponse, resp.body)! + if tokens.access_token == '' { + return error('token exchange returned an empty access token') + } + eprintln('[oauth] token exchange completed') return tokens.access_token } @@ -72,10 +85,19 @@ pub fn fetch_userinfo(cfg GoogleConfig, access_token string) !GoogleUserInfo { if access_token == '' { return error('access_token is required') } + eprintln('[oauth] userinfo request started') resp := http.fetch(http.FetchConfig{ - header: http.new_header(http.HeaderConfig{ .authorization, 'Bearer ${access_token}' }) - url: cfg.userinfo_url - })! + header: http.new_header(http.HeaderConfig{.authorization, 'Bearer ${access_token}'}) + url: cfg.userinfo_url + read_timeout: 10 * time.second + write_timeout: 10 * time.second + max_retries: 1 + disable_connection_reuse: true + }) or { return error('userinfo request failed: ${err}') } + if resp.status_code < 200 || resp.status_code >= 300 { + return error('userinfo returned HTTP ${resp.status_code}') + } + eprintln('[oauth] userinfo request completed') return json.decode(GoogleUserInfo, resp.body)! } @@ -83,4 +105,4 @@ struct TokenResponse { access_token string @[json: 'access_token'] expires_in int @[json: 'expires_in'] token_type string @[json: 'token_type'] -} \ No newline at end of file +} diff --git a/domain/auth_user/jwt.v b/domain/auth_user/jwt.v index e31ffaa..9fc3573 100644 --- a/domain/auth_user/jwt.v +++ b/domain/auth_user/jwt.v @@ -36,8 +36,8 @@ pub fn verify(secret string, token string) bool { if parts.len != 3 { return false } - signature_mirror := hmac.new(secret.bytes(), '${parts[0]}.${parts[1]}'.bytes(), - sha256.sum, sha256.block_size) + signature_mirror := hmac.new(secret.bytes(), '${parts[0]}.${parts[1]}'.bytes(), sha256.sum, + sha256.block_size) signature_from_token := base64.url_decode(parts[2]) if !hmac.equal(signature_from_token, signature_mirror) { return false @@ -73,4 +73,4 @@ pub fn issue(secret string, user_id int, email string, name string, plan string, exp: now + i64(ttl_hours) * 3600 } return make_token(secret, claims) -} \ No newline at end of file +} diff --git a/entities/api_key.v b/entities/api_key.v index 81bce0b..804344b 100644 --- a/entities/api_key.v +++ b/entities/api_key.v @@ -3,11 +3,11 @@ module entities @[table: 'api_keys'] pub struct ApiKey { pub mut: - id int @[primary; sql: serial] + id int @[primary; sql: serial] user_id int key_value string @[sql_type: 'TEXT'] label string @[sql_type: 'TEXT'] plan string @[sql_type: 'TEXT'] created_at string @[sql_type: 'TIMESTAMP'] - revoked_at ?string @[sql_type: 'TIMESTAMP'; null] + revoked_at ?string @[null; sql_type: 'TIMESTAMP'] } diff --git a/entities/user_identity.v b/entities/user_identity.v index 3cabc2e..88f87df 100644 --- a/entities/user_identity.v +++ b/entities/user_identity.v @@ -3,7 +3,7 @@ module entities @[table: 'user_identities'] pub struct UserIdentity { pub mut: - id int @[primary; sql: serial] + id int @[primary; sql: serial] user_id int provider string @[sql_type: 'TEXT'] provider_uid string @[sql_type: 'TEXT'] diff --git a/main.v b/main.v index a1c12f6..5470d25 100644 --- a/main.v +++ b/main.v @@ -24,6 +24,7 @@ struct App { mut: health_state &health.State health_pool &pool.ConnectionPool + pg_holder &infradb_pg.PgHolder server_ready chan &veb.Server } @@ -76,17 +77,21 @@ fn main() { infradb.apply_startup_migrations() or { eprintln('Startup migration skipped: ${err}') } infradb_pg.apply_pg_startup_migrations() or { - if conf_env.is_production(env) { - eprintln('PG startup migration failed: ${err}') - exit(1) - } - eprintln('PG startup migration skipped: ${err}') + eprintln('PG startup migration skipped; readiness disabled') + } + pg_holder := infradb_pg.new() or { + eprintln('PostgreSQL pool initialization failed; readiness disabled') + &infradb_pg.PgHolder{} + } + if pg_holder.available() { + println('PostgreSQL pool inicializado: max_open_conns=5 max_idle_conns=2 conn_max_lifetime=30m') } mut app := &App{ env: env health_state: health.new_state() health_pool: infradb.new()! + pg_holder: pg_holder server_ready: chan &veb.Server{cap: 1} } @@ -98,16 +103,18 @@ fn main() { mut api_controller_v2 := &APIControllerV2{ pool_conn: infradb.new()! env: env + pg_holder: pg_holder } mut auth_controller := &AuthController{ env: env + pg_holder: pg_holder avatar_cache: auth_user.new_avatar_cache(env.avatar_cache_ttl_minutes) } api_controller.init_cors() api_controller_v2.init_cors() - api_controller_v2.init_rate_limit(env) + api_controller_v2.init_rate_limit(env, pg_holder) app.register_controller[APIController, web_ctx.WsCtx]('/api/v1', mut api_controller)! app.register_controller[APIControllerV2, web_ctx.WsCtx]('/api/v2', mut api_controller_v2)! @@ -119,6 +126,7 @@ fn main() { spawn wait_for_shutdown(app.health_state, app.server_ready) println('Starting Tabua Mare API on port ${port}') + defer { app.pg_holder.close() } veb.run[App, web_ctx.WsCtx](mut app, port) } @@ -275,11 +283,8 @@ pub fn (app &App) health_live(mut ctx web_ctx.WsCtx) veb.Result { @['/health/ready'; get; head] pub fn (mut app App) health_ready(mut ctx web_ctx.WsCtx) veb.Result { - connstr := rlock app.env { - app.env.postgresql_conn_str - } if app.health_state.is_ready_with_dependencies(infradb.sqlite_is_healthy(mut app.health_pool), - infradb_pg.is_healthy(connstr)) + app.pg_holder.is_healthy()) { return ctx.no_content() } diff --git a/ops/README.md b/ops/README.md index f0b6883..a3400ad 100644 --- a/ops/README.md +++ b/ops/README.md @@ -4,11 +4,14 @@ O script aceita somente Ubuntu 24.04 e instala atualizacoes, timezone `America/Sao_Paulo`, fail2ban, swap 2 GiB, `swappiness=10`, Docker/Coolify `4.1.2` e firewall da origem. +Use `ops/recover_vps.py` para uma VPS nova ou reconstruída. O wizard pede a chave SSH, caminhos dos backups, domínio e segredos sem gravá-los no Git, valida os arquivos locais e executa bootstrap/firewall. A importação do backup Coolify, env e SQLite continua com confirmação manual no painel/volumes, porque o formato e o destino dependem da instalação. `run_ssh.sh` é um atalho local ignorado pelo repositório. + +Execução manual, se necessária: + ```bash -./run_ssh.sh --dry-run tar -C ops -cf - bootstrap_vps.sh cloudflare-origin-firewall.sh \ - | ./run_ssh.sh -- 'mkdir -p /root/tabuamare-ops && tar -C /root/tabuamare-ops -xf -' -./run_ssh.sh -- 'bash /root/tabuamare-ops/bootstrap_vps.sh' + | ssh root@SEU_IP 'mkdir -p /root/tabuamare-ops && tar -C /root/tabuamare-ops -xf -' +ssh root@SEU_IP 'bash /root/tabuamare-ops/bootstrap_vps.sh' ``` O bootstrap fixa `AUTOUPDATE=false`. Atualizacoes futuras do Coolify ficam manuais. @@ -17,7 +20,7 @@ O firewall entra antes de Docker/Coolify. No boot, restaura os ultimos ranges Cl ## 2. Primeiro admin, sem expor porta 8000 ```bash -./run_ssh.sh -N -L 8000:127.0.0.1:8000 +ssh -N -L 8000:127.0.0.1:8000 root@SEU_IP ``` Abrir `http://localhost:8000` e criar imediatamente o primeiro admin. Nao criar conta no Let's Encrypt: o Traefik registra e renova o certificado automaticamente. @@ -131,7 +134,7 @@ O smoke publico envia o slot e `X-Tabuamare-Deploy-Secret`, sem registrar o segr Abrir e manter uma segunda sessao funcionando por chave. So depois: ```bash -CONFIRM_KEY_CONNECTION=yes ./run_ssh.sh -- \ +ssh root@SEU_IP \ 'CONFIRM_KEY_CONNECTION=yes bash /root/tabuamare-ops/bootstrap_vps.sh --harden-ssh' ``` diff --git a/ops/cloudflare-origin-firewall.sh b/ops/cloudflare-origin-firewall.sh index 72d4efa..972f17a 100755 --- a/ops/cloudflare-origin-firewall.sh +++ b/ops/cloudflare-origin-firewall.sh @@ -13,6 +13,7 @@ readonly admin_ports='8000,6001,6002' readonly cache_dir='/var/lib/tabuamare-cloudflare-firewall' readonly cache_v4="${cache_dir}/ips-v4" readonly cache_v6="${cache_dir}/ips-v6" +readonly public_iface='eth0' log() { printf '[firewall] %s\n' "$*" @@ -120,12 +121,12 @@ configure_rules() { --ctdir ORIGINAL --ctorigdstport "${port}" -j DROP done "${tool}" -w -A "${forward_chain}" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT - "${tool}" -w -A "${forward_chain}" -p tcp -m multiport --dports 80,443 \ + "${tool}" -w -A "${forward_chain}" -i "${public_iface}" -p tcp -m multiport --dports 80,443 \ -m set --match-set "${cf_set}" src -j ACCEPT - "${tool}" -w -A "${forward_chain}" -p tcp -m multiport --dports 80,443 -j DROP - "${tool}" -w -A "${forward_chain}" -p udp --dport 443 \ + "${tool}" -w -A "${forward_chain}" -i "${public_iface}" -p tcp -m multiport --dports 80,443 -j DROP + "${tool}" -w -A "${forward_chain}" -i "${public_iface}" -p udp --dport 443 \ -m set --match-set "${cf_set}" src -j ACCEPT - "${tool}" -w -A "${forward_chain}" -p udp --dport 443 -j DROP + "${tool}" -w -A "${forward_chain}" -i "${public_iface}" -p udp --dport 443 -j DROP "${tool}" -w -A "${forward_chain}" -j RETURN fi activate_chain "${tool}" DOCKER-USER "${forward_chain}" "${filter_chain}" diff --git a/ops/recover_vps.py b/ops/recover_vps.py new file mode 100644 index 0000000..a6d242c --- /dev/null +++ b/ops/recover_vps.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Wizard de reconstrução da VPS Tábua de Marés. + +O modo padrão é interativo. ``--dry-run`` apenas mostra as ações e nunca abre +conexão SSH nem grava credenciais. Segredos são lidos com getpass e enviados +somente por stdin de uma sessão SSH; nunca são impressos ou persistidos. +""" +from __future__ import annotations + +import argparse +import getpass +import shlex +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class Wizard: + def __init__(self, args: argparse.Namespace) -> None: + self.dry = args.dry_run + self.host = args.host or "" + self.user = args.user or "root" + self.key = args.key or "" + self.domain = args.domain or "tabuamare.api.br" + self.image = args.image or "" + self.coolify_backup = "" + self.env_file = "" + self.sqlite_a = "" + self.sqlite_b = "" + self.actions: list[str] = [] + + def say(self, message: str) -> None: + print(f"[recover] {message}") + + def ask(self, text: str, default: str = "") -> str: + if default: + value = input(f"{text} [{default}]: ").strip() + return value or default + return input(f"{text}: ").strip() + + def confirm(self, text: str) -> bool: + return input(f"{text} [digite SIM]: ").strip().upper() == "SIM" + + def run(self, command: list[str], stdin: str | None = None) -> None: + shown = " ".join(shlex.quote(x) for x in command) + self.actions.append(shown) + if self.dry: + self.say(f"DRY-RUN: {shown}") + return + subprocess.run(command, input=stdin, text=True, check=True) + + def ssh(self, remote: str, stdin: str | None = None) -> None: + cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"] + if self.key: + cmd += ["-i", self.key] + cmd += [f"{self.user}@{self.host}", "--", remote] + self.run(cmd, stdin) + + def upload(self, local: Path, remote: str) -> None: + data = local.read_text(encoding="utf-8") + target = shlex.quote(remote) + self.ssh(f"install -d -m 0755 /root/tabuamare-ops; cat > {target}; chmod 0755 {target}", data) + + def upload_text(self, text: str, remote: str, mode: str = "600") -> None: + """Envia configuração por stdin, sem passar conteúdo no argv/log.""" + target = shlex.quote(remote) + self.ssh(f"install -d -m 0700 /root/tabuamare-ops; cat > {target}; chmod {mode} {target}", text) + + def collect(self) -> None: + if not self.host: + self.host = self.ask("IP/hostname da nova VPS") + if not self.key: + self.key = self.ask("Caminho da chave SSH (vazio usa agente)") + self.domain = self.ask("Domínio público", self.domain) + self.image = self.ask("Imagem GHCR imutável (sha-...)", self.image) + self.coolify_backup = self.ask("Caminho local do backup do Coolify (opcional)") + self.env_file = self.ask("Caminho local do arquivo de ambiente A/B (opcional)") + self.sqlite_a = self.ask("Caminho local do SQLite A (opcional)") + self.sqlite_b = self.ask("Caminho local do SQLite B (opcional)") + self.ask("Dados/host do PostgreSQL externo (apenas referência)") + self.ask("Zona Cloudflare (opcional; sem automação de DNS)", self.domain) + for label, value in ( + ("backup Coolify", self.coolify_backup), + ("env A/B", self.env_file), + ("SQLite A", self.sqlite_a), + ("SQLite B", self.sqlite_b), + ): + if value and not Path(value).is_file(): + raise SystemExit(f"[recover] arquivo de {label} não existe: {value}") + # Segredos são somente coletados para confirmar disponibilidade. + if not self.dry: + getpass.getpass("APP_KEY (não será armazenado; Enter se restaurado pelo backup): ") + getpass.getpass("APP_PREVIOUS_KEYS (opcional): ") + getpass.getpass("CF_DNS_API_TOKEN (Enter se já existe na VPS): ") + + def execute(self) -> None: + self.collect() + self.say("Ações irreversíveis exigem confirmação explícita.") + if not self.confirm("Instalar/atualizar Ubuntu, Docker, firewall e Coolify 4.1.2 na VPS?"): + self.say("Cancelado antes de alterar a VPS.") + return + if not self.dry: + self.upload(ROOT / "ops/bootstrap_vps.sh", "/root/tabuamare-ops/bootstrap_vps.sh") + self.upload(ROOT / "ops/cloudflare-origin-firewall.sh", "/root/tabuamare-ops/cloudflare-origin-firewall.sh") + self.ssh("bash /root/tabuamare-ops/bootstrap_vps.sh") + self.ssh("docker version --format '{{.Server.Version}}' && docker inspect coolify --format '{{.State.Health.Status}}'") + + dynamic = ROOT / "ops/traefik/dynamic/tabuamare.yaml" + if dynamic.exists(): + rendered = dynamic.read_text(encoding="utf-8").replace("tabuamare.api.br", self.domain) + self.upload_text(rendered, "/root/tabuamare-ops/tabuamare.yaml") + self.say("Configuração Traefik renderizada e enviada para /root/tabuamare-ops/tabuamare.yaml; aplique-a no Proxy do Coolify após revisar.") + + if self.confirm("Aplicar firewall fail-closed (80/443 somente Cloudflare; portas administrativas bloqueadas)?"): + self.ssh("/usr/local/sbin/tabuamare-cloudflare-firewall --refresh") + + self.say("A configuração do Coolify e do Traefik deve ser feita pelo painel/túnel; nenhum token é salvo pelo wizard.") + if any((self.coolify_backup, self.env_file, self.sqlite_a, self.sqlite_b)): + self.say("Backups/env/SQLite foram apenas validados localmente; a importação deve ser feita no Coolify/volumes após revisar o formato e o destino.") + self.say(f"Crie/recupere tabuamare-a e tabuamare-b com a imagem {self.image or 'sha-'}, domínio {self.domain}, porta 3330 e volumes SQLite distintos.") + self.say("Defina DNS-01 Cloudflare, Full (strict), health /health/ready e balanceamento A/B no Traefik.") + self.say("Checklist manual: DNS A/CNAME e nameservers; callback Google OAuth; webhook e preços Stripe live; smoke HTTP; backup Coolify e rollback.") + if self.confirm("Executar validação final (Docker, Coolify, portas e HTTPS)?"): + self.ssh("docker ps --format '{{.Names}} {{.Status}}'; ss -ltn '( sport = :22 or sport = :80 or sport = :443 )'") + self.say("Concluído. Segredos permanecem somente no terminal/painel; faça o cutover DNS após validar A/B.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", help="não conecta nem altera a VPS") + parser.add_argument("--host", help="IP/hostname") + parser.add_argument("--user", default="root") + parser.add_argument("--key", help="chave SSH") + parser.add_argument("--domain", default="tabuamare.api.br") + parser.add_argument("--image", help="tag imutável sha-") + args = parser.parse_args() + try: + Wizard(args).execute() + except (KeyboardInterrupt, EOFError): + print("[recover] cancelado", file=sys.stderr) + return 130 + except subprocess.CalledProcessError as exc: + print(f"[recover] comando falhou (código {exc.returncode}); nenhuma credencial foi exibida", file=sys.stderr) + return exc.returncode or 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/repository/auth/api_keys.v b/repository/auth/api_keys.v index ef443e4..6918fb9 100644 --- a/repository/auth/api_keys.v +++ b/repository/auth/api_keys.v @@ -29,8 +29,12 @@ pub fn issue(mut db pg.DB, user_id int, label string, plan string) !string { raw := rand.bytes(32) or { return error('falha ao gerar api key') } key_value := 'tm_' + base64.url_encode(raw) - db.exec_param_many('INSERT INTO api_keys (user_id, key_value, label, plan) VALUES (($1), ($2), ($3), ($4))', - [user_id.str(), key_value, label, plan])! + db.exec_param_many('INSERT INTO api_keys (user_id, key_value, label, plan) VALUES (($1), ($2), ($3), ($4))', [ + user_id.str(), + key_value, + label, + plan, + ])! return key_value } @@ -56,6 +60,8 @@ pub fn list_by_user(mut db pg.DB, user_id int) ![]dto.ApiKey { // revoke marca uma api_key como revogada. pub fn revoke(mut db pg.DB, user_id int, key_id int) ! { - db.exec_param_many('UPDATE api_keys SET revoked_at = now() WHERE id = ($1) AND user_id = ($2)', - [key_id.str(), user_id.str()])! + db.exec_param_many('UPDATE api_keys SET revoked_at = now() WHERE id = ($1) AND user_id = ($2)', [ + key_id.str(), + user_id.str(), + ])! } diff --git a/repository/auth/dto/dto.v b/repository/auth/dto/dto.v index 7813c44..d438137 100644 --- a/repository/auth/dto/dto.v +++ b/repository/auth/dto/dto.v @@ -3,22 +3,22 @@ module dto // User representa um usuario do dominio de negocio (auth/dash). pub struct User { pub mut: - id int - email string - name string - avatar_url string - plan string - stripe_customer_id string + id int + email string + name string + avatar_url string + plan string + stripe_customer_id string stripe_subscription_id string } // ApiKey representa uma chave de API paga. pub struct ApiKey { pub mut: - id int - user_id int - key_value string - label string - plan string + id int + user_id int + key_value string + label string + plan string revoked bool } diff --git a/repository/auth/users.v b/repository/auth/users.v index 59e93d4..b39c1b9 100644 --- a/repository/auth/users.v +++ b/repository/auth/users.v @@ -19,18 +19,31 @@ pub: // Se nao, cria user + user_identity e retorna. pub fn upsert_by_provider(mut db pg.DB, provider string, provider_uid string, email string, name string, avatar_url string, raw_json string) !UpsertResult { // tenta buscar identidade existente - rows := db.exec_param_many('SELECT user_id FROM user_identities WHERE provider = ($1) AND provider_uid = ($2) LIMIT 1', - [provider, provider_uid])! + rows := db.exec_param_many('SELECT user_id FROM user_identities WHERE provider = ($1) AND provider_uid = ($2) LIMIT 1', [ + provider, + provider_uid, + ])! if rows.len > 0 { // existe: atualiza identity e user user_id := int_from_row(rows[0], 0) - db.exec_param_many('UPDATE user_identities SET email = ($1), name = ($2), avatar_url = ($3), raw_json = ($4), updated_at = now() WHERE provider = ($5) AND provider_uid = ($6)', - [email, name, avatar_url, raw_json, provider, provider_uid])! - db.exec_param_many('UPDATE users SET email = ($1), name = ($2), avatar_url = ($3), updated_at = now() WHERE id = ($4)', - [email, name, avatar_url, user_id.str()])! - - user_rows := db.exec_param('SELECT email, name, avatar_url, plan FROM users WHERE id = ($1) LIMIT 1', user_id.str())! + db.exec_param_many('UPDATE user_identities SET email = ($1), name = ($2), avatar_url = ($3), raw_json = ($4), updated_at = now() WHERE provider = ($5) AND provider_uid = ($6)', [ + email, + name, + avatar_url, + raw_json, + provider, + provider_uid, + ])! + db.exec_param_many('UPDATE users SET email = ($1), name = ($2), avatar_url = ($3), updated_at = now() WHERE id = ($4)', [ + email, + name, + avatar_url, + user_id.str(), + ])! + + user_rows := db.exec_param('SELECT email, name, avatar_url, plan FROM users WHERE id = ($1) LIMIT 1', + user_id.str())! if user_rows.len == 0 { return error('usuario nao encontrado apos upsert') } @@ -38,16 +51,27 @@ pub fn upsert_by_provider(mut db pg.DB, provider string, provider_uid string, em } // nao existe: cria user + identity - db.exec_param_many('INSERT INTO users (email, name, avatar_url, plan) VALUES (($1), ($2), ($3), ($4)) RETURNING id', - [email, name, avatar_url, 'free'])! + db.exec_param_many('INSERT INTO users (email, name, avatar_url, plan) VALUES (($1), ($2), ($3), ($4)) RETURNING id', [ + email, + name, + avatar_url, + 'free', + ])! // busca o id recem criado pelo email created := db.exec_param('SELECT id FROM users WHERE email = ($1) LIMIT 1', email)! if created.len == 0 { return error('falha ao criar usuario') } user_id := int_from_row(created[0], 0) - db.exec_param_many('INSERT INTO user_identities (user_id, provider, provider_uid, email, name, avatar_url, raw_json) VALUES (($1), ($2), ($3), ($4), ($5), ($6), ($7))', - [user_id.str(), provider, provider_uid, email, name, avatar_url, raw_json])! + db.exec_param_many('INSERT INTO user_identities (user_id, provider, provider_uid, email, name, avatar_url, raw_json) VALUES (($1), ($2), ($3), ($4), ($5), ($6), ($7))', [ + user_id.str(), + provider, + provider_uid, + email, + name, + avatar_url, + raw_json, + ])! return UpsertResult{ user_id: user_id @@ -61,20 +85,26 @@ pub fn upsert_by_provider(mut db pg.DB, provider string, provider_uid string, em // set_stripe_customer_id atualiza o stripe_customer_id do usuario. pub fn set_stripe_customer_id(mut db pg.DB, user_id int, customer_id string) ! { - db.exec_param_many('UPDATE users SET stripe_customer_id = ($1), updated_at = now() WHERE id = ($2)', - [customer_id, user_id.str()])! + db.exec_param_many('UPDATE users SET stripe_customer_id = ($1), updated_at = now() WHERE id = ($2)', [ + customer_id, + user_id.str(), + ])! } // set_stripe_subscription_id atualiza o stripe_subscription_id do usuario. pub fn set_stripe_subscription_id(mut db pg.DB, user_id int, subscription_id string) ! { - db.exec_param_many('UPDATE users SET stripe_subscription_id = ($1), updated_at = now() WHERE id = ($2)', - [subscription_id, user_id.str()])! + db.exec_param_many('UPDATE users SET stripe_subscription_id = ($1), updated_at = now() WHERE id = ($2)', [ + subscription_id, + user_id.str(), + ])! } // update_plan atualiza o plano do usuario. pub fn update_plan(mut db pg.DB, user_id int, plan string) ! { - db.exec_param_many('UPDATE users SET plan = ($1), updated_at = now() WHERE id = ($2)', - [plan, user_id.str()])! + db.exec_param_many('UPDATE users SET plan = ($1), updated_at = now() WHERE id = ($2)', [ + plan, + user_id.str(), + ])! } // find_plan_by_id retorna apenas o plano do usuario. @@ -102,7 +132,8 @@ pub fn find_id_by_stripe_customer(mut db pg.DB, customer_id string) !int { // find_by_id retorna um usuario pelo id. pub fn find_by_id(mut db pg.DB, user_id int) !dto.User { - rows := db.exec_param('SELECT id, email, name, avatar_url, plan, stripe_customer_id, stripe_subscription_id FROM users WHERE id = ($1) LIMIT 1', user_id.str())! + rows := db.exec_param('SELECT id, email, name, avatar_url, plan, stripe_customer_id, stripe_subscription_id FROM users WHERE id = ($1) LIMIT 1', + user_id.str())! if rows.len == 0 { return error('usuario nao encontrado') } diff --git a/repository/habor_mare/find_nearest_harbor.v b/repository/habor_mare/find_nearest_harbor.v index c850758..ca4f435 100644 --- a/repository/habor_mare/find_nearest_harbor.v +++ b/repository/habor_mare/find_nearest_harbor.v @@ -222,7 +222,7 @@ fn try_find_nearest_harbor_match_by_geohash(db db_provider.DB, lat f64, lng f64, } prefix_clause := prefixes.map("'${it}'").join(',') - query := "SELECT d.id, d.id_harbor_state FROM data_mare d JOIN geo_location g ON g.data_mare_id = d.id WHERE d.year = ${year}${state_clause} AND substr(g.geo_hash, 1, ${precision}) IN (${prefix_clause}) ORDER BY ${haversine_order_expr(lat, lng)} ASC LIMIT 1;" + query := 'SELECT d.id, d.id_harbor_state FROM data_mare d JOIN geo_location g ON g.data_mare_id = d.id WHERE d.year = ${year}${state_clause} AND substr(g.geo_hash, 1, ${precision}) IN (${prefix_clause}) ORDER BY ${haversine_order_expr(lat, lng)} ASC LIMIT 1;' return query_nearest_harbor_match(db, query)! } @@ -239,9 +239,7 @@ fn find_nearest_harbor_match_sqlite(mut pool_conn pool.ConnectionPool, lat f64, year := time.now().year for precision in geohash_precisions { nearest_by_precision := try_find_nearest_harbor_match_by_geohash(db, lat, lng, year, - state_filter, precision) or { - continue - } + state_filter, precision) or { continue } if nearest_by_precision.id > 0 && nearest_by_precision.harbor_state_id != '' { return nearest_by_precision } @@ -252,7 +250,7 @@ fn find_nearest_harbor_match_sqlite(mut pool_conn pool.ConnectionPool, lat f64, } else { " AND d.state = '${state_filter}'" } - full_scan_query := "SELECT d.id, d.id_harbor_state FROM data_mare d JOIN geo_location g ON g.data_mare_id = d.id WHERE d.year = ${year}${state_clause} ORDER BY ${haversine_order_expr(lat, lng)} ASC LIMIT 1;" + full_scan_query := 'SELECT d.id, d.id_harbor_state FROM data_mare d JOIN geo_location g ON g.data_mare_id = d.id WHERE d.year = ${year}${state_clause} ORDER BY ${haversine_order_expr(lat, lng)} ASC LIMIT 1;' full_scan_match := query_nearest_harbor_match(db, full_scan_query) or { should_release_conn = false pool_conn.put(conn) or { println(err.msg()) } diff --git a/repository/habor_mare/get_harbor_name_by_id.v b/repository/habor_mare/get_harbor_name_by_id.v index f654638..fb69587 100644 --- a/repository/habor_mare/get_harbor_name_by_id.v +++ b/repository/habor_mare/get_harbor_name_by_id.v @@ -16,16 +16,15 @@ pub fn get_harbor_by_ids(mut pool_conn pool.ConnectionPool, harbor_ids []string) conn := pool_conn.get()! db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } mut qb := orm.new_query[entities.DataMare](db) // harbors := []entities.DataMare{} year := time.now().year - harbors := qb.where('year = ? && id_harbor_state IN ?', orm.Primitive(year), ids_ordered.map(orm.Primitive(it)))!.query()! + harbors := qb.where('year = ? && id_harbor_state IN ?', orm.Primitive(year), + ids_ordered.map(orm.Primitive(it)))!.query()! ids := harbors.map(it.id) geo_location := sql db { @@ -71,9 +70,7 @@ pub fn get_harbor_by_ids_v1(mut pool_conn pool.ConnectionPool, ids []int) !types conn := pool_conn.get()! db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } mut qb := orm.new_query[entities.DataMare](db) diff --git a/repository/habor_mare/list_harbor_name_by_states.v b/repository/habor_mare/list_harbor_name_by_states.v index cf61219..c975dd4 100644 --- a/repository/habor_mare/list_harbor_name_by_states.v +++ b/repository/habor_mare/list_harbor_name_by_states.v @@ -13,9 +13,7 @@ pub fn list_harbor_name_by_states(mut pool_conn pool.ConnectionPool, state strin conn := pool_conn.get()! mut db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } year := time.now().year @@ -44,9 +42,7 @@ pub fn list_harbor_name_by_states_v1(mut pool_conn pool.ConnectionPool, state st conn := pool_conn.get()! mut db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } year := time.now().year diff --git a/repository/habor_mare/list_states.v b/repository/habor_mare/list_states.v index 402124f..fb3a904 100644 --- a/repository/habor_mare/list_states.v +++ b/repository/habor_mare/list_states.v @@ -13,9 +13,7 @@ pub fn list_states(mut pool_conn pool.ConnectionPool) !types.ResultValues[string conn := pool_conn.get()! mut db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } mut qb := orm.new_query[entities.DataMare](db) @@ -27,8 +25,6 @@ pub fn list_states(mut pool_conn pool.ConnectionPool) !types.ResultValues[string .query()! .map(it.state)) - - return types.ResultValues[string]{ data: distinct_states total: distinct_states.len diff --git a/repository/rate_limit/counters.v b/repository/rate_limit/counters.v index 14e61d8..b5f25a5 100644 --- a/repository/rate_limit/counters.v +++ b/repository/rate_limit/counters.v @@ -23,11 +23,17 @@ pub fn inc_and_check(mut db pg.DB, bucket string, window_kind string, window_key return false } - db.exec_param_many('INSERT INTO rate_limit_counters (bucket, window_kind, window_key, count) VALUES (($1), ($2), ($3), 1) ON CONFLICT (bucket, window_kind, window_key) DO UPDATE SET count = rate_limit_counters.count + 1', - [bucket, window_kind, window_key])! + db.exec_param_many('INSERT INTO rate_limit_counters (bucket, window_kind, window_key, count) VALUES (($1), ($2), ($3), 1) ON CONFLICT (bucket, window_kind, window_key) DO UPDATE SET count = rate_limit_counters.count + 1', [ + bucket, + window_kind, + window_key, + ])! - rows := db.exec_param_many('SELECT count FROM rate_limit_counters WHERE bucket = ($1) AND window_kind = ($2) AND window_key = ($3) LIMIT 1', - [bucket, window_kind, window_key])! + rows := db.exec_param_many('SELECT count FROM rate_limit_counters WHERE bucket = ($1) AND window_kind = ($2) AND window_key = ($3) LIMIT 1', [ + bucket, + window_kind, + window_key, + ])! if rows.len == 0 { return false } @@ -39,8 +45,11 @@ pub fn inc_and_check(mut db pg.DB, bucket string, window_kind string, window_key // get_count retorna o contador atual da janela sem incrementar. pub fn get_count(mut db pg.DB, bucket string, window_kind string, window_key string) !int { - rows := db.exec_param_many('SELECT count FROM rate_limit_counters WHERE bucket = ($1) AND window_kind = ($2) AND window_key = ($3) LIMIT 1', - [bucket, window_kind, window_key])! + rows := db.exec_param_many('SELECT count FROM rate_limit_counters WHERE bucket = ($1) AND window_kind = ($2) AND window_key = ($3) LIMIT 1', [ + bucket, + window_kind, + window_key, + ])! if rows.len == 0 { return 0 } @@ -52,6 +61,9 @@ pub fn get_count(mut db pg.DB, bucket string, window_kind string, window_key str // inc apenas incrementa o contador sem checar limite. pub fn inc(mut db pg.DB, bucket string, window_kind string, window_key string) ! { - db.exec_param_many('INSERT INTO rate_limit_counters (bucket, window_kind, window_key, count) VALUES (($1), ($2), ($3), 1) ON CONFLICT (bucket, window_kind, window_key) DO UPDATE SET count = rate_limit_counters.count + 1', - [bucket, window_kind, window_key])! -} \ No newline at end of file + db.exec_param_many('INSERT INTO rate_limit_counters (bucket, window_kind, window_key, count) VALUES (($1), ($2), ($3), 1) ON CONFLICT (bucket, window_kind, window_key) DO UPDATE SET count = rate_limit_counters.count + 1', [ + bucket, + window_kind, + window_key, + ])! +} diff --git a/repository/rate_limit/credits.v b/repository/rate_limit/credits.v index cce2d50..f4c04c9 100644 --- a/repository/rate_limit/credits.v +++ b/repository/rate_limit/credits.v @@ -1,7 +1,6 @@ module rate_limit import db.pg -import time // CreditCheck eh o resultado da checagem de creditos mensais. pub struct CreditCheck { @@ -12,20 +11,48 @@ pub: lim int } +// credit_remaining e a regra comum para limites novos e planos alterados. +pub fn credit_remaining(lim int, used int) int { + if lim == 0 { + return -1 + } + remaining := lim - used + return if remaining > 0 { remaining } else { 0 } +} + // ensure_credit_row cria (se necessario) a linha de creditos do bucket+mes e retorna o estado atual. // lim 0 significa ilimitado (remaining = -1). pub fn ensure_credit_row(mut db pg.DB, bucket string, plan string, lim int) !CreditCheck { month_key := window_key_month() - reset_at := time.now().add(30 * 24 * time.hour).format_ss() - - remaining_init := if lim == 0 { -1 } else { lim } - db.exec_param_many('INSERT INTO monthly_credits (bucket, month_key, plan, used, lim, remaining, reset_at) VALUES (($1), ($2), ($3), 0, ($4), ($5), ($6)) ON CONFLICT (bucket, month_key) DO NOTHING', - [bucket, month_key, plan, lim.str(), remaining_init.str(), reset_at])! + remaining_init := credit_remaining(lim, 0) + db.exec_param_many("INSERT INTO monthly_credits (bucket, month_key, plan, used, lim, remaining, reset_at) + VALUES (($1), ($2), ($3), 0, ($4), ($5), date_trunc('month', CURRENT_TIMESTAMP) + interval '1 month') + ON CONFLICT (bucket, month_key) DO UPDATE SET + plan = EXCLUDED.plan, + lim = EXCLUDED.lim, + remaining = CASE + WHEN EXCLUDED.lim = 0 THEN -1 + ELSE GREATEST(EXCLUDED.lim - monthly_credits.used, 0) + END, + reset_at = EXCLUDED.reset_at", [ + bucket, + month_key, + plan, + lim.str(), + remaining_init.str(), + ])! - rows := db.exec_param_many('SELECT used, lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', - [bucket, month_key])! + rows := db.exec_param_many('SELECT used, lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', [ + bucket, + month_key, + ])! if rows.len == 0 { - return CreditCheck{exceeded: false, remaining: remaining_init, used: 0, lim: lim} + return CreditCheck{ + exceeded: false + remaining: remaining_init + used: 0 + lim: lim + } } r := rows[0] used := val_int(r, 0) @@ -40,12 +67,19 @@ pub fn ensure_credit_row(mut db pg.DB, bucket string, plan string, lim int) !Cre } // get_current_month_usage retorna o estado atual dos créditos mensais sem decrementar. -pub fn get_current_month_usage(mut db pg.DB, bucket string) !CreditCheck { +pub fn get_current_month_usage(mut db pg.DB, bucket string, default_lim int) !CreditCheck { month_key := window_key_month() - rows := db.exec_param_many('SELECT used, lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', - [bucket, month_key])! + rows := db.exec_param_many('SELECT used, lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', [ + bucket, + month_key, + ])! if rows.len == 0 { - return CreditCheck{exceeded: false, remaining: 0, used: 0, lim: 0} + return CreditCheck{ + exceeded: false + remaining: credit_remaining(default_lim, 0) + used: 0 + lim: default_lim + } } r := rows[0] used := val_int(r, 0) @@ -65,23 +99,26 @@ pub fn get_current_month_usage(mut db pg.DB, bucket string) !CreditCheck { // lim 0 (ilimitado) nunca excede; apenas conta used (chamado via inc, nao decrement). pub fn decrement(mut db pg.DB, bucket string) !bool { month_key := window_key_month() - res := db.exec_param_many('UPDATE monthly_credits SET used = used + 1, remaining = remaining - 1 WHERE bucket = ($1) AND month_key = ($2) AND remaining > 0 RETURNING lim, remaining', - [bucket, month_key])! + res := db.exec_param_many('UPDATE monthly_credits SET used = used + 1, remaining = remaining - 1 WHERE bucket = ($1) AND month_key = ($2) AND remaining > 0 RETURNING lim, remaining', [ + bucket, + month_key, + ])! if res.len == 0 { // nenhuma linha atualizada — verifica se e excedido (remaining <= 0) // ou se a linha nao existe (ensure_credit_row falhou) - rows := db.exec_param_many('SELECT lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', - [bucket, month_key])! + rows := db.exec_param_many('SELECT lim, remaining FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', [ + bucket, + month_key, + ])! if rows.len == 0 { - return false + return error('monthly credit row ausente') } l := val_int(rows[0], 0) remaining := val_int(rows[0], 1) return l != 0 && remaining <= 0 } - l := val_int(res[0], 0) - remaining := val_int(res[0], 1) - return l != 0 && remaining == 0 + // A linha foi atualizada: remaining == 0 e o ultimo credito ainda e valido. + return false } fn val_int(r pg.Row, idx int) int { @@ -92,4 +129,4 @@ fn val_int(r pg.Row, idx int) int { return v.int() } return 0 -} \ No newline at end of file +} diff --git a/repository/rate_limit/credits_test.v b/repository/rate_limit/credits_test.v new file mode 100644 index 0000000..2d9dbd9 --- /dev/null +++ b/repository/rate_limit/credits_test.v @@ -0,0 +1,12 @@ +module rate_limit + +fn test_credit_remaining_allows_the_last_credit() { + assert credit_remaining(1, 0) == 1 + assert credit_remaining(1, 1) == 0 + assert credit_remaining(32000, 31999) == 1 +} + +fn test_credit_remaining_keeps_unlimited_as_minus_one() { + assert credit_remaining(0, 0) == -1 + assert credit_remaining(0, 9000) == -1 +} diff --git a/repository/tabua_mare/tabua_mare.v b/repository/tabua_mare/tabua_mare.v index 8b225ba..34fc856 100644 --- a/repository/tabua_mare/tabua_mare.v +++ b/repository/tabua_mare/tabua_mare.v @@ -14,9 +14,7 @@ pub fn get_tabua_mare_by_month_days(mut pool_conn pool.ConnectionPool, harbor_id conn := pool_conn.get()! db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } mut qb_month := orm.new_query[entities.MonthData](db) @@ -94,9 +92,7 @@ pub fn get_tabua_mare_by_month_days_v1(mut pool_conn pool.ConnectionPool, harbor conn := pool_conn.get()! db := conn as db_provider.DB defer { - pool_conn.put(conn) or { - println(err.msg()) - } + pool_conn.put(conn) or { println(err.msg()) } } mut qb_month := orm.new_query[entities.MonthData](db) diff --git a/repository/tabuamare_dash/billing.v b/repository/tabuamare_dash/billing.v index e90d432..e0111f4 100644 --- a/repository/tabuamare_dash/billing.v +++ b/repository/tabuamare_dash/billing.v @@ -6,9 +6,9 @@ import repository.auth.dto // PlanStatus representa o status de plano/cobranca de um usuario. pub struct PlanStatus { pub: - user_id int - plan string - api_keys []dto.ApiKey + user_id int + plan string + api_keys []dto.ApiKey } // get_plan_status retorna o plano do usuario e suas api_keys ativas. @@ -40,4 +40,4 @@ pub fn get_plan_status(mut db pg.DB, user_id int) !PlanStatus { plan: plan api_keys: keys } -} \ No newline at end of file +} diff --git a/repository/tabuamare_dash/panel.v b/repository/tabuamare_dash/panel.v index 3337d20..5901848 100644 --- a/repository/tabuamare_dash/panel.v +++ b/repository/tabuamare_dash/panel.v @@ -5,8 +5,8 @@ import db.pg // PanelData agrega dados para o painel do usuario (dashboard). pub struct PanelData { pub: - plan_status PlanStatus - usage_month UsageSummary + plan_status PlanStatus + usage_month UsageSummary } // get_panel_data retorna os dados agregados do painel para um usuario. @@ -15,15 +15,15 @@ pub fn get_panel_data(mut db pg.DB, user_id int) !PanelData { bucket := 'key:user:${user_id}' usage := get_usage_month(mut db, bucket) or { UsageSummary{ - bucket: bucket - used: 0 - lim: 0 + bucket: bucket + used: 0 + lim: 0 remaining: 0 - plan: plan_status.plan + plan: plan_status.plan } } return PanelData{ plan_status: plan_status usage_month: usage } -} \ No newline at end of file +} diff --git a/repository/tabuamare_dash/usage_metrics.v b/repository/tabuamare_dash/usage_metrics.v index 32a59d7..30973b4 100644 --- a/repository/tabuamare_dash/usage_metrics.v +++ b/repository/tabuamare_dash/usage_metrics.v @@ -1,6 +1,7 @@ module tabuamare_dash import db.pg +import db // UsageSummary resume o uso de um bucket (ip ou api_key) no mes corrente. pub struct UsageSummary { @@ -15,8 +16,10 @@ pub: // get_usage_month retorna o resumo de uso do bucket no mes corrente. pub fn get_usage_month(mut db pg.DB, bucket string) !UsageSummary { month_key := current_month_key() - rows := db.exec_param_many('SELECT bucket, used, lim, remaining, plan FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', - [bucket, month_key])! + rows := db.exec_param_many('SELECT bucket, used, lim, remaining, plan FROM monthly_credits WHERE bucket = ($1) AND month_key = ($2) LIMIT 1', [ + bucket, + month_key, + ])! if rows.len == 0 { return UsageSummary{ bucket: bucket @@ -38,7 +41,7 @@ pub fn get_usage_month(mut db pg.DB, bucket string) !UsageSummary { fn current_month_key() string { // reusa a logica de janela de mes do repository.rate_limit via SQL - rows := db.exec('SELECT to_char(now(), \'YYYYMM\')') or { return '' } + rows := db.exec("SELECT to_char(now(), 'YYYYMM')") or { return '' } if rows.len == 0 || rows[0].vals.len == 0 { return '' } @@ -66,4 +69,4 @@ fn val_str(r pg.Row, idx int) string { return v } return '' -} \ No newline at end of file +} diff --git a/run-lite.sh b/run-lite.sh deleted file mode 100755 index 3c452bf..0000000 --- a/run-lite.sh +++ /dev/null @@ -1 +0,0 @@ -v -g -d using_sqlite watch --only-watch=*.v,*.html,*.css,*.js --before "cls" run . 3330 diff --git a/run_ssh.sh b/run_ssh.sh deleted file mode 100755 index f6f9302..0000000 --- a/run_ssh.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -set +x - -readonly ssh_host="${SSH_VPS_HOST:-167.148.161.67}" -readonly ssh_user="${SSH_VPS_USER:-root}" -readonly ssh_port="${SSH_VPS_PORT:-22}" -readonly env_file="${SSH_ENV_FILE:-${HOME}/.config/tabua-mare/ssh.env}" -readonly windows_ssh="${SSH_WINDOWS_BIN:-/mnt/c/Windows/System32/OpenSSH/ssh.exe}" -readonly windows_key_wsl="${SSH_KEY_WINDOWS_WSL:-/mnt/c/Users/andre/.ssh/tabua-api}" -readonly windows_key="${SSH_KEY_WINDOWS:-C:\Users\andre\.ssh\tabua-api}" -readonly linux_key="${SSH_KEY_LINUX:-${HOME}/.ssh/tabua-api}" -readonly linux_ssh="${SSH_BIN:-ssh}" -readonly sshpass_bin="${SSHPASS_BIN:-sshpass}" -readonly target="${ssh_user}@${ssh_host}" - -fail() { - printf 'ERRO: %s\n' "$*" >&2 - exit 1 -} - -if [[ -f "${env_file}" ]]; then - mode="$(stat -c '%a' "${env_file}")" - [[ "${mode}" =~ ^[0-7]00$ ]] || fail "permissao insegura em ${env_file}; use chmod 600" - # Caminho configuravel, fora do repositorio. - # shellcheck disable=SC1090 - source "${env_file}" -fi - -common_options=( - -p "${ssh_port}" - -o ConnectTimeout=15 - -o ServerAliveInterval=30 - -o ServerAliveCountMax=3 - -o StrictHostKeyChecking=accept-new -) - -method='' -if [[ -x "${windows_ssh}" && -f "${windows_key_wsl}" ]]; then - method='windows-key' -elif command -v "${linux_ssh}" >/dev/null 2>&1 && [[ -f "${linux_key}" ]]; then - key_mode="$(stat -c '%a' "${linux_key}")" - [[ "${key_mode}" =~ ^[0-7]00$ ]] || fail "permissao insegura em ${linux_key}; use chmod 600" - method='linux-key' -elif [[ -n "${SSH_PASS_VPS:-}" ]] && { [[ -x "${sshpass_bin}" ]] || command -v "${sshpass_bin}" >/dev/null 2>&1; }; then - method='password-fallback' -else - fail 'nenhuma chave utilizavel e fallback SSH_PASS_VPS/sshpass indisponivel' -fi - -if [[ "${1:-}" == --dry-run ]]; then - case "${method}" in - windows-key) printf 'method=windows-key host=%s identity=%s\n' "${target}" "${windows_key}" ;; - linux-key) printf 'method=linux-key host=%s identity=%s\n' "${target}" "${linux_key}" ;; - password-fallback) printf 'method=password-fallback host=%s env=%s\n' "${target}" "${env_file}" ;; - esac - exit 0 -fi - -ssh_extra=() -remote_command=() -parsing_remote=0 -for arg in "$@"; do - if [[ "${arg}" == -- && "${parsing_remote}" == 0 ]]; then - parsing_remote=1 - continue - fi - if [[ "${parsing_remote}" == 0 ]]; then - ssh_extra+=("${arg}") - else - remote_command+=("${arg}") - fi -done - -case "${method}" in - windows-key) - exec "${windows_ssh}" "${common_options[@]}" -i "${windows_key}" \ - "${ssh_extra[@]}" "${target}" "${remote_command[@]}" - ;; - linux-key) - exec "${linux_ssh}" "${common_options[@]}" -i "${linux_key}" \ - "${ssh_extra[@]}" "${target}" "${remote_command[@]}" - ;; - password-fallback) - SSHPASS="${SSH_PASS_VPS}" exec "${sshpass_bin}" -e "${linux_ssh}" \ - "${common_options[@]}" "${ssh_extra[@]}" "${target}" "${remote_command[@]}" - ;; -esac diff --git a/scripts/check_production_domain.sh b/scripts/check_production_domain.sh deleted file mode 100755 index 328f99f..0000000 --- a/scripts/check_production_domain.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -old_domain='tabuamare.devtu.qzz.io' -new_origin='https://tabuamare.api.br' - -fail() { - printf 'FAIL: %s\n' "$*" >&2 - exit 1 -} - -cd "${root_dir}" - -mapfile -t active_files < <(git ls-files | grep -Ev \ - '^(\.plans/|docs/superpowers/|scripts/check_production_domain\.sh$)') -matches="$(grep -InsIF "${old_domain}" "${active_files[@]}" || true)" -if [[ -n "${matches}" ]]; then - printf 'FAIL: dominio antigo em superficie ativa:\n%s\n' "${matches}" >&2 - exit 1 -fi - -grep -Fq "${new_origin}" README.md || fail 'README sem nova origem' -grep -Fq "" pages/og.html || fail 'og:url incorreto' -grep -Fq "" pages/og.html || fail 'canonical incorreto' -grep -Fq "\"url\": \"${new_origin}\"" pages/og.html || fail 'JSON-LD url incorreta' -grep -Fxq "URL_ENV=${new_origin}" .env.template || fail 'URL_ENV de producao nao esta ativo' -grep -Fxq "GOOGLE_REDIRECT_URI=${new_origin}/auth/google/callback" .env.template || \ - fail 'callback Google de producao nao esta ativo' -grep -Fxq 'DB_SQLITE_PATH=/app/data/taubinha.sqlite' .env.template || fail 'SQLite de producao nao esta ativo' -grep -Fq "${new_origin}/auth/webhook" .env.template || fail 'webhook Stripe de producao ausente' -grep -Fq "ENV PORT=3330" Dockerfile || fail 'porta de producao ausente na imagem' -grep -Fq "URL_ENV=${new_origin}" Dockerfile || fail 'URL_ENV de producao ausente na imagem' - -printf 'PASS: superficie ativa usa %s\n' "${new_origin}" diff --git a/scripts/rate_limit_probe.py b/scripts/rate_limit_probe.py deleted file mode 100644 index e7831e0..0000000 --- a/scripts/rate_limit_probe.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Probe simples do limite por IP da API V2, sem dependências externas.""" - -from __future__ import annotations - -import argparse -import json -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - - -@dataclass -class Result: - index: int - status: int | None - elapsed: float - retry_after: str - body: str - error: str = "" - - -def request(url: str, index: int) -> Result: - started = time.monotonic() - req = Request(url, headers={"Accept": "application/json"}) - try: - with urlopen(req, timeout=20) as response: - body = response.read().decode("utf-8", errors="replace") - return Result(index, response.status, time.monotonic() - started, - response.headers.get("Retry-After", ""), body) - except HTTPError as error: - body = error.read().decode("utf-8", errors="replace") - return Result(index, error.code, time.monotonic() - started, - error.headers.get("Retry-After", ""), body) - except (URLError, TimeoutError, OSError) as error: - return Result(index, None, time.monotonic() - started, "", "", str(error)) - - -def print_result(result: Result) -> None: - suffix = f" retry-after={result.retry_after}s" if result.retry_after else "" - print(f"req={result.index} status={result.status or 'ERR'} " - f"time={result.elapsed:.3f}s{suffix}") - if result.error: - print(f" error={result.error}") - elif result.body: - try: - payload = json.loads(result.body) - print(f" response={json.dumps(payload, ensure_ascii=False)[:180]}") - except json.JSONDecodeError: - print(f" response={result.body[:180]}") - - -def burst(url: str, count: int) -> list[Result]: - with ThreadPoolExecutor(max_workers=count) as executor: - futures = [executor.submit(request, url, index) for index in range(1, count + 1)] - return sorted((future.result() for future in as_completed(futures)), key=lambda item: item.index) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Testa rate-limit por IP da API V2") - parser.add_argument("--url", default="http://localhost:3330/api/v2/states") - parser.add_argument("--requests", type=int, default=5) - parser.add_argument("--wait-after", action="store_true", - help="aguarda Retry-After e testa uma requisição novamente") - args = parser.parse_args() - - if args.requests < 1: - parser.error("--requests deve ser maior que zero") - - print(f"burst url={args.url} simultaneas={args.requests}") - results = burst(args.url, args.requests) - for result in results: - print_result(result) - - if not args.wait_after: - return 0 - - retry_values = [int(result.retry_after) for result in results if result.retry_after.isdigit()] - wait_seconds = max(retry_values, default=60) - print(f"aguardando {wait_seconds}s para testar a proxima janela...") - time.sleep(wait_seconds) - released = request(args.url, 1) - print("apos-a-janela:") - print_result(released) - return 0 if released.status == 200 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke_compose.sh b/scripts/smoke_compose.sh deleted file mode 100755 index a8947f3..0000000 --- a/scripts/smoke_compose.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -compose_file="${root_dir}/docker-compose.yml" - -fail() { - printf 'FAIL: %s\n' "$*" >&2 - exit 1 -} - -grep -Eq '^ tabuamare-a:$' "${compose_file}" || fail 'servico tabuamare-a ausente' -grep -Eq '^ tabuamare-b:$' "${compose_file}" || fail 'servico tabuamare-b ausente' -mapfile -t services < <(awk ' - /^services:$/ { in_services = 1; next } - /^volumes:$/ { in_services = 0 } - in_services && /^ [a-zA-Z0-9_-]+:$/ { - name = $1 - sub(/:$/, "", name) - print name - } -' "${compose_file}") -[[ "${#services[@]}" -eq 2 ]] || fail "Compose deve ter apenas A/B; encontrou ${services[*]}" - -service_data_volume() { - local service="$1" - awk -v service="${service}" ' - $0 == " " service ":" { in_service = 1; next } - in_service && /^ [a-zA-Z0-9_-]+:$/ { exit } - in_service && /^ - [^:]+:\/app\/data$/ { - mount = $2 - sub(/:\/app\/data$/, "", mount) - print mount - exit - } - ' "${compose_file}" -} - -a_static_volume="$(service_data_volume tabuamare-a)" -b_static_volume="$(service_data_volume tabuamare-b)" -[[ "${a_static_volume}" == sqlite-a ]] || fail "A usa volume inesperado: ${a_static_volume:-ausente}" -[[ "${b_static_volume}" == sqlite-b ]] || fail "B usa volume inesperado: ${b_static_volume:-ausente}" -[[ "${a_static_volume}" != "${b_static_volume}" ]] || fail 'A e B compartilham volume no Compose fonte' -grep -Eq '^ sqlite-a:$' "${compose_file}" || fail 'declaracao sqlite-a ausente' -grep -Eq '^ sqlite-b:$' "${compose_file}" || fail 'declaracao sqlite-b ausente' -# Contrato literal do Compose, nao expansao shell. -# shellcheck disable=SC2016 -grep -Fq 'path: ${TABUAMARE_ENV_FILE:-.env}' "${compose_file}" || fail 'env_file nao aceita arquivo isolado' -grep -Fq 'required: false' "${compose_file}" || fail '.env continua obrigatorio em checkout limpo' -grep -Fq '/health/ready' "${compose_file}" || fail 'healthcheck readiness ausente' -grep -Fq 'stop_grace_period: 30s' "${compose_file}" || fail 'stop grace de 30s ausente' -grep -Fq 'mem_limit: 512m' "${compose_file}" || fail 'limite de RAM ausente' -grep -Fq 'mem_reservation: 256m' "${compose_file}" || fail 'reserva de RAM ausente' - -if grep -Eqi 'cloudflared|CLOUDFLARE_TUNNEL|^[[:space:]]+nginx:' "${compose_file}"; then - fail 'nginx/cloudflared ainda presente no Compose ativo' -fi -if grep -Fq -- '- sqlite-data:/app/data' "${compose_file}"; then - fail 'volume SQLite compartilhado ainda presente' -fi -for legacy_file in \ - deploy.sh \ - start.sh \ - dockerfiles/Dockerfile.compose \ - dockerfiles/Dockerfile.tabuamare \ - dockerfiles/entrypoint.sh \ - dockerfiles/nginx.single.conf \ - dockerfiles/supervisord.single.conf \ - nginx/nginx.conf \ - nginx/conf.d/maisfoco.conf; do - [[ ! -e "${root_dir}/${legacy_file}" ]] || fail "artefato legado ainda ativo: ${legacy_file}" -done - -if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then - printf 'PASS: topologia A/B estatica; Docker indisponivel, runtime adiado\n' - exit 0 -fi - -cd "${root_dir}" -config_json="$(TABUAMARE_ENV_FILE=/dev/null docker compose config --format json)" -python3 -c ' -import json -import sys - -config = json.load(sys.stdin) -sources = {} -for service in ("tabuamare-a", "tabuamare-b"): - matches = [item.get("source") for item in config["services"][service].get("volumes", []) - if item.get("target") == "/app/data"] - if len(matches) != 1: - raise SystemExit(f"{service}: esperado um mount /app/data, recebido {matches}") - sources[service] = matches[0] -if sources["tabuamare-a"] == sources["tabuamare-b"]: - raise SystemExit(f"volumes efetivos compartilhados: {sources}") -' <<<"${config_json}" - -if [[ "${COMPOSE_RUNTIME:-0}" != 1 ]]; then - printf 'PASS: topologia A/B e docker compose config\n' - exit 0 -fi - -[[ -n "${COMPOSE_TEST_ENV_FILE:-}" ]] || fail 'COMPOSE_TEST_ENV_FILE obrigatorio para runtime' -[[ -f "${COMPOSE_TEST_ENV_FILE}" ]] || fail 'arquivo de ambiente de teste inexistente' -test_env_file="$(realpath "${COMPOSE_TEST_ENV_FILE}")" -[[ "${test_env_file}" != "$(realpath -m "${root_dir}/.env")" ]] || fail 'runtime smoke nao pode usar .env local' -[[ "${COMPOSE_TEST_ALLOW_DB_MUTATIONS:-}" == yes ]] || \ - fail 'defina COMPOSE_TEST_ALLOW_DB_MUTATIONS=yes para confirmar DB isolado' -grep -Eq '^POSTGRESQL_CONN_STR=.+$' "${test_env_file}" || fail 'POSTGRESQL_CONN_STR ausente no ambiente de teste' - -project="tabuamare-smoke-${RANDOM}-${RANDOM}" -cleanup() { - TABUAMARE_ENV_FILE="${test_env_file}" docker compose -p "${project}" down --volumes --remove-orphans >/dev/null 2>&1 || true -} -trap cleanup EXIT - -up_args=(up -d) -if [[ "${COMPOSE_BUILD:-1}" == 1 ]]; then - up_args+=(--build) -else - up_args+=(--no-build) -fi -TABUAMARE_ENV_FILE="${test_env_file}" docker compose -p "${project}" "${up_args[@]}" - -wait_http() { - local url="$1" - local expected="$2" - local code='000' - for _ in $(seq 1 180); do - code="$(curl -sS -o /dev/null -w '%{http_code}' "${url}" 2>/dev/null || true)" - [[ "${code}" == "${expected}" ]] && return 0 - sleep 1 - done - fail "${url} retornou ${code}, esperado ${expected}" -} - -wait_http 'http://127.0.0.1:3330/health/ready' 204 -wait_http 'http://127.0.0.1:3340/health/ready' 204 -wait_http 'http://127.0.0.1:3330/api/v2/states' 200 -wait_http 'http://127.0.0.1:3340/api/v2/states' 200 - -a_id="$(TABUAMARE_ENV_FILE="${test_env_file}" docker compose -p "${project}" ps -q tabuamare-a)" -b_id="$(TABUAMARE_ENV_FILE="${test_env_file}" docker compose -p "${project}" ps -q tabuamare-b)" -a_volume="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Name}}{{end}}{{end}}' "${a_id}")" -b_volume="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Name}}{{end}}{{end}}' "${b_id}")" - -[[ -n "${a_volume}" ]] || fail 'mount SQLite A nao encontrado' -[[ -n "${b_volume}" ]] || fail 'mount SQLite B nao encontrado' -[[ "${a_volume}" != "${b_volume}" ]] || fail 'A e B compartilham o mesmo volume SQLite' - -app_uid() { - local container_id="$1" - docker exec "${container_id}" sh -eu -c ' - pid="$(pidof TabuaMareAPI)" - [ -n "${pid}" ] - awk "/^Uid:/ { print \$2 }" "/proc/${pid}/status" - ' -} - -a_uid="$(app_uid "${a_id}")" -b_uid="$(app_uid "${b_id}")" -[[ "${a_uid}" == 10001 ]] || fail "processo A usa UID ${a_uid}, esperado 10001" -[[ "${b_uid}" == 10001 ]] || fail "processo B usa UID ${b_uid}, esperado 10001" - -printf 'PASS: A/B saudaveis, API v2 200, UID 10001, volumes distintos %s != %s\n' "${a_volume}" "${b_volume}" diff --git a/scripts/test_ops_contracts.sh b/scripts/test_ops_contracts.sh index 896ecfd..1c81512 100755 --- a/scripts/test_ops_contracts.sh +++ b/scripts/test_ops_contracts.sh @@ -7,6 +7,8 @@ firewall="${root_dir}/ops/cloudflare-origin-firewall.sh" traefik="${root_dir}/ops/traefik/dynamic/tabuamare.yaml" readme="${root_dir}/ops/README.md" +bash "${root_dir}/scripts/test_pg_pool_contract.sh" + fail() { printf 'FAIL: %s\n' "$*" >&2 exit 1 @@ -90,6 +92,9 @@ assert_last_before 'verify_docker_firewall_dependencies' \ grep -Fq 'https://www.cloudflare.com/ips-v4' "${firewall}" || fail 'ranges IPv4 nao oficiais' grep -Fq 'https://www.cloudflare.com/ips-v6' "${firewall}" || fail 'ranges IPv6 nao oficiais' grep -Fq 'DOCKER-USER' "${firewall}" || fail 'cadeia DOCKER-USER ausente' +grep -Fq "readonly public_iface='eth0'" "${firewall}" || fail 'interface publica do firewall nao definida' +grep -Fq ' -i "${public_iface}" -p tcp -m multiport --dports 80,443' "${firewall}" || \ + fail 'regras Docker nao limitadas ao trafego de entrada' grep -Fq 'ipset swap' "${firewall}" || fail 'atualizacao de ranges nao atomica' grep -Fq '8000,6001,6002' "${firewall}" || fail 'portas administrativas nao bloqueadas' grep -Fq 'tabuamare-cloudflare-firewall.timer' "${firewall}" || fail 'timer de atualizacao ausente' @@ -155,7 +160,7 @@ grep -Fq 'coolify-admin.tabuamare.api.br' "${traefik}" || fail 'router admin aus grep -Fq 'www.tabuamare.api.br' "${traefik}" || fail 'router www ausente' if grep -RIEq '(sk_(live|test)_[A-Za-z0-9]{12,}|whsec_[A-Za-z0-9]{12,}|CF_DNS_API_TOKEN=[A-Za-z0-9_-]{12,}|SSH_PASS_VPS=.{8,})' \ - "${root_dir}/ops" "${root_dir}/run_ssh.sh"; then + "${root_dir}/ops"; then fail 'possivel segredo em artefato versionado' fi diff --git a/scripts/test_pg_pool_contract.sh b/scripts/test_pg_pool_contract.sh new file mode 100755 index 0000000..9affbb1 --- /dev/null +++ b/scripts/test_pg_pool_contract.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +middleware="${root_dir}/shareds/rate_limit/middleware.v" +auth="${root_dir}/auth_controller.v" +api_v2="${root_dir}/api_v2.v" +pool="${root_dir}/shareds/infradb_pg/infradb_pg.v" +main="${root_dir}/main.v" + +grep -Fq 'max_open_conns: 5' "${pool}" || { + echo 'FAIL: max_open_conns do pool PostgreSQL nao e 5' >&2 + exit 1 +} +grep -Fq 'max_idle_conns: 2' "${pool}" || { + echo 'FAIL: max_idle_conns do pool PostgreSQL nao e 2' >&2 + exit 1 +} +grep -Fq 'conn_max_lifetime: 30 * time.minute' "${pool}" || { + echo 'FAIL: conn_max_lifetime do pool PostgreSQL nao e 30 minutos' >&2 + exit 1 +} +grep -Fq 'pg_holder.close()' "${main}" || { + echo 'FAIL: pool PostgreSQL nao e fechado no shutdown' >&2 + exit 1 +} +grep -Fq 'PostgreSQL pool inicializado: max_open_conns=5 max_idle_conns=2 conn_max_lifetime=30m' "${main}" || { + echo 'FAIL: log seguro de inicializacao do pool ausente' >&2 + exit 1 +} + +grep -Fq 'pg_holder &infradb_pg.PgHolder' "${middleware}" || { + echo 'FAIL: rate-limit sem holder PostgreSQL compartilhado' >&2 + exit 1 +} +if grep -Fq 'pg.connect_with_conninfo(connstr)' "${middleware}"; then + echo 'FAIL: rate-limit ainda abre PostgreSQL por request' >&2 + exit 1 +fi +grep -Eq 'pg_holder[[:space:]]+\??&infradb_pg\.PgHolder' "${auth}" || { + echo 'FAIL: AuthController sem holder PostgreSQL compartilhado' >&2 + exit 1 +} +if grep -Fq 'ac.close_db(mut db)' "${auth}"; then + echo 'FAIL: AuthController ainda fecha o pool compartilhado por request' >&2 + exit 1 +fi +if grep -Fq 'defer { db.close()' "${auth}"; then + echo 'FAIL: AuthController ainda fecha PostgreSQL compartilhado diretamente' >&2 + exit 1 +fi +grep -Fq 'pg_holder &infradb_pg.PgHolder' "${api_v2}" || { + echo 'FAIL: APIControllerV2 sem holder PostgreSQL compartilhado' >&2 + exit 1 +} +if grep -Fq 'pg.connect_with_conninfo(connstr)' "${api_v2}"; then + echo 'FAIL: endpoint usage ainda abre PostgreSQL por request' >&2 + exit 1 +fi +for request_file in "${middleware}" "${auth}" "${api_v2}"; do + if grep -Fq 'db.close()' "${request_file}"; then + echo "FAIL: ${request_file} fecha PostgreSQL por request" >&2 + exit 1 + fi +done +echo 'PASS: pool PostgreSQL compartilhado configurado' diff --git a/scripts/test_run_ssh.sh b/scripts/test_run_ssh.sh deleted file mode 100755 index e858fe0..0000000 --- a/scripts/test_run_ssh.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runner="${root_dir}/run_ssh.sh" -tmp_dir="$(mktemp -d)" -secret='segredo-que-nao-pode-aparecer' - -cleanup() { - rm -rf "${tmp_dir}" -} -trap cleanup EXIT - -fail() { - printf 'FAIL: %s\n' "$*" >&2 - exit 1 -} - -[[ -x "${runner}" ]] || fail 'run_ssh.sh ausente ou nao executavel' - -mkdir -p "${tmp_dir}/config" "${tmp_dir}/bin" -printf 'SSH_PASS_VPS=%q\n' "${secret}" >"${tmp_dir}/config/ssh.env" -chmod 600 "${tmp_dir}/config/ssh.env" -cat >"${tmp_dir}/bin/ssh.exe" <<'SSH' -#!/usr/bin/env bash -printf '%s\n' "$@" >"${SSH_CAPTURE_FILE:-/dev/null}" -SSH -printf '#!/usr/bin/env bash\nexit 0\n' >"${tmp_dir}/bin/sshpass" -chmod +x "${tmp_dir}/bin/ssh.exe" "${tmp_dir}/bin/sshpass" -touch "${tmp_dir}/tabua-api" -chmod 600 "${tmp_dir}/tabua-api" - -output="$( - SSH_ENV_FILE="${tmp_dir}/config/ssh.env" \ - SSH_WINDOWS_BIN="${tmp_dir}/bin/ssh.exe" \ - SSH_KEY_WINDOWS_WSL="${tmp_dir}/tabua-api" \ - SSH_KEY_WINDOWS='C:\Users\andre\.ssh\tabua-api' \ - "${runner}" --dry-run -)" -grep -Fq 'method=windows-key' <<<"${output}" || fail 'chave Windows nao foi priorizada' -if grep -Fq "${secret}" <<<"${output}"; then - fail 'senha apareceu no dry-run com chave' -fi - -SSH_CAPTURE_FILE="${tmp_dir}/ssh-args" \ - SSH_ENV_FILE="${tmp_dir}/config/ssh.env" \ - SSH_WINDOWS_BIN="${tmp_dir}/bin/ssh.exe" \ - SSH_KEY_WINDOWS_WSL="${tmp_dir}/tabua-api" \ - SSH_KEY_WINDOWS='C:\Users\andre\.ssh\tabua-api' \ - "${runner}" -N -L 8000:127.0.0.1:8000 -- printf connected -python3 - "${tmp_dir}/ssh-args" <<'PY' -import sys -args = open(sys.argv[1], encoding="utf-8").read().splitlines() -assert args.index("-L") < args.index("root@167.148.161.67") -assert args.index("root@167.148.161.67") < args.index("printf") -PY - -output="$( - SSH_ENV_FILE="${tmp_dir}/config/ssh.env" \ - SSH_WINDOWS_BIN="${tmp_dir}/ausente" \ - SSH_KEY_LINUX="${tmp_dir}/ausente" \ - SSHPASS_BIN="${tmp_dir}/bin/sshpass" \ - "${runner}" --dry-run -)" -grep -Fq 'method=password-fallback' <<<"${output}" || fail 'fallback sshpass nao selecionado' -if grep -Fq "${secret}" <<<"${output}"; then - fail 'senha apareceu no dry-run do fallback' -fi - -printf 'PASS: SSH prioriza chave e fallback nao expoe senha\n' diff --git a/scripts/test_seed_sqlite.sh b/scripts/test_seed_sqlite.sh index a60fd93..5d732ba 100755 --- a/scripts/test_seed_sqlite.sh +++ b/scripts/test_seed_sqlite.sh @@ -26,6 +26,7 @@ import sys path, value = sys.argv[1:] connection = sqlite3.connect(path) connection.execute("CREATE TABLE seed_info (value TEXT NOT NULL)") +connection.execute("CREATE TABLE data_mare (id INTEGER PRIMARY KEY)") connection.execute("INSERT INTO seed_info(value) VALUES (?)", (value,)) connection.commit() connection.close() diff --git a/shareds/components_view/navbar.v b/shareds/components_view/navbar.v index 60aaef3..f47a2c8 100644 --- a/shareds/components_view/navbar.v +++ b/shareds/components_view/navbar.v @@ -9,8 +9,8 @@ pub fn (cv ComponentsView) navbar(current_page string, is_logged_in bool) string dev_mode: true }) return engine.render('navbar.html', { - 'is_root': is_root - 'current_page': current_page - 'is_logged_in': is_logged_in + 'is_root': is_root + 'current_page': current_page + 'is_logged_in': is_logged_in }) or { '' } } diff --git a/shareds/infradb/migrations.v b/shareds/infradb/migrations.v index 21ef757..f6ae96a 100644 --- a/shareds/infradb/migrations.v +++ b/shareds/infradb/migrations.v @@ -50,7 +50,8 @@ fn ensure_geo_hash_indexes(mut db sqlite.DB) ! { } fn backfill_geo_hash(mut db sqlite.DB) ! { - rows := db.exec("SELECT id, CAST(lat AS REAL), CAST(lng AS REAL) FROM geo_location WHERE geo_hash = '' OR geo_hash IS NULL;")! + rows := + db.exec("SELECT id, CAST(lat AS REAL), CAST(lng AS REAL) FROM geo_location WHERE geo_hash = '' OR geo_hash IS NULL;")! for row in rows { if row.vals.len < 3 { continue diff --git a/shareds/infradb_pg/infradb_pg.v b/shareds/infradb_pg/infradb_pg.v index 2aeefac..f38e300 100644 --- a/shareds/infradb_pg/infradb_pg.v +++ b/shareds/infradb_pg/infradb_pg.v @@ -2,64 +2,81 @@ module infradb_pg import db.pg import net.urllib -import sync +import time import shareds.conf_env -// PgConn e' a conexao PostgreSQL (pg.DB tem pool interno thread-safe). -// Usamos uma unica &pg.DB em vez de pool.ConnectionPool do V (bug em V 0.5.1). +// PgConn e' a conexao PostgreSQL compartilhada. O pg.DB mantem o pool interno +// thread-safe, configurado com limites de 5 conexoes, 2 ociosas e lifetime de 30 min. pub type PgConn = &pg.DB // PgHolder envolve a &pg.DB para que closures de middleware capturem o holder // (struct wrapper) em vez da &pg.DB direta, evitando o bug de captura de // referencia &pg.DB em closures no V 0.5.1 (handler trava no primeiro acesso). -@[nocopy] +@[heap] pub struct PgHolder { mut: - lock sync.Mutex - db &pg.DB = unsafe { nil } + db &pg.DB = unsafe { nil } + available bool } // new cria e retorna um holder com a conexao PG (e guarda a conexao internamente). -// Retorna none se a conexao falhar (o app continua; auth/rate-limit fica desligado). +// Retorna none se a conexao falhar. pub fn new() ?&PgHolder { env := conf_env.load_env() mut db := if env.postgresql_conn_str != '' { - pg.connect_with_conninfo(env.postgresql_conn_str) or { return none } + pg.connect_with_conninfo(env.postgresql_conn_str, pg.PoolConfig{ + max_open_conns: 5 + max_idle_conns: 2 + conn_max_lifetime: 30 * time.minute + }) or { return none } } else { - pg.connect(pg_config_from_env(env)) or { return none } + pg.connect(pg_config_from_env(env), pg.PoolConfig{ + max_open_conns: 5 + max_idle_conns: 2 + conn_max_lifetime: 30 * time.minute + }) or { return none } } return &PgHolder{ - db: unsafe { &db } + db: db + available: true } } -// is_healthy confirma que o PostgreSQL obrigatorio aceita conexao e consulta. -pub fn is_healthy(connstr string) bool { - if connstr == '' { +// db retorna a conexao PG do holder. pg.DB gerencia o pool interno thread-safe. +pub fn (h &PgHolder) db() &pg.DB { + return h.db +} + +// available informa se o pool foi inicializado. O processo pode subir sem PG +// para que /health/live responda; nesse caso readiness permanece falsa. +pub fn (h &PgHolder) available() bool { + return h.available +} + +// raw retorna a conexao PG bruta (para repositories que usam mut db pg.DB). +pub fn (h &PgHolder) raw() &pg.DB { + return h.db +} + +// is_healthy valida uma conexao do pool compartilhado sem abrir outra conexao. +pub fn (h &PgHolder) is_healthy() bool { + if !h.available { return false } - mut db := pg.connect_with_conninfo(connstr) or { return false } - defer { - db.close() or {} - } + mut db := h.db db.exec('SELECT 1') or { return false } return true } -// db retorna a conexao PG do holder (thread-safe). -pub fn (mut h PgHolder) db() &pg.DB { - h.lock.lock() - defer { - h.lock.unlock() +// close encerra o pool somente no shutdown do processo. +pub fn (h &PgHolder) close() { + if !h.available { + return } - return h.db -} - -// raw retorna a conexao PG bruta (para repositories que usam mut db pg.DB). -pub fn (mut h PgHolder) raw() &pg.DB { - return h.db + mut db := h.db + db.close() or { eprintln('PostgreSQL pool close failed') } } // pg_config_from_env constroi um pg.Config a partir das vars individuais (DB_*). diff --git a/shareds/rate_limit/middleware.v b/shareds/rate_limit/middleware.v index fe1709b..882ae18 100644 --- a/shareds/rate_limit/middleware.v +++ b/shareds/rate_limit/middleware.v @@ -7,78 +7,60 @@ import shareds.conf_env import shareds.types import domain.auth_user import repository.auth as repo_auth -import repository.auth.dto import repository.rate_limit as rl +import shareds.infradb_pg pub struct RateLimitOpts { pub mut: - env conf_env.EnvConfig + env conf_env.EnvConfig + pg_holder &infradb_pg.PgHolder +} + +pub struct ApiKeyIdentity { +pub: + found bool + bucket string + plan string + key_value string } // rate_limit_middleware retorna um MiddlewareOptions para o veb que aplica rate-limit por IP/api_key. -// Nota: cria uma conexao PG nova por request porque capturar &pg.DB em closure do veb -// triga um bug no V 0.5.1 (handler trava no primeiro acesso). Para rate-limit -// (1-2 queries por request) o custo de open/close e' aceitavel. +// As consultas continuam ocorrendo em toda requisicao para preservar revogacao de +// chaves e limites atuais, mas usam o pool PostgreSQL compartilhado da aplicacao. pub fn rate_limit_middleware(opts RateLimitOpts) veb.MiddlewareOptions[web_ctx.WsCtx] { env := opts.env - connstr := env.postgresql_conn_str + pg_holder := opts.pg_holder return veb.MiddlewareOptions[web_ctx.WsCtx]{ - handler: fn [env, connstr] (mut ctx web_ctx.WsCtx) bool { - return do_rate_limit(mut ctx, env, connstr) + handler: fn [env, pg_holder] (mut ctx web_ctx.WsCtx) bool { + return do_rate_limit(mut ctx, env, pg_holder) } } } // do_rate_limit executa a logica de rate-limit fora da closure para evitar // o limite de niveis de expressao do checker do V. -fn do_rate_limit(mut ctx web_ctx.WsCtx, env conf_env.EnvConfig, connstr string) bool { +fn do_rate_limit(mut ctx web_ctx.WsCtx, env conf_env.EnvConfig, pg_holder &infradb_pg.PgHolder) bool { ip := ctx.ip() ctx.ip = ip ctx.plan = 'anon' - - if connstr == '' { - return reject_dependency(mut ctx, 'PostgreSQL nao configurado') - } - - mut db_pg := pg.connect_with_conninfo(connstr) or { - eprintln('rate_limit: pg connect failed: ${err}') + if !pg_holder.available() { return reject_dependency(mut ctx, 'PostgreSQL indisponivel') } - defer { - db_pg.close() or {} - } + + mut db_pg := pg_holder.db() mut bucket := 'ip:${ip}' mut plan := 'anon' - api_key := extract_api_key(mut ctx) - if api_key != '' { - mut key_found := true - key := repo_auth.find_by_key(mut db_pg, api_key) or { - if err.msg() != 'api key nao encontrada' { - eprintln('rate_limit api key lookup failed: ${err}') - return reject_dependency(mut ctx, 'Falha ao consultar rate-limit') - } - key_found = false - dto.ApiKey{} - } - if key_found && !key.revoked { - // valida se o plano do usuario ainda permite o plano da key - // evita furo: usuario cancelou, mas a key antiga continua paga - mut effective_plan := key.plan - user_plan := repo_auth.find_plan_by_id(mut db_pg, key.user_id) or { - eprintln('rate_limit user plan lookup failed: ${err}') - return reject_dependency(mut ctx, 'Falha ao consultar rate-limit') - } - if !is_plan_allowed(key.plan, user_plan) { - effective_plan = user_plan - } - - bucket = 'key:${key.key_value}' - ctx.api_key = key.key_value - plan = effective_plan - ctx.plan = effective_plan - } + identity := resolve_api_key_identity(mut db_pg, extract_api_key(mut ctx)) or { + eprintln('rate_limit api key lookup failed: ${err}') + return reject_dependency(mut ctx, 'Falha ao consultar rate-limit') + } + if identity.found { + bucket = identity.bucket + ctx.api_key = identity.key_value + plan = identity.plan + ctx.plan = identity.plan } limit_rpm, limit_monthly := plan_limits(env, plan) @@ -115,6 +97,39 @@ pub fn is_plan_allowed(key_plan string, user_plan string) bool { return false } +// effective_plan aplica a regra da chave ao plano atual do usuario. +// Uma chave antiga nunca pode manter privilegios depois de um downgrade. +pub fn effective_plan(key_plan string, user_plan string) string { + if is_plan_allowed(key_plan, user_plan) { + return key_plan + } + return user_plan +} + +// resolve_api_key_identity concentra a consulta e a regra de downgrade de uma chave. +// Chave ausente/revogada vira anonimo; falha real no banco sobe para 503 no chamador. +pub fn resolve_api_key_identity(mut db pg.DB, api_key string) !ApiKeyIdentity { + if api_key == '' { + return ApiKeyIdentity{} + } + key := repo_auth.find_by_key(mut db, api_key) or { + if err.msg() == 'api key nao encontrada' { + return ApiKeyIdentity{} + } + return err + } + if key.revoked { + return ApiKeyIdentity{} + } + user_plan := repo_auth.find_plan_by_id(mut db, key.user_id)! + return ApiKeyIdentity{ + found: true + bucket: 'key:${key.key_value}' + plan: effective_plan(key.plan, user_plan) + key_value: key.key_value + } +} + fn apply_limits(mut ctx web_ctx.WsCtx, mut db pg.DB, bucket string, plan string, limit_rpm int, limit_monthly int) bool { minute_key := rl.window_key_minute() exceeded_minute := rl.inc_and_check(mut db, bucket, 'minute', minute_key, limit_rpm) or { diff --git a/shareds/rate_limit/readability_test.v b/shareds/rate_limit/readability_test.v new file mode 100644 index 0000000..3b95b58 --- /dev/null +++ b/shareds/rate_limit/readability_test.v @@ -0,0 +1,7 @@ +module rate_limit + +fn test_effective_plan_downgrades_a_key_to_user_plan() { + assert effective_plan('plan10', 'free') == 'free' + assert effective_plan('plan5', 'plan10') == 'plan5' + assert effective_plan('free', 'free') == 'free' +} diff --git a/tests/health_state_test.v b/tests/health_state_test.v index 89940da..dc90783 100644 --- a/tests/health_state_test.v +++ b/tests/health_state_test.v @@ -34,6 +34,15 @@ fn test_sqlite_health_requires_a_working_pool() ! { } fn test_postgres_health_fails_closed_without_connection() { - assert !infradb_pg.is_healthy('') - assert !infradb_pg.is_healthy('postgresql://health:health@127.0.0.1:1/health?connect_timeout=1') + mut unavailable := &infradb_pg.PgHolder{} + assert !unavailable.available() + assert !unavailable.is_healthy() +} + +fn test_postgres_pool_caps_open_connections() ! { + holder := infradb_pg.new() or { return } + mut db := holder.db() + stats := db.stats() + assert stats.max_open_connections == 5 + holder.close() }