diff --git a/.editorconfig b/.editorconfig index b219b5b7..124645fc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,3 @@ -# editorconfig.org - root = true [*] diff --git a/middleware/load-options.js b/middleware/load-options.js index a624b9f6..e72d7ad7 100644 --- a/middleware/load-options.js +++ b/middleware/load-options.js @@ -1,62 +1,42 @@ const currentYear = new Date().getFullYear() -module.exports = (req, res, next) => { - const parts = req.url.split('/') +const yearRegex = /^@?(\d{4})$/ +const yearRangeRegex = /^(\d{4})-(\d{4})$/ - res.locals.options = parts.reduce( - (acc, curr) => { - if (!curr) return acc +const getUrlParts = url => { + if (url === '/') { + return [] + } - let match = curr.match(/^@?(\d{4})$/) || [] + return url.slice(1).split('/') +} - if (match.length) { - // Pinned year - if (curr.startsWith('@')) { - acc.pinnedYear = parseInt(curr.substr(1), 10) - } else { - acc.startYear = parseInt(curr, 10) - } - return acc +module.exports = (request, response, next) => { + const urlParts = getUrlParts(request.url) + + response.locals.options = { + format: 'html', + startYear: null, + endYear: currentYear + } + + for (const urlPart of urlParts) { + if (yearRegex.test(urlPart)) { + if (urlPart.startsWith('@')) { + response.locals.options.pinnedYear = Number.parseInt(urlPart.slice(1)) + } else { + response.locals.options.startYear = Number.parseInt(urlPart) } + } else if (yearRangeRegex.test(urlPart)) { + const [startYear, endYear] = urlPart.match(yearRangeRegex).slice(1) - match = curr.match(/^(\d{4})-(\d{4})$/) || [] - - if (match.length) { - acc.startYear = parseInt(match[1], 10) - acc.endYear = parseInt(match[2], 10) - - return acc - } - - if (curr.startsWith('license')) { - acc.format = curr - .split('.') - .pop() - .trim() - return acc - } - - if (curr.startsWith('+')) { - acc.license = curr.substr(1).toUpperCase() - return acc - } - - acc.sha = curr // not actually supported now - 2019-06-19 - return acc - }, - { - format: 'html', - startYear: null, - endYear: currentYear, - sha: null + response.locals.options.startYear = Number.parseInt(startYear) + response.locals.options.endYear = Number.parseInt(endYear) + } else if (urlPart.startsWith('license')) { + response.locals.options.format = urlPart.split('.')[1].trim() + } else if (urlPart.startsWith('+')) { + response.locals.options.license = urlPart.slice(1).toUpperCase() } - ) - - if (res.locals.options.sha) { - res.setHeader( - 'X-note', - 'SHA and commit pinning is no longer supported, showing you latest release' - ) } next() diff --git a/middleware/load-user.js b/middleware/load-user.js index 483c2345..58103291 100644 --- a/middleware/load-user.js +++ b/middleware/load-user.js @@ -1,27 +1,26 @@ const path = require('path') const loadJsonFile = require('load-json-file') -module.exports = async (req, res, next) => { - const id = req.hostname.split('.')[0] - res.locals.id = id +module.exports = async (request, response, next) => { + response.locals.id = request.hostname.split('.')[0] - if (req.method.toUpperCase() !== 'GET') { + if (request.method.toUpperCase() !== 'GET') { return next() } // Otherwise load up the user json file - res.locals.user = { + response.locals.user = { copyright: '' } try { - res.locals.user = { - ...res.locals.user, - ...await loadJsonFile(path.join(__dirname, '..', 'users', `${id}.json`)) + response.locals.user = { + ...response.locals.user, + ...await loadJsonFile(path.join(__dirname, '..', 'users', `${response.locals.id}.json`)) } } catch ({ code, message }) { if (code !== 'ENOENT') { - res + response .code(500) .send( `An internal error occurred - open an issue on https://github.com/remy/mit-license with the following information: ${message}` diff --git a/package.json b/package.json index 0f50409c..4058d9f7 100644 --- a/package.json +++ b/package.json @@ -26,16 +26,17 @@ "dependencies": { "@octokit/rest": "^18.0.6", "@sindresorhus/is": "^3.1.2", - "any-size": "^1.0.2", + "any-size": "^1.2.0", "btoa": "^1.2.1", "cors": "^2.8.5", + "create-html-element": "^3.0.0", "ejs": "^3.1.5", "escape-goat": "^3.0.0", "express": "^4.17.1", "express-minify": "^1.0.0", + "gravatar-url": "^3.1.0", "html-text": "^1.0.1", "load-json-file": "^6.2.0", - "md5": "^2.3.0", "path-exists": "^4.0.0", "postcss-middleware": "^1.1.4", "postcss-preset-env": "^6.7.0", diff --git a/routes/get.js b/routes/get.js index a55e96d4..1db5c870 100644 --- a/routes/get.js +++ b/routes/get.js @@ -1,97 +1,118 @@ -const md5 = require('md5') const path = require('path') const { htmlEscape, htmlUnescape } = require('escape-goat') const stripHtml = require('html-text') const is = require('@sindresorhus/is') +const getGravatarUrl = require('gravatar-url') +const createHtmlElement = require('create-html-element') +const { renderFile } = require('ejs') -function getCopyrightHTML (user, plain) { - let html = '' - - const name = is.string(user) - ? user - : plain - ? user.name || user.copyright - : htmlEscape(user.name || user.copyright) - - if (user.url) { - html = `${name}` - } else { - html = name +const getCopyrightName = (user, isPlainText) => { + if (is.string(user)) { + return user } + const copyright = user.name || user.copyright + + return isPlainText ? copyright : htmlEscape(copyright) +} + +const getCopyrightHtml = (user, isPlainText) => { + const name = getCopyrightName(user, isPlainText) + let html = user.url ? createHtmlElement({ + name: 'a', + attributes: { + href: user.url + }, + text: name + }) : name + if (user.email) { - html += ` <${ - plain ? user.email : htmlEscape(user.email) - }>` + html += ` <${createHtmlElement({ + name: 'a', + attributes: { + href: `mailto:${user.email}` + }, + text: user.email + })}>` } return html } -module.exports = (req, res) => { - const { user, options } = res.locals +const getGravatarEmail = user => { + if (user.gravatar && user.email) { + // Supports regular format + return user.email.trim().toLowerCase() + } + + if (is.object(user.copyright[0]) && user.gravatar) { + // Supports multi-user format + return user.copyright[0].email.trim().toLowerCase() + } +} + +const removeFalsy = array => array.filter(Boolean) + +module.exports = async (_, response) => { + const { user, options } = response.locals + const isPlainText = options.format !== 'html' + let name - let gravatar // No error and valid if (user.copyright) { if (is.string(user.copyright)) { - name = getCopyrightHTML(user, options.format !== 'html') - } else if (is.array(user.copyright) && user.copyright.every(val => is.string(val))) { + name = getCopyrightHtml(user, isPlainText) + } else if (is.array(user.copyright) && user.copyright.every(value => is.string(value))) { // Supports: ['Remy Sharp', 'Richie Bendall'] - name = user.copyright - .map(v => (options.format !== 'html' ? v : htmlEscape(v))) - .join(', ') + name = user.copyright.map(value => (isPlainText ? value : htmlEscape(value))).join(', ') } else { - name = user.copyright.map(getCopyrightHTML).join(', ') + name = user.copyright.map(value => getCopyrightHtml(value)).join(', ') } } - if (user.gravatar && user.email) { - // Supports regular format - gravatar = `Profile image` - } else if (is.object(user.copyright[0]) && user.gravatar) { - // Supports multi-user format - gravatar = `Profile image` + let gravatar + const gravatarEmail = getGravatarEmail(user) + + if (gravatarEmail) { + gravatar = createHtmlElement({ + name: 'img', + attributes: { + id: 'gravatar', + alt: 'Profile image', + src: getGravatarUrl(gravatarEmail) + } + }) } const year = options.pinnedYear ? options.pinnedYear - : [options.startYear, options.endYear].filter(Boolean).join('-') + : removeFalsy([options.startYear, options.endYear]).join('-') const license = (options.license || user.license || 'MIT').toUpperCase() const format = options.format || user.format || 'html' - const args = { - info: `${year} ${name}`, - theme: user.theme || 'default', - gravatar - } - - const filename = path.join(__dirname, '..', 'licenses', license) - req.app.render(filename, args, (error, content) => { - if (error) { - res.status(500).send(error) - return - } + try { + const content = await renderFile(path.join(__dirname, '..', 'licenses', `${license}.ejs`), { + info: `${year} ${name}`, + theme: user.theme || 'default', + gravatar + }) if (format === 'txt') { const plain = content.match(/
(.*)<\/article>/ms)[1] - res + response .set('Content-Type', 'text/plain; charset=UTF-8') .send(htmlUnescape(stripHtml(plain)).trim()) return } if (format === 'html') { - res.send(content) + response.send(content) return } - - res.json({ ...user, ...options }) - }) + response.json({ ...user, ...options }) + } catch (error) { + response.status(500).send(error) + } } diff --git a/routes/post.js b/routes/post.js index c803e56b..e49a32fb 100644 --- a/routes/post.js +++ b/routes/post.js @@ -14,41 +14,45 @@ const github = new Octokit({ const yn = require('yn') const is = require('@sindresorhus/is') -const { validDomainId } = require('./utils') +const { isDomainId } = require('./utils') function getUserData ({ query, body }) { // If query parameters provided - if (size(query) > 0) return query + if (size(query) > 0) { + return query + } // If the data parsed as {'{data: "value"}': ''} - if (size(body) === 1 && !Object.values(body)[0]) return JSON.parse(Object.keys(body)[0]) + if (size(body) === 1 && !Object.values(body)[0]) { + return JSON.parse(Object.keys(body)[0]) + } // Fallback return body } // HTTP POST API -module.exports = async (req, res) => { - const { hostname } = req +module.exports = async (request, response) => { + const { hostname } = request // Get different parts of hostname (example: remy.mit-license.org -> ['remy', 'mit-license', 'org']) const params = hostname.split('.') // This includes the copyright, year, etc. - const userData = getUserData(req) + const userData = getUserData(request) // If there isn't enough part of the hostname if (params.length < 2) { - res.status(400).send('Please specify a subdomain in the URL.') + response.status(400).send('Please specify a subdomain in the URL.') return } // Extract the name from the URL - const id = params[0] + const [id] = params - if (!validDomainId(id)) { + if (!isDomainId(id)) { // Return a vague error intentionally - res + response .status(400) .send( 'User already exists - to update values, please send a pull request on https://github.com/remy/mit-license' @@ -58,9 +62,8 @@ module.exports = async (req, res) => { } // Check if the user file exists in the users directory - const exists = await pathExists(path.join(__dirname, '..', 'users', `${id}.json`)) - if (exists) { - res + if (await pathExists(path.join(__dirname, '..', 'users', `${id}.json`))) { + response .status(409) .send( 'User already exists - to update values, please send a pull request on https://github.com/remy/mit-license' @@ -72,7 +75,7 @@ module.exports = async (req, res) => { // Parse the string version of a boolean or similar userData.gravatar = yn(userData.gravatar, { lenient: true }) if (is.undefined(userData.gravatar)) { - res + response .status(400) .send( 'The "gravatar" JSON property must be a boolean.' @@ -84,28 +87,29 @@ module.exports = async (req, res) => { // File doesn't exist // If copyright property and key doesn't exist if (!userData.copyright) { - res.status(400).send('JSON requires "copyright" property and value') + response.status(400).send('JSON requires "copyright" property and value') return } try { - await github.repos.createOrUpdateFileContents({ - owner: 'remy', - repo: 'mit-license', - path: `users/${id}.json`, - message: `Automated creation of user ${id}.`, - content: btoa(JSON.stringify(userData, 0, 2)), - committer: { - name: 'MIT License Bot', - email: 'remy@leftlogic.com' - } - }) + await Promise.all([ + github.repos.createOrUpdateFileContents({ + owner: 'remy', + repo: 'mit-license', + path: `users/${id}.json`, + message: `Automated creation of user ${id}.`, + content: btoa(JSON.stringify(userData, 0, 2)), + committer: { + name: 'MIT License Bot', + email: 'remy@leftlogic.com' + } + }), + writeJsonFile(path.join(__dirname, '..', 'users', `${id}.json`), userData, { indent: undefined }) + ]) - await writeJsonFile(path.join(__dirname, '..', 'users', `${id}.json`), userData, { indent: undefined }) - - res.status(201).send(`MIT license page created: https://${hostname}`) - } catch (err) { - res + response.status(201).send(`MIT license page created: https://${hostname}`) + } catch { + response .status(500) .send( 'Unable to create new user - please send a pull request on https://github.com/remy/mit-license' diff --git a/routes/utils.js b/routes/utils.js index d07c3d94..763b46a2 100644 --- a/routes/utils.js +++ b/routes/utils.js @@ -1,3 +1 @@ -module.exports = { - validDomainId: str => /^[\w-_]+$/.test(str) -} +exports.isDomainId = value => /^[\w-_]+$/.test(value) diff --git a/server.js b/server.js index a87f1b4e..17d77852 100644 --- a/server.js +++ b/server.js @@ -24,7 +24,6 @@ app.use( ) app.use(favicon(path.join(__dirname, 'favicon.ico'))) app.set('views', path.join(__dirname, '/licenses')) -app.set('view engine', 'ejs') // Setup static files app.use('/robots.txt', express.static('robots.txt')) @@ -34,12 +33,11 @@ app.use( postcssMiddleware({ plugins: [ require('postcss-preset-env')({ - overrideBrowserslist: '>= 0%', - stage: 0 + overrideBrowserslist: '>= 0%' }) ], - src (req) { - return path.join(__dirname, 'themes', req.path) + src (request) { + return path.join(__dirname, 'themes', request.path) } }), express.static('themes') diff --git a/test.js b/test.js index 1eee5192..9b4315b1 100644 --- a/test.js +++ b/test.js @@ -1,7 +1,7 @@ const { promises: fs } = require('fs') const writeJsonFile = require('write-json-file') const CSS = require('css') -const { validDomainId } = require('./routes/utils') +const { isDomainId } = require('./routes/utils') const hasFlag = require('has-flag') const getExtension = require('file-ext') const path = require('path-extra') @@ -23,7 +23,7 @@ async function report (content, fix) { }) } - if (!validDomainId(path.base(user))) { + if (!isDomainId(path.base(user))) { await report(`${user} is not a valid domain id.`) } diff --git a/yarn.lock b/yarn.lock index b701101f..b0f5033f 100644 Binary files a/yarn.lock and b/yarn.lock differ