Initial commit
This commit is contained in:
commit
143f19b1b7
|
|
@ -0,0 +1,17 @@
|
|||
.env
|
||||
.env.*
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
uploads/
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
docs/
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
uploads/
|
||||
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
@ -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).
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,298 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 📎 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)
|
||||
|
||||
[](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
|
||||

|
||||
|
||||
### Telegram Upload
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 🧹 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
from .routes import router
|
||||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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,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
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
from .start import *
|
||||
from .upload import *
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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,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
|
||||
|
|
@ -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,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)
|
||||
|
|
@ -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,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 |
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in New Issue