将文件附加到表单

作者:Reuven M. Lerner

对于任何从事 Web 工作一段时间的人来说,这是一个相对简单的问题:您如何允许网站访问者向您发送他们的姓名和地址?最简单的解决方案是使用包含一个或多个文本字段的 HTML 表单。表单的内容将被发送到站点服务器上的 CGI 程序,该程序将检索字段的内容。

如果我们有兴趣发送的不仅仅是几个单词或短语呢?如果我们有兴趣允许网站访问者输入大量文本呢?我们可以使用 <textarea> 标签,它为用户提供了更大的书写空间。但是 <textarea> 元素,像所有 HTML 表单元素一样,仍然有点笨拙。如果有一种方法可以像将文件附加到电子邮件消息一样,简单地将文件附加到 HTML 表单,那不是很好吗?

使用相对不为人知的文件元素,我们可以做到这一点。文件元素在许多方面类似于文本和隐藏元素,因为它们包含文本字符串,而不是简单的开/关指示器(如复选框)或多个可能的字符串之一(如单选按钮和选择列表)。除了发送文件名之外,文件元素还会将文件内容与 HTML 表单一起发送。

在我们介绍文件元素的一些实际用途之前,让我们看一个简单的例子,了解什么是可能的。我们在 列表 1 中显示的初始表单包含一个文件元素和一个提交按钮。它类似于我们在之前 At the Forge 的文章中看到的表单,并且可能与您在其他网站上看到的表单非常相似。表单以 <form> 标签开始,指示发送数据时应使用的方法 (POST) 以及数据要发送到的 CGI 程序 (/cgi-bin/upload-file.pl)。然后我们有一个表单元素,以及一个用于发送数据的“提交”按钮。

此表单与我们之前看到的表单之间有两个区别。首先,表单元素具有第三个属性 (ENCTYPE),我们通常可以忽略它,因为默认值 (application/x-www-form-urlencoded) 对于大多数用途来说已经足够了。但是,URL 编码(其中字符被替换为百分号,后跟其十六进制 ASCII 代码,例如,空格字符变为 %20)在用于大型文件时效率低下,尤其是在这些文件包含大量需要编码的字符时。此外,我们希望将表单元素与正在上传的文件(或多个文件)分开,并且我们希望使用 MIME 样式的内容类型标头标记上传的文件,以指示正在发送的数据类型。

由于所有这些原因,使用表单元素需要使用新的 ENCTYPE 设置(在 Web 上 http://www.internic.net/ 上提供的 RFC 1867 中定义):multipart/form-data。使用此编码类型,每个上传文件的内容将单独发送,不进行 URL 编码,并带有描述其中包含的数据类型的“Content-type”标头。除了必须记住在任何包含文件元素的表单顶部显式设置编码类型之外,我们不必太担心文件提交到我们的 CGI 程序的方式。

上面表单中的另一个新元素是文件元素本身。当在 HTML 源代码中呈现时,文件元素看起来与“文本”元素非常相似。我们为其分配一个名称,并且该值大概来自用户。

文件元素在两个方面与其他元素不同。首先,它告诉用户的浏览器不仅发送文件元素中指定的文件名,还发送与该名称关联的文件的内容。

然而,对于用户来说更明显的是,文件元素在用户的浏览器中显示为文本字段和按钮的组合。用户可以通过在文本字段中键入来输入文件名,或者——这是不寻常的部分——她可以使用“浏览”按钮弹出的对话框浏览文件系统。当用户使用“浏览”按钮选择要上传的文件时,文件名将输入到文本字段中,就好像用户键入它一样。

接收文件

现在我们知道了如何设置用于上传文件的表单,让我们看一下一个小的 CGI 程序,它将接受上传的文件。首先,在 列表 2 中,我们将简单地让我们的程序在屏幕上打印上传的文件。

让我们运行一下这个程序,以防您不完全熟悉用 Perl 编写的 CGI 程序。首先,我们使用 -w 标志启动 Perl,以警告我们是否正在做一些特别愚蠢的事情。我们还打开诊断,以便 Perl 在检测到错误时给我们一个详细的错误消息。

通常,我编写的任何程序都包含 use strict 行,以捕获我可能构建的潜在危险或愚蠢的结构。但是,正如您将看到的,我们稍后将使用引用进行一些操作,并且在处理引用时我们必须关闭 strict 包,以使我们的程序不会崩溃。因此,在导入“strict”模块后,我们立即使用“no”编译指示(一种告诉 Perl 如何处理您的程序的构造)关闭对引用的严格检查。

然后,我们加载 CGI.pm,这个包负责处理 CGI 程序的大部分脏活累活。我们创建 CGI 的一个实例,并使用“header”方法向用户的浏览器发送一个初始 MIME 样式的标头,指示我们将在我们的响应中发送 HTML 编码的文本。

接下来,我们从名为 userfile 的表单元素中检索用户输入的文件名的值,并将其放入名为 $userfile 的变量中。到目前为止,$userfile 可能来自文本或隐藏元素,就像来自表单元素一样容易。

现在到了精彩的部分。我们使用 $userfile 作为文件句柄,并使用 <> 运算符迭代它以检索文件的内容。我必须承认,当我第一次编写利用上传文件的程序时,我震惊了——我真的可以使用我分配为文件句柄的变量吗?答案是它确实工作得很好。

检查上传文件的类型

我们的程序至少有一个问题。如果用户上传 GIF 或 JPEG 图像会发生什么?我们最终会在用户的屏幕上显示大量垃圾,因为图像将被发送,然后像 HTML 一样显示。

一种解决方案是使用可以与文件元素一起使用的 accept 属性。理论上,accept 应该设置为用户应该被允许通过表单发送的一个或多个文件类型。因此,如果我们只对接收 HTML 文件感兴趣,我们可以说

<P>File to upload: <INPUT NAME="userfile"
        TYPE="file" value=""
        accept="*.html"></P>

此语句会将用户限制为上传扩展名为 .html 的文件,这些文件大概是 HTML 文件。在实践中,我发现虽然 accept 设置更改了浏览按钮弹出的窗口中的过滤器,但 accept 设置并未强制执行,并且用户可以在文本字段中输入他们可能喜欢的任何内容。

如果我们真的有兴趣确保只有 HTML 文件被上传到我们的程序,我们需要修改我们的 CGI 程序,以便它在显示文件之前检查文件的 Content-type 标头。如果文件的 Content-type 为 text/html,则认为它是可接受的并打印;否则,将显示一条简短的错误消息。

我们可以通过在文件名 ($userfile) 上调用 uploadInfo 来检查与文件关联的标头,它返回对哈希的引用,哈希又包含与特定文件关联的所有标头。列表 3 是我们之前程序的稍微修改后的版本,它在文件之前打印标头。

一旦我们检索到列表 3 中所示的标头,就相对容易只接收某些类型的文件。例如,我们可以将以下行添加到我们的程序中,以确保上传的文件具有 text/html 的 Content-type

if ($headers{"Content-Type"} ne "text/html")
        {
        print "<P>Sorry, only HTML files.</P>\n";
        exit;
        }
处理文件

上传文件很好,但是上传文件的目的是使用它们,而不仅仅是在屏幕上显示给用户看。现在我们已经了解了如何将程序从用户的浏览器上传到 Web 服务器上运行的 CGI 程序,让我们尝试将此程序用于一些实际用途。

让我们举一个简单的例子,这个例子来自我为一个站点编写的程序。我们的站点位于从 Web 空间提供商租用的服务器上,这意味着虽然我们可以控制我们自己的 HTML 文件和 CGI 程序,但系统管理(包括用户名)由我们租用空间的​​公司控制。

当我们这些在网站上工作的人决定我们希望允许各个附属组织和小组的成员在特定目录中添加 HTML 文件时,问题就开始了。也就是说,我们为每个与我们网站关联的组创建了一个目录,并期望每个组都能够根据需要添加和修改 HTML 文件。

但是,我们的网站只有一个登录名,我们当然不希望通过将该用户名和密码提供给 40 或 50 个附属组织中的每一个来危及网站的安全性。与此同时,考虑到每个组织在一个月内最多只会更改十个 HTML 文件,为每个组订购用户名似乎是一种极端且代价高昂的措施。

我们最终决定允许用户使用类似于我们上面看到的 HTML 表单将文件上传到他们组织的目录中——并且仅上传到该目录中。虽然上面的表单只要求用户输入文件名(直接键入或单击浏览按钮),但我们现在要求提供三个附加信息:文件应存放到的目录、文件到达该目录后应给定的名称以及该目录的密码。

我们要求提供目录名称和密码,以确保用户仅将文件存放到系统管理员已授予他们权限的目录中。密码不是一个完美的安全系统,但它们运行良好、可移植且易于理解。通过这种方式,A 组的成员可以上传到 A 目录,B 组的成员可以上传到 B 目录,并且两组都可以确保没有人修改他们目录中的文件。

可以将文件上传到 CGI 程序的 HTML 表单的一个可能版本在 列表 4 中。请注意,在该文件中,我使用了 HTML 表格来分隔元素。做出此选择纯粹是出于美观原因,以便每个表单元素彼此对齐。

当用户在表单中输入文件名、目录名、密码和目标名称并单击“上传文件”按钮时,这四个表单元素将与文件元素(名为 userfile)中命名的文件的内容一起发送到 CGI 程序 (/cgi-bin/upload-file.pl)。我们想要编写一个程序,该程序获取 userfile 的内容,并使用适当的名称(文件名元素)将其保存在适当的目录(section 元素)中。当然,所有这些只有在用户输入该 section 的正确密码时才会发生。

编写一个执行此操作的程序听起来很简单,对吧?嗯,是的;请查看 列表 5,了解基本版本可能是什么样子。

此程序中的密码系统是一个简单的哈希,其键是不同的 section,其值是这些 section 的密码。如果您的站点上的 section 数量很少,您可以在程序中设置密码,如列表 5 所示。

即使以这种方式添加新的 section 和密码很容易,但对于如此简单的东西,修改源代码并不是一个好主意。最好将密码信息放在文本文件、DBM 样式的文件(基本上是保存到磁盘的哈希)甚至小型 SQL 数据库中(正如我们在今年早些时候的 At the Forge 系列文章中看到的那样)。再说一遍,如果 section 的数量很少并且不经常更改,您可能只需坚持此处显示的示例系统。

为了确保用户不会滥用上传系统,我们删除了上传文件名中第一个斜杠(包括第一个斜杠)之前的所有内容。这使得某人很难通过利用 “..” 目录名(一个意味着“使用此目录的父目录”的选项)尝试将其文件之一存放到其他人的目录中。

在检查以确保我们已收到所有必需的信息后,我们将正确的密码与用户输入的密码进行比较

&log_and_die("Incorrect password")
        unless ($PASSWORD{$section} eq $password);

现在我们已经确定我们拥有所有需要的信息并且用户已获得授权,我们使用一个简单的 “while” 循环将文件保存到磁盘,该循环从 $userfile(可以将其视为文件句柄)读取并打印到 FILE,即我们在列表 5 中为将信息保存到磁盘而创建的句柄

open (FILE, ">$saved_filename") || &log_and_die(
           "Cannot write to $saved_filename: $! ");
        while (<$userfile>)
        {
        print FILE;
        }
close (FILE);
最后,让我们看一下列表 5 末尾显示的 log_and_die,这是我包含在许多 CGI 程序中的一个子例程,它允许我们以相对优雅的方式终止程序,并向用户和错误日志发送合理的消息。与产生如今在网站上非常常见的不友好的“500 服务器错误”消息相比,这是一种更好的程序崩溃方式。

当我们执行诸如以下语句时

open (FILE, ">$saved_filename") || &log_and_die(
        "Cannot write to $saved_filename: $! ");

我们是在说,“打开文件 $saved_filename 以进行写入,并允许我使用 FILE 文件句柄写入 $saved_filename。但是,如果您无法打开文件,请向用户发送一条消息,指示您无法打开文件,以及 $!,Perl 的特殊变量,其中包含最近的错误消息。” 此消息不仅对您网站的访问者更好,而且在调试程序时对您也更有用。

这个程序有一些缺点值得一提。缺乏智能备份系统(这相对容易添加)、无法处理子目录(当您想要将图像与文本分开时很有用)以及相当原始的用户界面都是这个系统的缺点。但是随着时间的推移,这已被证明是在不安全(即,通过相同的用户名和密码让每个与网站关联的人访问)和费用(即,为每个关联组购买一个新帐户,即使这些帐户很少使用)之间取得良好折衷。

文件标签不是您每天都会使用的东西,但是当您需要允许用户将信息上传到您的服务器时,它会非常方便。

Attaching Files to Forms
Reuven M. Lerner 是一位居住在以色列海法的互联网和 Web 顾问,自 1993 年初以来一直使用 Web。在业余时间,他烹饪、阅读并参与社区的教育项目志愿者活动。您可以通过 reuven@netvision.net.il 与他联系。
加载 Disqus 评论