Last active
June 27, 2023 20:02
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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