/** 簡易HTTPサーバ */ import fs from 'fs' import http from 'http' import path from 'path' import os from 'os' // 定数 const HTTPSERVER_LOGID = '[簡易HTTPサーバ]' const ERR_NOHTTPSERVER = '最初に『簡易HTTPサーバ起動時』を実行してサーバを起動する必要があります。' const MAX_BODY_SIZE_POST = 10 * 1024 * 1024 // 10MB // オブジェクト type EasyURLActionType = 'static' | 'callback' type EasyURLCallback = (req: any, res: any) => void type EasyHTTPOnStart = (sys: any) => void class EasyURLItem { url: string action: EasyURLActionType path: string callback: EasyURLCallback constructor(action: EasyURLActionType) { this.action = action this.url = '' this.path = '' this.callback = (_req, _res) => {} } } class EasyURLDispather { server: any sys: any items: EasyURLItem[] curReq: any curRes: any isEnd: boolean usedHeader: boolean constructor(sys: any) { this.server = null this.items = [] this.sys = sys this.curReq = null this.curRes = null this.isEnd = false this.usedHeader = false } doRequest(req: any, res: any) { this.curReq = req this.curRes = res console.log(`${HTTPSERVER_LOGID} 要求あり URL=` + req.url) const params = this.parseURL(req.url) const url = params['?URL'] this.sys.__setSysVar('GETデータ', params) const runDispatcher = (postData: any) => { this.sys.__setSysVar('POSTデータ', postData) // URLの一致を調べてアクションを実行 const filtered = this.items.filter(v => url.startsWith(v.url)).sort((a, b) => { return b.url.length - a.url.length }) for (const it of filtered) { let isBreak = false if (it.action === 'static') { isBreak = this.doRequestStatic(req, res, it) } else if (it.action === 'callback') { isBreak = this.doRequestCallback(req, res, it) } if (isBreak) { break } } } if (req.method === 'POST') { let bodySize = 0 const chunks: Buffer[] = [] req.on('data', (chunk: Buffer) => { bodySize += chunk.length if (bodySize > MAX_BODY_SIZE_POST) { res.statusCode = 413 res.end('Request entity too large.') req.destroy() return } chunks.push(chunk) }) req.on('end', async() => { const bodyBuffer = Buffer.concat(chunks) let postData: any = {} let filesData: any[] = [] const contentType = req.headers['content-type'] || '' try { if (contentType.indexOf('multipart/form-data') >= 0) { const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/) if (boundaryMatch) { const boundary = (boundaryMatch[1] || boundaryMatch[2] || '').trim() const parsed = await parseMultipart(bodyBuffer, boundary) postData = parsed.fields filesData = parsed.files } } else if (contentType.indexOf('application/json') >= 0) { const bodyStr = bodyBuffer.toString('utf-8') try { postData = JSON.parse(bodyStr) } catch { postData = bodyStr } } else if (contentType.indexOf('application/x-www-form-urlencoded') >= 0) { const bodyStr = bodyBuffer.toString('utf-8') const searchParams = new URLSearchParams(bodyStr) const obj: any = {} for (const [key, val] of searchParams.entries()) { obj[key] = val } postData = obj } else { postData = bodyBuffer.toString('utf-8') } } catch (err: any) { console.error(`${HTTPSERVER_LOGID} アップロード保存エラー: ${err.message}`) res.statusCode = 500 res.end('Failed to save upload file.') return } this.sys.__setSysVar('FILESデータ', filesData) runDispatcher(postData) }) } else { this.sys.__setSysVar('FILESデータ', []) runDispatcher({}) } } return404(res: any) { console.error(HTTPSERVER_LOGID, 404, '見当たりません。') res.statusCode = 404 res.end('

404 見当たりません。

') } doRequestStatic(req: any, res: any, it: EasyURLItem): boolean { const params = this.parseURL(req.url) const rawUrl = params['?URL'] || '' let url: string = ('' + rawUrl).replace(/\.\./g, '') // URLの..を許可しない url = url.substring(it.url.length) let fpath = path.join(it.path, url) console.log('FILE=', fpath) if (!fs.existsSync(fpath)) { this.return404(res) return true } // ディレクトリなら index.html を確認 if (isDir(fpath)) { fpath = path.join(fpath, 'index.html') console.log('FILE(DIR)=', fpath) if (!fs.existsSync(fpath)) { this.return404(res) return true } } // ファイルを読んで返す fs.readFile(fpath, (err, data) => { if (err) { res.statusCode = 500 res.end('Failed to read file.') console.warn(HTTPSERVER_LOGID, 'read error file=', fpath) return true } const mime = getMIMEType(fpath) res.writeHead(200, { 'Content-Type': mime }) res.end(data) return true }) return true } doRequestCallback(req: any, res: any, it: EasyURLItem): boolean { this.isEnd = false this.usedHeader = false it.callback(req, res) if (!this.isEnd) { return true } return true } addItem(it: EasyURLItem) { this.items.push(it) } parseURL(uri: string): any { const params: any = {} if (uri.indexOf('?') >= 0) { const a = uri.split('?') params['?URL'] = a[0] const q = String(a[1]).split('&') for (const kv of q) { const qq = kv.split('=') const key = decodeURIComponent(qq[0]) const val = decodeURIComponent(qq[1]) params[key] = val } } else { params['?URL'] = uri } return params } } async function parseMultipart(body: Buffer, boundary: string): Promise<{ files: any[], fields: any }> { const fields: any = {} const files: any[] = [] const boundaryBuffer = Buffer.from('--' + boundary) let pos = 0 const parts: Buffer[] = [] while (true) { const nextIdx = body.indexOf(boundaryBuffer, pos) if (nextIdx === -1) { break } if (pos > 0) { let endPos = nextIdx if (body[nextIdx - 2] === 13 && body[nextIdx - 1] === 10) { endPos -= 2 } else if (body[nextIdx - 1] === 10) { endPos -= 1 } parts.push(body.subarray(pos, endPos)) } pos = nextIdx + boundaryBuffer.length } for (const part of parts) { if (part.length === 0) { continue } let start = 0 if (part[0] === 13 && part[1] === 10) { start = 2 } else if (part[0] === 10) { start = 1 } const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'), start) let bodyStart = 0 let headerStr = '' if (headerEnd !== -1) { headerStr = part.toString('utf-8', start, headerEnd) bodyStart = headerEnd + 4 } else { const headerEndLf = part.indexOf(Buffer.from('\n\n'), start) if (headerEndLf !== -1) { headerStr = part.toString('utf-8', start, headerEndLf) bodyStart = headerEndLf + 2 } } if (bodyStart === 0) { continue } const partBody = part.subarray(bodyStart) const headers: any = {} const lines = headerStr.split(/\r?\n/) for (const line of lines) { const idx = line.indexOf(':') if (idx !== -1) { const key = line.substring(0, idx).trim().toLowerCase() const val = line.substring(idx + 1).trim() headers[key] = val } } const contentDisposition = headers['content-disposition'] || '' const nameMatch = contentDisposition.match(/name="([^"]+)"/) const filenameMatch = contentDisposition.match(/filename="([^"]+)"/) if (nameMatch) { const name = nameMatch[1] if (filenameMatch) { const filename = filenameMatch[1] const safeFilename = path.basename(filename).replace(/[\\/]/g, '_') const contentType = headers['content-type'] || 'application/octet-stream' const uploadDir = path.join(os.tmpdir(), 'nako3-plugin_httpserver_upload') if (!fs.existsSync(uploadDir)) { await fs.promises.mkdir(uploadDir, { recursive: true }) } const uniqueName = Date.now() + '_' + Math.random().toString(36).substring(2, 8) + '_' + safeFilename const filepath = path.join(uploadDir, uniqueName) await fs.promises.writeFile(filepath, partBody) files.push({ fieldName: name, name: filename, path: filepath, size: partBody.length, type: contentType }) } else { fields[name] = partBody.toString('utf-8') } } } return { files, fields } } // MIMEタイプ const MimeTypes: any = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.nako3': 'text/nadesiko3', '.png': 'image/png', '.gif': 'image/gif', '.svg': 'svg+xml' } function getMIMEType(url: string) { let ext = '.txt' const m = url.match(/(\.[a-z0-9_]+)$/) if (m) { ext = m[1] } if (MimeTypes[ext]) { return MimeTypes[ext] } return 'text/plain' } // ディレクトリか判定 function isDir(pathName: string) { try { // node v12以下ではエラーがあると例外を返す const stats = fs.statSync(pathName) if (stats && stats.isDirectory()) { return true } } catch { return false } } const PluginHttpServer = { 'meta': { type: 'const', value: { pluginName: 'plugin_httpserver', // プラグインの名前 description: 'HTTPサーバプラグイン', // プラグインの説明 pluginVersion: '3.6.0', // プラグインのバージョン nakoRuntime: ['cnako'], // 対象ランタイム nakoVersion: '3.6.0' // 要求なでしこバージョン } }, '初期化': { type: 'func', josi: [], pure: true, fn: function(sys: any) { sys.__httpserver = null } }, // @簡易HTTPサーバ 'GETデータ': { type: 'const', value: '' }, // @GETでーた 'POSTデータ': { type: 'const', value: '' }, // @POSTでーた 'FILESデータ': { type: 'const', value: '' }, // @FILESでーた '簡易HTTPサーバ起動時': { // @ポート番号PORTを指定して簡易HTTPサーバを起動して、CALLBACKを実行する。 // @かんいHTTPさーばきどうしたとき type: 'func', josi: [['を'], ['の', 'で']], pure: true, fn: function(callback: EasyHTTPOnStart, port: number, sys: any) { // 管理オブジェクトを作成する const dp = sys.__httpserver = new EasyURLDispather(sys) // サーバオブジェクトを生成 dp.server = http.createServer((req: any, res: any) => { dp.doRequest(req, res) }) dp.server.on('error', (err: any) => { console.error(`${HTTPSERVER_LOGID} エラー: ${err.message}`) }) // サーバ起動 dp.server.listen(port, () => { console.log(`${HTTPSERVER_LOGID} ポート番号(${port})で監視開始`) if (typeof callback === 'string') { callback = sys.__findFunc(callback) } callback(sys) }) } }, '簡易HTTPサーバ静的パス指定': { // @静的コンテンツのパスを指定。URLをPATHへマップする。 // @かんいHTTPさーばせいてきぱすしてい type: 'func', josi: [['を'], ['に', 'へ']], pure: true, fn: function(url: string, path: string, sys: any) { if (sys.__httpserver === null) { throw new Error(ERR_NOHTTPSERVER) } const dp: EasyURLDispather = sys.__httpserver const it: EasyURLItem = new EasyURLItem('static') it.url = url it.path = path dp.addItem(it) } }, '簡易HTTPサーバ受信時': { // @URLを指定して合致するリクエストが来たら処理を実行する。 // @かんいHTTPさーばじゅしんしたとき type: 'func', josi: [['を'], ['に', 'へ', 'で']], pure: true, fn: function(callback: EasyURLCallback, url: string, sys: any) { if (sys.__httpserver === null) { throw new Error(ERR_NOHTTPSERVER) } const dp: EasyURLDispather = sys.__httpserver const it: EasyURLItem = new EasyURLItem('callback') if (url === '') { url = '/' } if (url.charAt(0) !== '/') { url = '/' + url } it.url = url if (typeof callback === 'string') { callback = sys.__findFunc(callback) } it.callback = callback dp.addItem(it) } }, '簡易HTTPサーバ出力': { // @受信時に、データSを出力する。 // @かんいHTTPさーばしゅつりょく type: 'func', josi: [['を', 'と', 'の']], pure: true, fn: function(s: string, sys: any) { if (sys.__httpserver === null) { throw new Error(ERR_NOHTTPSERVER) } const dp: EasyURLDispather = sys.__httpserver if (dp.curRes === null) { throw new Error('『簡易HTTPサーバ受信時』のみ出力が可能です。') } if (!dp.usedHeader) { dp.usedHeader = true dp.curRes.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) } dp.curRes.end(s) dp.isEnd = true } }, '簡易HTTPサーバヘッダ出力': { // @受信時にステータスコードNOで、ヘッダHEAD(辞書形式)を出力する。 // @かんいHTTPさーばへっだしゅつりょく type: 'func', josi: [['で'], ['を', 'の', 'と']], pure: true, fn: function(no: number, head: string, sys: any) { if (sys.__httpserver === null) { throw new Error(ERR_NOHTTPSERVER) } const dp: EasyURLDispather = sys.__httpserver if (dp.curRes === null) { throw new Error('『簡易HTTPサーバ受信時』のみ出力が可能です。') } dp.curRes.writeHead(no, head) dp.isEnd = true dp.usedHeader = true } }, '簡易HTTPサーバ移動': { // @受信時にヘッダ302(リダイレクト)を出力してURLへページを移動力する。 // @かんいHTTPさーばいどう type: 'func', josi: [['へ', 'に']], pure: true, fn: function(url: string, sys: any) { if (sys.__httpserver === null) { throw new Error(ERR_NOHTTPSERVER) } const dp: EasyURLDispather = sys.__httpserver if (dp.curRes === null) { throw new Error('『簡易HTTPサーバ受信時』のみ出力が可能です。') } console.log(HTTPSERVER_LOGID, '移動=', url) dp.curRes.writeHead(302, { 'Location': url }) dp.curRes.end(`JUMP`) dp.isEnd = true } } } export default PluginHttpServer