|
| 1 | +const http = require('http'); |
| 2 | +const fs = require('fs'); |
| 3 | +const path = require('path'); |
| 4 | +const frontmatter = require('front-matter'); |
| 5 | +const marked = require('marked'); |
| 6 | +const netlifyCmsOAuth = require('netlify-cms-oauth-provider-node'); |
| 7 | + |
| 8 | +const port = process.env.PORT || 3000; |
| 9 | +const hostname = process.env.HOSTNAME || 'localhost'; |
| 10 | + |
| 11 | +// Create the handlers, using env variables for the ones not explicitly specified. |
| 12 | +const netlifyCmsOAuthHandlers = netlifyCmsOAuth.createHandlers({ |
| 13 | + origin: `${hostname}:${port}`, |
| 14 | + completeUrl: `http://${hostname}${port === 80 ? '' : `:${port}`}/api/admin/auth/complete`, |
| 15 | + adminPanelUrl: `http://${hostname}${port === 80 ? '' : `:${port}`}/admin`, |
| 16 | + oauthProvider: 'github', |
| 17 | +}, { |
| 18 | + useEnv: true, |
| 19 | +}); |
| 20 | + |
| 21 | +/** |
| 22 | + * Return a 404 to the user. This is our fallback route. |
| 23 | + * |
| 24 | + * @param {IncomingMessage} req |
| 25 | + * @param {OutgoingMessage} res |
| 26 | + * @return {Promise<void>} |
| 27 | + */ |
| 28 | +async function handleNotFound(req, res) { |
| 29 | + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); |
| 30 | + res.end('Not found.'); |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Creates a request handler that performs a redirect. |
| 35 | + * |
| 36 | + * TODO This probably doesn't need to be a factory function / decorator like it is based on the usages below. |
| 37 | + * |
| 38 | + * @param {string} to |
| 39 | + * @param {number=302} status |
| 40 | + * @return {function(req: IncomingMessage, res: OutgoingMessage): void} |
| 41 | + */ |
| 42 | +function createRedirectHandler(to, status = 302) { |
| 43 | + return function (req, res) { |
| 44 | + res.writeHead(status, { Location: to, 'Content-Type': 'text/html; charset=utf-8' }); |
| 45 | + res.end(`Redirecting to <a href="${to.replace('"', '"')}">${to}</a>...`); |
| 46 | + }; |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Creates a request handler that serves a specific static file. |
| 51 | + * |
| 52 | + * @param {string} filename An absolute file path or one relative to the server root directory |
| 53 | + * @param {(string|null)=} type A valid mime type. If not set, no Content-Type will be sent to the client. |
| 54 | + * @return {function(req: IncomingMessage, res: OutgoingMessage, ctx: object): Promise<void>} |
| 55 | + */ |
| 56 | +function createStaticFileHandler(filename, type = null) { |
| 57 | + return async function (req, res, ctx) { |
| 58 | + try { |
| 59 | + // Get the full absolute path to the file. |
| 60 | + const fullPath = path.resolve(__dirname, filename); |
| 61 | + // Get its contents |
| 62 | + // TODO Improve by using streams or just node-static |
| 63 | + const fileContents = await fs.promises.readFile(fullPath, { encoding: 'utf8' }); |
| 64 | + // Generate the headers containing the mime type |
| 65 | + const headers = type ? { 'Content-Type': type } : {}; |
| 66 | + // Write the header and file contents out |
| 67 | + res.writeHead(200, headers); |
| 68 | + res.write(fileContents); |
| 69 | + } catch (error) { |
| 70 | + // If an error occurred, we'll just write out a 404 |
| 71 | + // TODO Improve error handling, i.e. a 500 when this is an unexpected error |
| 72 | + return handleNotFound(req, res, ctx); |
| 73 | + } finally { |
| 74 | + // Always end the response |
| 75 | + res.end(); |
| 76 | + } |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * Handles the request to kick off the admin OAuth flow using this library. |
| 82 | + * |
| 83 | + * @param {IncomingMessage} req |
| 84 | + * @param {OutgoingMessage} res |
| 85 | + * @return {Promise<void>} |
| 86 | + */ |
| 87 | +async function handleAdminAuthBegin(req, res) { |
| 88 | + // Generate the auth URI and redirect the user there. |
| 89 | + const authorizationUri = await netlifyCmsOAuthHandlers.begin(); |
| 90 | + return createRedirectHandler(authorizationUri)(req, res); |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Handles the request to complete the admin OAuth flow using this library. |
| 95 | + * |
| 96 | + * @param {IncomingMessage} req |
| 97 | + * @param {OutgoingMessage} res |
| 98 | + * @param {URL} parsedRequest |
| 99 | + * @return {Promise<void>} |
| 100 | + */ |
| 101 | +async function handleAdminAuthComplete(req, res, { parsedRequest }) { |
| 102 | + // Extract the code from the query parameters |
| 103 | + const code = parsedRequest.searchParams.get('code') || null; |
| 104 | + // Allow the library to complete the oauth flow, exchange the auth code for an access token, and generate the popup HTML that |
| 105 | + // will hand it off to the netlify-cms admin panel using the `postMessage` API. |
| 106 | + const content = await netlifyCmsOAuthHandlers.complete(code); |
| 107 | + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); |
| 108 | + res.end(content); |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Will serve the netlify-cms config file. |
| 113 | + * |
| 114 | + * @type {function(IncomingMessage, OutgoingMessage): Promise<void>} |
| 115 | + */ |
| 116 | +const handleAdminConfig = createStaticFileHandler('config.yml', 'text/yaml; charset=utf-8'); |
| 117 | + |
| 118 | +/** |
| 119 | + * Will serve the netlify-cms main HTML file. |
| 120 | + * |
| 121 | + * @type {function(IncomingMessage, OutgoingMessage): Promise<void>} |
| 122 | + */ |
| 123 | +const handleAdmin = createStaticFileHandler('admin.html', 'text/html; charset=utf-8'); |
| 124 | + |
| 125 | +/** |
| 126 | + * Possibly handle a requested markdown page. The provided route is compared to our available markdown page files |
| 127 | + * and if one is found, it's compiled via `front-matter` and `marked` served to the user as HTML. Otherwise it does |
| 128 | + * nothing. |
| 129 | + * |
| 130 | + * @param {IncomingMessage} req |
| 131 | + * @param {OutgoingMessage} res |
| 132 | + * @param {string} route |
| 133 | + * @return {Promise<boolean>} Resolves with true if the request was handled (a page was found and rendered) or false |
| 134 | + * otherwise. |
| 135 | + */ |
| 136 | +async function handlePage(req, res, { route }) { |
| 137 | + // Resolve the path to the possible markdown file |
| 138 | + const fullPathToPossibleMarkdownFile = path.resolve( |
| 139 | + __dirname, |
| 140 | + '../common/pages', |
| 141 | + `${route.replace(/^\//, '')}.md`, |
| 142 | + ); |
| 143 | + // Attempt to read the file, returning false ("no I did not handle this request") if any error occurs reading the file. |
| 144 | + let markdownFileContents = null; |
| 145 | + try { |
| 146 | + markdownFileContents = await fs.promises.readFile(fullPathToPossibleMarkdownFile, { encoding: 'utf8' }); |
| 147 | + } catch (ignored) { |
| 148 | + return false; |
| 149 | + } |
| 150 | + // Parse the file first using `front-matter` and `marked` |
| 151 | + const fileFrontmatter = frontmatter(markdownFileContents); |
| 152 | + const htmlFileContents = marked(fileFrontmatter.body); |
| 153 | + // Generate the HTML, using the frontmatter to generate the title. |
| 154 | + const finalHtml = `<html lang="en"><head><title>${fileFrontmatter.attributes.title || ''}</title>` |
| 155 | + + `</head><body>\n${htmlFileContents}</body></html>`; |
| 156 | + // Serve the HTML to the user. |
| 157 | + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); |
| 158 | + res.end(finalHtml); |
| 159 | + return true; |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * Our server request handler. Node's `http` server module will run this function for every incoming request. |
| 164 | + * |
| 165 | + * @param {IncomingMessage} req |
| 166 | + * @param {OutgoingMessage} res |
| 167 | + * @return {Promise<void>} |
| 168 | + */ |
| 169 | +async function handleRequest(req, res) { |
| 170 | + // Parse the request |
| 171 | + const parsedRequest = new URL(req.url, `http://${req.headers.host}`); |
| 172 | + const route = parsedRequest.pathname.toLowerCase().trim().replace(/\/+$/, '') || '/'; |
| 173 | + |
| 174 | + // Generate a context object that gets passed to all of our handlers so they have extra info about the request |
| 175 | + const ctx = { parsedRequest, route }; |
| 176 | + |
| 177 | + // Redirect to canonical routes if the original route doesn't match the final processed route. |
| 178 | + if (ctx.route !== ctx.parsedRequest.pathname) { |
| 179 | + console.log(`Redirecting: '${ctx.parsedRequest.pathname}' -> '${ctx.route}'`); |
| 180 | + await createRedirectHandler(`http://${req.headers.host}${ctx.route}`, 301)(req, res, ctx); |
| 181 | + return; |
| 182 | + } |
| 183 | + |
| 184 | + // Manually suppoort some aliases |
| 185 | + // TODO Also redirect from one to the other so that one is treated as canonical |
| 186 | + // TODO Abstract this out into a Map or something |
| 187 | + if (ctx.route === '/') { |
| 188 | + console.log(`Serving alias: '${ctx.route}' => '/home'...`); |
| 189 | + ctx.route = '/home'; |
| 190 | + } else { |
| 191 | + console.log(`Serving: '${ctx.route}'`); |
| 192 | + } |
| 193 | + |
| 194 | + // Simplistic routing using a good, old-fashioned set of conditionals |
| 195 | + if (ctx.route === '/api/admin/auth/begin') { |
| 196 | + return handleAdminAuthBegin(req, res, ctx); |
| 197 | + } else if (ctx.route === '/api/admin/auth/complete') { |
| 198 | + return handleAdminAuthComplete(req, res, ctx); |
| 199 | + } else if (ctx.route === '/admin/config.yml' || ctx.route === '/config.yml') { |
| 200 | + return handleAdminConfig(req, res, ctx); |
| 201 | + } else if (ctx.route.startsWith('/admin')) { |
| 202 | + return handleAdmin(req, res, ctx); |
| 203 | + } |
| 204 | + |
| 205 | + // If none of the above explicit routes matched, see if we can match against the markdown pages |
| 206 | + const handledPage = await handlePage(req, res, ctx); |
| 207 | + |
| 208 | + // If the markdown pages didn't match, finally just send a 404 |
| 209 | + if (!handledPage) { |
| 210 | + return handleNotFound(req, res, ctx); |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +// Create the server |
| 215 | +// TODO Support `https` |
| 216 | +const server = http.createServer(handleRequest); |
| 217 | + |
| 218 | +// Listen on the desired port |
| 219 | +server.listen(port, () => { |
| 220 | + console.log(`Listening on port ${port}...`); |
| 221 | +}); |
0 commit comments