diff --git a/middleware/load-options.js b/lib/load-options.js similarity index 54% rename from middleware/load-options.js rename to lib/load-options.js index e72d7ad7..f3e43984 100644 --- a/middleware/load-options.js +++ b/lib/load-options.js @@ -11,10 +11,10 @@ const getUrlParts = url => { return url.slice(1).split('/') } -module.exports = (request, response, next) => { - const urlParts = getUrlParts(request.url) +const loadOptions = url => { + const urlParts = getUrlParts(url) - response.locals.options = { + const options = { format: 'html', startYear: null, endYear: currentYear @@ -23,21 +23,23 @@ module.exports = (request, response, next) => { for (const urlPart of urlParts) { if (yearRegex.test(urlPart)) { if (urlPart.startsWith('@')) { - response.locals.options.pinnedYear = Number.parseInt(urlPart.slice(1)) + options.pinnedYear = Number.parseInt(urlPart.slice(1), 10) } else { - response.locals.options.startYear = Number.parseInt(urlPart) + options.startYear = Number.parseInt(urlPart, 10) } } else if (yearRangeRegex.test(urlPart)) { const [startYear, endYear] = urlPart.match(yearRangeRegex).slice(1) - response.locals.options.startYear = Number.parseInt(startYear) - response.locals.options.endYear = Number.parseInt(endYear) + options.startYear = Number.parseInt(startYear, 10) + options.endYear = Number.parseInt(endYear, 10) } else if (urlPart.startsWith('license')) { - response.locals.options.format = urlPart.split('.')[1].trim() + options.format = urlPart.split('.')[1].trim() } else if (urlPart.startsWith('+')) { - response.locals.options.license = urlPart.slice(1).toUpperCase() + options.license = urlPart.slice(1).toUpperCase() } } - next() + return options } + +export default loadOptions diff --git a/lib/load-user.js b/lib/load-user.js new file mode 100644 index 00000000..acb3fc7b --- /dev/null +++ b/lib/load-user.js @@ -0,0 +1,26 @@ +import {fileURLToPath} from 'node:url' +import path, {dirname} from 'node:path' +import loadJsonFile from 'load-json-file' + +const directoryName = dirname(fileURLToPath(import.meta.url)) + +const loadUser = async hostname => { + const [id] = hostname.split('.') + + const user = { + copyright: '' // Fallback + } + + try { + return { + ...user, + ...await loadJsonFile(path.join(directoryName, '..', 'users', `${id}.json`)) + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error + } + } +} + +export default loadUser diff --git a/middleware/load-user.js b/middleware/load-user.js deleted file mode 100644 index 9d602abb..00000000 --- a/middleware/load-user.js +++ /dev/null @@ -1,33 +0,0 @@ -const path = require('path') -const loadJsonFile = require('load-json-file') - -module.exports = async (request, response, next) => { - const id = request.hostname.split('.')[0] - - if (request.method.toUpperCase() !== 'GET') { - return next() - } - - // Otherwise load up the user json file - response.locals.user = { - copyright: '' - } - - try { - response.locals.user = { - ...response.locals.user, - ...await loadJsonFile(path.join(__dirname, '..', 'users', `${id}.json`)) - } - } catch ({ code, message }) { - if (code !== 'ENOENT') { - response - .code(500) - .send( - `An internal error occurred - open an issue on https://github.com/remy/mit-license with the following information: ${message}` - ) - return - } - } - - next() -} diff --git a/package.json b/package.json index 589b5a3a..fc39e38f 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "mit-licence", "description": "Hosted MIT License with details controlled through this repo", "private": true, + "type": "module", "version": "2.0.0", "main": "server.js", "repository": { @@ -17,42 +18,45 @@ "dev": "nodemon .", "serve": "node server.js", "test": "node test.js", - "lint": "standard" + "lint": "xo" }, "bugs": { "url": "https://github.com/remy/mit-license/issues" }, "license": "MIT", "dependencies": { - "@octokit/rest": "^18.0.6", - "@sindresorhus/is": "^3.1.2", + "@octokit/rest": "^18.5.3", + "@sindresorhus/is": "^4.0.1", "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", + "ejs": "^3.1.6", + "escape-goat": "^4.0.0", "express": "^4.17.1", "express-minify": "^1.0.0", - "gravatar-url": "^3.1.0", + "gravatar-url": "^4.0.0", "html-text": "^1.0.1", "load-json-file": "^6.2.0", "path-exists": "^4.0.0", "postcss-middleware": "^1.1.4", "postcss-preset-env": "^6.7.0", + "read-pkg": "^6.0.0", "serve-favicon": "^2.5.0", "temp-dir": "^2.0.0", "write-json-file": "^4.3.0", "yn": "^4.0.0" }, "devDependencies": { + "@babel/eslint-parser": "^7.14.3", + "@babel/plugin-syntax-top-level-await": "^7.12.13", "css": "^3.0.0", "file-ext": "^1.0.0", - "has-flag": "^4.0.0", + "has-flag": "^5.0.0", "husky": "^4.3.0", - "nodemon": "^2.0.4", + "nodemon": "^2.0.7", "path-extra": "^4.3.0", - "standard": "^14.3.4" + "xo": "^0.40.1" }, "resolutions": { "postcss-middleware/vinyl-fs/glob-stream/micromatch/braces": "^3.0.2" @@ -62,5 +66,18 @@ "pre-commit": "npm run lint", "pre-push": "npm test" } + }, + "xo": { + "space": 2, + "semicolon": false, + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "babelOptions": { + "plugins": [ + "@babel/plugin-syntax-top-level-await" + ] + } + } } } diff --git a/routes/get.js b/routes/get.js index 284940ea..70faac79 100644 --- a/routes/get.js +++ b/routes/get.js @@ -1,10 +1,16 @@ -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') +import {fileURLToPath} from 'node:url' +import path, {dirname} from 'node:path' +import {htmlEscape, htmlUnescape} from 'escape-goat' +import stripHtml from 'html-text' +import is from '@sindresorhus/is' +import getGravatarUrl from 'gravatar-url' +import createHtmlElement from 'create-html-element' +import {renderFile} from 'ejs' + +import loadUser from '../lib/load-user.js' +import loadOptions from '../lib/load-options.js' + +const directoryName = dirname(fileURLToPath(import.meta.url)) const getCopyrightName = (user, isPlainText) => { if (is.string(user)) { @@ -53,9 +59,24 @@ const getGravatarEmail = user => { const removeFalsy = array => array.filter(Boolean) -module.exports = async (_, response) => { - const { user, options } = response.locals - const isPlainText = options.format !== 'html' +const getRoute = async (request, response) => { + let user + try { + user = await loadUser(request.hostname) + } catch ({message}) { + response + .status(500) + .send(`An internal error occurred - open an issue on https://github.com/remy/mit-license with the following information: ${message}`) + return + } + + const options = loadOptions(request.url) + const year = options.pinnedYear ? + options.pinnedYear : + removeFalsy([options.startYear, options.endYear]).join('-') + const license = (options.license || user.license || 'MIT').toUpperCase() + const format = options.format || user.format || 'html' + const isPlainText = format !== 'html' let name @@ -85,21 +106,15 @@ module.exports = async (_, response) => { }) } - const year = options.pinnedYear - ? options.pinnedYear - : removeFalsy([options.startYear, options.endYear]).join('-') - const license = (options.license || user.license || 'MIT').toUpperCase() - const format = options.format || user.format || 'html' - try { - const content = await renderFile(path.join(__dirname, '..', 'licenses', `${license}.ejs`), { + const content = await renderFile(path.join(directoryName, '..', 'licenses', `${license}.ejs`), { info: `${year} ${name}`, theme: user.theme || 'default', gravatar }) if (format === 'txt') { - const { articleContent } = content.match(/
(?.*)<\/article>/ms).groups + const {articleContent} = content.match(/
(?.*)<\/article>/ms).groups response .set('Content-Type', 'text/plain; charset=UTF-8') @@ -111,8 +126,11 @@ module.exports = async (_, response) => { response.send(content) return } - response.json({ ...user, ...options }) + + response.json({...user, ...options}) } catch (error) { response.status(500).send(error) } } + +export default getRoute diff --git a/routes/post.js b/routes/post.js index e49a32fb..eff6031d 100644 --- a/routes/post.js +++ b/routes/post.js @@ -1,22 +1,29 @@ -const path = require('path') -const btoa = require('btoa') -const { version } = require(path.join(__dirname, '..', 'package.json')) -const size = require('any-size') -const { Octokit } = require('@octokit/rest') -const pathExists = require('path-exists') -const writeJsonFile = require('write-json-file') +import path, {dirname} from 'node:path' +import toBase64 from 'btoa' +import {readPackageAsync as readPackage} from 'read-pkg' +import size from 'any-size' +import {Octokit} from '@octokit/rest' +import pathExists from 'path-exists' +import writeJsonFile from 'write-json-file' +import yn from 'yn' +import is from '@sindresorhus/is' + +import {fileURLToPath} from 'node:url' + +import {isDomainId} from './utils.js' + +const directoryName = dirname(fileURLToPath(import.meta.url)) + +const {version} = await readPackage() + const github = new Octokit({ // GitHub personal access token auth: process.env.github_token, // User agent with version from package.json userAgent: `mit-license v${version}` }) -const yn = require('yn') -const is = require('@sindresorhus/is') -const { isDomainId } = require('./utils') - -function getUserData ({ query, body }) { +function getUserData({query, body}) { // If query parameters provided if (size(query) > 0) { return query @@ -31,24 +38,23 @@ function getUserData ({ query, body }) { return body } -// HTTP POST API -module.exports = async (request, response) => { - const { hostname } = request +const postRoute = async (request, response) => { + const {hostname} = request // Get different parts of hostname (example: remy.mit-license.org -> ['remy', 'mit-license', 'org']) - const params = hostname.split('.') + const parameters = hostname.split('.') // This includes the copyright, year, etc. const userData = getUserData(request) // If there isn't enough part of the hostname - if (params.length < 2) { + if (parameters.length < 2) { response.status(400).send('Please specify a subdomain in the URL.') return } // Extract the name from the URL - const [id] = params + const [id] = parameters if (!isDomainId(id)) { // Return a vague error intentionally @@ -62,7 +68,7 @@ module.exports = async (request, response) => { } // Check if the user file exists in the users directory - if (await pathExists(path.join(__dirname, '..', 'users', `${id}.json`))) { + if (await pathExists(path.join(directoryName, '..', 'users', `${id}.json`))) { response .status(409) .send( @@ -73,7 +79,7 @@ module.exports = async (request, response) => { if (userData.gravatar) { // Parse the string version of a boolean or similar - userData.gravatar = yn(userData.gravatar, { lenient: true }) + userData.gravatar = yn(userData.gravatar, {lenient: true}) if (is.undefined(userData.gravatar)) { response .status(400) @@ -98,13 +104,13 @@ module.exports = async (request, response) => { repo: 'mit-license', path: `users/${id}.json`, message: `Automated creation of user ${id}.`, - content: btoa(JSON.stringify(userData, 0, 2)), + content: toBase64(JSON.stringify(userData, 0, 2)), committer: { name: 'MIT License Bot', email: 'remy@leftlogic.com' } }), - writeJsonFile(path.join(__dirname, '..', 'users', `${id}.json`), userData, { indent: undefined }) + writeJsonFile(path.join(directoryName, '..', 'users', `${id}.json`), userData, {indent: undefined}) ]) response.status(201).send(`MIT license page created: https://${hostname}`) @@ -116,3 +122,6 @@ module.exports = async (request, response) => { ) } } + +// HTTP POST API +export default postRoute diff --git a/routes/utils.js b/routes/utils.js index 763b46a2..78317984 100644 --- a/routes/utils.js +++ b/routes/utils.js @@ -1 +1 @@ -exports.isDomainId = value => /^[\w-_]+$/.test(value) +export const isDomainId = value => /^[\w-_]+$/.test(value) diff --git a/server.js b/server.js index 2ed970fd..04bf9066 100644 --- a/server.js +++ b/server.js @@ -5,16 +5,25 @@ IMPORTANT: Set the `github_token` environment variable to a personal access to Server port: The `PORT` environment variable can also be set to control the port the server should be hosted on. */ -const express = require('express') -const minify = require('express-minify') -const favicon = require('serve-favicon') -const postcssMiddleware = require('postcss-middleware') -const tempDirectory = require('temp-dir') -const path = require('path') +import path, {dirname} from 'node:path' +import express from 'express' +import minify from 'express-minify' +import favicon from 'serve-favicon' +import postcssMiddleware from 'postcss-middleware' +import tempDirectory from 'temp-dir' +import postcssPresetEnv from 'postcss-preset-env' +import cors from 'cors' + +import {fileURLToPath} from 'node:url' + +import postRoute from './routes/post.js' +import getRoute from './routes/get.js' // Server const PORT = process.env.PORT || 8080 +const directoryName = dirname(fileURLToPath(import.meta.url)) + // Prepare application const app = express() app.use( @@ -22,22 +31,22 @@ app.use( cache: tempDirectory }) ) -app.use(favicon(path.join(__dirname, 'favicon.ico'))) -app.set('views', path.join(__dirname, '/licenses')) +app.use(favicon(path.join(directoryName, 'favicon.ico'))) +app.set('views', path.join(directoryName, '/licenses')) // Setup static files app.use('/robots.txt', express.static('robots.txt')) -app.use('/favicon.ico', express.static(`${__dirname}/favicon.ico`)) +app.use('/favicon.ico', express.static(`${directoryName}/favicon.ico`)) app.use( '/themes', postcssMiddleware({ plugins: [ - require('postcss-preset-env')({ + postcssPresetEnv({ overrideBrowserslist: '>= 0%' }) ], - src (request) { - return path.join(__dirname, 'themes', request.path) + src(request) { + return path.join(directoryName, 'themes', request.path) } }), express.static('themes') @@ -46,7 +55,7 @@ app.use( // Middleware // CORS -app.use(require('cors')()) +app.use(cors()) // Parse URL-encoded bodies (as sent by HTML forms) app.use( express.urlencoded({ @@ -56,13 +65,9 @@ app.use( // Parse JSON bodies (as sent by API clients) app.use(express.json()) -// Capture the id from the subdomain and options from parts of the url -app.use(require('./middleware/load-user')) -app.use(require('./middleware/load-options')) - // HTTP endpoints -app.post('/', require('./routes/post')) -app.get('/*', require('./routes/get')) +app.post('/', postRoute) +app.get('/*', getRoute) // Start listening for HTTP requests app.listen(PORT, () => { diff --git a/test.js b/test.js index 9b4315b1..7320a4aa 100644 --- a/test.js +++ b/test.js @@ -1,78 +1,79 @@ -const { promises: fs } = require('fs') -const writeJsonFile = require('write-json-file') -const CSS = require('css') -const { isDomainId } = require('./routes/utils') -const hasFlag = require('has-flag') -const getExtension = require('file-ext') -const path = require('path-extra') -const is = require('@sindresorhus/is') +import {promises as fs} from 'node:fs' +import writeJsonFile from 'write-json-file' +import {parse as parseCSS} from 'css' +import hasFlag from 'has-flag' +import getExtension from 'file-ext' +import path from 'path-extra' +import is from '@sindresorhus/is' +import {isDomainId} from './routes/utils.js' -async function report (content, fix) { +async function report(content, fix) { console.error(content) - if (fix && hasFlag('--fix')) await fix() + if (fix && hasFlag('--fix')) { + await fix() + } + process.exitCode = 1 } -(async () => { - const users = await fs.readdir('users') +const users = await fs.readdir('users') - for (const user of users) { - if (getExtension(user) !== 'json') { - await report(`${user} is not a json file`, async () => { - await fs.unlink(user) - }) - } +for await (const user of users) { + if (getExtension(user) !== 'json') { + await report(`${user} is not a json file`, async () => { + await fs.unlink(user) + }) + } - if (!isDomainId(path.base(user))) { - await report(`${user} is not a valid domain id.`) - } + if (!isDomainId(path.base(user))) { + await report(`${user} is not a valid domain id.`) + } + + try { + const data = await fs.readFile(path.join('users', user), 'utf8') try { - const data = await fs.readFile(path.join('users', user), 'utf8') + const parsedData = JSON.parse(data) - try { - const parsedData = JSON.parse(data) - - if (!parsedData.locked && !parsedData.copyright) { - report(`Copyright not specified in ${user}`) - } - - if (parsedData.version) { - await report(`Version tag found in ${user}`, async () => { - delete parsedData.version - await writeJsonFile(path.join('users', user), parsedData, { indent: 2 }) - }) - } - - if (is.string(parsedData.gravatar)) { - await report(`Gravatar boolean encoded as string found in ${user}`, async () => { - parsedData.gravatar = parsedData.gravatar === 'true' - const stringified = `${JSON.stringify(parsedData, 0, 2)}\n` - await fs.writeFile(path.join('users', user), stringified) - }) - } - } catch ({ message }) { - report(`Invalid JSON in ${user} (${message})`) + if (!parsedData.locked && !parsedData.copyright) { + report(`Copyright not specified in ${user}`) } - } catch ({ message }) { - report(`Unable to read ${user} (${message})`) + + if (parsedData.version) { + await report(`Version tag found in ${user}`, async () => { + delete parsedData.version + await writeJsonFile(path.join('users', user), parsedData, {indent: 2}) + }) + } + + if (is.string(parsedData.gravatar)) { + await report(`Gravatar boolean encoded as string found in ${user}`, async () => { + parsedData.gravatar = parsedData.gravatar === 'true' + const stringified = `${JSON.stringify(parsedData, 0, 2)}\n` + await fs.writeFile(path.join('users', user), stringified) + }) + } + } catch ({message}) { + report(`Invalid JSON in ${user} (${message})`) + } + } catch ({message}) { + report(`Unable to read ${user} (${message})`) + } +} + +const themes = await fs.readdir('themes') + +for await (const theme of themes) { + if (getExtension(theme) === 'css') { + try { + const cssData = await fs.readFile(path.join('themes', theme), 'utf8') + try { + parseCSS(cssData) + } catch ({message}) { + report(`Invalid CSS in ${theme} (${message})`) + } + } catch ({message}) { + report(`Unable to read ${theme} (${message})`) } } - - const themes = await fs.readdir('themes') - - for (const theme of themes) { - if (getExtension(theme) === 'css') { - try { - const cssData = await fs.readFile(path.join('themes', theme), 'utf8') - try { - CSS.parse(cssData) - } catch ({ message }) { - report(`Invalid CSS in ${theme} (${message})`) - } - } catch ({ message }) { - report(`Unable to read ${theme} (${message})`) - } - } - } -})() +} diff --git a/yarn.lock b/yarn.lock index c7a651d7..e2706fae 100644 Binary files a/yarn.lock and b/yarn.lock differ