第十七章 CGI脚本

(警告:缺乏适当安全防护措施的CGI脚本可能会让您的网站陷入危险状态。本文中的脚本只是简单的样例而不保证在真实网站使用是安全的。)

CGI脚本是驻留在Web服务器上的脚本,而且可以被客户端(浏览器)运行。客户端通过脚本的URL来访问脚本,就像访问普通页面一样。服务器识别出请求的URL是一个脚本,于是就运行该脚本。服务器如何识别特定的URL为脚本取决于服务器的管理员。在本文中我们假设脚本都存放在一个单独的文件夹,名为cgi-bin。因此,www.foo.org网站上的testcgi.scm脚本可以通过 http://www.foo.org/cgi-bin/testcgi.scm 来访问。

服务器以nobody用户的身份来运行脚本,不应当期望这个用户有PATH的环境变量或者该变量正确设置(这太主观了)。因此用Scheme编写的脚本的“引导行”会比我们在一般Scheme脚本中更加清楚才行。也就是说,下面这行代码:

":";exec mzscheme -r $0 "$@"

隐式的假设有一个特定的shell(如bash),而且设置好了PATH变量,而mzscheme程序在PATH的路径里。对于CGI脚本,我们需要多写一些:

#!/bin/sh
":";exec /usr/local/bin/mzscheme -r $0 "$@"

这样指定了shell和Scheme可执行文件的绝对路径。控制从shell交接给Scheme的过程和普通脚本一致。

17.1 例:显示环境变量

下面是一个Scheme编写的CGI脚本的示例,testcgi.scm。该文件会输出一些常用CGI环境变量的设置。这些信息作为一个新的,刚刚创建的页面返回给浏览器。返回的页面就是该CGI脚本向标准输出里写入的任何东西。这就是CGI脚本如何回应对它们的调用——通过返回给它们(客户端)一个新页面。

注意脚本首先输出下面这行:

content-type: text/plain

后面跟一个空行。这是Web服务器提供页面服务的标准方式。这两行不会在页面上显示出来。它们只是提醒浏览器下面将发送的页面是纯文本(也就是非标记)文字。这样浏览器就会恰当的显示这个页面了。如果我们要发送的页面是用HTML标记的,content-type就是text/html

下面是脚本testcgi.scm

#!/bin/sh
":";exec /usr/local/bin/mzscheme -r $0 "$@"

;Identify content-type as plain text.

(display "content-type: text/plain") (newline)
(newline)

;Generate a page with the requested info.  This is
;done by simply writing to standard output.

(for-each
 (lambda (env-var)
   (display env-var)
   (display " = ")
   (display (or (getenv env-var) ""))
   (newline))
 '("AUTH_TYPE"
   "CONTENT_LENGTH"
   "CONTENT_TYPE"
   "DOCUMENT_ROOT"
   "GATEWAY_INTERFACE"
   "HTTP_ACCEPT"
   "HTTP_REFERER" ; [sic]
   "HTTP_USER_AGENT"
   "PATH_INFO"
   "PATH_TRANSLATED"
   "QUERY_STRING"
   "REMOTE_ADDR"
   "REMOTE_HOST"
   "REMOTE_IDENT"
   "REMOTE_USER"
   "REQUEST_METHOD"
   "SCRIPT_NAME"
   "SERVER_NAME"
   "SERVER_PORT"
   "SERVER_PROTOCOL"
   "SERVER_SOFTWARE"))

testcgi.scm可以直接从浏览器上打开,URL是:

http://www.foo.org/cgi-bin/testcgi.scm

此外,testcgi.scm也可以放在HTML文件的链接中,这样可以直接点击,如:

... To view some common CGI environment variables, click 
<a href="http://www.foo.org/cgi-bin/testcgi.scm">here</a>. 
...

而一旦触发了testcg.scm,它就会生成一个包括环境变量设置的纯文本页面。下面是一个示例输出:

AUTH_TYPE = 
CONTENT_LENGTH = 
CONTENT_TYPE = 
DOCUMENT_ROOT = /home/httpd/html 
GATEWAY_INTERFACE = CGI/1.1 
HTTP_ACCEPT = image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, */* 
HTTP_REFERER = 
HTTP_USER_AGENT = Mozilla/3.01Gold (X11; I; Linux 2.0.32 i586) 
PATH_INFO = 
PATH_TRANSLATED = 
QUERY_STRING = 
REMOTE_HOST = 127.0.0.1 
REMOTE_ADDR = 127.0.0.1 
REMOTE_IDENT = 
REMOTE_USER = 
REQUEST_METHOD = GET 
SCRIPT_NAME = /cgi-bin/testcgi.scm 
SERVER_NAME = localhost.localdomain 
SERVER_PORT = 80 
SERVER_PROTOCOL = HTTP/1.0 
SERVER_SOFTWARE = Apache/1.2.4

17.2 示例:显示选择的环境变量

testcgi.scm没有从用户获得任何输入。一个更专注的脚本会从用户那里获得一个环境变量,然后输出这个变量的设置,此外不返回任何东西。为了做这个,我们需要一个机制把参数传递给CGI脚本。HTML的表单提供了这种功能。下面是完成这个目标的一个简单的HTML页面:

<html> 
<head> 
<title>Form for checking environment variables</title> 
</head> 
<body> 

<form method=get  
      action="http://www.foo.org/cgi-bin/testcgi2.scm"> 
Enter environment variable: <input type=text name=envvar size=30> 
<p> 

<input type=submit> 
</form> 

</body> 
</html>

用户在文本框中输入希望的环境变量(如GATEWAY_INTERFACE)并点击提交按钮。这会把所有表单里的信息——这里,参数envvar的值是GATEWAY_INTERFACE——收集并发送到该表单对应的CGI脚本即testcgi2.scm。这些信息可以用两种方法来发送:

  1. 如果表单的method属性是GET(默认),那么这些信息通过环境变量QUERY_STRING来传递给脚本
  2. 如果表单的method属性是POST,那么这些信息会在稍后发送到CGI脚本的标准输入中。

我们的表单使用QUERY_STRING的方式。

把信息从QUERY_STRING中提取出来并输出相应的页面是testcgi2.scm脚本的事情。

发给CGI脚本的信息,不论通过环境变量还是通过标准输入,都被格式化为一串“参数/值”的键值对。键值对之间用&字符分隔开。每个键值对中参数的名字在前面而且与参数值之间用=分开。这种情况下,只有一个键值对,即envvar=GATEWAY_INTERFACE

下面是testcgi2.scm脚本:

#!/bin/sh
":";exec /usr/local/bin/mzscheme -r $0 "$@"

(display "content-type: text/plain") (newline)
(newline)

;string-index returns the leftmost index in string s
;that has character c

(define string-index
  (lambda (s c)
    (let ((n (string-length s)))
      (let loop ((i 0))
        (cond ((>= i n) #f)
              ((char=? (string-ref s i) c) i)
              (else (loop (+ i 1))))))))

;split breaks string s into substrings separated by character c

(define split
  (lambda (c s)
    (let loop ((s s))
      (if (string=? s "") '()
          (let ((i (string-index s c)))
            (if i (cons (substring s 0 i)
                        (loop (substring s (+ i 1)
                                         (string-length s))))
                (list s)))))))

(define args
  (map (lambda (par-arg)
         (split #\= par-arg))
       (split #\& (getenv "QUERY_STRING"))))

(define envvar (cadr (assoc "envvar" args)))

(display envvar)
(display " = ")
(display (getenv envvar))

(newline)

注意辅助过程splitQUERY_STRING&分隔为键值对并进一步用=把参数名和参数值分开。(如果我们是用POST方法,我们需要把参数名和参数值从标准输入中提取出来。)

<input type=text><input type=submit>是HTML表单的两个不同的输入标签。参考文献27来查看全部。

17.3 CGI脚本相关问题(utilities)

在上面的例子中,参数名和参数值都假设没有包含=&字符。通常情况他们会包含。为了适应这种字符,而不会不小心把他们当成分割符,CGI参数传递机制要求所有除了字母、数字和下划线以外的“特殊”字符都要编码进行传输。空格被编码为+,其他的特殊字符被编码为3个字符的序列,包括一个%字符紧跟着这个字符的16进制码。因此,20% + 30% = 50%, &c.会被编码为:

20%25+%2b+30%25+%3d+50%25%2c+%26c%2e

(空格变成+%变为%25+变为%2b=变为%3d,变为%2c&变为%26.变为%2e

除了把获得和解码表单的代码写在每个CGI脚本中,把这些函数放在一个库文件cgi.scm中。这样testcgi2.scm的代码写起来更紧凑:

#!/bin/sh
":";exec /usr/local/bin/mzscheme -r $0 "$@"

;Load the cgi utilities

(load-relatve "cgi.scm")

(display "content-type: text/plain") (newline)
(newline)

;Read the data input via the form

(parse-form-data)

;Get the envvar parameter

(define envvar (form-data-get/1 "envvar"))

;Display the value of the envvar

(display envvar)
(display " = ")
(display (getenv envvar))
(newline)

这个简短一些的CGI脚本用了两个定义在cgi.scm中的通用过程。parse-form-data过程读取用户通过表单提交的数据,包括参数和值。

form-data-get/1找到与特定参数关联的值。

cgi.scm定义了一个全局表叫*form-data-table*来存放表单数据。

;Load our table definitions

(load-relative "table.scm")

;Define the *form-data-table*

(define *form-data-table* (make-table 'equ string=?))

使用诸如parse-form-data等通用过程的一个好处是我们可以不用管用户是用那种方式(get或post)提交的数据。

(define parse-form-data
  (lambda ()
    ((if (string-ci=? (or (getenv "REQUEST_METHOD") "GET") "GET")
         parse-form-data-using-query-string
         parse-form-data-using-stdin))))

环境变量REQUEST_METHOD表示使用那种方式传送表单数据。如果方法是GET,那么表单数据被作为字符串通过另一个环境变量QUERY_STRING传输。辅助过程parse-form-data-using-query-string用来拆散QUERY_STRING

(define parse-form-data-using-query-string
  (lambda ()
    (let ((query-string (or (getenv "QUERY_STRING") "")))
      (for-each
       (lambda (par=arg)
         (let ((par/arg (split #\= par=arg)))
           (let ((par (url-decode (car par/arg)))
                 (arg (url-decode (cadr par/arg))))
             (table-put! 
              *form-data-table* par
              (cons arg 
                    (table-get *form-data-table* par '()))))))
       (split #\& query-string)))))

辅助过程split,和它的辅助过程string-index,在第二节中定义过了。正如之前提到的,输入的表单数据是一串用&分割的键值对。每个键值对中先是参数名,然后是一个=号,后面是值。每个键值对都放到一个全局的表*form-data-table*里。

每个参数名和参数值都被编码了,所以我们需要用url-decode过程来解码得到它们的真实表示。

(define url-decode
  (lambda (s)
    (let ((s (string->list s)))
      (list->string
       (let loop ((s s))
         (if (null? s) '()
             (let ((a (car s)) (d (cdr s)))
               (case a
                 ((#\+) (cons #\space (loop d)))
                 ((#\%) (cons (hex->char (car d) (cadr d))
                              (loop (cddr d))))
                 (else (cons a (loop d)))))))))))

+被转换为空格,通过过程hex->char,%xy这种形式的词也被转换为其ascii编码是十六进制数xy的字符。

(define hex->char
  (lambda (x y)
    (integer->char
     (string->number (string x y) 16))))

我们还需要一个处理POST方法传输数据的程序。辅助过程parse-form-data-using-stdin就是做这个的。

(define parse-form-data-using-stdin
  (lambda ()
    (let* ((content-length (getenv "CONTENT_LENGTH"))
           (content-length
             (if content-length
                 (string->number content-length) 0))
           (i 0))
      (let par-loop ((par '()))
        (let ((c (read-char)))
          (set! i (+ i 1))
          (if (or (> i content-length)
                  (eof-object? c) (char=? c #\=))
              (let arg-loop ((arg '()))
                (let ((c (read-char)))
                  (set! i (+ i 1))
                  (if (or (> i content-length)
                          (eof-object? c) (char=? c #\&))
                      (let ((par (url-decode
                                   (list->string
                                     (reverse! par))))
                            (arg (url-decode
                                   (list->string
                                     (reverse! arg)))))
                        (table-put! *form-data-table* par
                          (cons arg (table-get *form-data-table*
                                      par '())))
                        (unless (or (> i content-length)
                                    (eof-object? c))
                          (par-loop '())))
                      (arg-loop (cons c arg)))))
              (par-loop (cons c par))))))))

POST方法通过脚本的标准输入传输表单数据。传输的字符数放在环境变量CONTENT_LENGTH里。parse-form-data-using-stdin从标准输入读取对应的字符,也像之前那样设置*form-data-table*,保证参数名和值被解码。

剩下就是从*form-data-table*取回特定参数的值。主要这个这个表中每个参数都关联着一个列表,这是为了适应一个参数多个值的情况。form-data-get取回一个参数对应的所有值。如果只有一个值,就返回这个值。

(define form-data-get
  (lambda (k)
    (table-get *form-data-table* k '())))

form-data-get/1返回一个参数的第一个(或最重要的)值。

(define form-data-get/1
  (lambda (k . default)
    (let ((vv (form-data-get k)))
      (cond ((pair? vv) (car vv))
            ((pair? default) (car default))
            (else "")))))

在我们目前的例子当中,CGI脚本都是生成纯文本,通常我们希望生成一个HTML页面。把CGI脚本和HTML表单结合起来生成一系列带有表单的HTML页面是很常见的。把不同方法的响应代码放在一个CGI脚本里也是很常见的。不论任何情况,有一些辅助过程把字符串输出为HTML格式(即,把HTML特殊字符进行编码))都是很有帮助的:

(define display-html
  (lambda (s . o)
    (let ((o (if (null? o) (current-output-port)
                 (car o))))
      (let ((n (string-length s)))
        (let loop ((i 0))
          (unless (>= i n)
            (let ((c (string-ref s i)))
              (display
               (case c
                 ((#\<) "&lt;")
                 ((#\>) "&gt;")
                 ((#\") "&quot;")
                 ((#\&) "&amp;")
                 (else c)) o)
              (loop (+ i 1)))))))))

17.4 一个CGI做的计算器

下面是一个CGI计算器的脚本,cgicalc.scm,使用了Scheme任意精度的算术功能。

#!/bin/sh
":";exec /usr/local/bin/mzscheme -r $0

;Load the CGI utilities
(load-relative "cgi.scm")

(define uhoh #f)

(define calc-eval
  (lambda (e)
    (if (pair? e)
        (apply (ensure-operator (car e))
               (map calc-eval (cdr e)))
        (ensure-number e))))

(define ensure-operator
  (lambda (e)
    (case e
      ((+) +)
      ((-) -)
      ((*) *)
      ((/) /)
      ((**) expt)
      (else (uhoh "unpermitted operator")))))

(define ensure-number
  (lambda (e)
    (if (number? e) e
        (uhoh "non-number"))))

(define print-form
  (lambda ()
    (display "<form action=\"")
    (display (getenv "SCRIPT_NAME"))
    (display "\">
  Enter arithmetic expression:<br>
  <input type=textarea name=arithexp><p>
  <input type=submit value=\"Evaluate\">
  <input type=reset value=\"Clear\">
</form>")))

(define print-page-begin
  (lambda ()
    (display "content-type: text/html

<html>
  <head>
    <title>A Scheme Calculator</title>
  </head>
  <body>")))

(define print-page-end
  (lambda ()
    (display "</body>
</html>")))

(parse-form-data)

(print-page-begin)

(let ((e (form-data-get "arithexp")))
  (unless (null? e)
    (let ((e1 (car e)))
      (display-html e1)
      (display "<p>
  =&gt;&nbsp;&nbsp;")
      (display-html
       (call/cc
        (lambda (k)
          (set! uhoh
                (lambda (s)
                  (k (string-append "Error: " s))))
          (number->string
           (calc-eval (read (open-input-string (car e))))))))
      (display "<p>"))))

(print-form)
(print-page-end)