مجله ایرانیکاسرورآموزش سرور، هاست، وردپرس و شبکه
دسته‌بندی نشده

ساخت لینک دانلود یک‌بارمصرف با پایتون | Flask و Nginx

ساخت لینک دانلود یک‌بارمصرف با پایتون؛ Token امن، تاریخ انقضا و Nginx اگر می‌خواهید یک فایل را فقط یک بار در اختیار کاربر قرار دهید، لینک تاریخ‌دار Nginx به‌تنهایی کافی نیست. لینک امضاشده تا…

ساخت لینک دانلود یک‌بارمصرف با پایتون | Flask و Nginx
خلاصه سریع این آموزش

ساخت لینک دانلود یک‌بارمصرف با پایتون؛ Token امن، تاریخ انقضا و Nginx اگر می‌خواهید یک فایل را فقط یک بار در اختیار کاربر قرار دهید، لینک تاریخ‌دار Nginx به‌تنهایی کافی…

۱۹ دقیقه مطالعه ۰ دیدگاه ۱۰ مهر ۱۴۰۵ آخرین به‌روزرسانی

فهرست مطالب

ساخت لینک دانلود یک‌بارمصرف با پایتون؛ 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 توسعه داد.

اشتراک‌گذاری مقاله
گفت‌وگو درباره آموزش

دیدگاه‌ها (۰)

هنوز دیدگاهی ثبت نشده است.

دیدگاه یا سؤال خود را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *