commit 143f19b1b78ca0b67f307d9efb6bd386bed1ae10 Author: Kaki Filem Team Date: Sat Jan 31 20:35:11 2026 +0800 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..23abc7f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.env +.env.* + +__pycache__/ +*.pyc + +uploads/ + +.venv/ +venv/ + +.git +.gitignore +.DS_Store +Thumbs.db + +docs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c38c7ed --- /dev/null +++ b/.env.example @@ -0,0 +1,146 @@ +# ========================= +# Telegram Bot +# ========================= + +# Get API_ID and API_HASH from: +# https://my.telegram.org -> Development Tools +API_ID=123456 +API_HASH=your_api_hash + +# Create a bot and get BOT_TOKEN from: +# https://t.me/BotFather +BOT_TOKEN=your_bot_token + + +# ========================= +# Database & Cache +# ========================= + +# PostgreSQL connection string +# Railway: Add a PostgreSQL plugin and copy DATABASE_URL +# Local: postgresql://user:password@localhost:5432/filelink +DATABASE_URL=postgresql://user:password@localhost:5432/filelink + +# Redis connection string +# Railway: Add a Redis plugin and copy REDIS_URL +# Local: redis://localhost:6379 +REDIS_URL=redis://localhost:6379 + + +# ========================= +# Public URL +# ========================= + +# Publicly accessible base URL +# +# Local development: +# BASE_URL=http://localhost:8000 +# +# Railway: +# BASE_URL=${RAILWAY_PUBLIC_DOMAIN} +# +# Custom domain: +# BASE_URL=https://files.example.com +# +# Note: +# - If no scheme is provided, https:// is assumed. +# - Railway public domains are always served over HTTPS. +# +BASE_URL=http://localhost:8000 + + +# ========================= +# Limits +# ========================= + +# Maximum file size accepted by the bot (in MB) +# Telegram limits still apply: +# - Free account: up to 2 GB +# - Telegram Premium: up to 4 GB +MAX_FILE_MB=4096 + +# Maximum number of uploads processed at the same time +# Prevents server overload and Telegram flood limits +# +# Recommended values: +# 1 = Local testing / debugging +# 2–3 = Railway or small VPS (recommended) +# 4+ = Large VPS (advanced users only) +# +# Does NOT limit downloads +MAX_CONCURRENT_TRANSFERS=3 + + +# ========================= +# Rate Limiting +# ========================= + +# Maximum number of requests per IP +GLOBAL_RATE_LIMIT_REQUESTS=60 + +# Time window in seconds +GLOBAL_RATE_LIMIT_WINDOW=10 + + +# ========================= +# Access Control (Optional) +# ========================= + +# Comma-separated Telegram user IDs allowed to use the bot +# Example: 123456789,987654321 +# Leave empty to allow everyone +ALLOWED_USER_IDS=123456789 + + +# ========================= +# Admin Dashboard (Optional) +# ========================= + +# Enable or disable admin dashboard entirely +ADMIN_ENABLED=true + +# Admin login credentials +# Used to access: /admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-me-now + +# Secret key used to sign admin session cookies +# Generate with: +# python -c "import secrets; print(secrets.token_urlsafe(32))" +SESSION_SECRET=change-this-to-a-long-random-string + +# ========================= +# Storage Backend (Optional) +# ========================= + +# Storage backend to use for uploaded files +# +# Available options: +# - local (default): store files in ./uploads +# - s3: store files in an S3-compatible bucket +# +# To enable S3-compatible storage: +STORAGE_BACKEND=local + +# ------------------------- +# S3-Compatible Storage +# ------------------------- +# +# This project supports S3-compatible object storage such as: +# - Railway Storage Buckets (recommended on Railway) +# - AWS S3 +# - Cloudflare R2 +# +# IMPORTANT: +# - On Railway, these variables are injected automatically +# when you connect a Storage Bucket to the service. +# - You do NOT need to set them manually on Railway. +# +# Uncomment and configure ONLY if you are using S3 outside Railway. +# +# AWS_ENDPOINT_URL=https://storage.example.com +# AWS_S3_BUCKET_NAME=your-bucket-name +# AWS_DEFAULT_REGION=auto +# AWS_ACCESS_KEY_ID=your-access-key +# AWS_SECRET_ACCESS_KEY=your-secret-key + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13f2d6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.env +.env.* +!.env.example + +__pycache__/ +*.pyc +*.pyo +*.pyd + +.venv/ +venv/ + +uploads/ + +.git/ +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0ade59e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing Guide + +Thanks for your interest in contributing! + +## How to Contribute + +You can help by: +- Reporting bugs +- Suggesting new features +- Improving documentation +- Submitting pull requests + +## Before Submitting a PR + +- Open an issue first for large changes +- Keep changes focused and minimal +- Follow the existing code style +- Test your changes locally + +## Feature Requests + +This project intentionally stays **simple and self-hosted**. + +Good feature ideas: +- Performance improvements +- UX improvements +- Security hardening +- Better error handling +- Documentation improvements + +Avoid: +- Torrent / mirror functionality +- Download-count based restrictions +- Anything violating platform ToS + +## Legal Notice + +You are responsible for ensuring your contribution does not introduce +illegal behavior or violate third-party terms of service. + +## License of Contributions + +By submitting a contribution, you agree that your contribution +will be licensed under the same license as the project +(Apache License 2.0). \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..724069e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --log-level warning"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a9b600 --- /dev/null +++ b/LICENSE @@ -0,0 +1,85 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable copyright license to reproduce, prepare + Derivative Works of, publicly display, publicly perform, sublicense, + and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable (except as stated in this section) patent + license to make, have made, use, offer to sell, sell, import, and + otherwise transfer the Work. + +4. Redistribution. + You may reproduce and distribute copies of the Work or Derivative Works + thereof in any medium, with or without modifications, and in Source or + Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that + You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work; and + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file. + +5. Submission of Contributions. +6. Trademarks. +7. Disclaimer of Warranty. +8. Limitation of Liability. +9. Accepting Warranty or Additional Liability. + +END OF TERMS AND CONDITIONS diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..cf9e90f --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +This project was created by Aman (2025). + +You may use, modify, and distribute this software +under the Apache License, Version 2.0. + +Any redistribution must retain this NOTICE file +and all copyright notices in source files. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa8b64 --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ +![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg) +![Platform](https://img.shields.io/badge/platform-Telegram-blue) +![Python](https://img.shields.io/badge/python-3.13-blue) +![Framework](https://img.shields.io/badge/FastAPI-async-green) +![Deploy](https://img.shields.io/badge/deploy-Railway-purple) +![Docker](https://img.shields.io/badge/docker-supported-blue) + +# πŸ“Ž Telegram File Link Bot + +A self-hosted **Telegram bot** that generates **secure, rate-limited download links** for uploaded files, with **time-based expiration (TTL)**, an **optional admin dashboard**, and **automatic cleanup**. + +The project can run **locally**, on a **VPS**, or on any cloud platform. +For the easiest setup, **Railway is recommended**. + +--- + +## πŸš€ Deployment (Railway – Recommended) + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/telegram-file-to-link-bot?referralCode=nIQTyp&utm_medium=integration&utm_source=template&utm_campaign=generic) + +This repository is designed to be deployed **directly as a Railway template**. + +### Why Railway? +- **Free public domain** (`*.railway.app`) included +- **Automatic HTTPS** (no SSL setup needed) +- **One-click PostgreSQL & Redis** +- **Easy environment variable management** +- **No server maintenance** or manual provisioning + +### Steps +1. Click **Deploy on Railway** +2. Railway will create a new project from this template +3. Add PostgreSQL and Redis plugins +4. Set the required environment variables +5. Optional: Enable Persistent Storage (Railway Buckets) +6. Deploy + +Your bot and download API will be live within minutes. + +> You can still deploy this project locally or on any VPS. + +--- + +## ✨ Features + +### πŸ€– Telegram Bot +- Upload files via Telegram and receive a public download link +- Supports documents, videos, audio, photos, animations, voice, and video notes +- Preserves original file quality +- Optional private bot mode (allowed user IDs) +- Built with Pyrogram / Pyrofork + +--- + +### πŸ”— File Links +- Unique file IDs +- Direct downloads via FastAPI +- Correct filenames and headers +- Supports HTTP range requests (206 Partial Content) + +--- + +### ⏳ Expiration (TTL Only) +- Optional expiration per file +- Time-based expiration only (no download limits) +- Unlimited downloads are always allowed +- Files expire automatically when TTL is reached + +--- + +### πŸ”„ Upload Concurrency Control +- Limits how many file uploads are processed at the same time +- Prevents server overload and Telegram flood limits +- Extra uploads are automatically queued +- Fully configurable via environment variables + +--- + +### βš™οΈ Mode System (TTL Control) + +TTL is controlled **per user** via Telegram commands. + +Examples: +- `/mode ttl 30` β†’ 30 minutes +- `/mode ttl 2h` β†’ 2 hours +- `/mode ttl 1d` β†’ 1 day +- `/mode ttl 0` or `/mode reset` β†’ Never expire + +TTL is stored internally in **seconds**. + +--- + +### πŸ“Š Admin Dashboard (Optional) + +Enabled only if `ADMIN_ENABLED=true`. + +Features: +- Secure session-based login +- View total files, downloads, and active files +- Search files by name +- Disable files (expire immediately) +- Delete files +- View top downloads, recent uploads, and expiring files +- Light / Dark mode toggle + +When enabled, the admin dashboard is available at: + +``` +https://your-domain.com/admin +``` + +> If `ADMIN_ENABLED=false`, the admin dashboard routes are not registered and the bot still works normally. + +--- + +## πŸ“Έ Screenshots + +### Admin Dashboard +![Admin Dashboard](docs/screenshots/admin-dashboard.jpg) + +### Telegram Upload +![Telegram Upload](docs/screenshots/telegram-upload.jpg) + +--- + +### 🧹 Automatic Cleanup +- Background task removes expired files +- Cleans database records and Redis cache +- Deletes expired objects from S3-compatible storage automatically +- Safe against partial failures + +--- + +### 🚦 Rate Limiting +- Global per-IP rate limiting +- Redis-backed +- Proper `Retry-After` headers + +--- + +## πŸ—„οΈ Storage Backends + +This project supports **two storage backends**, selectable via environment variables. + +### Local Filesystem (default) +- Files are stored in the `uploads/` directory +- Suitable for local development and small deployments +- Files are removed automatically when TTL expires + +### S3-Compatible Object Storage (Recommended for Production) +- Files are stored in an S3-compatible bucket +- Supports **Railway Storage Buckets**, AWS S3, Cloudflare R2, and similar services +- Files are served via **presigned URLs** +- No service egress for downloads +- Files persist across deployments +- Automatic cleanup when TTL expires + +On Railway, S3 credentials are injected automatically when you connect a Storage Bucket. + +Enable S3 storage by setting: +``` +STORAGE_BACKEND=s3 +``` + +--- + +### S3-Compatible Storage (Advanced / Non-Railway) + +If you are using S3-compatible storage **outside of Railway** +(e.g. AWS S3, Cloudflare R2, MinIO, Backblaze B2), +you must provide the following environment variables: + +```env +AWS_ENDPOINT_URL=https://storage.example.com +AWS_S3_BUCKET_NAME=your-bucket-name +AWS_DEFAULT_REGION=auto +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +``` + +--- + +## 🧱 Tech Stack +- Python **3.11+** (tested on 3.13) +- FastAPI +- Pyrogram / Pyrofork +- PostgreSQL (asyncpg) +- Redis +- Jinja2 +- Vanilla HTML / CSS / JS +- S3-compatible object storage + +--- + +## ⚠️ Database Schema Management + +This project automatically creates and maintains its database schema at startup. + +Migrations are intentionally omitted to keep the template simple and easy to deploy. +For larger or multi-tenant deployments, adding a migration system is recommended. + +--- + +## βš™οΈ Environment Variables + +Create a `.env` file in the project root. +> Tip: Rename `.env.example` to `.env` and fill in your values. + +### Telegram Bot +``` +API_ID=your_api_id +API_HASH=your_api_hash +BOT_TOKEN=your_bot_token +``` + +### Upload Concurrency +``` +MAX_CONCURRENT_TRANSFERS=3 +``` + +### Database & Cache +``` +DATABASE_URL=postgresql://user:password@localhost:5432/filelink +REDIS_URL=redis://localhost:6379 +``` + +### Storage Backend +Choose where uploaded files are stored. + +Local filesystem (default): +``` +STORAGE_BACKEND=local +``` + +S3-compatible storage: +``` +STORAGE_BACKEND=s3 +``` +> If not set, the bot defaults to local filesystem storage. + +### Public URL +Local development: +``` +BASE_URL=http://localhost:8000 +``` + +Railway: +``` +BASE_URL=${RAILWAY_PUBLIC_DOMAIN} +``` + +Custom domain: +``` +BASE_URL=https://files.example.com +``` + +### Rate Limiting +``` +GLOBAL_RATE_LIMIT_REQUESTS=60 +GLOBAL_RATE_LIMIT_WINDOW=10 +``` + +### Access Control (Optional) +``` +ALLOWED_USER_IDS=123456789 +``` + +### Admin Dashboard (Optional) +``` +ADMIN_ENABLED=true +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-me-now +SESSION_SECRET=change-me +``` + +--- + +## ▢️ Running Locally + +``` +pip install -r requirements.txt +uvicorn app.main:app --reload --log-level warning +``` + +--- + +## 🐳 Running with Docker + +``` +docker build -t telegram-file-link-bot . +docker run -d --env-file .env -p 8000:8000 telegram-file-link-bot +``` + +--- + +## πŸ“œ License + +Apache License 2.0 diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..a8c9652 --- /dev/null +++ b/admin/auth.py @@ -0,0 +1,21 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from fastapi import Request +from fastapi.responses import RedirectResponse + +def admin_required(request: Request): + if not request.session.get("admin_id"): + return RedirectResponse( + url="/admin/login", + status_code=303, + ) diff --git a/admin/bootstrap.py b/admin/bootstrap.py new file mode 100644 index 0000000..76d55ca --- /dev/null +++ b/admin/bootstrap.py @@ -0,0 +1,43 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +from passlib.hash import argon2 +from db.database import Database + +async def bootstrap_admin(): + if os.getenv("ADMIN_ENABLED", "false").lower() != "true": + return + + email = os.getenv("ADMIN_EMAIL") + password = os.getenv("ADMIN_PASSWORD") + + if not email or not password: + return + + count = await Database.pool.fetchval( + "SELECT COUNT(*) FROM admins" + ) + + if count > 0: + return + + await Database.pool.execute( + """ + INSERT INTO admins (email, password_hash) + VALUES ($1, $2) + """, + email, + argon2.hash(password), + ) + + print("βœ… Admin bootstrapped (Argon2)") diff --git a/admin/routes.py b/admin/routes.py new file mode 100644 index 0000000..ef57b42 --- /dev/null +++ b/admin/routes.py @@ -0,0 +1,198 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from passlib.hash import argon2 + +from db.database import Database +from admin.settings_store import get_setting +from admin.auth import admin_required + +router = APIRouter(prefix="/admin", tags=["admin"]) +templates = Jinja2Templates(directory="admin/templates") + +@router.get("") +async def admin_root(): + return RedirectResponse("/admin/", status_code=302) + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + +@router.post("/login") +async def login( + request: Request, + email: str = Form(...), + password: str = Form(...), +): + admin = await Database.pool.fetchrow( + "SELECT id, password_hash FROM admins WHERE email=$1", + email, + ) + + if not admin or not argon2.verify(password, admin["password_hash"]): + return templates.TemplateResponse( + "login.html", + {"request": request, "error": "Invalid credentials"}, + status_code=401, + ) + + request.session["admin_id"] = admin["id"] + return RedirectResponse("/admin/", status_code=303) + +@router.get("/settings", response_class=HTMLResponse) +async def settings_page( + request: Request, + auth=Depends(admin_required), +): + if isinstance(auth, RedirectResponse): + return auth + + stats = await Database.pool.fetchrow(""" + SELECT + COALESCE(SUM(file_size), 0) AS used_bytes, + COUNT(*) AS total_files, + COALESCE(MAX(file_size), 0) AS largest_file + FROM files + """) + + cleanup = await get_setting("cleanup_enabled", "true") + + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "used_bytes": stats["used_bytes"], + "total_files": stats["total_files"], + "largest_file": stats["largest_file"], + "cleanup_enabled": cleanup == "true", + }, + ) + +@router.post("/settings/save") +async def save_settings( + request: Request, + auth=Depends(admin_required), +): + if isinstance(auth, RedirectResponse): + return auth + + return RedirectResponse("/admin/settings", status_code=303) + +@router.post("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/admin/login", status_code=303) + +@router.get("/", response_class=HTMLResponse) +async def dashboard( + request: Request, + q: str | None = None, + auth=Depends(admin_required), +): + if isinstance(auth, RedirectResponse): + return auth + + stats = await Database.pool.fetchrow(""" + SELECT + COUNT(*) AS total_files, + COALESCE(SUM(downloads), 0) AS total_downloads, + COUNT(*) FILTER ( + WHERE expires_at IS NULL OR expires_at > NOW() + ) AS active_files + FROM files + """) + + files = await Database.pool.fetch(""" + SELECT file_id, name, downloads, expires_at + FROM files + WHERE name ILIKE $1 + ORDER BY downloads DESC + LIMIT 50 + """, f"%{q or ''}%") + + top_files = await Database.pool.fetch(""" + SELECT name, downloads + FROM files + ORDER BY downloads DESC + LIMIT 5 + """) + + recent_files = await Database.pool.fetch(""" + SELECT name, created_at + FROM files + ORDER BY created_at DESC + LIMIT 5 + """) + + expiring_files = await Database.pool.fetch(""" + SELECT name, expires_at + FROM files + WHERE expires_at IS NOT NULL + AND expires_at > NOW() + ORDER BY expires_at ASC + LIMIT 5 + """) + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "stats": stats, + "files": files, + "top_files": top_files, + "recent_files": recent_files, + "expiring_files": expiring_files, + "query": q or "", + }, + ) + +@router.post("/file/{file_id}/delete") +async def delete_file(file_id: str, auth=Depends(admin_required)): + if isinstance(auth, RedirectResponse): + return auth + + row = await Database.pool.fetchrow( + "SELECT path FROM files WHERE file_id=$1", + file_id, + ) + + if row: + try: + os.remove(row["path"]) + except Exception: + pass + + await Database.pool.execute( + "DELETE FROM files WHERE file_id=$1", + file_id, + ) + + return RedirectResponse("/admin/", status_code=303) + + +@router.post("/file/{file_id}/disable") +async def disable_file(file_id: str, auth=Depends(admin_required)): + if isinstance(auth, RedirectResponse): + return auth + + await Database.pool.execute( + "UPDATE files SET expires_at = NOW() WHERE file_id=$1", + file_id, + ) + + return RedirectResponse("/admin/", status_code=303) + + diff --git a/admin/settings_store.py b/admin/settings_store.py new file mode 100644 index 0000000..6597dcb --- /dev/null +++ b/admin/settings_store.py @@ -0,0 +1,33 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from db.database import Database + +async def get_setting(key: str, default: str): + row = await Database.pool.fetchrow( + "SELECT value FROM settings WHERE key=$1", + key, + ) + return row["value"] if row else default + + +async def set_setting(key: str, value: str): + await Database.pool.execute( + """ + INSERT INTO settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value + """, + key, + value, + ) diff --git a/admin/templates/dashboard.html b/admin/templates/dashboard.html new file mode 100644 index 0000000..54e8360 --- /dev/null +++ b/admin/templates/dashboard.html @@ -0,0 +1,123 @@ + + + + + + Admin Dashboard + + + + + + +
+

πŸ“Š Admin Dashboard

+ + +
+ +
+ +
+

Total Files

{{ stats.total_files }}

+

Total Downloads

{{ stats.total_downloads }}

+

Active Files

{{ stats.active_files }}

+
+ + + +
+
All Files
+ + + + + + + + + {% for f in files %} + + + + + + + {% endfor %} +
NameDownloadsStatusActions
{{ f.name }}{{ f.downloads }} + {% if f.expires_at %} + Expiring + {% else %} + Active + {% endif %} + +
+ +
+
+ +
+
+
+ +
+ +
+

πŸ”₯ Top Downloads

+
    + {% for f in top_files %} +
  • {{ f.name }} β€” {{ f.downloads }}
  • + {% endfor %} +
+
+ +
+

πŸ•’ Recent Uploads

+
    + {% for f in recent_files %} +
  • {{ f.name }}
  • + {% endfor %} +
+
+ +
+

⏳ Expiring Soon

+
    + {% for f in expiring_files %} +
  • {{ f.name }}
  • + {% endfor %} +
+
+ +
+ +
+ + + diff --git a/admin/templates/error.html b/admin/templates/error.html new file mode 100644 index 0000000..85c9fa0 --- /dev/null +++ b/admin/templates/error.html @@ -0,0 +1,38 @@ + + + + + + {{ title }} + + + + +
+

{{ icon }} {{ title }}

+

{{ message }}

+ + {% if hint %} +

{{ hint }}

+ {% endif %} + + {% if back_url %} + + ← Go back + + {% endif %} +
+ + diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..c74be2a --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,37 @@ + + + + + + Admin Login + + + + +
+

πŸ” Admin Login

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + +
+
+ + diff --git a/admin/templates/settings.html b/admin/templates/settings.html new file mode 100644 index 0000000..aad338f --- /dev/null +++ b/admin/templates/settings.html @@ -0,0 +1,54 @@ + + + + + + Admin Settings + + + + + + +
+

πŸ“Š System Info

+ + +
+ +
+ + + ← Back to Dashboard + + +
+

πŸ“¦ Storage Usage

+

Used: {{ (used_bytes / (1024**2)) | round(2) }} MB

+

Total files: {{ total_files }}

+

Largest file: {{ (largest_file / (1024**2)) | round(2) }} MB

+
+ +
+ + + diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..3a27ef1 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..6a5cfb6 --- /dev/null +++ b/api/routes.py @@ -0,0 +1,119 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +import boto3 +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from cache.redis import redis_client +from db.database import Database +from config import ( + GLOBAL_RATE_LIMIT_REQUESTS, + GLOBAL_RATE_LIMIT_WINDOW, + STORAGE_BACKEND, + AWS_ENDPOINT_URL, + AWS_S3_BUCKET_NAME, + AWS_DEFAULT_REGION, +) + +router = APIRouter() + +s3 = None +if STORAGE_BACKEND == "s3": + s3 = boto3.client( + "s3", + endpoint_url=AWS_ENDPOINT_URL, + region_name=AWS_DEFAULT_REGION, + ) + +def get_real_ip(request: Request) -> str: + return ( + request.headers.get("cf-connecting-ip") + or request.headers.get("x-forwarded-for", "").split(",")[0] + or request.client.host + ) + + +def rate_limit_response(window: int): + return JSONResponse( + status_code=429, + content={"error": "rate_limited", "retry_after": window}, + headers={"Retry-After": str(window)}, + ) + + +def check_rate_limit(key: str, limit: int, window: int): + count = redis_client.incr(key) + if count == 1: + redis_client.expire(key, window) + if count > limit: + return rate_limit_response(window) + + +@router.get("/file/{file_id}") +async def get_file(file_id: str, request: Request): + + ip = get_real_ip(request) + + if GLOBAL_RATE_LIMIT_REQUESTS > 0: + if resp := check_rate_limit( + f"rate:global:{ip}", + GLOBAL_RATE_LIMIT_REQUESTS, + GLOBAL_RATE_LIMIT_WINDOW, + ): + return resp + + key = f"file:{file_id}" + meta = redis_client.hgetall(key) + + if not meta: + row = await Database.pool.fetchrow( + "SELECT path, name, downloads FROM files WHERE file_id=$1", + file_id, + ) + if not row: + raise HTTPException(404, "File not found") + + meta = dict(row) + redis_client.hset(key, mapping=meta) + + download_key = f"downloaded:{ip}:{file_id}" + if not redis_client.exists(download_key): + redis_client.setex(download_key, 3600, 1) + + await Database.pool.execute( + "UPDATE files SET downloads = downloads + 1 WHERE file_id=$1", + file_id, + ) + + redis_client.hincrby(key, "downloads", 1) + + if STORAGE_BACKEND == "local": + if not os.path.exists(meta["path"]): + raise HTTPException(404, "File missing") + + return FileResponse( + path=meta["path"], + filename=meta["name"], + media_type="application/octet-stream", + ) + + url = s3.generate_presigned_url( + "get_object", + Params={ + "Bucket": AWS_S3_BUCKET_NAME, + "Key": meta["path"], + }, + ExpiresIn=3600, + ) + + return RedirectResponse(url, status_code=302) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0a8b3c9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,128 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +import asyncio +from fastapi import FastAPI, Request +from fastapi.exceptions import HTTPException +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import Response + +from config import ADMIN_ENABLED +from admin.bootstrap import bootstrap_admin +from admin.routes import router as admin_router + +from app.state import cleanup_expired_files +from api.routes import router as file_router +from bot.bot import tg_client +from db.database import Database +import bot.handlers + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +templates = Jinja2Templates( + directory=os.path.join(BASE_DIR, "admin", "templates") +) + +class CachedStaticFiles(StaticFiles): + async def get_response(self, path: str, scope): + response: Response = await super().get_response(path, scope) + if response.status_code == 200: + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + ) + return response + +app = FastAPI(title="Telegram File Link Bot") + +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SESSION_SECRET", "dev-secret"), + same_site="lax", + https_only=True, +) + +app.include_router(file_router) + +if ADMIN_ENABLED: + app.include_router(admin_router) + +app.mount( + "/static", + CachedStaticFiles(directory="static"), + name="static", +) + +@app.exception_handler(HTTPException) +async def custom_http_exception_handler( + request: Request, + exc: HTTPException, +): + context = { + "request": request, + "title": "Error", + "icon": "⚠️", + "message": exc.detail, + "hint": None, + } + + if exc.status_code == 404: + context.update( + title="File Not Found", + icon="πŸ”", + message="This download link is invalid or no longer available.", + hint="The file may have expired or been deleted by the owner.", + ) + + elif exc.status_code == 403: + context.update( + title="Access Denied", + icon="β›”", + message="You are not allowed to access this file.", + ) + + return templates.TemplateResponse( + "error.html", + context, + status_code=exc.status_code, + ) + +async def start_bot(): + try: + await tg_client.start() + print("πŸ€– Pyrogram bot started") + except Exception as e: + print("⚠️ Bot start skipped:", e) + + +async def stop_bot(): + await tg_client.stop() + print("πŸ›‘ Pyrogram bot stopped") + +@app.on_event("startup") +async def startup(): + await Database.connect() + + if ADMIN_ENABLED: + await bootstrap_admin() + + asyncio.create_task(start_bot()) + asyncio.create_task(cleanup_expired_files()) + + +@app.on_event("shutdown") +async def shutdown(): + await Database.close() + asyncio.create_task(stop_bot()) diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..95e6a2d --- /dev/null +++ b/app/state.py @@ -0,0 +1,83 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +import asyncio +import boto3 +from db.database import Database +from cache.redis import redis_client +from config import ( + STORAGE_BACKEND, + AWS_ENDPOINT_URL, + AWS_S3_BUCKET_NAME, + AWS_DEFAULT_REGION, +) + +CLEANUP_INTERVAL = 30 + +s3 = None +if STORAGE_BACKEND == "s3": + s3 = boto3.client( + "s3", + endpoint_url=AWS_ENDPOINT_URL, + region_name=AWS_DEFAULT_REGION, + ) + + +async def cleanup_expired_files(): + while True: + try: + async with Database.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT file_id, path + FROM files + WHERE expires_at IS NOT NULL + AND expires_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + """ + ) + + for row in rows: + file_id = row["file_id"] + path = row["path"] + + try: + if STORAGE_BACKEND == "local": + # Local filesystem cleanup + if path and os.path.exists(path): + os.remove(path) + else: + # S3 bucket cleanup + s3.delete_object( + Bucket=AWS_S3_BUCKET_NAME, + Key=path, + ) + except Exception as e: + # Never crash cleanup loop + print(f"⚠️ Failed to delete file {file_id}: {e}") + + # Clear Redis cache + redis_client.delete(f"file:{file_id}") + + if rows: + await conn.execute( + """ + DELETE FROM files + WHERE expires_at IS NOT NULL + AND expires_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + """ + ) + + except Exception as e: + print("❌ Cleanup error:", e) + + await asyncio.sleep(CLEANUP_INTERVAL) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..afdb62a --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,23 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from pyrogram import Client +from config import API_ID, API_HASH, BOT_TOKEN + +tg_client = Client( + "filelink_bot", + api_id=API_ID, + api_hash=API_HASH, + bot_token=BOT_TOKEN, + in_memory=True +) + diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..f5d6ea3 --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,2 @@ +from .start import * +from .upload import * \ No newline at end of file diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..d4fefe4 --- /dev/null +++ b/bot/handlers/start.py @@ -0,0 +1,29 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from pyrogram import filters +from bot.bot import tg_client +from bot.utils.access import is_allowed + +@tg_client.on_message(filters.private & filters.command("start")) +async def start_handler(_, message): + if not message.from_user: + return + + if not is_allowed(message.from_user.id): + await message.reply("🚫 This bot is private.") + return + + await message.reply( + "πŸ‘‹ Send me a file and I’ll generate a download link.\n" + "πŸ“Ž Send images as *File* to keep original quality." + ) diff --git a/bot/handlers/upload.py b/bot/handlers/upload.py new file mode 100644 index 0000000..d9a4193 --- /dev/null +++ b/bot/handlers/upload.py @@ -0,0 +1,178 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import asyncio +import uuid +import os +import re +from datetime import datetime, timedelta, timezone + +import boto3 +from pyrogram import filters +from bot.bot import tg_client +from cache.redis import redis_client +from bot.utils.access import is_allowed +from bot.utils.mode import get_mode, format_ttl +from config import ( + BASE_URL, + MAX_FILE_MB, + MAX_CONCURRENT_TRANSFERS, + STORAGE_BACKEND, + AWS_ENDPOINT_URL, + AWS_S3_BUCKET_NAME, + AWS_DEFAULT_REGION, +) +from db.database import Database + +UPLOAD_DIR = os.path.abspath("uploads") +os.makedirs(UPLOAD_DIR, exist_ok=True) + +upload_semaphore = asyncio.Semaphore(MAX_CONCURRENT_TRANSFERS) + +s3 = None +if STORAGE_BACKEND == "s3": + s3 = boto3.client( + "s3", + endpoint_url=AWS_ENDPOINT_URL, + region_name=AWS_DEFAULT_REGION, + ) + +def safe_filename(name: str) -> str: + return re.sub(r'[<>:"/\\|?*\x00-\x1F]', "_", name).strip() + +@tg_client.on_message( + filters.private & ( + filters.document + | filters.video + | filters.audio + | filters.photo + | filters.animation + | filters.voice + | filters.video_note + ) +) +async def upload_handler(_, message): + if not message.from_user: + return + + if not is_allowed(message.from_user.id): + await message.reply("🚫 Unauthorized") + return + + status = await message.reply("πŸ“₯ Queued for processing…") + + async with upload_semaphore: + await process_upload(message, status) + +async def process_upload(message, status): + media = ( + message.document + or message.video + or message.audio + or message.photo + or message.animation + or message.voice + or message.video_note + ) + + file_size = getattr(media, "file_size", None) + + if MAX_FILE_MB is not None and file_size: + max_bytes = MAX_FILE_MB * 1024 * 1024 + if file_size > max_bytes: + size_mb = file_size / (1024 * 1024) + await status.edit( + "❌ **File too large**\n\n" + f"Your file: **{size_mb:.2f} MB**\n" + f"Max allowed: **{MAX_FILE_MB} MB**" + ) + return + + await status.edit("⬇️ Downloading…") + temp_path = await message.download() + + if not temp_path: + await status.edit("❌ Download failed") + return + + if message.photo: + original_name = f"{uuid.uuid4().hex}.jpg" + elif hasattr(media, "file_name") and media.file_name: + original_name = safe_filename(media.file_name) + else: + original_name = f"{uuid.uuid4().hex}.bin" + + file_size = file_size or os.path.getsize(temp_path) + + file_id = uuid.uuid4().hex[:12] + ext = os.path.splitext(original_name)[1] + + if STORAGE_BACKEND == "local": + internal_path = os.path.join(UPLOAD_DIR, f"{file_id}{ext}") + os.replace(temp_path, internal_path) + stored_path = internal_path + else: + key = f"{file_id}{ext}" + s3.upload_file(temp_path, AWS_S3_BUCKET_NAME, key) + os.remove(temp_path) + stored_path = key + + user_mode = get_mode(message.from_user.id) + + if user_mode["ttl"] > 0: + ttl = user_mode["ttl"] + ttl_source = "πŸ‘€ Using your TTL" + else: + ttl = 0 + ttl_source = "β™Ύ No expiration" + + expires_at = ( + datetime.now(timezone.utc) + timedelta(seconds=ttl) + if ttl > 0 else None + ) + + await Database.pool.execute( + """ + INSERT INTO files ( + file_id, path, name, downloads, file_size, expires_at + ) + VALUES ($1, $2, $3, 0, $4, $5) + """, + file_id, + stored_path, + original_name, + file_size, + expires_at, + ) + + redis_client.delete(f"file:{file_id}") + redis_client.hset( + f"file:{file_id}", + mapping={ + "path": stored_path, + "name": original_name, + "downloads": 0, + "file_size": file_size, + "expires_at": int(expires_at.timestamp()) if expires_at else 0, + } + ) + + size_mb = file_size / (1024 * 1024) + + await status.edit( + "βœ… **File uploaded**\n\n" + f"{ttl_source}\n" + f"πŸ“„ **Name:** `{original_name}`\n" + f"πŸ“¦ **Size:** `{size_mb:.2f} MB`\n" + f"⏳ **Expires:** {format_ttl(ttl)}\n\n" + f"πŸ”— `{BASE_URL}/file/{file_id}`" + ) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/utils/access.py b/bot/utils/access.py new file mode 100644 index 0000000..d5010a1 --- /dev/null +++ b/bot/utils/access.py @@ -0,0 +1,18 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +from config import ALLOWED_USER_IDS + +def is_allowed(user_id: int) -> bool: + if ALLOWED_USER_IDS is None: + return True + return user_id in ALLOWED_USER_IDS diff --git a/bot/utils/mode.py b/bot/utils/mode.py new file mode 100644 index 0000000..00e146a --- /dev/null +++ b/bot/utils/mode.py @@ -0,0 +1,137 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import re +from pyrogram import filters +from bot.bot import tg_client +from cache.redis import redis_client +from bot.utils.access import is_allowed + +def get_mode(user_id: int) -> dict: + data = redis_client.hgetall(f"mode:{user_id}") + return { + "ttl": int(data.get("ttl", 0)), + } + + +def parse_ttl(value: str) -> int: + """ + Parse TTL string to seconds. + Supported: + 30 -> 30 minutes + 2h -> 2 hours + 1d -> 1 day + 0 -> never expire + """ + m = re.match(r"^(\d+)([mhd]?)$", value.lower()) + if not m: + return -1 + + amount, unit = m.groups() + amount = int(amount) + + if amount == 0: + return 0 + if unit == "h": + return amount * 3600 + if unit == "d": + return amount * 86400 + + return amount * 60 + + +def format_ttl(seconds: int) -> str: + if seconds == 0: + return "Never" + if seconds < 60: + return f"{seconds} seconds" + if seconds < 3600: + return f"{seconds // 60} minutes" + if seconds < 86400: + return f"{seconds // 3600} hours" + return f"{seconds // 86400} days" + + +@tg_client.on_message(filters.private & filters.command("mode")) +async def mode_handler(_, message): + if not message.from_user: + return + + user_id = message.from_user.id + if not is_allowed(user_id): + await message.reply("🚫 Admin only") + return + + args = message.text.split() + key = f"mode:{user_id}" + if len(args) == 1: + user = get_mode(user_id) + + if user["ttl"] > 0: + current = user["ttl"] + source = "πŸ‘€ Your TTL" + else: + current = 0 + source = "β™Ύ No expiration" + + await message.reply( + "πŸ“Œ **Mode (TTL)**\n\n" + f"Effective TTL: **{format_ttl(current)}**\n" + f"{source}\n\n" + "Set expiration for your uploads:\n" + "`/mode ttl 30` β†’ 30 minutes\n" + "`/mode ttl 2h` β†’ 2 hours\n" + "`/mode ttl 1d` β†’ 1 day\n" + "`/mode ttl 0` β†’ Never expire\n\n" + "`/mode reset`" + ) + return + + cmd = args[1].lower() + + if cmd == "ttl": + if len(args) != 3: + await message.reply("❌ Usage: `/mode ttl `") + return + + ttl_seconds = parse_ttl(args[2]) + + if ttl_seconds < 0: + await message.reply( + "❌ Invalid TTL format\n\n" + "Examples:\n" + "`/mode ttl 30`\n" + "`/mode ttl 2h`\n" + "`/mode ttl 1d`\n" + "`/mode ttl 0`" + ) + return + + if ttl_seconds > 30 * 86400: + await message.reply("❌ Max TTL is 30 days") + return + + redis_client.hset(key, mapping={"ttl": ttl_seconds}) + + await message.reply( + "⏳ TTL disabled" + if ttl_seconds == 0 + else f"⏳ TTL set to **{format_ttl(ttl_seconds)}**" + ) + return + + if cmd == "reset": + redis_client.delete(key) + await message.reply("♻️ Mode reset (Never expire)") + return + + await message.reply("❌ Unknown command") diff --git a/cache/__init__.py b/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cache/redis.py b/cache/redis.py new file mode 100644 index 0000000..cf7be41 --- /dev/null +++ b/cache/redis.py @@ -0,0 +1,36 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import redis +from config import REDIS_URL + +if not REDIS_URL: + raise RuntimeError("❌ REDIS_URL is required but not set") + +try: + redis_client = redis.from_url( + REDIS_URL, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5, + ) + redis_client.ping() + print("βœ… Redis connected (required)") +except Exception as e: + raise RuntimeError(f"❌ Redis connection failed: {e}") + +def delete_pattern(pattern: str): + """ + Safely delete keys by pattern without blocking Redis. + """ + for key in redis_client.scan_iter(match=pattern): + redis_client.delete(key) diff --git a/config.py b/config.py new file mode 100644 index 0000000..cd23894 --- /dev/null +++ b/config.py @@ -0,0 +1,71 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import os +from dotenv import load_dotenv + +load_dotenv() + + +def str2bool(v): + return str(v).lower() in ("1", "true", "yes", "on") + + +def env_int(name: str, default=None): + try: + value = os.getenv(name) + return int(value) if value is not None else default + except ValueError: + return default + + +def require(name: str) -> str: + value = os.getenv(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + +def normalize_base_url(value: str | None) -> str: + if not value: + return "http://localhost:8000" + if value.startswith("http://") or value.startswith("https://"): + return value + return f"https://{value}" + +API_ID = int(require("API_ID")) +API_HASH = require("API_HASH") +BOT_TOKEN = require("BOT_TOKEN") + +BASE_URL = normalize_base_url(os.getenv("BASE_URL")) +DATABASE_URL = require("DATABASE_URL") +REDIS_URL = require("REDIS_URL") + +GLOBAL_RATE_LIMIT_REQUESTS = env_int("GLOBAL_RATE_LIMIT_REQUESTS", 60) +GLOBAL_RATE_LIMIT_WINDOW = env_int("GLOBAL_RATE_LIMIT_WINDOW", 10) + +ALLOWED_USER_IDS = ( + list(map(int, os.getenv("ALLOWED_USER_IDS").split(","))) + if os.getenv("ALLOWED_USER_IDS") + else None +) + +ADMIN_ENABLED = str2bool(os.getenv("ADMIN_ENABLED", "false")) + +MAX_FILE_MB = env_int("MAX_FILE_MB", None) + +MAX_CONCURRENT_TRANSFERS = env_int("MAX_CONCURRENT_TRANSFERS", 3) + +STORAGE_BACKEND = os.getenv("STORAGE_BACKEND", "local") + +AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL") +AWS_S3_BUCKET_NAME = os.getenv("AWS_S3_BUCKET_NAME") +AWS_DEFAULT_REGION = os.getenv("AWS_DEFAULT_REGION", "auto") diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..b1c3402 --- /dev/null +++ b/db/database.py @@ -0,0 +1,84 @@ +# Copyright 2025 Aman +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import asyncpg +from config import DATABASE_URL + +if not DATABASE_URL: + raise RuntimeError("❌ DATABASE_URL is required but not set") + + +CREATE_FILES_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS files ( + file_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + name TEXT NOT NULL, + downloads INTEGER NOT NULL DEFAULT 0, + file_size BIGINT, + expires_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_files_expires +ON files (expires_at); +""" + +CREATE_ADMINS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS admins ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +""" +CREATE_SETTINGS_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +""" + +INSERT_DEFAULT_SETTINGS_SQL = """ +INSERT INTO settings (key, value) VALUES + ('default_max_downloads', '0'), + ('cleanup_enabled', 'true') +ON CONFLICT (key) DO NOTHING; +""" + +class Database: + pool: asyncpg.Pool | None = None + + @classmethod + async def connect(cls): + cls.pool = await asyncpg.create_pool( + DATABASE_URL, + min_size=1, + max_size=10, + command_timeout=60, + server_settings={"timezone": "UTC"}, + ) + await cls._init_schema() + print("βœ… PostgreSQL connected & schema ensured") + + @classmethod + async def _init_schema(cls): + async with cls.pool.acquire() as conn: + await conn.execute(CREATE_FILES_TABLE_SQL) + await conn.execute(CREATE_ADMINS_TABLE_SQL) + await conn.execute(CREATE_SETTINGS_TABLE_SQL) + await conn.execute(INSERT_DEFAULT_SETTINGS_SQL) + + @classmethod + async def close(cls): + if cls.pool: + await cls.pool.close() + print("πŸ›‘ PostgreSQL connection closed") diff --git a/docs/screenshots/admin-dashboard.jpg b/docs/screenshots/admin-dashboard.jpg new file mode 100644 index 0000000..d46747d Binary files /dev/null and b/docs/screenshots/admin-dashboard.jpg differ diff --git a/docs/screenshots/telegram-upload.jpg b/docs/screenshots/telegram-upload.jpg new file mode 100644 index 0000000..41c9478 Binary files /dev/null and b/docs/screenshots/telegram-upload.jpg differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..41f933a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +pyrofork==2.3.69 +fastapi==0.128.0 +uvicorn==0.38.0 +asyncpg==0.31.0 +python-dotenv==1.2.1 +redis==7.1.0 +TgCrypto-pyrofork==1.2.8 +passlib[argon2] +argon2-cffi==25.1.0 +python-multipart==0.0.20 +itsdangerous==2.2.0 +Jinja2==3.1.6 +boto3==1.42.25 \ No newline at end of file diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..b7f5b93 --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,328 @@ +/* +Copyright 2025 Aman + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +*/ + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: #f4f6fb; + color: #111827; +} + +main { + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.centered { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +body.centered { + margin: 0; + width: 100vw; + height: 100vh; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 28px; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; +} + +.admin-header h1 { + font-size: 20px; + font-weight: 600; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.nav-link { + text-decoration: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 14px; + color: #111827; + background: #e5e7eb; +} + +.nav-link:hover { + background: #d1d5db; +} + +button { + border: none; + cursor: pointer; + font-size: 14px; +} + +.logout { + background: #ef4444; + color: white; + padding: 8px 14px; + border-radius: 6px; +} + +.logout:hover { + background: #dc2626; +} + +#theme-toggle { + background: #111827; + color: #f9fafb; + padding: 8px 12px; + border-radius: 6px; +} + +.card { + background: #ffffff; + padding: 18px 20px; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0,0,0,0.06); +} + +.card h3 { + font-size: 13px; + color: #6b7280; + margin-bottom: 6px; + font-weight: 500; +} + +.card p { + font-size: 28px; + font-weight: 700; +} + +.dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.search-bar { + margin-bottom: 16px; +} + +.search-bar input { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #e5e7eb; + font-size: 14px; +} + +.table-card { + background: #ffffff; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0,0,0,0.06); + overflow: hidden; +} + +.table-header { + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; + font-weight: 600; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + padding: 12px 18px; + border-bottom: 1px solid #e5e7eb; +} + +td { + padding: 12px 18px; + border-bottom: 1px solid #f1f5f9; + font-size: 14px; +} + +td.right { + text-align: right; + font-weight: 600; +} + +tr:last-child td { + border-bottom: none; +} + +.badge { + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.badge.green { + background: #dcfce7; + color: #166534; +} + +.badge.orange { + background: #ffedd5; + color: #9a3412; +} + +.action-btn { + padding: 6px 10px; + font-size: 12px; + border-radius: 6px; + margin-left: 4px; +} + +.action-btn:hover { + opacity: 0.9; +} + +.action-delete { + background: #ef4444; + color: white; +} + +.action-delete:hover { + background: #dc2626; +} + +.action-disable { + background: #f59e0b; + color: white; +} + +.action-disable:hover { + background: #d97706; +} + +.action-enable { + background: #22c55e; + color: white; +} + +.auth-page { + background: #f4f6fb; +} + +.auth-card { + max-width: 380px; + width: 100%; + padding: 28px; + text-align: center; +} + +.auth-card h2 { + margin-bottom: 16px; +} + +.auth-card input { + width: 100%; + margin-top: 12px; +} + +.auth-card button { + width: 100%; + margin-top: 16px; +} + +.alert { + padding: 10px 12px; + border-radius: 6px; + margin-bottom: 12px; + font-size: 14px; +} + +.alert-error { + background: #fee2e2; + color: #991b1b; +} + +body.dark { + background: #020617; + color: #e5e7eb; +} + +body.dark .admin-header, +body.dark .card, +body.dark .table-card { + background: #020617; + border: 1px solid #1e293b; + box-shadow: none; +} + +body.dark .table-header { + border-color: #1e293b; +} + +body.dark th { + color: #94a3b8; +} + +body.dark td { + border-color: #1e293b; +} + +body.dark .nav-link { + background: #1e293b; + color: #e5e7eb; +} + +body.dark #theme-toggle { + background: #e5e7eb; + color: #020617; +} + +body.dark input { + background: #020617; + color: #e5e7eb; + border: 1px solid #1e293b; +} + +body.dark input::placeholder { + color: #94a3b8; +} + +@media (max-width: 768px) { + .dashboard { + grid-template-columns: 1fr; + } + + th:nth-child(2), + td:nth-child(2) { + display: none; + } +} diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..6636b6f --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,38 @@ +/** + * Copyright 2025 Aman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + */ + +(function () { + const key = "admin-theme"; + const btn = document.getElementById("theme-toggle"); + + if (!btn) return; + + const saved = localStorage.getItem(key); + + if (saved === "dark") { + document.body.classList.add("dark"); + } else if (!saved) { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + document.body.classList.add("dark"); + } + } + + btn.onclick = () => { + document.body.classList.toggle("dark"); + localStorage.setItem( + key, + document.body.classList.contains("dark") ? "dark" : "light" + ); + }; +})();