From 2dfa7eb70aad25adf5acea9a8a58e47228a3595b Mon Sep 17 00:00:00 2001 From: kakifilem Date: Fri, 13 Feb 2026 00:46:00 +0800 Subject: [PATCH] Initial commit --- .env.example | 20 ++++ .gitignore | 15 +++ CONTRIBUTING.md | 53 +++++++++++ Dockerfile | 12 +++ LICENSE.md | 21 +++++ README.md | 185 +++++++++++++++++++++++++++++++++++++ SECURITY.md | 41 ++++++++ bot.py | 18 ++++ config.py | 43 +++++++++ handlers/__init__.py | 0 handlers/private_upload.py | 89 ++++++++++++++++++ handlers/start.py | 17 ++++ requirements.txt | 4 + s3/client.py | 37 ++++++++ utils/__init__.py | 0 utils/filenames.py | 12 +++ utils/helpers.py | 4 + 17 files changed, 571 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 bot.py create mode 100644 config.py create mode 100644 handlers/__init__.py create mode 100644 handlers/private_upload.py create mode 100644 handlers/start.py create mode 100644 requirements.txt create mode 100644 s3/client.py create mode 100644 utils/__init__.py create mode 100644 utils/filenames.py create mode 100644 utils/helpers.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dcfcfb5 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Telegram +API_ID=123456 +API_HASH=your_api_hash_here +BOT_TOKEN=your_bot_token_here + +# Allowed Telegram user IDs (comma-separated) +ALLOWED_USERS=123456789 + +# S3 / S3-Compatible +S3_ENDPOINT= +S3_REGION=us-east-1 +S3_ACCESS_KEY=your_access_key +S3_SECRET_KEY=your_secret_key +S3_BUCKET=your_bucket_name + +# Return presigned download link after upload +ENABLE_PRESIGNED_URL=true + +# Presigned URL expiration (seconds) +PRESIGNED_EXPIRE_SECONDS=3600 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7495464 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env + +__pycache__/ +*.pyc +*.pyo +*.pyd + +.venv/ +venv/ + +downloads/* +logs/ + +.DS_Store +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c3eccce --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +Contributions are welcome πŸ™‚ + +If you have an idea, improvement, or bug fix, feel free to jump in. + +--- + +## How to contribute + +1. Fork the repository +2. Create a branch for your change +3. Make your changes +4. Open a pull request + +That’s all. + +--- + +## A few guidelines + +- Keep changes small and focused +- Follow the existing code style where possible +- Don’t commit secrets or credentials +- Test your changes before opening a PR +- Keep features within the scope of **private file upload and backup** + +--- + +## Project scope + +This project is meant for: +- Personal backups +- Private file storage +- Archiving Telegram content you own or manage + +PRs that go beyond this project’s scope may be declined to keep things simple and focused. + +--- + +## Ideas to work on + +These are possible future improvements, not a fixed roadmap: + +- Channel backup support +- Streaming uploads (no local disk) +- File deduplication +- Simple admin commands +- Better logging or error handling + +--- + +Thanks for taking the time to contribute! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab8adac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "bot.py"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..11618b8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..11aef40 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# Telegram β†’ S3 Uploader Bot + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Platform](https://img.shields.io/badge/platform-Telegram-blue) +![Python](https://img.shields.io/badge/python-3.13%2B-blue) +![Framework](https://img.shields.io/badge/framework-Pyrofork%20(async)-green) +![Deploy](https://img.shields.io/badge/deploy-Railway-purple) +![Docker](https://img.shields.io/badge/docker-supported-blue) + +A simple, private Telegram bot that uploads files to **any S3-compatible storage** +(AWS S3, Cloudflare R2, MinIO, etc.) and returns a **temporary presigned download link**. + +This project is designed to be: +- Simple +- Safe by default +- Easy to deploy +- Easy to extend + +--- + +## ✨ Features + +- Upload files from Telegram to S3-compatible storage +- Supports documents, videos, audio, and photos +- Private bucket (no public access required) +- Temporary **presigned download links** +- User allowlist (private bot) +- Works on local machine, VPS, Docker, and Railway +- Clean, minimal codebase + +--- + +## πŸ“¦ Supported Storage + +This bot works with any S3-compatible provider, including: + +- AWS S3 +- Cloudflare R2 +- MinIO (local NAS) +- Wasabi +- DigitalOcean Spaces +- Backblaze B2 (S3 API) + +--- + +## πŸ” Security Model + +- Files are uploaded to a **private bucket** +- Access is granted via **temporary presigned URLs** +- Links expire automatically +- Storage credentials are never exposed to users +- Bot usage can be restricted with an allowlist +- Local files are stored temporarily and cleaned up automatically + +This bot is intended for **private file upload and backup use cases**. + +--- + +## πŸš€ Deployment + +### β–Ά Deploy on Railway + +You can deploy this bot directly to Railway: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/telegram-s3-uploader?referralCode=nIQTyp&utm_medium=integration&utm_source=template&utm_campaign=generic) + +After deployment, set the required environment variables in the Railway dashboard. + +> When deployed on Railway, this template attaches a Railway Bucket by default. +> You can remove it and configure any other S3-compatible storage via environment variables. + +--- + +### β–Ά Run locally + +> Python 3.13 is supported. Python 3.11+ is recommended for widest compatibility. + +#### 1. Clone the repository +```bash +git clone https://github.com/BigDaddyAman/telegram-s3-uploader +cd telegram-s3-uploader +``` + +#### 2. Create virtual environment +```bash +python3.13 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +#### 3. Install dependencies +```bash +pip install --upgrade pip +pip install -r requirements.txt +``` + +#### 4. Configure environment + +Copy .env.example to .env and fill in your values: + +```bash +cp .env.example .env +``` + +#### 5. Run the bot +```bash +python bot.py +``` + +--- + +### β–Ά Run with Docker (build locally) + +```bash +docker build -t telegram-s3-uploader . +docker run --env-file .env telegram-s3-uploader +``` + +--- + +## βš™οΈ Environment Variables + +| Variable | Description | +|----------|-------------| +| API_ID | Telegram API ID | +| API_HASH | Telegram API hash | +| BOT_TOKEN | Telegram bot token | +| ALLOWED_USERS | Allowed Telegram user IDs (comma-separated) | +| S3_ENDPOINT | Custom S3 endpoint (leave empty for AWS) | +| S3_REGION | S3 region (required for AWS) | +| S3_ACCESS_KEY | S3 access key | +| S3_SECRET_KEY | S3 secret key | +| S3_BUCKET | Bucket name | +| PRESIGNED_EXPIRE_SECONDS | Presigned link expiration time (seconds) | +| ENABLE_PRESIGNED_URL | Enable or disable presigned download links (`true` / `false`) | + +--- + +## πŸ“ File Storage Layout + +Uploaded files are stored using a simple structure: + +> Files are downloaded to a temporary local directory and removed automatically after upload. + +``` +users/ +└── / + └── YYYYMMDD_HHMMSS_filename.ext +``` + +This makes the storage easy to browse and suitable for backups. + +--- + +## 🧭 Project Scope + +This project is intentionally kept small and focused. + +It is meant for: + +- Personal backups +- Private file storage +- Archiving Telegram content you own or manage + +Features related to public distribution, streaming, or content sharing are out of scope. + +--- + +## 🀝 Contributing + +Contributions are welcome! + +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and project scope. + +--- + +## πŸ” Security + +If you discover a security issue, please follow the instructions in [SECURITY.md](SECURITY.md). + +--- + +## πŸ“„ License + +This project is licensed under the MIT License. +See [LICENSE](LICENSE) for details. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..09d87ac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Supported versions + +Only the latest version of this project is supported. + +If you’re running an older version, please update before reporting issues. + +--- + +## Reporting a security issue + +If you discover a security issue, please **do not open a public issue**. + +Instead: +- Open a GitHub Security Advisory + or +- Contact the maintainer privately (if contact info is available) + +This helps prevent accidental exposure while the issue is being fixed. + +--- + +## Security notes + +- This bot uploads files to **private S3-compatible storage** +- Files are accessed using **temporary presigned URLs** +- Storage credentials are never shared with Telegram users +- Bot access can be restricted using an allowlist +- No public access is enabled by default + +--- + +## Responsibility + +You are responsible for: +- Protecting your credentials +- Controlling who can use your bot +- Running the bot in an environment you trust + +This project is provided as-is and is intended for **private file upload and backup use cases**. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..eecd97d --- /dev/null +++ b/bot.py @@ -0,0 +1,18 @@ +from pyrogram import Client +from config import API_ID, API_HASH, BOT_TOKEN +from handlers.private_upload import handle_private_upload +from handlers.start import handle_start + +app = Client( + "telegram-s3-uploader", + api_id=API_ID, + api_hash=API_HASH, + bot_token=BOT_TOKEN, +) + +handle_start(app) +handle_private_upload(app) +handle_private_upload(app) + +print("πŸš€ Bot started") +app.run() diff --git a/config.py b/config.py new file mode 100644 index 0000000..a2e32c1 --- /dev/null +++ b/config.py @@ -0,0 +1,43 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +API_ID = int(os.getenv("API_ID")) +API_HASH = os.getenv("API_HASH") +BOT_TOKEN = os.getenv("BOT_TOKEN") + +ALLOWED_USERS = os.getenv("ALLOWED_USERS", "") +if ALLOWED_USERS: + ALLOWED_USERS = {int(x.strip()) for x in ALLOWED_USERS.split(",")} +else: + ALLOWED_USERS = set() + +S3_ENDPOINT = os.getenv("S3_ENDPOINT") +S3_REGION = os.getenv("S3_REGION") +S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY") +S3_SECRET_KEY = os.getenv("S3_SECRET_KEY") +S3_BUCKET = os.getenv("S3_BUCKET") + +DOWNLOAD_DIR = "downloads" + +ENABLE_PRESIGNED_URL = os.getenv( + "ENABLE_PRESIGNED_URL", "true" +).lower() == "true" + +PRESIGNED_EXPIRE_SECONDS = int( + os.getenv("PRESIGNED_EXPIRE_SECONDS", "3600") +) + +required = [ + "API_ID", + "API_HASH", + "BOT_TOKEN", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", +] + +for var in required: + if not globals().get(var): + raise RuntimeError(f"Missing env var: {var}") diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/private_upload.py b/handlers/private_upload.py new file mode 100644 index 0000000..8ed4c01 --- /dev/null +++ b/handlers/private_upload.py @@ -0,0 +1,89 @@ +import os +import time +import shutil +from pyrogram import filters + +from s3.client import get_s3_client, generate_presigned_url +from utils.filenames import sanitize_filename +from utils.helpers import ensure_dir +from config import ( + DOWNLOAD_DIR, + S3_BUCKET, + ALLOWED_USERS, + ENABLE_PRESIGNED_URL, + PRESIGNED_EXPIRE_SECONDS, +) + +s3 = get_s3_client() + + +def handle_private_upload(app): + + @app.on_message( + filters.private + & (filters.document | filters.video | filters.audio | filters.photo) + ) + async def upload_handler(client, message): + user_id = message.from_user.id + + if ALLOWED_USERS and user_id not in ALLOWED_USERS: + await message.reply("β›” You are not allowed to use this bot.") + return + + status = await message.reply("πŸ“₯ Downloading...") + + ensure_dir(DOWNLOAD_DIR) + temp_dir = os.path.join(DOWNLOAD_DIR, f"{user_id}_{message.id}") + ensure_dir(temp_dir) + + try: + file = ( + message.document + or message.video + or message.audio + or message.photo + ) + + if file and getattr(file, "file_name", None): + ts = time.strftime("%Y%m%d_%H%M%S") + filename = f"{ts}_{sanitize_filename(file.file_name)}" + else: + if message.photo: + filename = sanitize_filename(None, fallback_ext=".jpg") + else: + filename = sanitize_filename(None) + + local_path = os.path.join(temp_dir, filename) + + file_path = await message.download(file_name=local_path) + + s3_key = f"users/{user_id}/{filename}" + + await status.edit("☁️ Uploading to storage...") + s3.upload_file(file_path, S3_BUCKET, s3_key) + + if ENABLE_PRESIGNED_URL: + presigned_url = generate_presigned_url( + s3=s3, + bucket=S3_BUCKET, + key=s3_key, + expires=PRESIGNED_EXPIRE_SECONDS, + ) + + minutes = PRESIGNED_EXPIRE_SECONDS // 60 + + await status.edit( + f"βœ… Uploaded!\n\n" + f"πŸ”— Download link (expires in {minutes} min):\n" + f"{presigned_url}" + ) + else: + await status.edit( + f"βœ… Uploaded!\n\n" + f"πŸ—‚ Stored at:\n" + f"`{s3_key}`" + ) + + finally: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/handlers/start.py b/handlers/start.py new file mode 100644 index 0000000..3b1a613 --- /dev/null +++ b/handlers/start.py @@ -0,0 +1,17 @@ +from pyrogram import filters +from config import ALLOWED_USERS + +def handle_start(app): + + @app.on_message(filters.command("start") & filters.private) + async def start_handler(client, message): + user_id = message.from_user.id + + if user_id not in ALLOWED_USERS: + await message.reply("β›” You are not allowed to use this bot.") + return + + await message.reply( + "βœ… Bot is alive!\n\n" + "πŸ“€ Send me any file and I will upload it to storage." + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9fe1645 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pyrofork==2.3.69 +TgCrypto-pyrofork==1.2.8 +boto3==1.42.47 +python-dotenv==1.2.1 diff --git a/s3/client.py b/s3/client.py new file mode 100644 index 0000000..fe0e6df --- /dev/null +++ b/s3/client.py @@ -0,0 +1,37 @@ +import boto3 +from botocore.client import Config +from config import ( + S3_ENDPOINT, + S3_REGION, + S3_ACCESS_KEY, + S3_SECRET_KEY, +) + +def get_s3_client(): + kwargs = { + "aws_access_key_id": S3_ACCESS_KEY, + "aws_secret_access_key": S3_SECRET_KEY, + + "config": Config(signature_version="s3v4"), + } + + if S3_ENDPOINT: + kwargs["endpoint_url"] = S3_ENDPOINT + kwargs["region_name"] = S3_REGION or "us-east-1" + else: + if not S3_REGION: + raise RuntimeError("AWS S3 requires S3_REGION") + kwargs["region_name"] = S3_REGION + + return boto3.client("s3", **kwargs) + + +def generate_presigned_url(s3, bucket: str, key: str, expires: int): + return s3.generate_presigned_url( + ClientMethod="get_object", + Params={ + "Bucket": bucket, + "Key": key, + }, + ExpiresIn=expires, + ) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/filenames.py b/utils/filenames.py new file mode 100644 index 0000000..f756b29 --- /dev/null +++ b/utils/filenames.py @@ -0,0 +1,12 @@ +import os +import re +import time + +def sanitize_filename(name: str | None, fallback_ext: str = "") -> str: + if not name: + ts = time.strftime("%Y%m%d_%H%M%S") + return f"file_{ts}{fallback_ext}" + + name = os.path.basename(name) + name = re.sub(r"[^\w\-. ]", "_", name) + return name.strip() diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..70dc93a --- /dev/null +++ b/utils/helpers.py @@ -0,0 +1,4 @@ +import os + +def ensure_dir(path: str): + os.makedirs(path, exist_ok=True)