A simple TG image hosting system, cobbled together out of boredom while tinkering with navigation

[login]
image
image

Includes an admin panel where you can delete images after logging in, as well as API functionality. You can simply have an AI integrate it into your own site.

It was entirely made with AI, so please don’t be too harsh. It has worked well after several days of testing.

I originally planned to deploy it on CF, but thought images couldn’t be stored permanently, so I changed it. After finishing, I discovered that images apparently can be stored permanently on CF after all, haha.

ImgTG — Telegram Image Hosting System

An image hosting service based on private Telegram channels, supporting API Key authentication and image uploads from any website/app.

Architecture

User/APP ──[API+Key]──→ Backend ──[Upload]──→ Telegram private channel (permanent storage)
                                      │
Browser ──[Access image]──→ Backend ──[Proxy]──→ Telegram CDN
  • Storage: Telegram private channel; Telegram stores files permanently for free
  • Access: The backend reverse-proxies Telegram files and returns publicly accessible URLs
  • Authentication: API Key (X-API-Key header or ?key= parameter); third parties can upload using the Key

I. Deployment Instructions

1. Preparation

Item Action
Telegram Bot Find @BotFather, create a bot, and obtain its token
Private channel Create a private channel and add the bot as a channel administrator
Channel ID Use @userinfobot to look up the channel ID (format: -100xxxxxxxxxx)

2. Installation

# Clone/upload the code to the server
cd /www/Node/tgimg
npm install

3. Configure .env

cp .env.example .env
TG_BOT_TOKEN=123456:ABC-DEF...           # Bot Token
TG_CHAT_ID=-1001234567890                # Private channel ID
BASIC_USER=admin                         # Admin panel username
BASIC_PASS=你的管理密码                   # Admin panel password
PORT=25741                               # Backend listening port (use a high port to avoid requiring root privileges)
BASE_URL=https://img.05.si               # Public domain (required; same-domain Key exemption depends on it)
SSL_KEY_PATH=                            # Leave blank = HTTP mode (use CF for HTTPS)
SSL_CERT_PATH=

BASE_URL must be filled in correctly: After reverse proxying, the host header received by the backend is an internal address. The domain in BASE_URL is used for same-domain Key exemption checks.

4. Start

node server.js
# Or use PM2 to run it as a daemon
pm2 start server.js --name imgtg
pm2 save

5. Reverse Proxy Configuration (Baota Panel + Cloudflare)

Three-layer architecture (important: each layer has its own role; no loop):

Browser (HTTPS) → CF edge (Always Use HTTPS) → Server:80 (HTTP) → Baota reverse proxy → 127.0.0.1:25741 (HTTP) → Backend

Cloudflare Dashboard

Setting Value
DNS A record img → server IP, orange cloud (proxy enabled)
SSL/TLS Set encryption mode to Flexible
Always Use HTTPS On (HTTP→HTTPS redirection is handled at the CF edge)

Baota Panel

Action Description
Add website img.05.si Bind the domain
Reverse proxy Target: http://127.0.0.1:25741
Force HTTPS Must be turned off ← the most important step

Why Baota’s “Force HTTPS” must be turned off: Baota performs the HTTP→HTTPS redirect at the reverse-proxy layer, which conflicts with the proxy itself and causes an infinite Too many redirects loop. Move the HTTPS redirect to the CF edge (Always Use HTTPS); Baota should only perform pure reverse proxying.

6. Verification

# Being able to open the upload page means deployment succeeded
curl http://img.05.si/

Visit https://img.05.si/admin and enter BASIC_USER / BASIC_PASS to access the admin panel, where you can manage API Keys and images.


II. API Usage Instructions

Basic Information

Item Value
Upload URL https://img.05.si/upload or https://img.05.si/api/upload
Image access https://img.05.si/image/{id} (public, no Key required)
Authentication X-API-Key request header or ?key= URL parameter
Same-domain Key exemption Uploading from the img.05.si site itself requires no Key (based on the BASE_URL domain)

1. Upload an Image

Request:

POST /upload
Content-Type: multipart/form-data
X-API-Key: sk-xxxxxxxxxxxx
Parameter Type Description
file File Image/video file (JPG/PNG/GIF/WebP/SVG/BMP/MP4/WebM, maximum 50 MB)

Response:

{
  "success": true,
  "image_id": "a1b2c3d4e5f6",
  "url": "https://img.05.si/image/a1b2c3d4e5f6",
  "is_video": false,
  "markdown": "![image](https://img.05.si/image/a1b2c3d4e5f6)",
  "html": "<img src=\"https://img.05.si/image/a1b2c3d4e5f6\" alt=\"image\">",
  "bbcode": "[img]https://img.05.si/image/a1b2c3d4e5f6[/img]"
}

Errors:

Status code Description
403 Invalid or missing API Key
400 No file uploaded
413 File exceeds 50 MB
500 Upload failed (Telegram API error)

2. Access an Image (Public)

GET /image/{id}

Returns the image binary directly and can be used in <img src>. No API Key is required.

3. Examples in Various Languages

cURL

curl -X POST https://img.05.si/upload \
  -H "X-API-Key: sk-xxxxxxxxxxxx" \
  -F "file=@/path/to/image.jpg"

JavaScript (Browser / fetch)

async function uploadImage(file) {
  const formData = new FormData();
  formData.append('file', file);

  const res = await fetch('https://img.05.si/upload', {
    method: 'POST',
    headers: { 'X-API-Key': 'sk-xxxxxxxxxxxx' },
    body: formData,
  });

  const data = await res.json();
  if (data.success) {
    return data.url;  // https://img.05.si/image/xxxx
  }
  throw new Error(data.error);
}

JavaScript (XHR with Progress)

function uploadImage(file, onProgress) {
  return new Promise((resolve, reject) => {
    const fd = new FormData();
    fd.append('file', file);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://img.05.si/upload');
    xhr.setRequestHeader('X-API-Key', 'sk-xxxxxxxxxxxx');
    xhr.upload.onprogress = e => {
      if (e.lengthComputable && onProgress) {
        onProgress(Math.round(e.loaded / e.total * 100));
      }
    };
    xhr.onload = () => {
      const d = JSON.parse(xhr.responseText);
      d.success ? resolve(d.url) : reject(new Error(d.error));
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send(fd);
  });
}

Python

import requests

def upload_image(file_path, api_key='sk-xxxxxxxxxxxx'):
    with open(file_path, 'rb') as f:
        res = requests.post(
            'https://img.05.si/upload',
            headers={'X-API-Key': api_key},
            files={'file': f}
        )
    data = res.json()
    if data['success']:
        return data['url']
    raise Exception(data['error'])

PHP

function uploadImage($filePath, $apiKey = 'sk-xxxxxxxxxxxx') {
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL => 'https://img.05.si/upload',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ["X-API-Key: $apiKey"],
        CURLOPT_POSTFIELDS => ['file' => new CURLFile($filePath)],
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $res = curl_exec($ch);
    curl_close($ch);
    $data = json_decode($res, true);
    return $data['success'] ? $data['url'] : throw new Exception($data['error']);
}

Java

import java.io.*;
import java.net.*;

public class ImgTG {
    public static String upload(File file, String apiKey) throws Exception {
        String boundary = "----WebKitFormBoundary" + System.currentTimeMillis();
        HttpURLConnection conn = (HttpURLConnection)
            new URL("https://img.05.si/upload").openConnection();
        conn.setDoOutput(true);
        conn.setRequestMethod("POST");
        conn.setRequestProperty("X-API-Key", apiKey);
        conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);

        try (OutputStream os = conn.getOutputStream()) {
            os.write(("--" + boundary + "\r\n").getBytes());
            os.write("Content-Disposition: form-data; name=\"file\"; filename=\""
                + file.getName() + "\"\r\n\r\n".getBytes());
            try (FileInputStream fis = new FileInputStream(file)) {
                byte[] buf = new byte[4096]; int n;
                while ((n = fis.read(buf)) > 0) os.write(buf, 0, n);
            }
            os.write(("\r\n--" + boundary + "--\r\n").getBytes());
        }

        try (BufferedReader br = new BufferedReader(
                new InputStreamReader(conn.getInputStream()))) {
            String line = br.readLine(); // Returns JSON; parse it to extract the URL
            return line; // In practice, parse it with Gson/Jackson
        }
    }
}

HTML Form (Simplest)

<form action="https://img.05.si/upload" method="post" enctype="multipart/form-data">
  <input type="hidden" name="key" value="sk-xxxxxxxxxxxx">
  <input type="file" name="file">
  <button type="submit">上传</button>
</form>

4. API Key Management

After logging in to the admin panel at https://img.05.si/admin with Basic Auth (BASIC_USER/BASIC_PASS):

Action Endpoint
Create Key POST /api/keys body {"name":"备注"} → returns sk-xxx
List Keys GET /api/keys
Delete Key DELETE /api/keys/{key}
List images GET /api/images
Delete image DELETE /api/delete/{id}

III. Frequently Asked Questions

Q1: Upload returns Too many redirects

Cause: “Force HTTPS” is enabled in the Baota panel, causing a loop with the reverse proxy.
Solution: Baota panel → SSL → turn off Force HTTPS, and use Cloudflare’s Always Use HTTPS instead.

Q2: Uploading from my own website returns Invalid API key

Cause: BASE_URL is missing or incorrect in .env, disabling the same-domain Key exemption.
Solution: Make sure BASE_URL=https://your-domain is set.

Q3: Uploading from another website returns Invalid API key

This is normal. Other websites must include an API Key:

headers: { 'X-API-Key': 'sk-xxxxxxxxxxxx' }
// or
fetch('https://img.05.si/upload?key=sk-xxxxxxxxxxxx', ...)

Q4: Port 80 returns EACCES: permission denied

Cause: On Linux, non-root users cannot bind to ports below 1024.
Solution: Use a high port such as PORT=25741 in .env, then reverse-proxy 80→25741 through Baota.

Q5: Image access returns 404

Image URL format: https://img.05.si/image/{id}, where {id} is the image_id returned after upload. Confirm that the ID is correct and that data/db.json has not been accidentally deleted.

tgimg.zip (1.1 MB)

[/login]

3 Likes

I actually wrote an article about a Telegram image hosting system too, but with richer storage options.

Do share! :face_savoring_food:

2 Likes

Share it so we can have some fun.

1 Like

Check out my profile—the post titled “How to Get an Image Hosting Service (the Third of the Three Essentials for Building a Website)” was written by me (you can also search for it directly).

Search for “How to get an image hosting service (the third of the three website-building tools)” and then click my article.

ok

1 Like

I suggest searching directly; it’s faster.

Wait, didn’t you already see it? You voted for my post.

I always just save them for later. I’ll need to find some time to actually deploy them.

1 Like

Writing these posts is using up almost all of Cloudflare’s daily free quota (writing posts alone uses up half of it every day)…

Yes, CF has usage limits, so I switched to deploying it on a server.

1 Like

You can also use virtual hosting (unmetered).

Hearing you say that, you could put it on a virtual host—lots of serv00 hosts are just sitting unused. Back then, all I could think about was servers, haha.

Don’t link a card to the virtual host yet.

Actually, there’s also a similar project on GitHub. If you want to deploy it and play around, you can check out CloudFlare-ImgBed and use Cloudflare for free.

There are quite a few, but I mainly added the API myself. Integrating it into my own website basically means I no longer need a separate image host.

That’s amazing!