第十七章 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脚本中更加清楚才行。也就是说,下面这行代码:

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

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

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

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

17.1 例:显示环境变量

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

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

  1. content-type: text/plain

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

下面是脚本testcgi.scm

  1. #!/bin/sh
  2. ":";exec /usr/local/bin/mzscheme -r $0 "$@"
  3. ;Identify content-type as plain text.
  4. (display "content-type: text/plain") (newline)
  5. (newline)
  6. ;Generate a page with the requested info. This is
  7. ;done by simply writing to standard output.
  8. (for-each
  9. (lambda (env-var)
  10. (display env-var)
  11. (display " = ")
  12. (display (or (getenv env-var) ""))
  13. (newline))
  14. '("AUTH_TYPE"
  15. "CONTENT_LENGTH"
  16. "CONTENT_TYPE"
  17. "DOCUMENT_ROOT"
  18. "GATEWAY_INTERFACE"
  19. "HTTP_ACCEPT"
  20. "HTTP_REFERER" ; [sic]
  21. "HTTP_USER_AGENT"
  22. "PATH_INFO"
  23. "PATH_TRANSLATED"
  24. "QUERY_STRING"
  25. "REMOTE_ADDR"
  26. "REMOTE_HOST"
  27. "REMOTE_IDENT"
  28. "REMOTE_USER"
  29. "REQUEST_METHOD"
  30. "SCRIPT_NAME"
  31. "SERVER_NAME"
  32. "SERVER_PORT"
  33. "SERVER_PROTOCOL"
  34. "SERVER_SOFTWARE"))

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

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

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

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

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

  1. AUTH_TYPE =
  2. CONTENT_LENGTH =
  3. CONTENT_TYPE =
  4. DOCUMENT_ROOT = /home/httpd/html
  5. GATEWAY_INTERFACE = CGI/1.1
  6. HTTP_ACCEPT = image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, */*
  7. HTTP_REFERER =
  8. HTTP_USER_AGENT = Mozilla/3.01Gold (X11; I; Linux 2.0.32 i586)
  9. PATH_INFO =
  10. PATH_TRANSLATED =
  11. QUERY_STRING =
  12. REMOTE_HOST = 127.0.0.1
  13. REMOTE_ADDR = 127.0.0.1
  14. REMOTE_IDENT =
  15. REMOTE_USER =
  16. REQUEST_METHOD = GET
  17. SCRIPT_NAME = /cgi-bin/testcgi.scm
  18. SERVER_NAME = localhost.localdomain
  19. SERVER_PORT = 80
  20. SERVER_PROTOCOL = HTTP/1.0
  21. SERVER_SOFTWARE = Apache/1.2.4

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

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

  1. <html>
  2. <head>
  3. <title>Form for checking environment variables</title>
  4. </head>
  5. <body>
  6. <form method=get
  7. action="http://www.foo.org/cgi-bin/testcgi2.scm">
  8. Enter environment variable: <input type=text name=envvar size=30>
  9. <p>
  10. <input type=submit>
  11. </form>
  12. </body>
  13. </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脚本:

  1. #!/bin/sh
  2. ":";exec /usr/local/bin/mzscheme -r $0 "$@"
  3. (display "content-type: text/plain") (newline)
  4. (newline)
  5. ;string-index returns the leftmost index in string s
  6. ;that has character c
  7. (define string-index
  8. (lambda (s c)
  9. (let ((n (string-length s)))
  10. (let loop ((i 0))
  11. (cond ((>= i n) #f)
  12. ((char=? (string-ref s i) c) i)
  13. (else (loop (+ i 1))))))))
  14. ;split breaks string s into substrings separated by character c
  15. (define split
  16. (lambda (c s)
  17. (let loop ((s s))
  18. (if (string=? s "") '()
  19. (let ((i (string-index s c)))
  20. (if i (cons (substring s 0 i)
  21. (loop (substring s (+ i 1)
  22. (string-length s))))
  23. (list s)))))))
  24. (define args
  25. (map (lambda (par-arg)
  26. (split #\= par-arg))
  27. (split #\& (getenv "QUERY_STRING"))))
  28. (define envvar (cadr (assoc "envvar" args)))
  29. (display envvar)
  30. (display " = ")
  31. (display (getenv envvar))
  32. (newline)

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

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

17.3 CGI脚本相关问题(utilities)

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

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

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

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

  1. #!/bin/sh
  2. ":";exec /usr/local/bin/mzscheme -r $0 "$@"
  3. ;Load the cgi utilities
  4. (load-relatve "cgi.scm")
  5. (display "content-type: text/plain") (newline)
  6. (newline)
  7. ;Read the data input via the form
  8. (parse-form-data)
  9. ;Get the envvar parameter
  10. (define envvar (form-data-get/1 "envvar"))
  11. ;Display the value of the envvar
  12. (display envvar)
  13. (display " = ")
  14. (display (getenv envvar))
  15. (newline)

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

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

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

  1. ;Load our table definitions
  2. (load-relative "table.scm")
  3. ;Define the *form-data-table*
  4. (define *form-data-table* (make-table 'equ string=?))

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

  1. (define parse-form-data
  2. (lambda ()
  3. ((if (string-ci=? (or (getenv "REQUEST_METHOD") "GET") "GET")
  4. parse-form-data-using-query-string
  5. parse-form-data-using-stdin))))

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

  1. (define parse-form-data-using-query-string
  2. (lambda ()
  3. (let ((query-string (or (getenv "QUERY_STRING") "")))
  4. (for-each
  5. (lambda (par=arg)
  6. (let ((par/arg (split #\= par=arg)))
  7. (let ((par (url-decode (car par/arg)))
  8. (arg (url-decode (cadr par/arg))))
  9. (table-put!
  10. *form-data-table* par
  11. (cons arg
  12. (table-get *form-data-table* par '()))))))
  13. (split #\& query-string)))))

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

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

  1. (define url-decode
  2. (lambda (s)
  3. (let ((s (string->list s)))
  4. (list->string
  5. (let loop ((s s))
  6. (if (null? s) '()
  7. (let ((a (car s)) (d (cdr s)))
  8. (case a
  9. ((#\+) (cons #\space (loop d)))
  10. ((#\%) (cons (hex->char (car d) (cadr d))
  11. (loop (cddr d))))
  12. (else (cons a (loop d)))))))))))

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

  1. (define hex->char
  2. (lambda (x y)
  3. (integer->char
  4. (string->number (string x y) 16))))

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

  1. (define parse-form-data-using-stdin
  2. (lambda ()
  3. (let* ((content-length (getenv "CONTENT_LENGTH"))
  4. (content-length
  5. (if content-length
  6. (string->number content-length) 0))
  7. (i 0))
  8. (let par-loop ((par '()))
  9. (let ((c (read-char)))
  10. (set! i (+ i 1))
  11. (if (or (> i content-length)
  12. (eof-object? c) (char=? c #\=))
  13. (let arg-loop ((arg '()))
  14. (let ((c (read-char)))
  15. (set! i (+ i 1))
  16. (if (or (> i content-length)
  17. (eof-object? c) (char=? c #\&))
  18. (let ((par (url-decode
  19. (list->string
  20. (reverse! par))))
  21. (arg (url-decode
  22. (list->string
  23. (reverse! arg)))))
  24. (table-put! *form-data-table* par
  25. (cons arg (table-get *form-data-table*
  26. par '())))
  27. (unless (or (> i content-length)
  28. (eof-object? c))
  29. (par-loop '())))
  30. (arg-loop (cons c arg)))))
  31. (par-loop (cons c par))))))))

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

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

  1. (define form-data-get
  2. (lambda (k)
  3. (table-get *form-data-table* k '())))

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

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

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

  1. (define display-html
  2. (lambda (s . o)
  3. (let ((o (if (null? o) (current-output-port)
  4. (car o))))
  5. (let ((n (string-length s)))
  6. (let loop ((i 0))
  7. (unless (>= i n)
  8. (let ((c (string-ref s i)))
  9. (display
  10. (case c
  11. ((#\<) "&lt;")
  12. ((#\>) "&gt;")
  13. ((#\") "&quot;")
  14. ((#\&) "&amp;")
  15. (else c)) o)
  16. (loop (+ i 1)))))))))

17.4 一个CGI做的计算器

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

  1. #!/bin/sh
  2. ":";exec /usr/local/bin/mzscheme -r $0
  3. ;Load the CGI utilities
  4. (load-relative "cgi.scm")
  5. (define uhoh #f)
  6. (define calc-eval
  7. (lambda (e)
  8. (if (pair? e)
  9. (apply (ensure-operator (car e))
  10. (map calc-eval (cdr e)))
  11. (ensure-number e))))
  12. (define ensure-operator
  13. (lambda (e)
  14. (case e
  15. ((+) +)
  16. ((-) -)
  17. ((*) *)
  18. ((/) /)
  19. ((**) expt)
  20. (else (uhoh "unpermitted operator")))))
  21. (define ensure-number
  22. (lambda (e)
  23. (if (number? e) e
  24. (uhoh "non-number"))))
  25. (define print-form
  26. (lambda ()
  27. (display "<form action=\"")
  28. (display (getenv "SCRIPT_NAME"))
  29. (display "\">
  30. Enter arithmetic expression:<br>
  31. <input type=textarea name=arithexp><p>
  32. <input type=submit value=\"Evaluate\">
  33. <input type=reset value=\"Clear\">
  34. </form>")))
  35. (define print-page-begin
  36. (lambda ()
  37. (display "content-type: text/html
  38. <html>
  39. <head>
  40. <title>A Scheme Calculator</title>
  41. </head>
  42. <body>")))
  43. (define print-page-end
  44. (lambda ()
  45. (display "</body>
  46. </html>")))
  47. (parse-form-data)
  48. (print-page-begin)
  49. (let ((e (form-data-get "arithexp")))
  50. (unless (null? e)
  51. (let ((e1 (car e)))
  52. (display-html e1)
  53. (display "<p>
  54. =&gt;&nbsp;&nbsp;")
  55. (display-html
  56. (call/cc
  57. (lambda (k)
  58. (set! uhoh
  59. (lambda (s)
  60. (k (string-append "Error: " s))))
  61. (number->string
  62. (calc-eval (read (open-input-string (car e))))))))
  63. (display "<p>"))))
  64. (print-form)
  65. (print-page-end)