Initial commit

This commit is contained in:
Kaki Filem Team 2026-01-31 20:35:11 +08:00
commit 143f19b1b7
38 changed files with 2465 additions and 0 deletions

17
.dockerignore Normal file
View File

@ -0,0 +1,17 @@
.env
.env.*
__pycache__/
*.pyc
uploads/
.venv/
venv/
.git
.gitignore
.DS_Store
Thumbs.db
docs/

146
.env.example Normal file
View File

@ -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
# 23 = 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

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
.env
.env.*
!.env.example
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
uploads/
.git/
.DS_Store
Thumbs.db
.vscode/
.idea/

45
CONTRIBUTING.md Normal file
View File

@ -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).

13
Dockerfile Normal file
View File

@ -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"]

85
LICENSE Normal file
View File

@ -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

7
NOTICE Normal file
View File

@ -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.

298
README.md Normal file
View File

@ -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

21
admin/auth.py Normal file
View File

@ -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,
)

43
admin/bootstrap.py Normal file
View File

@ -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)")

198
admin/routes.py Normal file
View File

@ -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)

33
admin/settings_store.py Normal file
View File

@ -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,
)

View File

@ -0,0 +1,123 @@
<!--
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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Admin Dashboard</title>
<link rel="stylesheet" href="/static/css/admin.css?v=1">
<script src="/static/js/admin.js?v=1" defer></script>
</head>
<body>
<header class="admin-header">
<h1>📊 Admin Dashboard</h1>
<nav class="header-actions">
<a href="/admin" class="nav-link">Dashboard</a>
<a href="/admin/settings" class="nav-link">Settings</a>
<button id="theme-toggle" type="button">🌙</button>
<form method="post" action="/admin/logout">
<button class="logout">Logout</button>
</form>
</nav>
</header>
<main>
<section class="dashboard">
<div class="card"><h3>Total Files</h3><p>{{ stats.total_files }}</p></div>
<div class="card"><h3>Total Downloads</h3><p>{{ stats.total_downloads }}</p></div>
<div class="card"><h3>Active Files</h3><p>{{ stats.active_files }}</p></div>
</section>
<form method="get" class="search-bar">
<input name="q" value="{{ query }}" placeholder="Search files...">
</form>
<div class="table-card">
<div class="table-header">All Files</div>
<table>
<tr>
<th>Name</th>
<th class="right">Downloads</th>
<th class="right">Status</th>
<th class="right">Actions</th>
</tr>
{% for f in files %}
<tr>
<td>{{ f.name }}</td>
<td class="right">{{ f.downloads }}</td>
<td class="right">
{% if f.expires_at %}
<span class="badge orange">Expiring</span>
{% else %}
<span class="badge green">Active</span>
{% endif %}
</td>
<td class="right">
<form method="post" action="/admin/file/{{ f.file_id }}/disable" style="display:inline">
<button class="action-btn action-disable">Disable</button>
</form>
<form method="post" action="/admin/file/{{ f.file_id }}/delete" style="display:inline">
<button class="action-btn action-delete"
onclick="return confirm('Delete this file permanently?')">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="dashboard" style="margin-top:30px">
<div class="card">
<h3>🔥 Top Downloads</h3>
<ul>
{% for f in top_files %}
<li>{{ f.name }} — {{ f.downloads }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h3>🕒 Recent Uploads</h3>
<ul>
{% for f in recent_files %}
<li>{{ f.name }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h3>⏳ Expiring Soon</h3>
<ul>
{% for f in expiring_files %}
<li>{{ f.name }}</li>
{% endfor %}
</ul>
</div>
</div>
</main>
</body>
</html>

View File

@ -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.
-->
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="centered">
<div class="card" style="max-width:420px;text-align:center">
<h2>{{ icon }} {{ title }}</h2>
<p style="margin-top:12px">{{ message }}</p>
{% if hint %}
<p class="muted" style="margin-top:8px">{{ hint }}</p>
{% endif %}
{% if back_url %}
<a href="{{ back_url }}" class="nav-link" style="margin-top:16px;display:inline-block">
← Go back
</a>
{% endif %}
</div>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Admin Login</title>
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="centered auth-page">
<div class="card auth-card">
<h2>🔐 Admin Login</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit" class="action-btn">Login</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,54 @@
<!--
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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Admin Settings</title>
<link rel="stylesheet" href="/static/css/admin.css?v=1">
<script src="/static/js/admin.js?v=1" defer></script>
</head>
<body>
<header class="admin-header">
<h1>📊 System Info</h1>
<nav class="header-actions">
<a href="/admin" class="nav-link">Dashboard</a>
<a href="/admin/settings" class="nav-link">Settings</a>
<button id="theme-toggle">🌙</button>
<form method="post" action="/admin/logout">
<button class="logout">Logout</button>
</form>
</nav>
</header>
<main>
<a href="/admin" class="nav-link" style="margin-bottom:16px;display:inline-block">
← Back to Dashboard
</a>
<div class="card">
<h3>📦 Storage Usage</h3>
<p><strong>Used:</strong> {{ (used_bytes / (1024**2)) | round(2) }} MB</p>
<p><strong>Total files:</strong> {{ total_files }}</p>
<p><strong>Largest file:</strong> {{ (largest_file / (1024**2)) | round(2) }} MB</p>
</div>
</main>
</body>
</html>

1
api/__init__.py Normal file
View File

@ -0,0 +1 @@
from .routes import router

119
api/routes.py Normal file
View File

@ -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)

128
app/main.py Normal file
View File

@ -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())

83
app/state.py Normal file
View File

@ -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)

0
bot/__init__.py Normal file
View File

23
bot/bot.py Normal file
View File

@ -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
)

2
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .start import *
from .upload import *

29
bot/handlers/start.py Normal file
View File

@ -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 Ill generate a download link.\n"
"📎 Send images as *File* to keep original quality."
)

178
bot/handlers/upload.py Normal file
View File

@ -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}`"
)

0
bot/utils/__init__.py Normal file
View File

18
bot/utils/access.py Normal file
View File

@ -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

137
bot/utils/mode.py Normal file
View File

@ -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 <minutes|h|d>`")
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")

0
cache/__init__.py vendored Normal file
View File

36
cache/redis.py vendored Normal file
View File

@ -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)

71
config.py Normal file
View File

@ -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")

0
db/__init__.py Normal file
View File

84
db/database.py Normal file
View File

@ -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")

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

13
requirements.txt Normal file
View File

@ -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

328
static/css/admin.css Normal file
View File

@ -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;
}
}

38
static/js/admin.js Normal file
View File

@ -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"
);
};
})();