ساخت لینک دانلود یکبارمصرف با پایتون؛ Token امن، تاریخ انقضا و Nginx
اگر میخواهید یک فایل را فقط یک بار در اختیار کاربر قرار دهید، لینک تاریخدار Nginx بهتنهایی کافی نیست. لینک امضاشده تا پایان زمان اعتبار میتواند چند بار استفاده شود؛ برای ساخت لینک دانلود واقعاً یکبارمصرف باید وضعیت Token را روی سرور ذخیره کنیم و بعد از اولین استفاده آن را باطل کنیم.
در این آموزش با Python، Flask، SQLite و Nginx یک سیستم کامل میسازیم که برای هر فایل Token تصادفی و غیرقابل حدس تولید میکند، فقط Hash آن را در دیتابیس نگه میدارد، تاریخ انقضا دارد و بعد از اولین دانلود Token را بهصورت اتمیک مصرف میکند. برای فایلهای بزرگ نیز Python خود فایل را Stream نمیکند؛ پس از تأیید Token، فایل با X-Accel-Redirect مستقیماً توسط Nginx ارسال میشود.
📌 خروجی نهایی این آموزش:
🔹 لینک دانلود تصادفی و غیرقابل حدس
🔹 Token با 256 بیت تصادفیبودن
🔹 ذخیره SHA-256 Token بهجای Token خام
🔹 تاریخ انقضای قابل تنظیم
🔹 مصرف Token فقط در اولین دانلود
🔹 جلوگیری از استفاده همزمان یک Token توسط دو کاربر
🔹 صفحه تأیید قبل از مصرف لینک
🔹 تحویل فایل توسط Nginx بهجای Python
🔹 اجرای Production با Gunicorn و systemd
تفاوت لینک یکبارمصرف با لینک تاریخدار چیست؟
این دو مفهوم شبیه هم هستند، اما یکسان نیستند.
| نوع لینک | تاریخ انقضا | تعداد استفاده | نیاز به State |
|---|---|---|---|
| Signed URL ساده | بله | نامحدود تا زمان انقضا | خیر |
| لینک یکبارمصرف Python | بله | یک بار | بله |
| Token با Redis | بله | یک یا چند بار طبق سیاست | بله |
اگر Token هیچجا ثبت نشود، سرور نمیتواند بفهمد قبلاً استفاده شده یا نه. به همین دلیل در این مقاله یک دیتابیس کوچک داریم که زمان ساخت، زمان انقضا و زمان مصرف Token را نگه میدارد.
معماری سیستم لینک دانلود یکبارمصرف
User | | GET /d/TOKEN v Flask | | Check token + expiration v Confirmation Page | | POST /d/TOKEN v Atomic consume in SQLite | | X-Accel-Redirect v Nginx internal location | v /srv/private-downloads/file.zip
نکته مهم این معماری این است که Flask فقط Token را بررسی میکند. فایل چندگیگابایتی از داخل Process پایتون عبور نمیکند؛ Nginx مسئول انتقال فایل است.
چرا فایل را با اولین GET تحویل نمیدهیم؟
اگر Token با اولین GET مصرف شود، ممکن است Preview Bot پیامرسان، Email Security Scanner، Antivirus یا Crawler قبل از کاربر URL را باز کند و لینک بدون اینکه کاربر فایل را دیده باشد بسوزد.
برای کاهش این مشکل، درخواست GET فقط یک صفحه تأیید نشان میدهد. Token زمانی مصرف میشود که کاربر دکمه «شروع دانلود» را بزند و Browser درخواست POST ارسال کند.
⚠️ توجه: این روش جلوی تمام Scannerهای پیشرفته را تضمینی نمیگیرد، اما احتمال سوختن ناخواسته لینک توسط Preview ساده را بسیار کمتر میکند. برای فایلهای بسیار حساس میتوان مرحله Login، PIN یا تأیید هویت کاربر را نیز اضافه کرد.
برای اجرای سیستم چه سروری نیاز داریم؟
این معماری برای تعداد کم تا متوسط دانلود بسیار سبک است؛ چون انتقال فایل بر عهده Nginx است و Python فقط چند Query کوچک روی دیتابیس اجرا میکند.
| استفاده | CPU | RAM | ذخیرهسازی |
|---|---|---|---|
| شخصی و آزمایشی | 1 vCPU | 1GB | براساس فایلها |
| دانلودسنتر کوچک | 2 vCPU+ | 2GB+ | SSD/NVMe متناسب با فایلها |
| ترافیک زیاد | براساس بار API | براساس Workerها | Network و Storage مهمتر هستند |
ساخت سیستم دانلود خصوصی روی سرور مجازی ایران
اگر فایلها برای کاربران داخل ایران هستند، میتوانید Python، Nginx و دیتابیس Token را روی یک سرور مجازی ایران قرار دهید. در این پروژه معمولاً سرعت Disk، فضای ذخیرهسازی، ترافیک و پهنای باند مهمتر از مصرف CPU برنامه Python هستند.
📌 تماس با پشتیبانی:
021-91302460
| ایرانیکاسرور
مرحله اول: نصب Python، Nginx و ابزارهای مورد نیاز
این آموزش روی Ubuntu 24.04 LTS قابل اجرا است. ابتدا سیستم را بهروزرسانی کنید:
sudo apt update sudo apt upgrade -y
سپس Python، محیط مجازی و Nginx را نصب کنید:
sudo apt install python3 python3-venv python3-pip nginx -y
نسخه Python را بررسی کنید:
python3 --version
مرحله دوم: ساخت User و مسیرهای پروژه
برنامه را با root اجرا نمیکنیم. یک User سیستمی مخصوص میسازیم:
sudo mkdir -p /opt/one-time-download sudo useradd --system \ --home /opt/one-time-download \ --shell /usr/sbin/nologin \ --gid www-data \ downloadapp sudo chown -R downloadapp:www-data /opt/one-time-download
مسیر فایلهای خصوصی را نیز ایجاد کنید:
sudo mkdir -p /srv/private-downloads sudo chown root:www-data /srv/private-downloads sudo chmod 750 /srv/private-downloads
فایلها را میتوانید با Permission محدود داخل این مسیر قرار دهید:
sudo install \ -o root \ -g www-data \ -m 640 \ /path/to/manual.pdf \ /srv/private-downloads/manual.pdf
مرحله سوم: ساخت Virtual Environment و نصب Flask
sudo -u downloadapp python3 -m venv /opt/one-time-download/venv sudo -u downloadapp /opt/one-time-download/venv/bin/pip install --upgrade pip sudo -u downloadapp /opt/one-time-download/venv/bin/pip install Flask==3.1.3 gunicorn
در زمان نگارش این مقاله Flask 3.1.3 نسخه پایدار فعلی در PyPI است. اگر بعداً نسخه جدیدتری منتشر شد، قبل از Upgrade تغییرات آن را بررسی کنید.
ساختار دیتابیس Tokenها
برای هر لینک اطلاعات زیر ذخیره میشود:
| فیلد | کاربرد |
|---|---|
| token_hash | SHA-256 Token اصلی |
| filename | نام فایل مجاز |
| expires_at | Unix Timestamp انقضا |
| used_at | زمان اولین مصرف Token |
| created_at | زمان ساخت لینک |
مرحله چهارم: کد کامل Python برای لینک یکبارمصرف
فایل برنامه را بسازید:
sudo nano /opt/one-time-download/app.py
کد زیر را داخل آن قرار دهید:
import hashlib
import os
import secrets
import sqlite3
import time
from pathlib import Path
from urllib.parse import quote
import click
from flask import Flask, Response, abort, render_template_string, request
app = Flask(__name__)
BASE_URL = os.getenv(
"BASE_URL",
"https://dl.example.com"
).rstrip("/")
FILE_ROOT = Path(
os.getenv(
"FILE_ROOT",
"/srv/private-downloads"
)
).resolve()
DB_PATH = Path(
os.getenv(
"DB_PATH",
"/opt/one-time-download/data/downloads.db"
)
)
CONFIRM_TEMPLATE = """
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>تأیید دانلود</title>
<style>
body {
font-family: sans-serif;
background: #f4f7fa;
color: #243746;
margin: 0;
padding: 30px 16px;
}
.box {
max-width: 620px;
margin: 60px auto;
background: #fff;
border: 1px solid #d8e3ed;
border-radius: 14px;
padding: 28px;
box-sizing: border-box;
}
h1 {
font-size: 22px;
margin-top: 0;
}
button {
border: 0;
background: #244f7a;
color: #fff;
padding: 13px 24px;
border-radius: 9px;
cursor: pointer;
font-size: 15px;
}
</style>
</head>
<body>
<div class="box">
<h1>لینک دانلود معتبر است</h1>
<p>فایل: <strong>{{ filename }}</strong></p>
<p>
این لینک فقط یک بار قابل استفاده است.
با شروع دانلود، Token مصرف خواهد شد.
</p>
<form method="post">
<button type="submit">شروع دانلود</button>
</form>
</div>
</body>
</html>
"""
def connect_db():
DB_PATH.parent.mkdir(
parents=True,
exist_ok=True
)
conn = sqlite3.connect(
DB_PATH,
timeout=5
)
conn.row_factory = sqlite3.Row
conn.execute(
"PRAGMA journal_mode=WAL"
)
conn.execute(
"PRAGMA busy_timeout=5000"
)
return conn
def init_db():
conn = connect_db()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS download_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS
idx_download_tokens_expires
ON download_tokens(expires_at)
""")
conn.commit()
finally:
conn.close()
def hash_token(token):
return hashlib.sha256(
token.encode("utf-8")
).hexdigest()
def validate_filename(filename):
if not filename:
raise ValueError("Empty filename")
path_name = Path(filename)
if path_name.name != filename:
raise ValueError(
"Subdirectories are not allowed"
)
full_path = (
FILE_ROOT / filename
).resolve()
if full_path.parent != FILE_ROOT:
raise ValueError(
"Invalid file path"
)
if not full_path.is_file():
raise FileNotFoundError(filename)
return full_path
def get_valid_token(token):
token_digest = hash_token(token)
now = int(time.time())
conn = connect_db()
try:
row = conn.execute("""
SELECT
filename,
expires_at,
used_at
FROM download_tokens
WHERE token_hash = ?
""", (token_digest,)).fetchone()
finally:
conn.close()
if row is None:
abort(404)
if row["used_at"] is not None:
abort(410)
if row["expires_at"] <= now:
abort(410)
try:
validate_filename(
row["filename"]
)
except (ValueError, FileNotFoundError):
abort(404)
return row
def consume_token(token):
token_digest = hash_token(token)
now = int(time.time())
conn = connect_db()
try:
conn.execute(
"BEGIN IMMEDIATE"
)
row = conn.execute("""
UPDATE download_tokens
SET used_at = ?
WHERE token_hash = ?
AND used_at IS NULL
AND expires_at > ?
RETURNING filename
""", (
now,
token_digest,
now
)).fetchone()
if row is None:
conn.rollback()
return None
conn.commit()
return row
except Exception:
conn.rollback()
raise
finally:
conn.close()
@app.route(
"/d/<token>",
methods=["GET", "POST"]
)
def download(token):
if request.method == "HEAD":
abort(405)
if request.method == "GET":
row = get_valid_token(token)
response = Response(
render_template_string(
CONFIRM_TEMPLATE,
filename=row["filename"]
),
status=200,
mimetype="text/html"
)
response.headers[
"Cache-Control"
] = "no-store"
response.headers[
"X-Robots-Tag"
] = "noindex, nofollow, noarchive"
return response
current = get_valid_token(token)
try:
validate_filename(
current["filename"]
)
except (ValueError, FileNotFoundError):
abort(404)
consumed = consume_token(token)
if consumed is None:
abort(410)
filename = consumed["filename"]
internal_uri = (
"/protected/"
+ quote(filename)
)
response = Response(
status=200
)
response.headers[
"X-Accel-Redirect"
] = internal_uri
response.headers[
"Content-Type"
] = "application/octet-stream"
response.headers[
"Content-Disposition"
] = (
"attachment; "
"filename*=UTF-8''"
+ quote(filename)
)
response.headers[
"Cache-Control"
] = "private, no-store"
response.headers[
"X-Robots-Tag"
] = "noindex, nofollow, noarchive"
return response
@app.errorhandler(410)
def gone(error):
return (
"این لینک منقضی شده یا قبلاً استفاده شده است.",
410
)
@app.cli.command("init-db")
def init_db_command():
init_db()
click.echo("Database initialized.")
@app.cli.command("create-link")
@click.argument("filename")
@click.option(
"--minutes",
default=60,
show_default=True,
type=click.IntRange(
min=1,
max=525600
)
)
def create_link(filename, minutes):
try:
validate_filename(filename)
except ValueError as exc:
raise click.ClickException(
str(exc)
)
except FileNotFoundError:
raise click.ClickException(
"File not found."
)
token = secrets.token_urlsafe(32)
token_digest = hash_token(token)
now = int(time.time())
expires_at = now + (minutes * 60)
conn = connect_db()
try:
conn.execute("""
INSERT INTO download_tokens (
token_hash,
filename,
expires_at,
used_at,
created_at
)
VALUES (?, ?, ?, NULL, ?)
""", (
token_digest,
filename,
expires_at,
now
))
conn.commit()
finally:
conn.close()
click.echo(
f"{BASE_URL}/d/{token}"
)
@app.cli.command("cleanup")
@click.option(
"--days",
default=7,
show_default=True,
type=click.IntRange(min=1)
)
def cleanup(days):
now = int(time.time())
cutoff = now - (days * 86400)
conn = connect_db()
try:
result = conn.execute("""
DELETE FROM download_tokens
WHERE expires_at < ?
OR (
used_at IS NOT NULL
AND used_at < ?
)
""", (
cutoff,
cutoff
))
conn.commit()
click.echo(
f"Deleted {result.rowcount} records."
)
finally:
conn.close()
بعد از ذخیره فایل، مالکیت آن را اصلاح کنید:
sudo chown downloadapp:www-data \ /opt/one-time-download/app.py sudo chmod 640 /opt/one-time-download/app.py
چرا از secrets.token_urlsafe استفاده کردیم؟
برای Token امنیتی نباید از random معمولی، Timestamp، شناسه افزایشی یا ترکیب قابل حدس استفاده کرد. ماژول استاندارد secrets پایتون مخصوص تولید مقدارهای رمزنگاریشده و غیرقابل پیشبینی طراحی شده است.
در این کد:
token = secrets.token_urlsafe(32)
32 بایت Random Data تولید میشود. Token نهایی نیز برای استفاده داخل URL مناسب است.
چرا Token خام را داخل دیتابیس ذخیره نمیکنیم؟
اگر Database به هر دلیل افشا شود و Tokenهای خام داخل آن باشند، مهاجم میتواند لینکهای معتبر را مستقیماً استفاده کند.
در این معماری فقط این مقدار ذخیره میشود:
SHA256(token)
Token اصلی فقط داخل URL تحویل کاربر میشود. وقتی درخواست میرسد، سرور Token را Hash میکند و Hash را با Database مقایسه میکند.
چون خود Token 32 بایت Random Data دارد، استفاده از SHA-256 بدون Salt در این کاربرد با Hash کردن Password متفاوت است؛ مهاجم فضای قابل حدس کوچکی برای Brute Force ندارد.
چطور جلوی دو دانلود همزمان را میگیریم؟
فرض کنید دو نفر در یک لحظه روی یک لینک کلیک کنند. این پیادهسازی نباید ابتدا SELECT کند، بعد چند میلیثانیه بعد used_at را تغییر دهد؛ چون ممکن است هر دو Request قبل از تغییر وضعیت Token را معتبر ببینند.
در کد از یک UPDATE شرطی استفاده کردهایم:
UPDATE download_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? RETURNING filename
همچنین Transaction با BEGIN IMMEDIATE شروع میشود. در نتیجه اولین Request که Token را Claim کند آن را مصرف میکند و Request دوم دیگر شرط used_at IS NULL را پاس نخواهد کرد.
مرحله پنجم: ساخت دیتابیس SQLite
ابتدا پوشه Data را ایجاد کنید:
sudo mkdir -p /opt/one-time-download/data sudo chown downloadapp:www-data \ /opt/one-time-download/data
سپس Database را Initialize کنید:
cd /opt/one-time-download sudo -u downloadapp ./venv/bin/flask --app app init-db
باید پیام زیر را ببینید:
Database initialized.
تست اولیه برنامه قبل از Nginx
برای تست موقت میتوانید Flask را فقط روی Localhost اجرا کنید:
cd /opt/one-time-download sudo -u downloadapp ./venv/bin/flask --app app run --host 127.0.0.1 --port 5000
⚠️ Flask Development Server را برای Production استفاده نکنید. در ادامه برنامه را با Gunicorn و systemd اجرا میکنیم.
مرحله ششم: اجرای Flask با Gunicorn و systemd
Service جدید ایجاد کنید:
sudo nano \ /etc/systemd/system/one-time-download.service
محتوا:
[Unit]
Description=One Time Download Service
After=network.target
[Service]
User=downloadapp
Group=www-data
WorkingDirectory=/opt/one-time-download
Environment="BASE_URL=https://dl.example.com"
Environment="FILE_ROOT=/srv/private-downloads"
Environment="DB_PATH=/opt/one-time-download/data/downloads.db"
RuntimeDirectory=one-time-download
RuntimeDirectoryMode=0750
ExecStart=/opt/one-time-download/venv/bin/gunicorn
--workers 2
--bind unix:/run/one-time-download/gunicorn.sock
--umask 007
--access-logfile -
--error-logfile -
app:app
Restart=on-failure
RestartSec=3
PrivateTmp=true
NoNewPrivileges=true
ProtectHome=true
[Install]
WantedBy=multi-user.target
دامنه را با Domain خودتان جایگزین کنید. سپس:
sudo systemctl daemon-reload sudo systemctl enable --now one-time-download sudo systemctl status one-time-download
مرحله هفتم: اتصال Nginx به Python
یک Virtual Host بسازید:
sudo nano \ /etc/nginx/sites-available/one-time-download
کانفیگ:
server {
listen 80;
server_name dl.example.com;
location / {
proxy_pass http://unix:/run/one-time-download/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /protected/ {
internal;
alias /srv/private-downloads/;
add_header X-Content-Type-Options nosniff always;
}
}
کانفیگ را فعال کنید:
sudo ln -s \ /etc/nginx/sites-available/one-time-download \ /etc/nginx/sites-enabled/one-time-download sudo nginx -t sudo systemctl reload nginx
چرا Location فایلها internal است؟
اگر کاربر بتواند مستقیم آدرس زیر را باز کند:
https://dl.example.com/protected/manual.pdf
کل سیستم Token بیفایده میشود. دستور internal در Nginx باعث میشود این مسیر از درخواست مستقیم خارجی قابل استفاده نباشد.
اما Flask بعد از تأیید Token این Header را برمیگرداند:
X-Accel-Redirect: /protected/manual.pdf
Nginx این درخواست داخلی را قبول کرده و فایل را مستقیماً ارسال میکند.
چرا فایل را با send_file پایتون ارسال نکردیم؟
برای فایلهای کوچک استفاده مستقیم از Flask قابل انجام است، اما برای دانلودسنتر انتخاب بهینهای نیست. اگر چند کاربر فایلهای چندگیگابایتی دانلود کنند، Workerهای برنامه مدت زیادی درگیر انتقال فایل میشوند.
در این معماری:
🔹 Python Token را بررسی میکند.
🔹 SQLite Token را مصرف میکند.
🔹 Nginx فایل را تحویل کاربر میدهد.
به این ترتیب Application Server آزاد میماند تا درخواستهای دیگر را پردازش کند.
مرحله هشتم: فعال کردن HTTPS
Token داخل URL یک Credential محسوب میشود. لینک را روی HTTP ارسال نکنید، چون ممکن است Token در مسیر شبکه دیده شود.
پس از اتصال رکورد A دامنه به IP سرور، Certbot را نصب کنید و Certificate دریافت کنید:
sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/local/bin/certbot sudo certbot --nginx -d dl.example.com
مرحله نهم: ساخت اولین لینک یکبارمصرف
فرض کنیم فایل زیر وجود دارد:
/srv/private-downloads/manual.pdf
برای ساخت لینک 60 دقیقهای:
cd /opt/one-time-download sudo -u downloadapp ./venv/bin/flask --app app create-link manual.pdf --minutes 60
خروجی چیزی شبیه این خواهد بود:
https://dl.example.com/d/3jPn7yP86fL9Jk1R...TOKEN...
این URL را برای کاربر ارسال کنید.
وقتی کاربر لینک را باز میکند چه اتفاقی میافتد؟
بار اول با GET:
🔹 Token Hash میشود.
🔹 وجود Token در Database بررسی میشود.
🔹 تاریخ انقضا بررسی میشود.
🔹 used_at باید خالی باشد.
🔹 صفحه تأیید نمایش داده میشود.
بعد از کلیک روی «شروع دانلود»:
🔹 Request از نوع POST ارسال میشود.
🔹 Token در Transaction اتمیک مصرف میشود.
🔹 used_at ثبت میشود.
🔹 Flask هدر X-Accel-Redirect ارسال میکند.
🔹 Nginx فایل را تحویل میدهد.
اگر دوباره روی لینک کلیک شود چه میشود؟
چون used_at دیگر NULL نیست، Token معتبر شناخته نمیشود و پاسخ زیر ارسال خواهد شد:
410 Gone
همین پاسخ برای Token منقضیشده نیز استفاده شده است.
ساخت لینک با مدت اعتبار متفاوت
لینک 10 دقیقهای:
sudo -u downloadapp \ ./venv/bin/flask \ --app app \ create-link file.zip \ --minutes 10
لینک 24 ساعته:
sudo -u downloadapp \ ./venv/bin/flask \ --app app \ create-link file.zip \ --minutes 1440
حتی اگر لینک 24 ساعت اعتبار داشته باشد، پس از اولین دانلود دیگر قابل استفاده نخواهد بود.
یک محدودیت مهم لینک واقعاً یکبارمصرف
در این طراحی Token قبل از شروع انتقال فایل مصرف میشود. این رفتار برای امنیت Strict مناسب است، اما یک پیامد دارد.
اگر کاربر دانلود را شروع کند و اینترنت او در وسط کار قطع شود، همان Token دیگر برای شروع دوباره دانلود معتبر نیست.
⚠️ اگر Resume برای شما مهم است: بهجای Token تکمصرف Strict باید مدل Session-Based طراحی کنید. برای مثال Token بعد از اولین کلیک وارد وضعیت active شود و برای چند دقیقه یا برای IP/User مشخص امکان Range Request و Resume داشته باشد؛ سپس پس از پایان Session باطل شود.
پاک کردن Tokenهای قدیمی از دیتابیس
در کد یک فرمان cleanup نیز داریم. برای حذف Tokenهای قدیمیتر از هفت روز:
cd /opt/one-time-download sudo -u downloadapp ./venv/bin/flask --app app cleanup --days 7
میتوانید همین فرمان را با Cron یا systemd timer روزانه اجرا کنید.
SQLite، PostgreSQL یا Redis؛ کدام بهتر است؟
| راهکار | مناسب برای | مزیت | محدودیت |
|---|---|---|---|
| SQLite | پروژه کوچک و متوسط | ساده و بدون سرویس جدا | Write Concurrency محدودتر |
| PostgreSQL | سامانه Production بزرگتر | Transaction و Concurrency قوی | نیازمند سرویس جدا |
| Redis | Tokenهای کوتاهعمر و ترافیک زیاد | TTL و عملیات اتمیک سریع | نیازمند Redis و طراحی Persistence |
برای یک دانلودسنتر شخصی یا سیستم تحویل فایل به مشتری، SQLite معمولاً شروع بسیار خوبی است. اگر صدها یا هزاران Token همزمان در ثانیه پردازش میکنید، PostgreSQL یا Redis ارزش بررسی دارند.
آیا میتوان Token را به IP کاربر قفل کرد؟
بله. میتوانید هنگام ساخت لینک IP مجاز را نیز داخل دیتابیس ذخیره کنید و هنگام Download مقدار request.remote_addr را مقایسه کنید.
اما این روش همیشه مناسب نیست؛ IP کاربران موبایل، VPN و برخی سرویسدهندگان ممکن است تغییر کند. اگر Nginx یا CDN جلوی Flask قرار دارد نیز باید Real Client IP را بهصورت صحیح و امن Forward کنید.
روش بهتر برای سایتهای دارای حساب کاربری
اگر سایت شما Login دارد، بهتر است Token علاوه بر فایل به User ID نیز متصل شود.
در این حالت حتی اگر لینک افشا شود، سرور قبل از Download بررسی میکند که User فعلی همان شخصی است که Token برای او صادر شده است.
این مدل برای فروش فایل، پنل مشتریان، فاکتور، Backup خصوصی یا تحویل اسناد امنیت بیشتری نسبت به لینک ناشناس دارد.
اضافه کردن محدودیت سرعت دانلود
اگر میخواهید همان قابلیت مقاله دانلودسنتر را نیز داشته باشید، میتوانید داخل Location داخلی Nginx سرعت را محدود کنید:
location /protected/ {
internal;
alias /srv/private-downloads/;
limit_rate_after 5m;
limit_rate 2m;
add_header X-Content-Type-Options nosniff always;
}
در این مثال پس از پنج مگابایت ابتدایی، سرعت هر Request تقریباً به دو مگابایت بر ثانیه محدود میشود.
چطور مطمئن شویم لینک واقعاً یکبارمصرف است؟
یک لینک پنجدقیقهای بسازید و آن را در Browser باز کنید. صفحه تأیید باید نمایش داده شود.
روی «شروع دانلود» بزنید. فایل باید Download شود.
حالا همان URL را دوباره باز کنید. پاسخ باید:
410 Gone
برای تست Race Condition نیز میتوانید لینک را در دو Browser مختلف باز کنید و تقریباً همزمان دکمه Download را بزنید. فقط یکی از Requestها باید Token را Claim کند.
مشاهده Log برنامه Python
چون Gunicorn از طریق systemd اجرا میشود، Logها را میتوانید با journalctl مشاهده کنید:
sudo journalctl \ -u one-time-download \ -f
Logهای Nginx نیز در مسیرهای معمول زیر قرار دارند:
sudo tail -f /var/log/nginx/access.log sudo tail -f /var/log/nginx/error.log
چکلیست امنیتی لینکهای دانلود یکبارمصرف
✅ از secrets و Token تصادفی قوی استفاده کنید.
✅ Token خام را در دیتابیس ذخیره نکنید.
✅ Token را داخل Logهای Application چاپ نکنید.
✅ فایلها را در مسیر public سایت قرار ندهید.
✅ از Nginx internal برای مسیر واقعی فایل استفاده کنید.
✅ HTTPS را اجباری کنید.
✅ زمان اعتبار Token را بیش از نیاز طولانی نکنید.
✅ برنامه را با root اجرا نکنید.
✅ Permission دیتابیس و فایلها را محدود کنید.
✅ برای فایلهای حساستر Token را به User Account متصل کنید.
لینک یکبارمصرف چه چیزی را محافظت نمیکند؟
لینک یکبارمصرف کنترل میکند چند بار فایل از سرور شما دریافت شود، اما DRM نیست.
بعد از اینکه کاربر فایل را دانلود کرد، فایل روی دستگاه او قرار دارد و میتواند آن را کپی یا برای شخص دیگری ارسال کند. اگر خود فایل نیز باید محافظت شود، به راهکارهای دیگری مانند Encryption، Watermark، DRM یا کنترل دسترسی داخل خود فایل نیاز دارید.
رفع خطاهای رایج
502 Bad Gateway
وضعیت Gunicorn را بررسی کنید:
sudo systemctl status one-time-download ls -lah /run/one-time-download/gunicorn.sock
لینک بلافاصله 404 میشود
مطمئن شوید فایل واقعاً داخل /srv/private-downloads قرار دارد و نام فایل دقیقاً با نامی که هنگام create-link وارد کردهاید یکسان است.
لینک بلافاصله 410 میشود
زمان سیستم را بررسی کنید:
timedatectl
همچنین ممکن است Token قبلاً مصرف شده باشد.
Nginx فایل را پیدا نمیکند
Permission مسیر را بررسی کنید:
namei -l \ /srv/private-downloads/manual.pdf
User یا Group مربوط به Nginx باید بتواند فایل را Read کند.
database is locked
برای پروژه کوچک WAL و busy_timeout کمک میکنند، اما اگر Write همزمان بسیار زیاد دارید، زمان مهاجرت از SQLite به PostgreSQL یا Redis رسیده است.
در راهاندازی Python، Nginx یا سیستم دانلود مشکل دارید؟
اگر در نصب Python، راهاندازی Gunicorn، تنظیم Nginx، SSL، Permission فایلها یا انتخاب VPS مناسب برای دانلودسنتر مشکل دارید، میتوانید درخواست بررسی و کانفیگ سرور را برای ایرانیکاسرور ارسال کنید.
📌 تماس با پشتیبانی:
021-91302460
| ایرانیکاسرور
سؤالات متداول درباره لینک دانلود یکبارمصرف با Python
لینک دانلود یکبارمصرف چگونه کار میکند؟
برای هر فایل یک Token تصادفی ساخته و وضعیت آن در دیتابیس ذخیره میشود. هنگام اولین دانلود Token به حالت مصرفشده تغییر میکند و درخواستهای بعدی رد میشوند.
چرا فقط Signed URL کافی نیست؟
Signed URL بدون ذخیره State فقط میتواند صحت لینک و تاریخ انقضا را بررسی کند. سرور بدون دیتابیس یا State نمیداند URL قبلاً استفاده شده است یا نه.
آیا Token خام در SQLite ذخیره میشود؟
خیر. در این پیادهسازی فقط SHA-256 Token ذخیره میشود و Token اصلی فقط در URLی قرار میگیرد که به کاربر تحویل داده میشود.
اگر دو نفر همزمان یک لینک را باز کنند چه میشود؟
هر دو ممکن است صفحه تأیید را ببینند، اما مصرف Token با Transaction و UPDATE شرطی انجام میشود؛ بنابراین فقط اولین درخواست POST موفق خواهد شد.
چرا فایل را با Flask ارسال نمیکنیم؟
برای فایلهای بزرگ بهتر است Flask فقط مجوز را بررسی کند و Nginx با X-Accel-Redirect فایل را مستقیماً ارسال کند تا Workerهای Python درگیر انتقال طولانی فایل نشوند.
آیا لینک تاریخ انقضا هم دارد؟
بله. هنگام ساخت Token مدت اعتبار مشخص میشود. لینک با اولین استفاده یا رسیدن به زمان انقضا، هرکدام زودتر اتفاق بیفتد، غیرقابل استفاده میشود.
آیا دانلود قطعشده قابل Resume است؟
در حالت Strict این مقاله خیر؛ Token با شروع Download مصرف میشود. برای Resume باید مدل Session-Based با مدت اعتبار کوتاه طراحی شود.
SQLite برای Production مناسب است؟
برای سیستمهای کوچک و متوسط با Write محدود میتواند مناسب باشد. برای ترافیک و Concurrency بالا بهتر است PostgreSQL یا Redis بررسی شود.
آیا میتوان لینک را برای یک کاربر خاص ساخت؟
بله. میتوان User ID، IP، Order ID یا هر شناسه دیگری را کنار Token ذخیره کرد و پیش از مصرف Token آن را بررسی کرد.
جمعبندی
برای ساخت لینک دانلود واقعاً یکبارمصرف، فقط اضافه کردن Timestamp یا Signature به URL کافی نیست. سرور باید وضعیت Token را نگه دارد تا بتواند تشخیص دهد یک لینک قبلاً مصرف شده است.
در معماری این مقاله Python و Flask مسئول Authentication لینک، SQLite مسئول نگهداری State و Nginx مسئول انتقال سریع فایل است. Token با secrets ساخته میشود، فقط Hash آن در دیتابیس قرار میگیرد و عملیات مصرف با Transaction اتمیک انجام میشود.
این ساختار برای تحویل فایل به مشتری، محصول دیجیتال، گزارش خصوصی، Backup، خروجی نرمافزار، فایل پروژه یا هر سناریویی که دسترسی کوتاهمدت و کنترلشده نیاز دارد، نقطه شروع مناسبی است.
اگر پروژه بزرگتر شود، همین معماری را میتوان با PostgreSQL یا Redis، پنل مدیریت Token، API ساخت لینک، محدودیت تعداد دانلود، احراز هویت کاربر، ثبت Audit Log و Session قابل Resume توسعه داد.





دیدگاهها (۰)