第十七章 CGI腳本
================
<font color="red">(警告:缺乏適當安全防護措施的CGI腳本可能會讓您的網站陷入危險狀態。本文中的腳本只是簡單的樣例而不保證在真實網站使用是安全的。)</font>
CGI腳本是駐留在Web服務器上的腳本,而且可以被客戶端(瀏覽器)運行。客戶端通過腳本的URL來訪問腳本,就像訪問普通頁面一樣。服務器識別出請求的URL是一個腳本,于是就運行該腳本。服務器如何識別特定的URL為腳本取決于服務器的管理員。在本文中我們假設腳本都存放在一個單獨的文件夾,名為cgi-bin。因此,www.foo.org網站上的`testcgi.scm`腳本可以通過 http://www.foo.org/cgi-bin/testcgi.scm 來訪問。
服務器以`nobody`用戶的身份來運行腳本,不應當期望這個用戶有`PATH`的環境變量或者該變量正確設置(這太主觀了)。因此用Scheme編寫的腳本的“引導行”會比我們在一般Scheme腳本中更加清楚才行。也就是說,下面這行代碼:
```shell
":";exec mzscheme -r $0 "$@"
```
隱式的假設有一個特定的shell(如bash),而且設置好了`PATH`變量,而mzscheme程序在PATH的路徑里。對于CGI腳本,我們需要多寫一些:
```shell
#!/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`:
```scheme
#!/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文件的鏈接中,這樣可以直接點擊,如:
```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
<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`腳本:
```scheme
#!/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)
```
注意輔助過程`split`把`QUERY_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`的代碼寫起來更緊湊:
```scheme
#!/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*`來存放表單數據。
```scheme
;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)提交的數據。
```scheme
(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`:
```scheme
(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`過程來解碼得到它們的真實表示。
```scheme
(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`的字符。
```scheme
(define hex->char
(lambda (x y)
(integer->char
(string->number (string x y) 16))))
```
我們還需要一個處理POST方法傳輸數據的程序。輔助過程`parse-form-data-using-stdin`就是做這個的。
```scheme
(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`取回一個參數對應的所有值。如果只有一個值,就返回這個值。
```scheme
(define form-data-get
(lambda (k)
(table-get *form-data-table* k '())))
```
`form-data-get/1`返回一個參數的第一個(或最重要的)值。
```scheme
(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特殊字符進行編碼))都是很有幫助的:
```scheme
(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
((#\<) "<")
((#\>) ">")
((#\") """)
((#\&) "&")
(else c)) o)
(loop (+ i 1)))))))))
```
## 17.4 一個CGI做的計算器
下面是一個CGI計算器的腳本,`cgicalc.scm`,使用了Scheme任意精度的算術功能。
```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>
=> ")
(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)
```