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