搜索全站
建站 15 分钟阅读

用 WordPress 做资源下载站:下载 CPT、权限控制与防盗链

资源站最怕被盗链拖垮带宽。本文给出下载 CPT 与文件字段的完整实现,讲清公开直链、中间页校验、nginx X-Accel-Redirect 三种下载方式的性能差异,并附防盗链与限速配置。

U
UFO_PF 2025-06-12 · 阅读 15 分钟
用 建站
阅读提示
本文约 5,902 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:一个月跑掉 3TB 流量,账单比服务器还贵

有人把模板素材放在 WordPress 的 uploads 目录里,文章里直接写 https://站点/wp-content/uploads/xxx.zip。上线第一个月收到云厂商账单:CDN 流量 3TB,费用是服务器本身的四倍。查日志发现,这个 zip 被十几个采集站直接引用,还有人把它放进了自己的”资源合集”页面——所有下载请求都不经过他的站点,纯给别人贡献带宽。

资源下载站有三个绕不开的问题:文件放哪、谁能下、怎么防止被盗链。第一个决定成本结构,第二个决定商业模式,第三个决定站点能不能活下去。很多人的做法是”先放上去再说”,等账单来了才回头补救,此时外链已经扩散,改路径会导致大量失效引用。因此这三件事必须在建站初期就定好。

原理分析:三种下载方式的性能与安全性对比

第一种是公开直链,即文件放在 Web 可访问目录,直接给出 URL。优点是不消耗 PHP 进程,缺点是零权限控制、零防盗链。

第二种是 PHP 中间页校验,先验证登录态与 nonce,再用 readfile() 输出文件。优点是权限可控,缺点是大文件会长时间占用 PHP 进程和内存,几十个人同时下载就能把 PHP-FPM 打满。

第三种是 nginx 的 X-Accel-Redirect(Apache 对应 X-Sendfile):PHP 只负责校验,校验通过后返回一个特殊响应头,由 Web 服务器接管文件传输。这兼顾了权限控制与传输性能,是资源站的正确解法。

方式 权限控制 性能 防盗链
公开直链 无 高 无
PHP readfile 强 低 中
X-Accel-Redirect 强 高 强

防盗链则靠两个层面:一是 nginx 的 valid_referers 判断来源,二是签名链接(时间戳 + 密钥生成哈希,过期失效)。两者结合,既拦普通盗链,也防高级抓取。

保姆级实操

步骤 1:注册下载 CPT 与资源分类法。

add_action( 'init', 'mimangfei_register_download_cpt' );
function mimangfei_register_download_cpt() {
    register_post_type( 'download_item', array(
        'labels'      => array(
            'name'          => '资源下载',
            'singular_name' => '资源',
            'add_new_item'  => '添加资源',
        ),
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => array( 'slug' => 'downloads', 'with_front' => false ),
        'supports'     => array( 'title', 'editor', 'thumbnail' ),
        'show_in_rest' => true,
        'menu_icon'    => 'dashicons-download',
    ) );

    register_taxonomy( 'download_cat', 'download_item', array(
        'label'        => '资源分类',
        'public'       => true,
        'hierarchical' => true,
        'rewrite'      => array( 'slug' => 'downloads/cat' ),
        'show_in_rest' => true,
    ) );
}

步骤 2:用媒体库管理文件并记录元信息。 上传后记录文件 ID、大小、版本,便于前台展示。

add_action( 'save_post_download_item', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    $att_id = isset( $_POST['download_file_id'] ) ? absint( $_POST['download_file_id'] ) : 0;
    if ( ! $att_id ) {
        return;
    }
    update_post_meta( $post_id, '_file_id', $att_id );

    $path = get_attached_file( $att_id );
    if ( $path && file_exists( $path ) ) {
        update_post_meta( $post_id, '_file_size', filesize( $path ) );
        update_post_meta( $post_id, '_file_hash', hash_file( 'sha256', $path ) );
    }
} );

步骤 3:下载处理——校验后用 X-Accel-Redirect 输出。

add_action( 'template_redirect', function () {
    if ( ! isset( $_GET['dl'] ) ) {
        return;
    }
    $post_id = absint( $_GET['dl'] );
    $nonce   = isset( $_GET['_wpnonce'] ) ? $_GET['_wpnonce'] : '';

    if ( ! wp_verify_nonce( $nonce, 'download_' . $post_id ) ) {
        wp_die( '下载链接无效或已过期', '下载失败', array( 'response' => 403 ) );
    }

    if ( ! current_user_can( 'read' ) ) {
        wp_die( '请先登录后再下载', '需要登录', array( 'response' => 401 ) );
    }

    $att_id = (int) get_post_meta( $post_id, '_file_id', true );
    $path   = $att_id ? get_attached_file( $att_id ) : '';
    if ( ! $path || ! file_exists( $path ) ) {
        wp_die( '文件不存在', '下载失败', array( 'response' => 404 ) );
    }

    $clicks = (int) get_post_meta( $post_id, '_dl_count', true );
    update_post_meta( $post_id, '_dl_count', $clicks + 1 );

    // 交给 nginx 传输文件,PHP 进程到此为止
    nocache_headers();
    header( 'Content-Type: application/octet-stream' );
    header( 'Content-Disposition: attachment; filename="' . rawurlencode( basename( $path ) ) . '"' );
    header( 'Content-Length: ' . filesize( $path ) );
    header( 'X-Accel-Redirect: /protected-files/' . basename( $path ) );
    exit;
} );

步骤 4:让文件目录不可直接访问。 文件放到 Web 根目录之外,只通过内部 location 暴露给 X-Accel-Redirect。

# 真实文件放在 /data/files,不对外暴露
location /protected-files/ {
    internal;
    alias /data/files/;
    limit_rate 2m;                 # 单连接限速 2MB/s
    limit_rate_after 20m;          # 前 20MB 不限速
}

# 防盗链:仅允许本站与空 referer 访问静态资源
location ~* \.(zip|rar|7z|pdf|mp4)$ {
    valid_referers none blocked server_names *.mimangfei.com;
    if ($invalid_referer) {
        return 403;
    }
    expires 7d;
    add_header Cache-Control "public";
}

步骤 5:生成带有效期的签名下载链接。 用于临时分享,60 分钟后自动失效。

function mimangfei_signed_download_url( $post_id, $ttl = 3600 ) {
    $secret = wp_salt( 'auth' );
    $expire = time() + $ttl;
    $sig    = hash_hmac( 'sha256', $post_id . '|' . $expire, $secret );
    return add_query_arg( array(
        'dl'       => $post_id,
        'expires'  => $expire,
        'sig'      => $sig,
    ), home_url( '/download/' ) );
}

步骤 6:前端输出下载按钮。 带上 nonce,防止链接被随意传播。

<?php
$post_id = get_the_ID();
$url     = add_query_arg( array(
    'dl'       => $post_id,
    '_wpnonce' => wp_create_nonce( 'download_' . $post_id ),
), home_url( '/download/' ) );
$size    = size_format( (int) get_post_meta( $post_id, '_file_size', true ) );
?>
<a class="btn-download" href="<?php echo esc_url( $url ); ?>" rel="nofollow">
    立即下载(<?php echo esc_html( $size ); ?>)
</a>

常见踩坑

  • 用 readfile() 输出 500MB 的大文件,并发一上来 PHP-FPM 进程全被占满,整个站点 502。
  • internal location 的 alias 路径写错,下载时报 nginx 404 且错误日志提示 “open() … failed (2: No such file or directory)”。
  • valid_referers 把搜索引擎和微信内置浏览器的来源也拦了,正常分享打不开。
  • 文件仍在 wp-content/uploads/ 下,防盗链形同虚设,别人绕过中间页直接下载。
  • nonce 默认 24 小时有效期,下载按钮被缓存后链接失效,页面显示 “The link you followed has expired.”
  • 案例:某素材站用 PHP 直接输出文件,平时没问题。一次活动推流后 200 人同时下载一个 800MB 的压缩包,PHP 进程瞬间耗尽,不仅下载失败,连前台页面都打不开。改成 X-Accel-Redirect 后,同样的并发下 PHP 只承担校验,文件由 nginx 传输,CPU 占用下降九成。
  • 下载计数写在页面渲染里,被缓存后计数不准(应放在跳转处理流程中)。
  • 只做了 referer 防盗链,被人直接抓包拿 URL,完全没有时效性限制。

总结

资源站的验收标准是三个”能不能”:能不能精确控制”谁可以下载”、能不能防止文件被直接盗链、能不能在大并发下不影响正常访问。行动清单:① 文件放 Web 根目录之外;② 用 X-Accel-Redirect 而非 readfile();③ 下载链接带 nonce 或签名;④ valid_referers 放行空来源与搜索引擎;⑤ 限速与流量告警;⑥ 计数逻辑放在跳转流程中。存储与带宽成本如何压下来,见 资源站文件存储与流量成本、素材站会员下载权限设计 与 WordPress 防盗链配置。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。