Files
miti99bot/internal/server/router.go
T
tiennm99 6cde38b6f4 refactor: rename module to miti99bot, canonicalize AWS deploy path
Rename:
- Go module github.com/tiennm99/miti99bot-go → github.com/tiennm99/miti99bot
- CloudFormation stack miti99bot-aws-port → miti99bot
- Drop "port", "Cloud Run", "GCP", "cutover", "Phase NN" framing from
  active code and docs — project reads as canonical AWS-Lambda from now on.

AWS deploy guide + flow fix:
- New docs/deploy-aws-free-tier-guide.md — Ubuntu 24.04 ARM64 onboarding
  with project-local venv (pip awscli + sam-cli), SSM secrets via read -s,
  idempotent OIDC provider + role creation, $1 budget alarm.
- Drop sam build from the pipeline — provided.al2023 + makefile builder
  expects a Makefile in CodeUri (build/lambda/, the output dir), so the
  step always fails. sam deploy --template-file template.yaml now reads
  the raw template and zips build/lambda/ directly.
- Rollback section rewritten — use continue-update-rollback /
  cancel-update-stack / git-SHA redeploy. Drop the broken
  --use-previous-template recipe.
- DynamoDB free-tier row corrected (on-demand is 2.5M read / 1M write
  request units, not 25 RCU/WCU).

Updated:
- README.md fully rewritten (drops port/legacy framing, lists modules,
  points new users at the free-tier guide).
- aws/README.md retitled "AWS account setup", phase numbers stripped.
- Makefile / .github/workflows/deploy.yml — sam deploy flow.
- samconfig.toml — stack_name = "miti99bot".
- Go comments — Cloud Run → Lambda, Cloud Scheduler → EventBridge
  Scheduler, Cloud Logging → CloudWatch Logs.
- Struct field GCPProject → FirestoreProject (env GOOGLE_CLOUD_PROJECT
  unchanged).

Plus advisory reports under plans/reports/ from the code-reviewer +
researcher passes that informed the fixes.

Verified: go vet ./..., go build ./..., go test ./... all green.
2026-05-13 22:05:38 +07:00

96 lines
2.9 KiB
Go

package server
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"regexp"
"strings"
"github.com/go-telegram/bot"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/telegram"
)
// cronNameRe limits cron path segments to a safe alphabet so log injection via
// the route is impossible (newlines, ANSI escapes, etc. are rejected at the
// router boundary). Same shape as Telegram command names.
var cronNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
// cronAuthHeader is the shared-secret header EventBridge Scheduler attaches when
// invoking /cron/{name}.
const cronAuthHeader = "X-Cron-Token"
// Config wires the router's runtime dependencies.
type Config struct {
Bot *bot.Bot
Registry *modules.Registry
WebhookSecret string
// CronSecret protects /cron/{name} against unauthenticated calls; EventBridge
// Scheduler attaches it as the X-Cron-Token header. Empty means /cron/{name}
// is fully disabled (404).
CronSecret string
}
// New builds the application's HTTP handler. Routes:
//
// GET / → health
// POST /webhook → Telegram update intake (constant-time secret check)
// POST /cron/{name} → EventBridge Scheduler entry (shared-secret check)
//
// Anything else is 404. All routes pass through LogRequests so every
// request emits a structured `req` log line (CloudWatch Logs consumes them
// for 5xx-rate alerts and per-route latency).
func New(cfg Config) http.Handler {
mux := http.NewServeMux()
mux.Handle("/", HealthHandler())
mux.Handle("/webhook", telegram.WebhookHandler(cfg.Bot, cfg.WebhookSecret))
mux.Handle("/cron/", cronHandler(cfg.Registry, cfg.CronSecret))
return LogRequests(mux)
}
func cronHandler(reg *modules.Registry, secret string) http.HandlerFunc {
secretBytes := []byte(secret)
cronDisabled := secret == ""
return func(w http.ResponseWriter, r *http.Request) {
if cronDisabled {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
got := []byte(r.Header.Get(cronAuthHeader))
if subtle.ConstantTimeCompare(got, secretBytes) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
name := strings.TrimPrefix(r.URL.Path, "/cron/")
if !cronNameRe.MatchString(name) {
http.NotFound(w, r)
return
}
log.Info("cron triggered", "route", "/cron", "name", name)
ctx, cancel := context.WithTimeout(r.Context(), defaultCronTimeout)
defer cancel()
if err := modules.DispatchScheduled(ctx, name, reg); err != nil {
if errors.Is(err, modules.ErrCronNotFound) {
http.NotFound(w, r)
return
}
log.Error("cron failed", "route", "/cron", "name", name, "err", err)
http.Error(w, "cron failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}