chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
267
internal/rpc/upload_webfile.go
Normal file
267
internal/rpc/upload_webfile.go
Normal 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 消息的地图缩略图
|
||||
// (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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue