chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,267 @@
package rpc
import (
"context"
"errors"
"io"
"math"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// validFiniteCoord 校验坐标是有限值且在 [-bound, bound] 内NaN/±Inf 一律拒绝)。
func validFiniteCoord(v, bound float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= -bound && v <= bound
}
// 本文件实现 upload.getWebFile。当前唯一真实路径是 geo/venue 消息的地图缩略图
// inputWebFileGeoPointLocationTDesktop 与 DrKLO Android 渲染位置消息时都会调它。
// 地图图像由 files 服务提供:配置 Mapbox 代理时为真实静态地图(落盘缓存保证分片一致),
// 否则确定性占位合成(见 app/files/maptile.go 与 maptile_proxy.go
// access_hash 不做校验(发送时随机生成且未持久化,地图缩略图无机密性可言)。
// URL 形态inputWebFileLocation只代理 inline registry 中已登记的 web document
// 任意客户端自造 URL/access_hash 仍显式拒绝,避免把服务端变成开放代理。
// maxWebFileChunkLimit 与 upload.getFile 的单次上限一致,防止客户端超大 limit 放大内存。
const (
maxWebFileChunkLimit = maxUploadGetFileChunkLimit
inlineWebFileFetchTimeout = 10 * time.Second
)
type inlineWebFileFetcher func(ctx context.Context, document domain.BotInlineWebDocument) ([]byte, string, error)
var fetchInlineWebFile inlineWebFileFetcher = defaultFetchInlineWebFile
func (r *Router) registerUploadWebFile(d *tg.ServerDispatcher) {
d.OnUploadGetWebFile(r.onUploadGetWebFile)
}
func (r *Router) onUploadGetWebFile(ctx context.Context, req *tg.UploadGetWebFileRequest) (*tg.UploadWebFile, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, locationInvalidErr()
}
if req.Offset < 0 || req.Limit <= 0 || req.Limit > maxWebFileChunkLimit {
return nil, limitInvalidErr()
}
if loc, ok := req.Location.(*tg.InputWebFileLocation); ok {
return r.onUploadGetRegisteredWebFile(ctx, loc, req.Offset, req.Limit)
}
loc, ok := req.Location.(*tg.InputWebFileGeoPointLocation)
if !ok {
return nil, locationInvalidErr()
}
point, ok := loc.GeoPoint.(*tg.InputGeoPoint)
// NaN 与所有比较都为 false会穿过区间校验必须显式拒绝NaN/Inf 会污染缓存 key 与上游 URL
if !ok || !validFiniteCoord(point.Lat, 90) || !validFiniteCoord(point.Long, 180) {
return nil, locationInvalidErr()
}
full, mime := r.deps.Files.GeoMapTile(point.Lat, point.Long, loc.W, loc.H, loc.Zoom, loc.Scale)
if len(full) == 0 {
return nil, locationInvalidErr()
}
fileType := webFileStorageType(mime)
if req.Offset >= len(full) {
return &tg.UploadWebFile{
Size: len(full),
MimeType: mime,
FileType: fileType,
Mtime: int(r.clock.Now().Unix()),
Bytes: []byte{},
}, nil
}
end := req.Offset + req.Limit
if end > len(full) {
end = len(full)
}
return &tg.UploadWebFile{
Size: len(full),
MimeType: mime,
FileType: fileType,
Mtime: int(r.clock.Now().Unix()),
Bytes: full[req.Offset:end],
}, nil
}
func (r *Router) onUploadGetRegisteredWebFile(ctx context.Context, loc *tg.InputWebFileLocation, offset, limit int) (*tg.UploadWebFile, error) {
if loc.URL == "" || loc.AccessHash == 0 {
return nil, locationInvalidErr()
}
_, data, mime, err := r.registeredInlineWebDocumentBytes(ctx, loc.URL, loc.AccessHash)
if err != nil {
return nil, locationInvalidErr()
}
fileType := storageFileType(mime, data)
if offset >= len(data) {
return &tg.UploadWebFile{
Size: len(data),
MimeType: mime,
FileType: fileType,
Mtime: int(r.clock.Now().Unix()),
Bytes: []byte{},
}, nil
}
end := offset + limit
if end > len(data) {
end = len(data)
}
return &tg.UploadWebFile{
Size: len(data),
MimeType: mime,
FileType: fileType,
Mtime: int(r.clock.Now().Unix()),
Bytes: append([]byte(nil), data[offset:end]...),
}, nil
}
func (r *Router) registeredInlineWebDocumentBytes(ctx context.Context, url string, accessHash int64) (domain.BotInlineWebDocument, []byte, string, error) {
document, data, mime, ok := r.inlines.webDocumentForDownloadContext(ctx, r.clock.Now(), url, accessHash)
if !ok {
return domain.BotInlineWebDocument{}, nil, "", locationInvalidErr()
}
if len(data) == 0 {
fetched, fetchedMime, err := fetchInlineWebFile(ctx, document)
if err != nil {
return domain.BotInlineWebDocument{}, nil, "", err
}
if len(fetched) == 0 || len(fetched) > domain.MaxBotInlineWebSize {
return domain.BotInlineWebDocument{}, nil, "", locationInvalidErr()
}
data = fetched
mime = normalizeInlineWebFileMime(fetchedMime, data, document.MimeType)
r.inlines.cacheWebDocumentBytesContext(ctx, r.clock.Now(), document.URL, document.AccessHash, data, mime)
}
if mime == "" {
mime = document.MimeType
}
return document, data, mime, nil
}
func webFileStorageType(mime string) tg.StorageFileTypeClass {
switch mime {
case "image/jpeg":
return &tg.StorageFileJpeg{}
case "image/webp":
return &tg.StorageFileWebp{}
default:
return &tg.StorageFilePng{}
}
}
func defaultFetchInlineWebFile(ctx context.Context, document domain.BotInlineWebDocument) ([]byte, string, error) {
if err := validateInlineWebURL(document.URL, true); err != nil {
return nil, "", err
}
ctx, cancel := context.WithTimeout(ctx, inlineWebFileFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, document.URL, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", "telesrv-inline-webfile")
client := &http.Client{
Timeout: inlineWebFileFetchTimeout,
Transport: &http.Transport{
Proxy: nil,
DialContext: safeExternalDialContext,
},
}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, "", errors.New("webfile fetch status " + strconv.Itoa(resp.StatusCode))
}
if resp.ContentLength > int64(domain.MaxBotInlineWebSize) {
return nil, "", webDocumentSizeTooBigErr()
}
limited := io.LimitReader(resp.Body, int64(domain.MaxBotInlineWebSize)+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, "", err
}
if len(data) > domain.MaxBotInlineWebSize {
return nil, "", webDocumentSizeTooBigErr()
}
return data, resp.Header.Get("Content-Type"), nil
}
func safeExternalDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if port == "" {
port = "443"
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
var lastErr error
dialer := &net.Dialer{Timeout: 5 * time.Second}
for _, candidate := range ips {
if !safeExternalIP(candidate.IP) {
lastErr = errors.New("unsafe webfile host")
continue
}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("webfile host has no safe address")
}
func safeExternalIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
if v4 := ip.To4(); v4 != nil {
first, second := v4[0], v4[1]
if first == 0 || first >= 224 || first == 127 || first == 169 && second == 254 {
return false
}
if first == 100 && second >= 64 && second <= 127 {
return false
}
if first == 198 && (second == 18 || second == 19) {
return false
}
}
return true
}
func normalizeInlineWebFileMime(header string, data []byte, fallback string) string {
mime := strings.TrimSpace(strings.Split(header, ";")[0])
if !validInlineWebMime(mime) {
mime = http.DetectContentType(data)
}
if !validInlineWebMime(mime) {
mime = fallback
}
return mime
}