Files

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 ![img](/app/workspace/...) — 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
}