首页
/ copyparty 的 on404/on403 Handler 插件机制:自定义 404、随机图片重定向与权限覆盖

copyparty 的 on404/on403 Handler 插件机制:自定义 404、随机图片重定向与权限覆盖

2026-09-07 17:18:20作者:晏闻田Solitary

copyparty 允许你用一个 Python 脚本"劫持"HTTP 404 与 403 响应,为不存在的文件或无权限的请求提供自定义处理逻辑。本文基于仓库中的 handler 插件说明HTTP 客户端实现 展开,讲清 handler 插件的加载方式、main(cli, vn, rem) API、服务端调用链与返回值语义,并逐个走读 bin/handlers/ 下的官方示例,最后说明该机制的适用边界与安全注意点。

一、handler 插件解决什么问题

默认的 Web 文件服务器在文件缺失时只能返回固定的 404 页面,而 copyparty 通过 handler 插件把这两个"失败响应"变成了可编程的钩子:

  • on404:文件/目录不存在时执行的插件,可以返回自定义消息、重定向客户端、从同目录随机挑一张图代替,甚至自动"变出"文件让 404 彻底消失;
  • on403:用户无访问权限时执行的插件,最典型的用途是按客户端 IP 等条件放行访问。

这两个配置项在 配置解析表 中被注册为 volume 级(卷级)flag:

"handlers\n(better explained in --help-handlers)": {
    "on404=PY": "handle 404s by executing PY file",
    "on403=PY": "handle 403s by executing PY file",
}

即它们以 on404=PY文件路径 / on403=PY文件路径 的形式配置,值为任意一个定义了 main() 的 Python 文件。

二、加载方式:全局或单卷

按照 插件说明文档,加载方式有两种:

  1. 全局加载:启动参数中直接指定,例如让所有卷的 404 都交给 sorry.py 处理:

    copyparty --on404 ~/dev/copyparty/bin/handlers/sorry.py /somedir
    
  2. 针对特定卷加载:在卷名前加 flag 前缀,语法为 :flag1,flag2=...,例如只对某个卷启用:

    copyparty :c,on404=~/handlers/sorry.py /somedir
    

    这里 c 表示该卷可写,on404=~/handlers/sorry.py 表示 404 由该插件接管。卷级 flag 会记录在 VFS 节点的 flags 字典中,服务端正是通过 vn.flags["on404"] / vn.flags["on403"] 判断是否有插件可用。

三、插件 API:main(cli, vn, rem)

每个 handler 插件必须定义 main(),接收 3 个参数(原文档 api 一节):

参数 含义
cli copyparty/httpcli.py 的连接实例,可访问 cli.ip(客户端 IP)、cli.vpath(原始请求路径)、cli.reply()(直接写响应)、cli.redirect()cli.can_read
vn 与请求 URL 重叠的 VFS 节点(volume node),可通过 vn.vpath 得到卷挂载路径、vn.canonical(rem) 把剩余路径解析为真实文件系统路径
rem 位于该 VFS 挂载点之下的 URL 剩余部分

三者满足 vn.vpath + rem == cli.vpath == 原始请求路径 的对应关系。注意 redirect.py 中特别强调:vn.vpathcli.vpath 都不带前导 /,拼接或打印时需要自行补上。

四、服务端调用链:on40x 如何执行插件

4.1 统一入口 on40x

httpcli.py 中的 on40x() 是 404 与 403 钩子的共同实现:

def on40x(self, mods: list[str], vn: VFS, rem: str) -> str:
    for mpath in mods:
        try:
            mod = loadpy(mpath, self.args.hot_handlers)
        except Exception as ex:
            self.log("import failed: {!r}".format(ex))
            continue

        ret = mod.main(self, vn, rem)
        if ret:
            return ret.lower()

    return ""  # unhandled / fallthrough

从源码结构看有几个关键行为:

  • 插件按列表顺序依次尝试,第一个返回非空值的插件生效,其余插件被跳过;
  • 插件文件导入失败(语法错误、路径不存在等)只会记一条 import failed 日志并 continue,不会让服务崩溃;
  • 返回值会被 lower() 归一化后交给调用方判断;
  • 所有插件都没处理时返回空串 "",表示"unhandled / fallthrough",走默认响应路径。

4.2 插件文件的运行时加载 loadpy

插件不是随服务启动静态导入的,而是由 util.py 中的 loadpy() 在请求触发时动态加载:

def loadpy(ap: str, hot: bool) -> Any:
    ap = os.path.expanduser(expand_osenv_c(ap))
    mdir, mfile = os.path.split(absreal(ap))
    mname = mfile.rsplit(".", 1)[0]
    sys.path.insert(0, mdir)
    ...

它支持 ~ 展开与环境变量展开,并把插件所在目录临时插入 sys.path,因此插件可以相对导入同目录的其他模块。第二个参数来自 self.args.hot_handlers——从参数命名可以推断,开启该选项后插件支持热重载,便于调试时改完即生效。

4.3 404 钩子触发点

GET 处理流程中,当对真实路径 bos.stat(abspath) 失败(文件不存在)时:

except:
    if "on404" not in vn.flags:
        return self.tx_404(not self.can_read)

    ret = self.on40x(vn.flags["on404"], vn, rem)
    if ret == "true":
        return True
    elif ret == "false":
        return False
    elif ret == "retry":
        ...

也就是说:卷上没配 on404 就原样返回 404;配了就交给插件,插件返回 "true" 表示"请求已处理完毕"。

4.4 403 钩子触发点与 "allow" 权限覆盖

403 分支位于 权限检查处:当用户对该资源 can_readcan_writecan_get 全部为假,且卷配置了 on403 时触发:

if not self.can_read and not self.can_write and not self.can_get:
    t = "@%s has no access to %r"

    if self.vn.realpath and "on403" in self.vn.flags:
        t += " (on403)"
        ...
        ret = self.on40x(self.vn.flags["on403"], self.vn, self.rem)
        if ret == "true":
            return True
        elif ret == "false":
            return False
        elif ret == "home":
            self.uparam["h"] = ""
        elif ret == "allow":
            self.log("plugin override; access permitted")
            self.can_read = self.can_write = self.can_move = True
            self.can_delete = self.can_get = self.can_upget = True
            self.can_admin = True
        else:
            return self.tx_404(True)

从这段代码可以确认插件返回值的完整语义(针对 403 场景):

返回值 效果
"true" 请求视为已处理,停止后续处理
"false" 请求被拒绝,按访问失败处理
"home" 把用户重定向/重置到首页上下文
"allow" 权限覆盖:把 can_readcan_writecan_movecan_deletecan_getcan_upgetcan_admin 全部置为 True,即插件放行后用户获得该卷的完整管理权限
空串/其他 按默认 404 处理

这意味着 403 插件返回 "allow" 是极强的动作——它不是一般意义的"允许只读",而是授予全部权限,编写插件时需要明确意识到这一点。

五、官方示例插件走读

bin/handlers/ 目录提供了 8 个示例,以下选取 4 个核心示例完整讲解。

5.1 sorry.py —— 自定义 404 消息

sorry.py 全文只有几行,是理解 API 的最小样板:

def main(cli, vn, rem):
    msg = f"sorry {cli.ip} but {cli.vpath} doesn't exist"

    return str(cli.reply(msg.encode("utf-8"), 404, "text/plain"))

它直接用 cli.reply(body, status, content_type) 写出一个自定义的 404 纯文本响应,并把客户端 IP 写进消息体。返回 str(...) 为真值,服务端将其视为已处理。

5.2 redirect.py —— 301/302 重定向或"页面已迁移"错误页

redirect.py 封装了三种重定向方式:

def send_http_302_temporary_redirect(cli, new_path):
    # new_path 可以是:
    #   - "http://a.com/"  跳到其他网站
    #   - "/foo/bar"       跳到本服务器(注意必须带前导 '/')
    cli.reply(b"redirecting...", 302, headers={"Location": new_path})

def send_http_301_permanent_redirect(cli, new_path):
    cli.reply(b"redirecting...", 301, headers={"Location": new_path})

def send_errorpage_with_redirect_link(cli, new_path):
    # 返回一个带跳转链接的错误页,new_path 不带前导 '/'
    cli.redirect(new_path, click=False, msg="this page has moved")

def main(cli, vn, rem):
    print(f"this client just hit a 404: {cli.ip}")
    print(f"they were accessing this volume: /{vn.vpath}")
    print(f"and the original request-path (straight from the URL) was /{cli.vpath}")
    print(f"...which resolves to the following filesystem path: {vn.canonical(rem)}")

    new_path = "/foo/bar/"
    # 三选一:
    send_http_302_temporary_redirect(cli, new_path)
    # send_http_301_permanent_redirect(cli, new_path)
    # send_errorpage_with_redirect_link(cli, new_path)

    return "true"

示例里同时演示了 API 的"调试面":vn.canonical(rem) 可把相对挂载点的剩余路径还原为真实文件系统绝对路径,print 输出会进入 copyparty 的服务日志,便于排查是谁、以什么 URL 打到了 404。

5.3 randpic.py —— 404 时随机返回同目录图片

randpic.py 实现"访问 /foo/bar/randpic.jpg 时,若文件不存在则 302 到该目录下随机一张同名后缀的图片",常用于相册随机图接口:

def main(cli, vn, rem):
    req_fn = rem.split("/")[-1]
    if not cli.can_read or not req_fn.startswith("randpic"):
        return

    req_abspath = vn.canonical(rem)
    req_ap_dir = os.path.dirname(req_abspath)
    files_in_dir = os.listdir(req_ap_dir)

    if "." in req_fn:
        file_ext = "." + req_fn.split(".")[-1]
        files_in_dir = [x for x in files_in_dir if x.lower().endswith(file_ext)]

    if not files_in_dir:
        return

    selected_file = random.choice(files_in_dir)

    req_url = "/".join([vn.vpath, rem]).strip("/")
    req_dir = req_url.rsplit("/", 1)[0]
    new_url = "/".join([req_dir, quote(selected_file)]).strip("/")

    cli.reply(b"redirecting...", 302, headers={"Location": "/" + new_url})
    return "true"

注意它体现了插件的"条件弃权"写法:请求文件名不以 randpic 开头、目录里没有匹配文件时直接 return(返回 None),on40x 会继续尝试下一个插件或回落到默认 404;文件名含扩展名时用 urllib.parse.quote 对选中的文件做 URL 编码,处理空格等特殊字符。

5.4 ip-ok.py —— 按 IP 放行 403

ip-ok.py 全文:

def main(cli, vn, rem):
    if cli.ip == "1.2.3.4":
        return "allow"

对应 4.4 节的 "allow" 分支:客户端 IP 匹配时,服务端把该请求的全部权限标志置真。这是一个极简的白名单模板,实际使用时应替换为 CIDR 匹配、内网段判断等逻辑,并牢记放行即全权限覆盖的语义。

5.5 其余示例概览

插件说明文档 还列出了 4 个示例,可按需打开对应文件阅读:

  • never404.py:按需自动创建空文件,"保证 404 从此不存在";
  • caching-proxy.py:把 copyparty 改造成简易缓存代理(squid/varnish 风格);
  • 404-to-fail2ban.py:把 404 请求落成 fail2ban 可解析的日志,用于封禁恶意扫描;
  • nooo.py:回复无尽的 "noooooooooooooo",属于演示性质的玩具插件。

六、限制与注意事项

  1. on403 只覆盖"基础 HTTP 访问"权限检查。原文档 notes 一节明确指出:on403 只在普通 HTTP 请求的 trivial 场景生效(作者自述实现 on404 时顺手加的),对 SFTP/WebDAV/其他协议通道的鉴权不生效。这与源码一致——403 钩子位于 HTTP 请求的权限判断路径上,且要求 vn.realpath 为真(真实文件卷)。
  2. 插件返回 "allow" 等于授予全量权限(含 can_admin),编写 403 插件时必须让匹配条件足够严格。
  3. 插件在运行时被导入执行loadpy 会把插件目录插入 sys.path 并执行其模块级代码,插件文件相当于不受沙箱保护的服务器端代码,只应指向自己信任的脚本路径。
  4. 多个插件可叠加on40x 按配置列表顺序执行,取第一个非空返回;条件不满足时返回 None 即可把机会让给后续插件或默认行为。
  5. 路径约定vn.vpath / cli.vpath 无前导 /Location 头与 vn.canonical() 输出的用法如 5.2 节所示,照抄示例可减少拼接错误。

七、延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐