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('/')
}
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

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",
"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"
]
}
}
}
}

View file

@ -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>(?<articleContent>.*)<\/article>/ms).groups
const {articleContent} = content.match(/<article>(?<articleContent>.*)<\/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

View file

@ -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

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
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, () => {

131
test.js
View file

@ -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})`)
}
}
}
})()
}

BIN
yarn.lock

Binary file not shown.