node 初步 (四) --- HTTP 模塊和靜態文件服務器

HTTP 模塊

HTTP 模塊,是 node 中最重要的模塊,沒有之一。
該模塊提供了執行 HTTP 服務和產生 HTTP 請求的功能,實際上我們之後利用 node 寫的服務器,主要就是用的 HTTP 模塊,先來看一下一個簡單 HTTP 服務器需要的代碼。

const http = require('http')
const server = http.createServer()
const port = 8899

server.on('request', (request, response) => {
  console.log(request.method, request.url)
  console.log(request.headers)

  response.writeHead(200, {'content-Type': 'text/html'})
  response.write('<h1>hello world</h1>')
  response.end()
})
 
server.listen(port, () => {
  console.log('server listening on port', port)
})

HTTP 模塊背後實際上是建立了一個 TCP 鏈接並監聽 port 端口,只有在有人鏈接過來併發送正確的 HTTP 報文且能夠正確的被解析出來時 request 事件纔會觸發,該事件的回調函數一共有兩個對象,一個是請求對象一個是響應對象,可以通過這兩個對象對 request 事件進行相應的響應。這裏的 request 和 response 已經被解析好了,可以直接從 request 中讀取相關屬性,同時 response 寫入的內容會直接寫入響應體中, 因爲響應頭已經被自動寫入好了。
HTTP 模塊是如何實現的呢,大體代碼如下:

class httpServer {
  constructor(requestHandler) {
    var net = require('net')
    this.server = net.createServer(conn => {
      var head = parse() // parse data comming from conn
      if (isHttp(conn)) {
        requestHandler(new RequestObject(conn), new ResponseObject(conn))
      } else {
        conn.end()
      }
    })
  }

  listen(prot, f) {
    this.server.listen(port, f)
  }
}

在 node 中執行該腳本,打開瀏覽器訪問 localhost:8899 端口,瀏覽器就會向服務器發送一個請求,服務器就會響應一個簡單的 HTML 頁面。
一個真實的 web 服務器要比這個複雜得多,它需要根據請求的方法來判斷客戶端嘗試執行的動作,並根據請求的 URL 來找出動作處理的資源,而不是像我們這樣無腦的輸出。

HTTP 模塊的 request 函數還可以用來充當一個 HTTP 客戶端,由於不在瀏覽器環境下運行了,所以不存在跨域請求的問題,我們可以向任何的服務器發送請求。

$ node
> req = http.request('http://www.baidu.com/',response => global.res = response)
> req.end()
> global.res.statusCode

具體的 API ,可以查看文檔

練習 - 簡易留言板

用 HTTP 模塊來寫一個簡單的留言板

const http = require('http')
const server = http.createServer()
const port = 8890
const querystring = require('querystring')

const msgs = [{
  content: 'hello',
  name: 'zhangsan'
}]

server.on('request', (request, response) => {
  if (request.method == 'GET') {

    response.writeHead(200, {
      'Content-Type': 'text/html; charset=UTF-8'
    })

    response.write(`
      <form action='/' method='post'>
        <input name='name'/>
        <textarea name='content'></textarea>
        <button>提交</button>
      </form>
      <ul>
        ${
      msgs.map(msg => {
        return `
              <li>
                <h3>${msg.name}</h3>
                <pre>${msg.content}</pre>
              </li>
            `
      }).join('')
      }
      </ul>
    `)
  }

  if (request.method == 'POST') {
    request.on('data', data => {
      var msg = querystring.parse(data.toString())
      msgs.push(msg)
    })
    response.writeHead(301, {
      'Location': '/'
    })
    response.end()
  }
})

server.listen(port, () => {
  console.log('server listening on port', port)
})

練習 - 靜態文件服務器

用 HTTP 模塊實現一個靜態文件服務器:

  • http://localhost:8090/ 能夠訪問到電腦某一個文件夾(如:c:/foo/bar/baz/ )的內容
  • 如果訪問到文件夾,那麼返回該文件夾下的 index.html 文件
  • 如果該文件不存在,返回包含該文件夾的內容的一個頁面,且內容可以點擊
  • 需要對特定的文件返回正確的 Content-Type
const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')

const port = 8090
/* const baseDir = './' // 這裏的'./'相對於 node 的工作目錄路徑,而不是文件所在的路徑*/
const baseDir = __dirname // 這裏的__dirname 是文件所在的絕對路徑

const server = http.createServer((req, res) => {
  var targetPath = decodeURIComponent(path.join(baseDir, req.url)) //目標地址是基準路徑和文件相對路徑的拼接,decodeURIComponent()是將路徑中的漢字進行解碼
  console.log(req.method, req.url, baseDir, targetPath)
  fs.stat(targetPath, (err, stat) => { // 判斷文件是否存在
    if (err) { // 如果不存在,返回404
      res.writeHead(404)
      res.end('404 Not Found')
    } else {
      if (stat.isFile()) { // 判斷是否是文件
        fs.readFile(targetPath, (err, data) => {
          if (err) { // 即使文件存在也有打不開的可能,比如閱讀權限等
            res.writeHead(502)
            res.end('502 Internal Server Error')
          } else {
            res.end(data)
          }
        })
      } else if (stat.isDirectory()) { 
        var indexPath = path.join(targetPath, 'index.html') // 如果是文件夾,拼接index.html文件的地址
        fs.stat(indexPath, (err, stat) => {
          if (err) { // 如果文件夾中沒有index.html文件
            if (!req.url.endsWith('/')) { // 如果地址欄裏不以/結尾,則跳轉到以/結尾的相同地址
              res.writeHead(301, {
                'Location': req.url + '/'
              })
              res.end()
              return
            }
            fs.readdir(targetPath, {withFileTypes: true}, (err, entries) => {
              res.writeHead(200, {
               'Content-Type': 'text/html; charset=UTF-8'
              })
              res.end(`
              ${
                entries.map(entry => {// 判斷是否是文件夾, 如果是文件夾,在後面添加一個'/',返回一個頁面,包含文件夾內的文件明,且每個文件名都是一個鏈接
                  var slash = entry.isDirectory() ? '/' : '' 
                    return ` 
                      <div>
                        <a href='${entry.name}${slash}'>${entry.name}${slash}</a>
                      </div>
                    `
                  }).join('')
                }
              `)
            })
          } else { // 如果有index.html文件  直接返回文件內容
            fs.readFile(indexPath, (err, data) => {
              res.end(data)
            })
          }
        })
      }
    }
  })

})

server.listen(port, () => {
  console.log(port)
})

上面的這端代碼利用了回調函數的方式,已經實現了一個簡單的靜態文件服務器,可是代碼的縮進層級過高,可以用async,await的方式使代碼簡潔一點,同時還有一些細節需要完善,比如不同類型的文件需要以不同的格式來打開等,下面一段代碼進行優化

const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')

const port = 8090
const baseDir = __dirname

var mimeMap = { // 創建一個對象,包含一些文件類型
  '.jpg': 'image/jpeg',
  '.html': 'text/html',
  '.css': 'text/stylesheet',
  '.js': 'application/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.txt': 'text/plain',
  'xxx': 'application/octet-stream',
}
const server = http.createServer(async (req, res) => {
  var targetPath = decodeURIComponent(path.join(baseDir, req.url))
  console.log(req.method, req.url, baseDir, targetPath)
  try {
    var stat = await fsp.stat(targetPath)
    if (stat.isFile()) {
      try {
        var data = await fsp.readFile(targetPath)
        var type = mimeMap[path.extname(targetPath)]
        if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
        } else { //如果不在,就以流的方式解碼
          res.writeHead(200, {'Content-Type': `application/octet-stream`})
        }
        res.end(data)
      } catch(e) {
        res.writeHead(502)
        res.end('502 Internal Server Error')
      }
    } else if (stat.isDirectory()) {
      var indexPath = path.join(targetPath, 'index.html')
      try {
        await fsp.stat(indexPath)
        var indexContent = await fsp.readFile(indexPath)
        var type = mimeMap[path.extname(indexPath)]
        if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
        } else { //如果不在,就以流的方式解碼
          res.writeHead(200, {'Content-Type': `application/octet-stream`})
        }
        res.end(indexContent)
      } catch(e) {
        if (!req.url.endsWith('/')) { 
          res.writeHead(301, {
            'Location': req.url + '/'
          })
          res.end()
          return
        }
        var entries = await fsp.readdir(targetPath, {withFileTypes: true})
        res.writeHead(200, {
          'Content-Type': 'text/html; charset=UTF-8'
        })
        res.end(`
          ${
            entries.map(entry => {
              var slash = entry.isDirectory() ? '/' : ''
                return `
                  <div>
                    <a href='${entry.name}${slash}'>${entry.name}${slash}</a>
                  </div>
                `
            }).join('') 
          }
        `)
      }
    }
  } catch(e) {
      res.writeHead(404)
      res.end('404 Not Found')
  }
})

server.listen(port, () => {
  console.log(port)
})

比如這樣,縮進層級明顯減少,利用自己創建 mimeMap 對象的方式找到對應的解碼方式雖然可以,不過還是比較麻煩,需要自己寫很多內容,而且也不能說列出所有的文件格式。其實 npm 上有可以專門通過拓展名來查詢文件 mime 類型的安裝包(如 mime)。使用前需要提前安裝一下 npm i mime,這樣寫會方便很多。

const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const mime = require('mime')

const port = 8090
const baseDir = __dirname

const server = http.createServer(async (req, res) => {
  var targetPath = decodeURIComponent(path.join(baseDir, req.url))
  console.log(req.method, req.url, baseDir, targetPath)
  try {
    var stat = await fsp.stat(targetPath)
    if (stat.isFile()) {
      try {
        var data = await fsp.readFile(targetPath)
        var type = mime.getType(targetPath)
        if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
        } else { //如果不在,就以流的方式解碼
          res.writeHead(200, {'Content-Type': `application/octet-stream`})
        }
        res.end(data)
      } catch(e) {
        res.writeHead(502)
        res.end('502 Internal Server Error')
      }
    } else if (stat.isDirectory()) {
      var indexPath = path.join(targetPath, 'index.html')
      try {
        await fsp.stat(indexPath)
        var indexContent = await fsp.readFile(indexPath)
        var type = mime.getType(indexPath)
        if (type) {
          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
        } else {
          res.writeHead(200, {'Content-Type': `application/octet-stream`})
        }
        res.end(indexContent)
      } catch(e) {
        if (!req.url.endsWith('/')) { 
          res.writeHead(301, {
            'Location': req.url + '/'
          })
          res.end()
          return
        }
        var entries = await fsp.readdir(targetPath, {withFileTypes: true})
        res.writeHead(200, {
          'Content-Type': 'text/html; charset=UTF-8'
        })
        res.end(`
          ${
            entries.map(entry => {
              var slash = entry.isDirectory() ? '/' : ''
                return `
                  <div>
                    <a href='${entry.name}${slash}'>${entry.name}${slash}</a>
                  </div>
                `
            }).join('') 
          }
        `)
      }
    }
  } catch(e) {
      res.writeHead(404)
      res.end('404 Not Found')
  }
})

server.listen(port, () => {
  console.log(port)
})

在剛剛的代碼中,路徑問題和解碼問題已經得到了很好的解決。我們還剩下最後一個,也是比較重要的一個問題,就是安全問題。
例如:訪問的基準路徑爲 /home/pi/www/, 而輸入的網址爲 http://localhost:8090/../../../../../../../etc/passwd
兩個路徑一合併,就化簡爲 /etc/passwd, 這裏有可能存儲的是用戶組的相關信息
同理,理論上可以通過這個方式將計算機上的任意一個文件訪問到
實際上,我們想要的是將基準路徑作爲 HTTP 服務器的根目錄,而無法將根目錄以外的文件訪問到
解決辦法就是 HTTP 服務器的訪問路徑一定要以基準路徑開頭
再例如:文件夾中可能會有一些隱藏文件夾,如.git,裏面存儲了一些用戶的提交信息。或者文件夾中有一些配置文件,裏面存有一些敏感信息,這個是不想被外界所訪問的

const server = http.createServer(async (req, res) => {  
                     ..
                     ..
  const baseDir = path.resolve('./')
   // 注意  這裏的 baseDir 必須是一個絕對路徑了
  var targetPath = decodeURIComponent(path.join(baseDir, req.url))
   // 阻止將 baseDir 以外的文件發送出去
  if (!targetPath.startsWith(baseDir)) { 
    res.end('hello hacker')
    return
  }
   // 阻止發送以點開頭的文件夾(隱藏文件)裏的文件
  if (targetPath.split(path.sep).some(seg => seg.startsWith('.'))) { // 這裏的path.sep 是讀取系統分隔符的方法
    res.end('hello hacker')
    return
  }
                     ..
                     ..

})

這樣,我們的靜態文件服務器纔算差不多寫完了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章