mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-13 02:19:28 +00:00
Implements Phases 02 (partial) and 03 of the go-port-cloud-run plan. Introduces module framework with per-module KV prefix isolation, health check endpoint, request timeout protection, and comprehensive test coverage. Cloud Run deployment deferred to Phase 01. Security hardening: constant-time secret comparison, cron auth bridge, and secrets stripped from dependency environment exports. Includes Dockerfile, GitHub CI workflow (vet + race + build), and integration tests for module lifecycle.
93 lines
2.6 KiB
Go
93 lines
2.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-telegram/bot"
|
|
|
|
"github.com/tiennm99/miti99bot-go/internal/modules"
|
|
"github.com/tiennm99/miti99bot-go/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 name. Replaced by OIDC in Phase 09.
|
|
const cronAuthHeader = "X-Cron-Token"
|
|
|
|
// Config wires the router's runtime dependencies.
|
|
type Config struct {
|
|
Bot *bot.Bot
|
|
Registry *modules.Registry
|
|
WebhookSecret string
|
|
|
|
// CronSecret is the shared-secret bridge until Phase 09 adds OIDC. Empty
|
|
// means /cron/{name} is fully disabled (404). Required to prevent
|
|
// unauthenticated triggering of billable side effects.
|
|
CronSecret string
|
|
}
|
|
|
|
// New builds the application's HTTP handler. Routes:
|
|
//
|
|
// GET / → health
|
|
// POST /webhook → Telegram update intake (constant-time secret check)
|
|
// POST /cron/{name} → Cloud Scheduler entry (shared-secret check; OIDC in Phase 09)
|
|
//
|
|
// Anything else is 404.
|
|
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 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.Printf("cron name=%s", 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.Printf("cron %s failed: %v", name, err)
|
|
http.Error(w, "cron failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|