From 863ae2e9900235f8c3acfdacd492bcd08f0f1934 Mon Sep 17 00:00:00 2001 From: onysd Date: Thu, 3 Sep 2026 08:33:27 +0300 Subject: [PATCH] files for previous commit --- cmd/telesrv/main.go | 2 ++ internal/rpc/deps.go | 4 ++++ internal/rpc/send_media_test.go | 3 +++ internal/rpc/upload.go | 26 ++++++++++++++++++++++++++ internal/rpc/upload_test.go | 31 +++++++++++++++++++++++++++++++ internal/store/media.go | 6 ++++++ internal/store/postgres/media.go | 6 ++++++ 7 files changed, 78 insertions(+) diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 9051ebbb..301b6d3d 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -954,6 +954,7 @@ func run(logger *zap.Logger) error { filesapp.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes), filesapp.WithStorageRetentionAges(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeByCategory, cfg.StorageRetentionMaxAgeAvatar), filesapp.WithStorageMaxTotalBytes(cfg.StorageMaxTotalBytes), + filesapp.WithSecretChatDeleteFileAfterDownload(cfg.SecretChatDeleteFileAfterDownload), filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir), externalMediaOption(cfg), webPagePreviewOption(cfg), @@ -1689,6 +1690,7 @@ func run(logger *zap.Logger) error { Photos: filesService, StickerSets: filesService, GifCatalog: filesService, + Storage: filesService, Bots: botsService, Emoji: filesService, Moderation: moderationService, diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 12070d2e..6642b5dd 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -941,6 +941,10 @@ type FilesService interface { GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) // CreateEncryptedFileFromUpload 把密聊上传分片组装成盲 blob 并铸造 EncryptedFile 快照(P2)。 CreateEncryptedFileFromUpload(ctx context.Context, file domain.UploadedFileRef, keyFingerprint int) (domain.EncryptedFileRef, error) + // DeleteEncryptedFileBlob deletes a secret-chat encrypted file's blob + // bytes once fully downloaded (TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD, + // no-op otherwise) -- see internal/app/files.Service.DeleteEncryptedFileBlob. + DeleteEncryptedFileBlob(ctx context.Context, locationKey string) error // GeoMapTile 渲染 geo 消息地图缩略占位图(upload.getWebFile),确定性、无外部依赖。 GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) // 资源读取(reaction / sticker / document)。 diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go index c2b725dc..bac20df0 100644 --- a/internal/rpc/send_media_test.go +++ b/internal/rpc/send_media_test.go @@ -73,6 +73,9 @@ func (f *fakeFiles) GetFile(_ context.Context, req domain.FileDownloadRequest) ( func (f *fakeFiles) CreateEncryptedFileFromUpload(context.Context, domain.UploadedFileRef, int) (domain.EncryptedFileRef, error) { return domain.EncryptedFileRef{ID: 9001, AccessHash: 9002, Size: 16, DCID: 2, KeyFingerprint: 7}, nil } +func (f *fakeFiles) DeleteEncryptedFileBlob(context.Context, string) error { + return nil +} func (f *fakeFiles) GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) { return []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4}, "image/png" } diff --git a/internal/rpc/upload.go b/internal/rpc/upload.go index a9d8db27..96f36a7b 100644 --- a/internal/rpc/upload.go +++ b/internal/rpc/upload.go @@ -104,6 +104,19 @@ func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileReque return nil, internalErr() } if found { + if isSecretChatFileFullyDownloaded(key, req.Offset, len(chunk.Bytes), chunk.Total) { + // The recipient has now received every byte of this secret-chat + // file. Best-effort, fire-and-forget: DeleteEncryptedFileBlob is + // itself a no-op unless TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD + // is enabled, and must never delay or fail this download response + // -- use a detached context since ctx may be canceled the moment + // this handler returns. + go func(locationKey string) { + if err := r.deps.Files.DeleteEncryptedFileBlob(context.Background(), locationKey); err != nil && r.log != nil { + r.log.Warn("delete secret chat file after download failed", zap.String("location_key", locationKey), zap.Error(err)) + } + }(key) + } return &tg.UploadFile{ Type: storageFileType(chunk.MimeType, chunk.Bytes), Mtime: 0, @@ -113,6 +126,19 @@ func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileReque return nil, locationInvalidErr() } +// isSecretChatFileFullyDownloaded reports whether a getFile response for +// location key key just delivered the last byte of a secret-chat encrypted +// file (location_key "enc:") -- the signal onUploadGetFile uses to +// fire-and-forget DeleteEncryptedFileBlob. total<=0 (unknown size) never +// counts as fully downloaded -- there is nothing to compare against, so +// treating that as "done" could delete a file mid-transfer. +func isSecretChatFileFullyDownloaded(key string, offset int64, chunkLen int, total int64) bool { + if !strings.HasPrefix(key, "enc:") || total <= 0 { + return false + } + return offset+int64(chunkLen) >= total +} + // authorizedFileLocationKey applies the authorization/capability checks which cannot be // expressed by a plain location-key conversion. Secret-chat blobs are addressed internally by // id, but the wire capability is the pair (id, access_hash); accepting id alone would let any diff --git a/internal/rpc/upload_test.go b/internal/rpc/upload_test.go index 3c923b77..19b3b49b 100644 --- a/internal/rpc/upload_test.go +++ b/internal/rpc/upload_test.go @@ -10,6 +10,37 @@ import ( "telesrv/internal/domain" ) +// TestIsSecretChatFileFullyDownloaded covers the fire-and-forget +// delete-after-download trigger in onUploadGetFile: only an "enc:"-prefixed +// (secret-chat) location key, with a known total size, whose chunk reaches +// the end of the file, counts as fully downloaded. +func TestIsSecretChatFileFullyDownloaded(t *testing.T) { + cases := []struct { + name string + key string + offset int64 + chunkLen int + total int64 + want bool + }{ + {"secret chat, last chunk reaches exact end", "enc:123", 900, 100, 1000, true}, + {"secret chat, last chunk overlaps past end", "enc:123", 950, 100, 1000, true}, + {"secret chat, still mid-file", "enc:123", 0, 100, 1000, false}, + {"secret chat, unknown total must never look done", "enc:123", 0, 100, 0, false}, + {"secret chat, negative total must never look done", "enc:123", 0, 100, -1, false}, + {"ordinary document key, even if it would otherwise look complete", "doc:123", 900, 100, 1000, false}, + {"ordinary photo key", "photo:123:x", 0, 1000, 1000, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isSecretChatFileFullyDownloaded(c.key, c.offset, c.chunkLen, c.total); got != c.want { + t.Fatalf("isSecretChatFileFullyDownloaded(%q, %d, %d, %d) = %v, want %v", + c.key, c.offset, c.chunkLen, c.total, got, c.want) + } + }) + } +} + func TestFileSaveErrMapsStorageCapacityWithoutFloodWait(t *testing.T) { err := fileSaveErr(domain.ErrStorageFull) if !tgerr.Is(err, "STORAGE_FULL") { diff --git a/internal/store/media.go b/internal/store/media.go index 6635e39c..517c1d6e 100644 --- a/internal/store/media.go +++ b/internal/store/media.go @@ -34,6 +34,12 @@ type MediaStore interface { // 供低磁盘空间守卫的周期性用量刷新使用(尤其是 s3 backend 的预算模式,没有 // 操作系统级"剩余空间"概念,只能靠这个累计值和配置的预算比较)。 SumFileBlobBytes(ctx context.Context) (int64, error) + // DeleteFileBlobRow deletes exactly one file_blobs row by its exact + // location_key -- no prefix matching (unlike the doc:/photo: sweep + // primitives, since callers of this method, e.g. secret-chat + // delete-after-download, already know the precise key and it never has + // sibling thumbnail/rendition rows to also clean up). + DeleteFileBlobRow(ctx context.Context, locationKey string) error // seed 状态。只记录静态资源 catalog 的内容 hash,用于启动时跳过未变化的重复导入; // 真实可服务性仍由 documents/file_blobs 校验保证,不能只相信这里的 hash。 diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index 860ea7e7..07777fa2 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -238,6 +238,12 @@ func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) erro }) } +// DeleteFileBlobRow deletes exactly one file_blobs row by its exact +// location_key -- see the store.MediaStore interface doc comment. +func (s *MediaStore) DeleteFileBlobRow(ctx context.Context, locationKey string) error { + return s.q.DeleteFileBlobRow(ctx, locationKey) +} + func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) { row, err := s.q.GetFileBlob(ctx, locationKey) if err != nil {