Skip to content

Instantly share code, notes, and snippets.

@k1sul1
Last active June 27, 2023 20:02
Show Gist options
  • Save k1sul1/0b6a08ffbf01ac77df4f8a72d6fb5bcd to your computer and use it in GitHub Desktop.
Save k1sul1/0b6a08ffbf01ac77df4f8a72d6fb5bcd to your computer and use it in GitHub Desktop.
Express.js server with a proxy and session management for making authenticated requests to WP without the gray hairs of OAuth
const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const session = require('express-session')
const redis = require('redis')
const RedisStore = require('connect-redis')(session)
const axios = require('axios')
const csurf = require('csurf')
const cookieParser = require('cookie-parser')
const listEndpoints = require('express-list-endpoints')
const btoa = require('../lib/btoa')
const wp = require('./wp')
// This is optional. You can use pretty much anything that express-session has an adapter for.
const sessionRedisClient = redis.createClient({ prefix: 'node_ss', host: 'noderedis' })
/**
* Hacks to enable bodyParser for json requests only
*/
const isMultipartRequest = function (req) {
let contentTypeHeader = req.headers['content-type']
return contentTypeHeader && contentTypeHeader.indexOf('multipart') > -1
}
const bodyParserJsonMiddleware = function () {
return function (req, res, next) {
if (isMultipartRequest(req)) {
return next()
}
return bodyParser.json()(req, res, next)
}
}
async function getInitialData() {
const { WP_USER, WP_PASSWORD, WP_PROXYURL } = process.env
// await new Promise(resolve => setTimeout(resolve, 5000)) // Wait to ensure that WP is running
if (!WP_USER || !WP_PASSWORD) {
console.error('Missing configuration details, did you create the .env file to server root?')
process.exit(1)
}
try {
const Authorization = `Basic ${btoa(`${WP_USER}:${WP_PASSWORD}`)}`
const headers = { Authorization }
const taxonomiesReq = await axios.get(`${WP_PROXYURL}/wp-json/wp/v2/taxonomies`, { headers })
const postTypesReq = await axios.get(`${WP_PROXYURL}/wp-json/wp/v2/types`, { headers })
return { taxonomies: taxonomiesReq.data, postTypes: postTypesReq.data }
} catch (e) {
console.error(e)
console.log("Failed to get WordPress data. Server can't start.")
console.log(`Does username \`${WP_USER}\` exist in WordPress?`)
// return setTimeout(getInitialData, 10000)
return false;
}
}
module.exports = async function apiServer() {
const data = await getInitialData()
if (!data) {
throw new Error('Unable to get WordPress data')
}
const { taxonomies, postTypes } = data
const app = express()
const port = 5000
const { FRONTEND_HOST, PUPPE_BASEURL } = process.env
const whitelist = [
`https://${FRONTEND_HOST}`,
`http://${FRONTEND_HOST}:3000`,
'http://localhost:3000',
PUPPE_BASEURL,
]
const corsOptions = {
origin (origin, callback) {
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(new Error(`CORS: Origin ${origin} is not allowed to access`))
}
},
credentials: true,
}
// I've been told that I may not need CSRF protection because CORS is configured correctly.
// I would've implemented it anyway but couldn't get it working at the time.
// const csrf = csurf({
// cookie: true,
// })
sessionRedisClient.on('connect', function() {
console.log('Session: redis client connected')
})
sessionRedisClient.on("error", function (err) {
console.log('Session: redis error')
console.error(err)
})
app.use(cookieParser(process.env.SESSION_SECRET || 'keyboard cat'))
// app.use(csrf)
app.disable('etag')
app.set('trust proxy', 1)
app.use(cors(corsOptions))
app.use(bodyParserJsonMiddleware())
app.use(session({
// Again, literally any session store should work. Pls no MemoryStore though. Think of the RAM.
store: new RedisStore({
client: sessionRedisClient,
}),
saveUninitialized: false,
secret: process.env.SESSION_SECRET || 'keyboard cat',
resave: false,
cookie: {
domain: process.env.SESSION_COOKIE_DOMAIN,
secure: false,
expires: 99999999999999999999999999999999999999999999, // How about never.
}
}))
// app.use('*', function (req, res, next) {
// res.cookie('_csrf', req.session.csrfSecret)
// res.cookie('_csrf', req.csrfToken())
// next()
// })
app.get('/', function (req, res) {
res.json(listEndpoints(app))
})
app.post('/login', async function (req, res) {
const { username, password } = req.body
const authHeader = `Basic ${btoa(`${username}:${password}`)}`
try {
const { data } = await axios.get('https://nginx/wp-json/wp/v2/users/me', {
headers: {
Authorization: authHeader,
}
})
req.session.wpUser = data.id
req.session.apiAuthHeader = authHeader
req.session.save()
res.json({ success: 'Logged in succesfully!' })
} catch (e) {
// Technically there's about 500 other reasons that this can go wrong
// but let's assume that the server is always available
res.status(401).json({ error: 'Wrong username or password!' })
}
})
app.post('/logout', async (req, res) => {
if (req.session) {
req.session.destroy(e => {
if (!e) {
return res.json({ success: 'Logged out!' })
}
console.error(e)
res.status(500).json({ error: 'Something terrible happened, and your logout failed!' })
})
}
res.status(418).json({ error: 'Why would you log out when you haven\'t even logged in?' })
})
app.use('/wp', wp(postTypes, taxonomies))
app.use(function (err, req, res, next) {
console.error(err.stack)
if (err.message.indexOf('Unexpected token < in JSON') > -1) {
return res.status(500).json({ error: err.message })
}
res.status(500).send({ error: 'Something broke!' })
})
app.listen(port, () => console.log(`API server listening in port ${port}!`))
return app
const express = require('express')
const proxy = require('express-http-proxy')
/**
* I don't like how the data is structured in the WP API. Thus, some opionated changes can be found below.
* If you like the API as is, removing the modifications is trivial.
/
/**
* Sometimes JavaScript is a bit annoying.
*/
const isAFuckingObject = x => typeof x === 'object' && x !== null && !Array.isArray(x) && x
/**
* AFAIK modified.content.protected is only true when the post is password protected.
* Same for excerpts and titles. There's no password_protected field in the response,
* but IMO you don't need it. If there's a password on the post, the content will be empty.
* You can use that to display a password field, or if you really want to,
* add that field to the API response with register_rest_field.
*
* You can also just disable this function if you'd rather have these parts unchanged.
*/
const flattenRendered = (obj) => {
return !isAFuckingObject(obj) ? obj : Object.keys(obj).reduce((acc, k) => {
acc[k] = obj[k] && obj[k].rendered ? obj[k].rendered : flattenRendered(obj[k])
return acc
}, {})
}
module.exports = function wp(postTypes, taxonomies) {
const wpProxy = express.Router()
const wpAdmin = express.Router()
const transformContent = modified => {
modified = flattenRendered(modified)
if (modified.blocks) {
modified.content = modified.blocks
delete modified.blocks
for (let i = 0; i < modified.content.length; i++) {
const block = modified.content[i]
// post object acf field, populated with a filter
if (block.attrs && block.attrs.data && block.attrs.data.entries) {
for (let y = 0; y < block.attrs.data.entries.length; y++) {
const entry = block.attrs.data.entries[y]
if (entry.relevantPost) {
entry.relevantPost = flattenRendered(entry.relevantPost)
}
}
}
// Transform PostList block to same format
if (block.attrs && block.attrs.data && block.attrs.data.posts) {
for (let y = 0; y < block.attrs.data.posts.length; y++) {
let post = block.attrs.data.posts[y]
block.attrs.data.posts[y] = transformContent(post)
}
}
}
}
if (modified.acf) {
// relationship acf field, populated with a filter
if (modified.acf.projects) {
for (let i = 0; i < modified.acf.projects.length; i++) {
modified.acf.projects[i] = flattenRendered(modified.acf.projects[i])
}
}
}
modified.taxonomies = {}
Object.keys(taxonomies).forEach(k => {
const taxonomy = taxonomies[k]
const { rest_base: restBase } = taxonomy
if (modified[restBase]) {
modified.taxonomies[restBase] = modified[restBase]
delete modified[restBase]
}
})
/**
* I like ?_embed as a feature, but I dislike it's implementation. I don't want to map IDs to data in the frontend.
* So let's remove _embedded from the response, and move it's data where it should be.
*/
if (modified._embedded) {
let { author: authors, 'wp:term': allTerms = [], replies = [] } = modified._embedded
// It's a weird structure. All terms are inside one object, but grouped in arrays, so that each array only contains terms
// from the same taxonomy.
allTerms.forEach(taxonomy => {
if (!taxonomy.length) {
// For some reason, WP adds an empty array to the wp:term array if there's no tags
return;
}
taxonomy.forEach(term => {
// const { id, rest_base: restBase, slug, taxonomy } = term
// const taxonomy = getTaxonomyFromRestBase(restBase)
const { taxonomy, id: termId } = term
const { rest_base: restBase } = taxonomies[taxonomy]
modified.taxonomies[restBase][modified.taxonomies[restBase].findIndex(id => id === termId)] = term
})
})
/**
* Post may be password protected, which results in this kind of object being present
* {"code":"rest_cannot_read_post","message":"Sorry, you are not allowed to read the post for this comment.","data":{"status":401}}"
* Filter that out.
*/
replies = replies.filter(reply => reply.length)
modified.replies = replies
if (modified._embedded['wp:featuredmedia']) {
modified.featured_media = modified._embedded['wp:featuredmedia'][0]
modified.featured_media = flattenRendered(modified.featured_media)
}
if (authors) {
/**
* Replace the author IDs with the full objects.
* For some reason, the author field in _embedded is an array while *the* author field is an int.
* Maybe it's possible to have multiple authors in the future, which is why you can change this behaviour
* with en environment variable.
*/
if (process.env.WP_SUPPORTS_MULTIPLE_AUTHORS) {
authors.forEach(author => {
modified.author[modified.author.findIndex(id => id === author.id)] = author
})
} else {
modified.author = authors[0]
}
}
delete modified._embedded
}
if (!Object.keys(modified.taxonomies).length) {
delete modified.taxonomies
}
return modified
}
wpAdmin.use('/*', (req, res, next) => {
if (req.originalUrl.indexOf('/wp/wp-admin/admin-ajax.php') === 0) {
next()
} else {
res.status(500).json({ error: "I'm sorry, even if I wanted to let you go here, WP wouldn't let you." })
}
})
wpProxy.use('/wp-admin', wpAdmin)
wpProxy.use('/wp-includes', (req, res) => {
res.status(500).json({ error: "I'm don't think that there's anything here that you could use." })
})
wpProxy.get('/about', function (req, res) {
res.json({ message: '/wp/ is a proxy for WordPress REST API. Use the WordPress REST API handbook if lost. It makes "minor" modifications to data.' })
})
/**
* Proxy requests to WordPress. Handles authentication using basic auth.
* This makes basic auth usable in the context of a SPA,
* as storing the username and password in the client isn't necessary.
*/
wpProxy.use(
'/',
(req, res, next) => {
if (req.session.apiAuthHeader) {
req.headers['Authorization'] = req.session.apiAuthHeader
}
next()
},
proxy(process.env.WP_PROXYURL, {
/**
* Transform responses from WordPress. HTML is probably the worst possible format
* for a SPA, so it's replaced with block data when available.
*/
userResDecorator(proxyRes, proxyResData, userReq, userRes) {
const data = proxyResData.toString('utf8').trim()
const isLikelyXML = data.indexOf('<') === 0
const isLikelyJSON = !isLikelyXML && (data.indexOf('{') === 0 || data.indexOf('[') === 0)
if (isLikelyJSON) {
let json = JSON.parse(data)
if (Array.isArray(json)) {
json = json.map(transformContent)
} else {
json = transformContent(json)
}
return json
}
return proxyResData
},
}),
)
return wpProxy
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment