mit-license/server.ts
Richie Bendall 0b0afb9a83
Complete API
2019-06-01 20:36:28 +12:00

286 lines
10 KiB
TypeScript

/*
IMPORTANT: Set the `github_token` environment variable to a personal access token
with at least the `public_repo` scope for the API.
Server port: The `PORT` environment variable can also be set to control the port the server
should be hosted on.
*/
// Core
import * as path from 'path'
import * as fs from 'fs'
// Server
const PORT = process.env.PORT || 80
import express = require('express')
import compression = require('compression')
import minify = require('express-minify')
import postcssMiddleware = require('postcss-middleware')
// License viewing
import * as ejs from 'ejs'
import { yearNow, stripTags, trimArray } from './util'
import * as HTML from 'node-html-parser'
import md5 = require('md5');
import humanizeList from 'humanize-list'
import dayjs from 'dayjs'
// License creation
import btoa = require('btoa')
import gitpull = require('git-pull')
const github = require('@octokit/rest')({
// GitHub personal access token
auth: process.env.github_token,
// User agent with version from package.json
userAgent: `mit-license v${require('./package.json').version}`,
})
// Helpers
const yearNow = dayjs().year()
const stripTags = (val: any) => val.replace(/<(?:.|\n)*?>/gm, '')
function trimArray(arr: any[]) {
// Example: trimArray(['', '', 'abc', '', 'abc', 'abc', '', '']) -> ["abc", "", "abc", "abc"]
const handleVal = (val: any) => {
if (val !== '') {
valPassed = true
return val
}
else if (!valPassed) return null
else return val
}
let valPassed = false
arr = arr.map(handleVal)
valPassed = false
arr = arr.reverse().map(handleVal)
return arr.reverse().filter((val) => val !== null)
}
// Prepare application
const app = express()
app.use(compression())
app.use(minify({
cache: require('tmp').dirSync().name,
}))
app.set('view engine', 'ejs')
// Setup static files
app.use('/robots.txt', express.static('robots.txt'))
app.use('/users', express.static('users'))
app.use('/themes', postcssMiddleware({
plugins: [require('postcss-preset-env')({ browsers: '>= 0%', stage: 0 })],
src: (req) => path.join(__dirname, 'themes', req.path),
}))
app.use('/themes', express.static('themes'))
app.use('/favicon.ico', express.static(__dirname + '/favicon.ico'))
// Allow CORS
app.use((_req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
next()
})
// Parse URL-encoded bodies (as sent by HTML forms)
app.use(express.urlencoded({ extended: true }))
// Parse JSON bodies (as sent by API clients)
app.use(express.json())
// HTTP POST API
app.post('/', (req, res) => {
// Get differnet parts of hostname (example: remy.mit-license.org -> ['remy', 'mit-license', 'org'])
const params = req.hostname.split('.')
const conf = (() => {
// If query parameters provided
if (Object.keys(req.query).length > 0) return req.query
// If the data parsed as {'{data: "value"}': ''}
const keys = Object.keys(req.body)
if (keys.length === 1 && !Object.values(req.body)[0]) return JSON.parse(keys[0])
// Fallback
return req.body
})()
// If there isn't enough part of the hostname
if (params.length < 2) res.status(400).send('Please specify a subdomain in the URL.')
// Extract the name from the URL
const name = params[0]
// Check if the user file exists in the users directory
fs.access(path.join('users', `${name}.json`), fs.constants.F_OK, (err) => {
if (!err) {
// File already exists
res.status(409).send('User already exists - to update values, please send a pull request on https://github.com/remy/mit-license')
} else {
try {
// File doesn't exist
// If copyright property and key doesn't exist
if (!conf.copyright) res.status(400).send(`JSON requires "copyright" property and value`)
else {
github.repos.createFile({
owner: 'remy',
repo: 'mit-license',
path: `users/${params[0]}.json`,
message: `Automated creation of user ${params[0]}.`,
content: btoa(JSON.stringify(conf, null, 4)),
committer: {
name: 'MIT License Bot',
email: 'remy@leftlogic.com',
},
})
gitpull(__dirname, (err: any, _consoleOutput: any) => {
if (err) {
res.status(502).send(`Unable to create new user - please send a pull request on https://github.com/remy/mit-license`)
} else {
res.status(201).send(`MIT license page created: https://${req.hostname}`)
}
})
}
} catch (e) {
res.status(502).send(`Unable to create new user - please send a pull request on https://github.com/remy/mit-license`)
}
}
})
})
// Any other HTTP GET request
app.get('*', (req, res) => {
// Get user id (example: 'rem.mit-license.org/@2019' -> 'rem')
const id = req.hostname.split('.')[0]
// Get params (example: 'rem.mit-license.org/@2019' -> ['@2019'])
const params = req.path.split('/')
params.shift()
// Load the user data (example: from 'rem.mit-license.org/@2019' -> 'users/rem.json')
fs.readFile(path.join('users', `${id}.json`), 'utf8', (err, data: string) => {
let name: string; let theme: string; let gravatar: string
const user = JSON.parse(data || '{}')
// If error opening
if (err) {
if (err.code !== 'ENOENT') {
// Error is not "File not found"
res.status(500).end()
return
}
} else if (!user.locked && user.copyright) {
// No error and valid
name = (() => {
if (typeof user.copyright === 'string') {
// Supports regular format
let template: string
if (user.url) template = `<a href="${user.url}">${user.copyright}</a>`
else template = user.copyright
if (user.email) template += ` &lt;<a href="mailto:${user.email}">${user.email}</a>&gt;`
return template
} else if (user.copyright.every((val: any) => typeof val === 'string')) {
// Supports: ['Remy Sharp', 'Richie Bendall']
return humanizeList(user)
} else {
/*
Supports:
[{
"name": "Remy Sharp, https://remysharp.com",
"url": "https://remysharp.com",
"email": "remy@remysharp.com"
},{
"name": "Richie Bendall, https://www.richie-bendall.ml",
"url": "https://www.richie-bendall.ml",
"email": "richiebendall@gmail.com",
}]
*/
let template: string
return humanizeList(user.copyright.map((val) => {
if (val.url) template = `<a href="${val.url}">${val.name}</a>`
else template = val.copyright
if (val.email) template += ` &lt;<a href="mailto:${val.email}">${val.email}</a>&gt;`
return template
}))
}
})()
theme = user.theme
gravatar = (() => {
if (user.gravatar && user.email) {
// Supports regular format
return `<img id="gravatar" alt="Profile image" src="https://www.gravatar.com/avatar/${md5(user.email.trim().toLowerCase())}" />`
}
else if (typeof user.copyright[0] === 'object' && user.gravatar) {
// Supports mutli-user format
return `<img id="gravatar" alt="Profile image" src="https://www.gravatar.com/avatar/${md5(user.copyright[0].email.trim().toLowerCase())}" />`
}
else return ''
})()
}
const year = (() => {
// rem.mit-license.org/@2019
const customYear = params.find((val) => val.startsWith('@'))
// rem.mit-license.org/2019
const fromYear = params.find((val) => !isNaN(parseInt(val.replace('-', ''))))
// rem.mit-license.org/2018-2019
const rangeYear = params.find((val) => val.split('-').length === 2)
// If current year
if (customYear) return customYear.replace(/[@-]/g, '')
// If range year
if (rangeYear) return rangeYear
// If from year
if (fromYear) {
// If from year is same as current
if (parseInt(fromYear) === yearNow) return yearNow
return `${fromYear.replace('-', '')}-${yearNow.toString().replace('-', '')}`
}
return yearNow
})()
const customLicense = params.find((val) => val.startsWith('+'))
const license = customLicense ? customLicense.replace('+', '') : user.license || 'MIT'
const format = params.find((val) => val === 'license.html') ? 'html' : params.find((val) => val === 'license.txt') ? 'txt' : user.format || 'html'
const args = {
info: `${year} ${name || '&lt;copyright holders&gt;'}`,
theme: theme || 'default',
gravatar,
}
if (format === 'html') res.render(path.join(__dirname, 'licenses', license), args)
else {
ejs.renderFile(path.join(__dirname, 'licenses', `${license}.ejs`), args, (_err: any, str: string) =>
res
.set('Content-Type', 'text/plain; charset=UTF-8')
.send(
trimArray(
stripTags(HTML.parse(str).childNodes[0].childNodes[3].childNodes[1].toString())
.split('\n')
.map((val: string) => val.trim())
)
.join('\n')
)
)
}
})
})
// Start listening for HTTP requests
app.listen(PORT)