1279-line monolith → 7 files by responsibility: miniapp.go ( 89L) — Handler struct, NewHandler, RegisterRoutes, serveIndex types.go (180L) — exported types, interfaces, StateNotifier auth.go (131L) — requireAuth, isAllowed, ValidateInitData dev.go (477L) — dev proxy management + apiDev/serveDevProxy/apiDevConsole ws.go (224L) — wsLogs, wsOrchestration, wsUpgrader/wsClient api.go (168L) — JSON API handlers, writeJSON, sendSSEIfChanged logs.go (143L) — apiLogsSnapshot, download, cleanOldSnapshots go build ./pkg/miniapp/... and go test ./pkg/miniapp/... pass unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
3.5 KiB
Go
131 lines
3.5 KiB
Go
package miniapp
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// initDataMaxAge is the maximum age of initData before it is considered expired.
|
|
const initDataMaxAge = 24 * time.Hour
|
|
|
|
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
initData := r.URL.Query().Get("initData")
|
|
if initData == "" {
|
|
http.Error(w, `{"error":"missing initData"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !ValidateInitData(initData, h.botToken) {
|
|
http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if len(h.allowList) > 0 {
|
|
userID, _ := extractUserFromInitData(initData)
|
|
if userID == "" || !isAllowed(userID, h.allowList) {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// isAllowed checks whether userID matches any entry in the allow list.
|
|
// Logic mirrors BaseChannel.IsAllowed without importing channels package.
|
|
func isAllowed(userID string, allowList []string) bool {
|
|
if len(allowList) == 0 {
|
|
return true
|
|
}
|
|
for _, allowed := range allowList {
|
|
trimmed := strings.TrimPrefix(allowed, "@")
|
|
allowedID := trimmed
|
|
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
|
allowedID = trimmed[:idx]
|
|
}
|
|
if userID == allowed || userID == trimmed || userID == allowedID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractUserFromInitData parses user.id from the initData query string.
|
|
// initData contains a "user" param with JSON like {"id":123456,...}.
|
|
func extractUserFromInitData(initData string) (userID, chatID string) {
|
|
values, err := url.ParseQuery(initData)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
userJSON := values.Get("user")
|
|
if userJSON == "" {
|
|
return "", ""
|
|
}
|
|
var user struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := json.Unmarshal([]byte(userJSON), &user); err != nil || user.ID == 0 {
|
|
return "", ""
|
|
}
|
|
id := fmt.Sprintf("%d", user.ID)
|
|
// For Mini App commands, chatID = userID (private chat)
|
|
return id, id
|
|
}
|
|
|
|
// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature
|
|
// and checks that auth_date is not older than initDataMaxAge.
|
|
// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
|
func ValidateInitData(initData, botToken string) bool {
|
|
values, err := url.ParseQuery(initData)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
receivedHash := values.Get("hash")
|
|
if receivedHash == "" {
|
|
return false
|
|
}
|
|
|
|
// Check auth_date freshness
|
|
if authDateStr := values.Get("auth_date"); authDateStr != "" {
|
|
authDate, err := strconv.ParseInt(authDateStr, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if time.Since(time.Unix(authDate, 0)) > initDataMaxAge {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Build the data-check-string: sort all key=value pairs except "hash",
|
|
// join with newlines.
|
|
var pairs []string
|
|
for key := range values {
|
|
if key == "hash" {
|
|
continue
|
|
}
|
|
pairs = append(pairs, fmt.Sprintf("%s=%s", key, values.Get(key)))
|
|
}
|
|
sort.Strings(pairs)
|
|
dataCheckString := strings.Join(pairs, "\n")
|
|
|
|
// secret_key = HMAC-SHA256("WebAppData", bot_token)
|
|
secretKeyMac := hmac.New(sha256.New, []byte("WebAppData"))
|
|
secretKeyMac.Write([]byte(botToken))
|
|
secretKey := secretKeyMac.Sum(nil)
|
|
|
|
// hash = HMAC-SHA256(secret_key, data_check_string)
|
|
hashMac := hmac.New(sha256.New, secretKey)
|
|
hashMac.Write([]byte(dataCheckString))
|
|
computedHash := hex.EncodeToString(hashMac.Sum(nil))
|
|
|
|
return hmac.Equal([]byte(computedHash), []byte(receivedHash))
|
|
}
|