From c203bc11c0ce66114a715daf5fba2b5927e20d33 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Mon, 4 May 2026 10:44:34 +0330 Subject: [PATCH] feat(v2/ezytel): inline images as base64 data URIs by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetChannelInfo and GetChannelMessages now embed avatars and post images as data:image/jpeg;base64,… URIs directly in their JSON/HTML, so a client can render them with no second ProxyImage round-trip. Adds disable_inline_images=true on both requests as an opt-out that preserves the legacy "cache/.jpg" / "proxy.php?url=" form. Image fetches reuse the existing translate.goog front and disk cache; GetChannelMessages fans out to 8 workers per page. --- v2/ezytel/ezytel.pb.go | 64 ++++++++--- v2/ezytel/ezytel.proto | 28 ++++- v2/ezytel/ezytel_service_imp.go | 186 +++++++++++++++++++++++++++++--- v2/ezytel/ezytel_test.go | 109 +++++++++++++++++++ 4 files changed, 355 insertions(+), 32 deletions(-) diff --git a/v2/ezytel/ezytel.pb.go b/v2/ezytel/ezytel.pb.go index 5f9bce1ca..5a51d3340 100644 --- a/v2/ezytel/ezytel.pb.go +++ b/v2/ezytel/ezytel.pb.go @@ -29,9 +29,14 @@ type ChannelInfoRequest struct { ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` // Last post id the client has already read; used to compute the // unread badge (newmsg). 0 means "everything is unread". - LastRead int64 `protobuf:"varint,2,opt,name=last_read,json=lastRead,proto3" json:"last_read,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + LastRead int64 `protobuf:"varint,2,opt,name=last_read,json=lastRead,proto3" json:"last_read,omitempty"` + // By default avatar_path in the response carries a + // "data:image/jpeg;base64,…" URI ready to drop into . + // Set this to true to opt out and receive the legacy + // "cache/.jpg" server-side file path instead. + DisableInlineImages bool `protobuf:"varint,3,opt,name=disable_inline_images,json=disableInlineImages,proto3" json:"disable_inline_images,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChannelInfoRequest) Reset() { @@ -78,13 +83,22 @@ func (x *ChannelInfoRequest) GetLastRead() int64 { return 0 } +func (x *ChannelInfoRequest) GetDisableInlineImages() bool { + if x != nil { + return x.DisableInlineImages + } + return false +} + type ChannelInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Plain-text preview of the most recent post, or "فایل" if it is a media post. Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` - // Local cache path of the avatar JPEG, relative to the cache dir - // returned by GetCacheDir(). Empty if the avatar could not be fetched. + // By default this is a "data:image/jpeg;base64,…" URI ready for + // . When the request set disable_inline_images=true it is + // the legacy "cache/.jpg" file path relative to GetCacheDir(). + // Empty if the avatar could not be fetched. AvatarPath string `protobuf:"bytes,3,opt,name=avatar_path,json=avatarPath,proto3" json:"avatar_path,omitempty"` // Unix epoch seconds of the most recent post. Date int64 `protobuf:"varint,4,opt,name=date,proto3" json:"date,omitempty"` @@ -193,9 +207,15 @@ type ChannelMessagesRequest struct { ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` // 0 = latest page; otherwise the "before" cursor returned by Telegram // (the data-before attribute of messages_more_wrap). - Before int64 `protobuf:"varint,2,opt,name=before,proto3" json:"before,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Before int64 `protobuf:"varint,2,opt,name=before,proto3" json:"before,omitempty"` + // By default every image URL in the returned html and the + // channel_avatar field is a "data:image/jpeg;base64,…" URI ready + // to render. Set this to true to opt out: html and channel_avatar + // will both carry the legacy "proxy.php?url=" placeholder + // form, requiring per-image ProxyImage calls. + DisableInlineImages bool `protobuf:"varint,3,opt,name=disable_inline_images,json=disableInlineImages,proto3" json:"disable_inline_images,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChannelMessagesRequest) Reset() { @@ -242,13 +262,25 @@ func (x *ChannelMessagesRequest) GetBefore() int64 { return 0 } +func (x *ChannelMessagesRequest) GetDisableInlineImages() bool { + if x != nil { + return x.DisableInlineImages + } + return false +} + type ChannelMessagesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Pre-rendered HTML fragment ready to be injected into .main_block. - // Image URLs have been rewritten to call ProxyImage; dates have been - // converted to the Persian calendar. + // Dates have been converted to the Persian calendar. By default + // and background-image:url(...) carry inline + // "data:image/jpeg;base64,…" payloads; with + // disable_inline_images=true they carry "proxy.php?url=" + // placeholders. Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"` - // Channel avatar URL embedded in the header (only set when before == 0). + // Channel avatar embedded in the header (only set when before == 0). + // "data:image/jpeg;base64,…" by default, "proxy.php?url=" + // placeholder when disable_inline_images=true. ChannelAvatar string `protobuf:"bytes,2,opt,name=channel_avatar,json=channelAvatar,proto3" json:"channel_avatar,omitempty"` // Last post id seen on the page (mirrors the lastread_ cookie). LastPostId int64 `protobuf:"varint,3,opt,name=last_post_id,json=lastPostId,proto3" json:"last_post_id,omitempty"` @@ -511,11 +543,12 @@ var File_v2_ezytel_ezytel_proto protoreflect.FileDescriptor const file_v2_ezytel_ezytel_proto_rawDesc = "" + "\n" + - "\x16v2/ezytel/ezytel.proto\x12\x06ezytel\"P\n" + + "\x16v2/ezytel/ezytel.proto\x12\x06ezytel\"\x84\x01\n" + "\x12ChannelInfoRequest\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x1b\n" + - "\tlast_read\x18\x02 \x01(\x03R\blastRead\"\xdd\x01\n" + + "\tlast_read\x18\x02 \x01(\x03R\blastRead\x122\n" + + "\x15disable_inline_images\x18\x03 \x01(\bR\x13disableInlineImages\"\xdd\x01\n" + "\vChannelInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1f\n" + @@ -526,11 +559,12 @@ const file_v2_ezytel_ezytel_proto_rawDesc = "" + "\x06newmsg\x18\x06 \x01(\tR\x06newmsg\x12 \n" + "\flast_post_id\x18\a \x01(\x03R\n" + "lastPostId\x12\x0e\n" + - "\x02ok\x18\b \x01(\bR\x02ok\"O\n" + + "\x02ok\x18\b \x01(\bR\x02ok\"\x83\x01\n" + "\x16ChannelMessagesRequest\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x16\n" + - "\x06before\x18\x02 \x01(\x03R\x06before\"v\n" + + "\x06before\x18\x02 \x01(\x03R\x06before\x122\n" + + "\x15disable_inline_images\x18\x03 \x01(\bR\x13disableInlineImages\"v\n" + "\x17ChannelMessagesResponse\x12\x12\n" + "\x04html\x18\x01 \x01(\tR\x04html\x12%\n" + "\x0echannel_avatar\x18\x02 \x01(\tR\rchannelAvatar\x12 \n" + diff --git a/v2/ezytel/ezytel.proto b/v2/ezytel/ezytel.proto index a0d9e49a0..21a68ac40 100644 --- a/v2/ezytel/ezytel.proto +++ b/v2/ezytel/ezytel.proto @@ -12,14 +12,21 @@ message ChannelInfoRequest { // Last post id the client has already read; used to compute the // unread badge (newmsg). 0 means "everything is unread". int64 last_read = 2; + // By default avatar_path in the response carries a + // "data:image/jpeg;base64,…" URI ready to drop into . + // Set this to true to opt out and receive the legacy + // "cache/.jpg" server-side file path instead. + bool disable_inline_images = 3; } message ChannelInfo { string name = 1; // Plain-text preview of the most recent post, or "فایل" if it is a media post. string description = 2; - // Local cache path of the avatar JPEG, relative to the cache dir - // returned by GetCacheDir(). Empty if the avatar could not be fetched. + // By default this is a "data:image/jpeg;base64,…" URI ready for + // . When the request set disable_inline_images=true it is + // the legacy "cache/.jpg" file path relative to GetCacheDir(). + // Empty if the avatar could not be fetched. string avatar_path = 3; // Unix epoch seconds of the most recent post. int64 date = 4; @@ -40,14 +47,25 @@ message ChannelMessagesRequest { // 0 = latest page; otherwise the "before" cursor returned by Telegram // (the data-before attribute of messages_more_wrap). int64 before = 2; + // By default every image URL in the returned html and the + // channel_avatar field is a "data:image/jpeg;base64,…" URI ready + // to render. Set this to true to opt out: html and channel_avatar + // will both carry the legacy "proxy.php?url=" placeholder + // form, requiring per-image ProxyImage calls. + bool disable_inline_images = 3; } message ChannelMessagesResponse { // Pre-rendered HTML fragment ready to be injected into .main_block. - // Image URLs have been rewritten to call ProxyImage; dates have been - // converted to the Persian calendar. + // Dates have been converted to the Persian calendar. By default + // and background-image:url(...) carry inline + // "data:image/jpeg;base64,…" payloads; with + // disable_inline_images=true they carry "proxy.php?url=" + // placeholders. string html = 1; - // Channel avatar URL embedded in the header (only set when before == 0). + // Channel avatar embedded in the header (only set when before == 0). + // "data:image/jpeg;base64,…" by default, "proxy.php?url=" + // placeholder when disable_inline_images=true. string channel_avatar = 2; // Last post id seen on the page (mirrors the lastread_ cookie). int64 last_post_id = 3; diff --git a/v2/ezytel/ezytel_service_imp.go b/v2/ezytel/ezytel_service_imp.go index 8b67cb76f..4438b5351 100644 --- a/v2/ezytel/ezytel_service_imp.go +++ b/v2/ezytel/ezytel_service_imp.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "crypto/tls" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -134,10 +135,123 @@ func (s *EzytelService) GetChannelMessages(ctx context.Context, in *ChannelMessa } } + if !in.DisableInlineImages { + // chanPic at this point is "proxy.php?url=" because it + // was captured after rewriteImgSources. Resolve it once so + // resp.ChannelAvatar is also a data: URI; the same placeholder + // inside content gets substituted by inlineProxyPlaceholders. + if chanPicURI := s.inlineProxyPlaceholder(ctx, chanPic); chanPicURI != "" { + resp.ChannelAvatar = chanPicURI + } + content = s.inlineProxyPlaceholders(ctx, content) + } + resp.Html = content return resp, nil } +// inlineProxyPlaceholder resolves a single "proxy.php?url=" +// reference into a data: URI. Returns "" on any failure (caller +// should keep the original value). +func (s *EzytelService) inlineProxyPlaceholder(ctx context.Context, ref string) string { + const prefix = "proxy.php?url=" + idx := strings.Index(ref, prefix) + if idx < 0 { + return "" + } + hexStr := ref[idx+len(prefix):] + raw, err := hex.DecodeString(hexStr) + if err != nil { + return "" + } + data, _, ferr := s.fetchImageBytes(ctx, "https://"+string(raw)) + if ferr != nil { + return "" + } + return dataURL(data, "image/jpeg") +} + +// inlineProxyPlaceholders walks the post-rewrite HTML and replaces every +// "proxy.php?url=" reference with a "data:image/jpeg;base64,…" +// URI. Fetches run in a bounded worker pool so a 20-image page does not +// serialise downloads. On per-image failure the placeholder is left +// alone so the rest of the page still renders. +func (s *EzytelService) inlineProxyPlaceholders(ctx context.Context, content string) string { + hexes := extractProxyHexes(content) + if len(hexes) == 0 { + return content + } + type result struct { + hex string + uri string + } + jobs := make(chan string, len(hexes)) + results := make(chan result, len(hexes)) + const workers = 8 + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for h := range jobs { + raw, err := hex.DecodeString(h) + if err != nil { + continue + } + data, _, ferr := s.fetchImageBytes(ctx, "https://"+string(raw)) + if ferr != nil { + continue + } + results <- result{hex: h, uri: dataURL(data, "image/jpeg")} + } + }() + } + for h := range hexes { + jobs <- h + } + close(jobs) + wg.Wait() + close(results) + + pairs := make([]string, 0, 2*len(hexes)) + for r := range results { + pairs = append(pairs, "proxy.php?url="+r.hex, r.uri) + } + if len(pairs) == 0 { + return content + } + return strings.NewReplacer(pairs...).Replace(content) +} + +// extractProxyHexes returns the set of distinct hex bodies referenced +// by "proxy.php?url=" in s. The hex token is the run of [0-9a-f] +// (case-insensitive) immediately after the prefix. +func extractProxyHexes(s string) map[string]struct{} { + out := map[string]struct{}{} + const prefix = "proxy.php?url=" + i := 0 + for { + idx := strings.Index(s[i:], prefix) + if idx < 0 { + return out + } + start := i + idx + len(prefix) + end := start + for end < len(s) { + c := s[end] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') { + end++ + continue + } + break + } + if end > start { + out[strings.ToLower(s[start:end])] = struct{}{} + } + i = end + } +} + // --------------------------------------------------------------------------- // RPC: GetChannelInfo — port of json_info() in libs.php. // --------------------------------------------------------------------------- @@ -159,6 +273,15 @@ func (s *EzytelService) GetChannelInfo(ctx context.Context, in *ChannelInfoReque if jerr := json.Unmarshal(data, &snap); jerr == nil { snap.Newmsg = "OFF" snap.Ok = false + // Snapshot stores the file-path form. Re-encode to a + // data: URI when the caller wants inline images. + if !in.DisableInlineImages && strings.HasPrefix(snap.AvatarPath, "cache/") { + if b, rerr := os.ReadFile(filepath.Join(s.cacheDir, strings.TrimPrefix(snap.AvatarPath, "cache/"))); rerr == nil { + snap.AvatarPath = dataURL(b, "image/jpeg") + } else { + snap.AvatarPath = "" + } + } return &snap, nil } } @@ -175,9 +298,12 @@ func (s *EzytelService) GetChannelInfo(ctx context.Context, in *ChannelInfoReque pic := strFind(html, []string{`.jpg" cache filename. Shared by ProxyImage, GetChannelInfo +// and GetChannelMessages so all three reuse the same cache. +func (s *EzytelService) fetchImageBytes(ctx context.Context, src string) ([]byte, string, error) { + hash := md5sum(strings.TrimPrefix(strings.TrimPrefix(src, "https://"), "http://")) + cacheName := hash + ".jpg" + if err := s.curlDownload(ctx, src, cacheName); err != nil { + return nil, cacheName, err + } + data, err := os.ReadFile(filepath.Join(s.cacheDir, cacheName)) + if err != nil { + return nil, cacheName, err + } + return data, cacheName, nil +} + +// dataURL builds a "data:;base64," URI ready to drop into +// . Empty contentType defaults to image/jpeg. +func dataURL(data []byte, contentType string) string { + if contentType == "" { + contentType = "image/jpeg" + } + return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data) +} + // --------------------------------------------------------------------------- // RPC: ParseChannels — port of file_parse() in libs.php. // --------------------------------------------------------------------------- diff --git a/v2/ezytel/ezytel_test.go b/v2/ezytel/ezytel_test.go index fcd200e13..7056a4472 100644 --- a/v2/ezytel/ezytel_test.go +++ b/v2/ezytel/ezytel_test.go @@ -1,8 +1,14 @@ package ezytel import ( + "bytes" "context" + "encoding/base64" "encoding/hex" + "io" + "net/http" + "strings" + "sync/atomic" "testing" "time" ) @@ -86,6 +92,109 @@ func TestStripTags(t *testing.T) { } } +// minimal 1x1 jpeg used by the stub transport +var fakeJPEG = []byte{ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, +} + +type stubRT struct { + body []byte + count int32 +} + +func (s *stubRT) RoundTrip(_ *http.Request) (*http.Response, error) { + atomic.AddInt32(&s.count, 1) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(s.body)), + Header: http.Header{}, + }, nil +} + +func TestDataURL(t *testing.T) { + got := dataURL([]byte{0xff, 0xd8, 0xff}, "") + want := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte{0xff, 0xd8, 0xff}) + if got != want { + t.Fatalf("dataURL: got %q want %q", got, want) + } +} + +func TestExtractProxyHexes(t *testing.T) { + html := ` and url('proxy.php?url=cafe01') and proxy.php?url=` // trailing empty + hexes := extractProxyHexes(html) + if _, ok := hexes["deadbeef"]; !ok { + t.Fatalf("missing deadbeef in %v", hexes) + } + if _, ok := hexes["cafe01"]; !ok { + t.Fatalf("missing cafe01 in %v", hexes) + } + if len(hexes) != 2 { + t.Fatalf("unexpected entries: %v", hexes) + } +} + +func TestInlineProxyPlaceholders(t *testing.T) { + s := NewEzytelService(t.TempDir()) + rt := &stubRT{body: fakeJPEG} + s.client = &http.Client{Transport: rt} + + hexedA := hex.EncodeToString([]byte("cdn.example.com/a.jpg")) + hexedB := hex.EncodeToString([]byte("cdn.example.com/b.jpg")) + html := `` + + out := s.inlineProxyPlaceholders(context.Background(), html) + if strings.Contains(out, "proxy.php?url=") { + t.Fatalf("placeholders not replaced: %s", out) + } + want := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(fakeJPEG) + if !strings.Contains(out, want) { + t.Fatalf("data URI missing in output: %s", out) + } + if got := atomic.LoadInt32(&rt.count); got != 2 { + t.Fatalf("expected 2 fetches, got %d", got) + } +} + +func TestDisableInlineImagesNoFetch(t *testing.T) { + s := NewEzytelService(t.TempDir()) + rt := &stubRT{body: fakeJPEG} + s.client = &http.Client{Transport: rt} + hexed := hex.EncodeToString([]byte("cdn.example.com/a.jpg")) + html := `` + + // Mirror the legacy short-circuit in GetChannelMessages: when the + // flag is true, inlineProxyPlaceholders is not called at all and + // no fetches happen. + in := &ChannelMessagesRequest{ChannelId: "x", DisableInlineImages: true} + if !in.DisableInlineImages { + t.Fatal("flag setup wrong") + } + if got := atomic.LoadInt32(&rt.count); got != 0 { + t.Fatalf("unexpected fetch before any work: %d", got) + } + if !strings.Contains(html, "proxy.php?url="+hexed) { + t.Fatal("placeholder missing in fixture") + } + if strings.Contains(html, "data:image") { + t.Fatalf("legacy html should not contain data: URI: %s", html) + } +} + +func TestInlineProxyPlaceholderSingle(t *testing.T) { + s := NewEzytelService(t.TempDir()) + s.client = &http.Client{Transport: &stubRT{body: fakeJPEG}} + hexed := hex.EncodeToString([]byte("cdn.example.com/avatar.jpg")) + got := s.inlineProxyPlaceholder(context.Background(), "proxy.php?url="+hexed) + want := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(fakeJPEG) + if got != want { + t.Fatalf("got %q want %q", got, want) + } + if got := s.inlineProxyPlaceholder(context.Background(), "https://no.placeholder/x"); got != "" { + t.Fatalf("expected empty for non-placeholder, got %q", got) + } +} + func contains(s, sub string) bool { return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) }