mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-08 00:21:55 +00:00
156 lines
6.0 KiB
Go
156 lines
6.0 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
crypto_rand "crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// FileTokenTTL is the default TTL for signed file tokens.
|
|
const FileTokenTTL = 5 * time.Minute
|
|
|
|
var (
|
|
fileSigningKey string
|
|
fileSigningKeyOnce sync.Once
|
|
)
|
|
|
|
// FileSigningKey returns a random 32-byte key for HMAC file token signing.
|
|
// Generated once at startup, lives in memory only. Tokens expire on restart
|
|
// which is acceptable for the short TTL — clients re-fetch signed URLs on reconnect.
|
|
func FileSigningKey() string {
|
|
fileSigningKeyOnce.Do(func() {
|
|
b := make([]byte, 32)
|
|
crypto_rand.Read(b)
|
|
fileSigningKey = base64.RawURLEncoding.EncodeToString(b)
|
|
})
|
|
return fileSigningKey
|
|
}
|
|
|
|
// SignFileToken creates a short-lived HMAC token for file access.
|
|
// Token format: {base64url_hmac_32bytes}.{unix_expiry}.
|
|
// The path is bound into the signature so tokens can't be reused for other files.
|
|
func SignFileToken(path, secret string, ttl time.Duration) string {
|
|
expiry := time.Now().Add(ttl).Unix()
|
|
sig := fileTokenHMAC(path, secret, expiry)
|
|
return fmt.Sprintf("%s.%d", sig, expiry)
|
|
}
|
|
|
|
// VerifyFileToken validates a signed file token against a path and secret.
|
|
// Returns true if the HMAC matches and the token has not expired.
|
|
func VerifyFileToken(token, path, secret string) bool {
|
|
parts := strings.SplitN(token, ".", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
expiry, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil || time.Now().Unix() > expiry {
|
|
return false
|
|
}
|
|
expected := fileTokenHMAC(path, secret, expiry)
|
|
return hmac.Equal([]byte(parts[0]), []byte(expected))
|
|
}
|
|
|
|
// fileTokenHMAC computes the HMAC signature component.
|
|
func fileTokenHMAC(path, secret string, expiry int64) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(fmt.Appendf(nil, "%s:%d", path, expiry))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// SignMediaPath converts a media ref path to a signed /v1/files/ URL.
|
|
// Handles legacy data where paths may already contain /v1/files/ prefixes
|
|
// and stale ?ft= tokens from prior signing bugs.
|
|
func SignMediaPath(rawPath, secret string) string {
|
|
if rawPath == "" {
|
|
return ""
|
|
}
|
|
// Defense-in-depth: reject path traversal (also blocked by handleServe)
|
|
if strings.Contains(rawPath, "..") {
|
|
return ""
|
|
}
|
|
// Strip stale ?ft= tokens
|
|
cleanPath := filepathToURLPath(rawPath)
|
|
cleanPath = staleTokenRe.ReplaceAllString(cleanPath, "")
|
|
cleanPath = strings.TrimRight(cleanPath, "?&")
|
|
// Strip all /v1/files/ and /v1/media/ prefixes (may be stacked from legacy bugs)
|
|
for strings.Contains(cleanPath, "/v1/files/") {
|
|
cleanPath = strings.Replace(cleanPath, "/v1/files/", "/", 1)
|
|
}
|
|
for strings.Contains(cleanPath, "/v1/media/") {
|
|
cleanPath = strings.Replace(cleanPath, "/v1/media/", "/", 1)
|
|
}
|
|
cleanPath = path.Clean(cleanPath)
|
|
urlPath := "/v1/files/" + strings.TrimPrefix(cleanPath, "/")
|
|
ft := SignFileToken(urlPath, secret, FileTokenTTL)
|
|
return urlPath + "?ft=" + ft
|
|
}
|
|
|
|
func filepathToURLPath(rawPath string) string {
|
|
return strings.ReplaceAll(rawPath, `\`, "/")
|
|
}
|
|
|
|
// fileURLRe matches /v1/files/... and /v1/media/... URLs in markdown and plain text.
|
|
// Captures the full URL path (stops at whitespace, closing paren, quote, or angle bracket).
|
|
var fileURLRe = regexp.MustCompile(`(/v1/(?:files|media)/[^\s)"'<>]+)`)
|
|
|
|
// SignFileURLs finds all /v1/files/ and /v1/media/ URLs in content and appends
|
|
// a signed ?ft= token. Used at delivery time (WS events, HTTP responses) to avoid
|
|
// persisting tokens in session messages. Also heals legacy data issues:
|
|
// - Strips stale ?ft= tokens and re-signs with fresh tokens
|
|
// - Fixes double /v1/files/v1/files/ prefix from old media mutation bug
|
|
// - Cleans ?ft= from markdown link display text [name?ft=xxx](url) → [name](url)
|
|
func SignFileURLs(content, secret string) string {
|
|
if secret == "" || !strings.Contains(content, "/v1/") {
|
|
return content
|
|
}
|
|
// Heal legacy: deduplicate /v1/files/v1/files/ → /v1/files/ and /v1/media/v1/media/ → /v1/media/
|
|
content = strings.ReplaceAll(content, "/v1/files/v1/files/", "/v1/files/")
|
|
content = strings.ReplaceAll(content, "/v1/files/v1/media/", "/v1/media/")
|
|
content = strings.ReplaceAll(content, "/v1/media/v1/media/", "/v1/media/")
|
|
// Heal legacy: convert bare absolute paths in markdown links to /v1/files/ URLs.
|
|
// Agent text may embed  — these need the /v1/files/ prefix to be served.
|
|
content = barePathInLinkRe.ReplaceAllString(content, "](/v1/files$1)")
|
|
// Heal legacy: clean ?ft=... from markdown link display text [name?ft=xxx](...) → [name](...)
|
|
content = linkTextFtRe.ReplaceAllString(content, "[$1]")
|
|
// Sign URLs
|
|
return fileURLRe.ReplaceAllStringFunc(content, func(url string) string {
|
|
// Strip stale ft= token if present (legacy data may have persisted tokens).
|
|
cleanURL := stripFileToken(url)
|
|
ft := SignFileToken(cleanURL, secret, FileTokenTTL)
|
|
sep := "?"
|
|
if strings.Contains(cleanURL, "?") {
|
|
sep = "&"
|
|
}
|
|
return cleanURL + sep + "ft=" + ft
|
|
})
|
|
}
|
|
|
|
// linkTextFtRe matches markdown link text containing ?ft=... e.g. [filename.md?ft=xxx](...)
|
|
// Captures the clean name before ?ft= for replacement.
|
|
var linkTextFtRe = regexp.MustCompile(`\[([^\]\s?]+)\?ft=[^\]]*\]`)
|
|
|
|
// barePathInLinkRe matches markdown link URLs that are bare absolute paths (not /v1/ URLs).
|
|
// Captures the absolute path so we can prepend /v1/files. Only matches inside ](...)
|
|
// to avoid false positives in prose text.
|
|
var barePathInLinkRe = regexp.MustCompile(`\]\((/(?:app|home|tmp|opt|data|workspace)[^\s)"'<>]*)\)`)
|
|
|
|
// staleTokenRe matches ?ft=... or &ft=... query parameter (greedy to end of URL segment).
|
|
var staleTokenRe = regexp.MustCompile(`[?&]ft=[^\s)"'<>&]*`)
|
|
|
|
// stripFileToken removes any ft= query parameter from a URL, cleaning up
|
|
// legacy session data that may have persisted signed tokens.
|
|
func stripFileToken(url string) string {
|
|
cleaned := staleTokenRe.ReplaceAllString(url, "")
|
|
// If we stripped ?ft=... (the only param), a trailing "?" might remain — remove it.
|
|
cleaned = strings.TrimRight(cleaned, "?&")
|
|
return cleaned
|
|
}
|