267 lines
8 KiB
Go
267 lines
8 KiB
Go
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 消息的地图缩略图
|
||
// (inputWebFileGeoPointLocation):TDesktop 与 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
|
||
}
|