Signed-off-by: Richie Bendall <richiebendall@gmail.com>
This commit is contained in:
Richie Bendall 2021-05-22 22:43:22 +12:00
parent 325cd76ca4
commit bd71a57b7a
No known key found for this signature in database
GPG key ID: 1C6A99DFA9D306FC
10 changed files with 223 additions and 178 deletions

View file

@ -11,10 +11,10 @@ const getUrlParts = url => {
return url.slice(1).split('/') return url.slice(1).split('/')
} }
module.exports = (request, response, next) => { const loadOptions = url => {
const urlParts = getUrlParts(request.url) const urlParts = getUrlParts(url)
response.locals.options = { const options = {
format: 'html', format: 'html',
startYear: null, startYear: null,
endYear: currentYear endYear: currentYear
@ -23,21 +23,23 @@ module.exports = (request, response, next) => {
for (const urlPart of urlParts) { for (const urlPart of urlParts) {
if (yearRegex.test(urlPart)) { if (yearRegex.test(urlPart)) {
if (urlPart.startsWith('@')) { if (urlPart.startsWith('@')) {
response.locals.options.pinnedYear = Number.parseInt(urlPart.slice(1)) options.pinnedYear = Number.parseInt(urlPart.slice(1), 10)
} else { } else {
response.locals.options.startYear = Number.parseInt(urlPart) options.startYear = Number.parseInt(urlPart, 10)
} }
} else if (yearRangeRegex.test(urlPart)) { } else if (yearRangeRegex.test(urlPart)) {
const [startYear, endYear] = urlPart.match(yearRangeRegex).slice(1) const [startYear, endYear] = urlPart.match(yearRangeRegex).slice(1)
response.locals.options.startYear = Number.parseInt(startYear) options.startYear = Number.parseInt(startYear, 10)
response.locals.options.endYear = Number.parseInt(endYear) options.endYear = Number.parseInt(endYear, 10)
} else if (urlPart.startsWith('license')) { } else if (urlPart.startsWith('license')) {
response.locals.options.format = urlPart.split('.')[1].trim() options.format = urlPart.split('.')[1].trim()
} else if (urlPart.startsWith('+')) { } else if (urlPart.startsWith('+')) {
response.locals.options.license = urlPart.slice(1).toUpperCase() options.license = urlPart.slice(1).toUpperCase()
} }
} }
next() return options
} }
export default loadOptions

26
lib/load-user.js Normal file
View file

@ -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: '<copyright holders>' // Fallback
}
try {
return {
...user,
...await loadJsonFile(path.join(directoryName, '..', 'users', `${id}.json`))
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
export default loadUser

View file

@ -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: '<copyright holders>'
}
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()
}

View file

@ -2,6 +2,7 @@
"name": "mit-licence", "name": "mit-licence",
"description": "Hosted MIT License with details controlled through this repo", "description": "Hosted MIT License with details controlled through this repo",
"private": true, "private": true,
"type": "module",
"version": "2.0.0", "version": "2.0.0",
"main": "server.js", "main": "server.js",
"repository": { "repository": {
@ -17,42 +18,45 @@
"dev": "nodemon .", "dev": "nodemon .",
"serve": "node server.js", "serve": "node server.js",
"test": "node test.js", "test": "node test.js",
"lint": "standard" "lint": "xo"
}, },
"bugs": { "bugs": {
"url": "https://github.com/remy/mit-license/issues" "url": "https://github.com/remy/mit-license/issues"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@octokit/rest": "^18.0.6", "@octokit/rest": "^18.5.3",
"@sindresorhus/is": "^3.1.2", "@sindresorhus/is": "^4.0.1",
"any-size": "^1.2.0", "any-size": "^1.2.0",
"btoa": "^1.2.1", "btoa": "^1.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-html-element": "^3.0.0", "create-html-element": "^3.0.0",
"ejs": "^3.1.5", "ejs": "^3.1.6",
"escape-goat": "^3.0.0", "escape-goat": "^4.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-minify": "^1.0.0", "express-minify": "^1.0.0",
"gravatar-url": "^3.1.0", "gravatar-url": "^4.0.0",
"html-text": "^1.0.1", "html-text": "^1.0.1",
"load-json-file": "^6.2.0", "load-json-file": "^6.2.0",
"path-exists": "^4.0.0", "path-exists": "^4.0.0",
"postcss-middleware": "^1.1.4", "postcss-middleware": "^1.1.4",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"read-pkg": "^6.0.0",
"serve-favicon": "^2.5.0", "serve-favicon": "^2.5.0",
"temp-dir": "^2.0.0", "temp-dir": "^2.0.0",
"write-json-file": "^4.3.0", "write-json-file": "^4.3.0",
"yn": "^4.0.0" "yn": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.14.3",
"@babel/plugin-syntax-top-level-await": "^7.12.13",
"css": "^3.0.0", "css": "^3.0.0",
"file-ext": "^1.0.0", "file-ext": "^1.0.0",
"has-flag": "^4.0.0", "has-flag": "^5.0.0",
"husky": "^4.3.0", "husky": "^4.3.0",
"nodemon": "^2.0.4", "nodemon": "^2.0.7",
"path-extra": "^4.3.0", "path-extra": "^4.3.0",
"standard": "^14.3.4" "xo": "^0.40.1"
}, },
"resolutions": { "resolutions": {
"postcss-middleware/vinyl-fs/glob-stream/micromatch/braces": "^3.0.2" "postcss-middleware/vinyl-fs/glob-stream/micromatch/braces": "^3.0.2"
@ -62,5 +66,18 @@
"pre-commit": "npm run lint", "pre-commit": "npm run lint",
"pre-push": "npm test" "pre-push": "npm test"
} }
},
"xo": {
"space": 2,
"semicolon": false,
"parser": "@babel/eslint-parser",
"parserOptions": {
"requireConfigFile": false,
"babelOptions": {
"plugins": [
"@babel/plugin-syntax-top-level-await"
]
}
}
} }
} }

View file

@ -1,10 +1,16 @@
const path = require('path') import {fileURLToPath} from 'node:url'
const { htmlEscape, htmlUnescape } = require('escape-goat') import path, {dirname} from 'node:path'
const stripHtml = require('html-text') import {htmlEscape, htmlUnescape} from 'escape-goat'
const is = require('@sindresorhus/is') import stripHtml from 'html-text'
const getGravatarUrl = require('gravatar-url') import is from '@sindresorhus/is'
const createHtmlElement = require('create-html-element') import getGravatarUrl from 'gravatar-url'
const { renderFile } = require('ejs') 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) => { const getCopyrightName = (user, isPlainText) => {
if (is.string(user)) { if (is.string(user)) {
@ -53,9 +59,24 @@ const getGravatarEmail = user => {
const removeFalsy = array => array.filter(Boolean) const removeFalsy = array => array.filter(Boolean)
module.exports = async (_, response) => { const getRoute = async (request, response) => {
const { user, options } = response.locals let user
const isPlainText = options.format !== 'html' 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 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 { try {
const content = await renderFile(path.join(__dirname, '..', 'licenses', `${license}.ejs`), { const content = await renderFile(path.join(directoryName, '..', 'licenses', `${license}.ejs`), {
info: `${year} ${name}`, info: `${year} ${name}`,
theme: user.theme || 'default', theme: user.theme || 'default',
gravatar gravatar
}) })
if (format === 'txt') { if (format === 'txt') {
const { articleContent } = content.match(/<article>(?<articleContent>.*)<\/article>/ms).groups const {articleContent} = content.match(/<article>(?<articleContent>.*)<\/article>/ms).groups
response response
.set('Content-Type', 'text/plain; charset=UTF-8') .set('Content-Type', 'text/plain; charset=UTF-8')
@ -111,8 +126,11 @@ module.exports = async (_, response) => {
response.send(content) response.send(content)
return return
} }
response.json({ ...user, ...options })
response.json({...user, ...options})
} catch (error) { } catch (error) {
response.status(500).send(error) response.status(500).send(error)
} }
} }
export default getRoute

View file

@ -1,22 +1,29 @@
const path = require('path') import path, {dirname} from 'node:path'
const btoa = require('btoa') import toBase64 from 'btoa'
const { version } = require(path.join(__dirname, '..', 'package.json')) import {readPackageAsync as readPackage} from 'read-pkg'
const size = require('any-size') import size from 'any-size'
const { Octokit } = require('@octokit/rest') import {Octokit} from '@octokit/rest'
const pathExists = require('path-exists') import pathExists from 'path-exists'
const writeJsonFile = require('write-json-file') 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({ const github = new Octokit({
// GitHub personal access token // GitHub personal access token
auth: process.env.github_token, auth: process.env.github_token,
// User agent with version from package.json // User agent with version from package.json
userAgent: `mit-license v${version}` 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 query parameters provided
if (size(query) > 0) { if (size(query) > 0) {
return query return query
@ -31,24 +38,23 @@ function getUserData ({ query, body }) {
return body return body
} }
// HTTP POST API const postRoute = async (request, response) => {
module.exports = async (request, response) => { const {hostname} = request
const { hostname } = request
// Get different parts of hostname (example: remy.mit-license.org -> ['remy', 'mit-license', 'org']) // 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. // This includes the copyright, year, etc.
const userData = getUserData(request) const userData = getUserData(request)
// If there isn't enough part of the hostname // 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.') response.status(400).send('Please specify a subdomain in the URL.')
return return
} }
// Extract the name from the URL // Extract the name from the URL
const [id] = params const [id] = parameters
if (!isDomainId(id)) { if (!isDomainId(id)) {
// Return a vague error intentionally // Return a vague error intentionally
@ -62,7 +68,7 @@ module.exports = async (request, response) => {
} }
// Check if the user file exists in the users directory // 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 response
.status(409) .status(409)
.send( .send(
@ -73,7 +79,7 @@ module.exports = async (request, response) => {
if (userData.gravatar) { if (userData.gravatar) {
// Parse the string version of a boolean or similar // 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)) { if (is.undefined(userData.gravatar)) {
response response
.status(400) .status(400)
@ -98,13 +104,13 @@ module.exports = async (request, response) => {
repo: 'mit-license', repo: 'mit-license',
path: `users/${id}.json`, path: `users/${id}.json`,
message: `Automated creation of user ${id}.`, message: `Automated creation of user ${id}.`,
content: btoa(JSON.stringify(userData, 0, 2)), content: toBase64(JSON.stringify(userData, 0, 2)),
committer: { committer: {
name: 'MIT License Bot', name: 'MIT License Bot',
email: 'remy@leftlogic.com' 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}`) 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

View file

@ -1 +1 @@
exports.isDomainId = value => /^[\w-_]+$/.test(value) export const isDomainId = value => /^[\w-_]+$/.test(value)

View file

@ -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 Server port: The `PORT` environment variable can also be set to control the port the server
should be hosted on. should be hosted on.
*/ */
const express = require('express') import path, {dirname} from 'node:path'
const minify = require('express-minify') import express from 'express'
const favicon = require('serve-favicon') import minify from 'express-minify'
const postcssMiddleware = require('postcss-middleware') import favicon from 'serve-favicon'
const tempDirectory = require('temp-dir') import postcssMiddleware from 'postcss-middleware'
const path = require('path') 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 // Server
const PORT = process.env.PORT || 8080 const PORT = process.env.PORT || 8080
const directoryName = dirname(fileURLToPath(import.meta.url))
// Prepare application // Prepare application
const app = express() const app = express()
app.use( app.use(
@ -22,22 +31,22 @@ app.use(
cache: tempDirectory cache: tempDirectory
}) })
) )
app.use(favicon(path.join(__dirname, 'favicon.ico'))) app.use(favicon(path.join(directoryName, 'favicon.ico')))
app.set('views', path.join(__dirname, '/licenses')) app.set('views', path.join(directoryName, '/licenses'))
// Setup static files // Setup static files
app.use('/robots.txt', express.static('robots.txt')) 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( app.use(
'/themes', '/themes',
postcssMiddleware({ postcssMiddleware({
plugins: [ plugins: [
require('postcss-preset-env')({ postcssPresetEnv({
overrideBrowserslist: '>= 0%' overrideBrowserslist: '>= 0%'
}) })
], ],
src (request) { src(request) {
return path.join(__dirname, 'themes', request.path) return path.join(directoryName, 'themes', request.path)
} }
}), }),
express.static('themes') express.static('themes')
@ -46,7 +55,7 @@ app.use(
// Middleware // Middleware
// CORS // CORS
app.use(require('cors')()) app.use(cors())
// Parse URL-encoded bodies (as sent by HTML forms) // Parse URL-encoded bodies (as sent by HTML forms)
app.use( app.use(
express.urlencoded({ express.urlencoded({
@ -56,13 +65,9 @@ app.use(
// Parse JSON bodies (as sent by API clients) // Parse JSON bodies (as sent by API clients)
app.use(express.json()) 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 // HTTP endpoints
app.post('/', require('./routes/post')) app.post('/', postRoute)
app.get('/*', require('./routes/get')) app.get('/*', getRoute)
// Start listening for HTTP requests // Start listening for HTTP requests
app.listen(PORT, () => { app.listen(PORT, () => {

131
test.js
View file

@ -1,78 +1,79 @@
const { promises: fs } = require('fs') import {promises as fs} from 'node:fs'
const writeJsonFile = require('write-json-file') import writeJsonFile from 'write-json-file'
const CSS = require('css') import {parse as parseCSS} from 'css'
const { isDomainId } = require('./routes/utils') import hasFlag from 'has-flag'
const hasFlag = require('has-flag') import getExtension from 'file-ext'
const getExtension = require('file-ext') import path from 'path-extra'
const path = require('path-extra') import is from '@sindresorhus/is'
const is = require('@sindresorhus/is') import {isDomainId} from './routes/utils.js'
async function report (content, fix) { async function report(content, fix) {
console.error(content) console.error(content)
if (fix && hasFlag('--fix')) await fix() if (fix && hasFlag('--fix')) {
await fix()
}
process.exitCode = 1 process.exitCode = 1
} }
(async () => { const users = await fs.readdir('users')
const users = await fs.readdir('users')
for (const user of users) { for await (const user of users) {
if (getExtension(user) !== 'json') { if (getExtension(user) !== 'json') {
await report(`${user} is not a json file`, async () => { await report(`${user} is not a json file`, async () => {
await fs.unlink(user) await fs.unlink(user)
}) })
} }
if (!isDomainId(path.base(user))) { if (!isDomainId(path.base(user))) {
await report(`${user} is not a valid domain id.`) await report(`${user} is not a valid domain id.`)
} }
try {
const data = await fs.readFile(path.join('users', user), 'utf8')
try { try {
const data = await fs.readFile(path.join('users', user), 'utf8') const parsedData = JSON.parse(data)
try { if (!parsedData.locked && !parsedData.copyright) {
const parsedData = JSON.parse(data) report(`Copyright not specified in ${user}`)
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})`)
} }
} 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})`)
}
}
}
})()

BIN
yarn.lock

Binary file not shown.