diff --git a/app.js b/app.js index 569c85b..68c3541 100644 --- a/app.js +++ b/app.js @@ -1,11 +1,11 @@ #!/usr/bin/env node + import createError from "http-errors" import express from "express" import path from "path" import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -import indexRouter from "./routes/index.js" import queryRouter from "./routes/query.js" import createRouter from "./routes/create.js" import updateRouter from "./routes/update.js" @@ -14,13 +14,12 @@ import overwriteRouter from "./routes/overwrite.js" import cors from "cors" let app = express() -app.use(express.json()) -app.use(express.text()) +app.use(express.json({ type: ['application/json', 'application/ld+json'] })) if(process.env.OPEN_API_CORS !== "false") { // This enables CORS for all requests. We may want to update this in the future and only apply to some routes. app.use(cors()) } -app.use(express.urlencoded({ extended: false })) + app.use(express.static(path.join(__dirname, 'public'))) /** @@ -117,7 +116,7 @@ app.use(function(req, res, next) { // error handler app.use(function(err, req, res, next) { res.status(err.status || 500) - res.send(err.message) + res.type('text/plain').send(err.message) }) export default app \ No newline at end of file diff --git a/rest.js b/rest.js new file mode 100644 index 0000000..e47a28c --- /dev/null +++ b/rest.js @@ -0,0 +1,60 @@ +/** + * Detects multiple MIME types smuggled into a single Content-Type header. + * The following are the cases that should result in a 415 (not a 500) + + - application/json text/plain + - application/json, text/plain + - text/plain; application/json + - text/plain; a=b, application/json + - application/json; a=b; text/plain; + - application/json; a=b text/plain; + - application/json; charset=utf-8, text/plain + - application/json; + + * @param {string} contentType - Lowercased Content-Type header value + * @returns {boolean} True if multiple MIME types are detected + */ +const hasMultipleContentTypes = (contentType) => { + const segments = contentType.split(";") + const mimeSegment = segments[0].trim() + // No commas or spaces allowed in MIME types + if (mimeSegment.includes(",") || mimeSegment.includes(" ")) return true + // Parameter values are tokens (no spaces/commas) or quoted strings per RFC 2045. + // Commas or spaces outside quotes indicate a smuggled MIME type. + return segments.slice(1).some(segment => { + const trimmed = segment.trim() + if (!trimmed.includes("=")) return true + const withoutQuoted = trimmed.replace(/"[^"]*"/g, "") + if (withoutQuoted.includes(",") || withoutQuoted.includes(" ")) return true + return false + }) +} + +/** + * Middleware to verify Content-Type headers for endpoints receiving JSON bodies. + * Responds with a 415 Invalid Media Type for Content-Type headers that are not for JSON bodies. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function + */ +const verifyJsonContentType = function (req, res, next) { + const contentType = (req.get("Content-Type") ?? "").toLowerCase() + const mimeType = contentType.split(";")[0].trim() + if (!mimeType) { + const err = new Error(`Missing or empty Content-Type header.`) + err.status = 415 + return next(err) + } + if (hasMultipleContentTypes(contentType)) { + const err = new Error(`Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.`) + err.status = 415 + return next(err) + } + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() + const err = new Error(`Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.`) + err.status = 415 + return next(err) +} + +export default { verifyJsonContentType } diff --git a/routes/create.js b/routes/create.js index 9b37a48..388b3ec 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,10 +1,11 @@ import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', checkAccessToken, async (req, res, next) => { +router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id if (req.body.id) { @@ -23,14 +24,38 @@ router.post('/', checkAccessToken, async (req, res, next) => { } } const createURL = `${process.env.RERUM_API_ADDR}create` - const result = await fetch(createURL, createOptions).then(res => res.json()) - res.setHeader("Location", result["@id"] ?? result.id) - res.status(201) - res.send(result) + const rerumResponse = await fetch(createURL, createOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${createURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${createURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + } + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(201).json(rerumResponse) } catch (err) { - console.log(err) - res.status(500).send(`Caught Error:${err}`) + console.error(err) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/delete.js b/routes/delete.js index eb85f9e..be95e12 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -3,36 +3,12 @@ import checkAccessToken from "../tokens.js" const router = express.Router() -/* Legacy delete pattern w/body */ - -/* DELETE a delete to the thing. */ -router.delete('/', checkAccessToken, async (req, res, next) => { - try { - const deleteBody = JSON.stringify(req.body) - - const deleteOptions = { - method: 'DELETE', - body: deleteBody, - headers: { - 'user-agent': 'TinyPen', - 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, - 'Content-Type' : "application/json" - } - } - const deleteURL = `${process.env.RERUM_API_ADDR}delete` - const result = await fetch(deleteURL, deleteOptions).then(res => res.text()) - res.status(204) - res.send(result) - } - catch (err) { - console.log(err) - res.status(500).send(`Caught Error:${err}`) - } -}) - -/* DELETE a delete to the thing. */ -router.delete('/:id', async (req, res, next) => { +/** + * DELETE an object by ID via the RERUM API. + * @route DELETE /delete/:id + * @param {string} id - The RERUM object ID to delete + */ +router.delete('/:id', checkAccessToken, async (req, res, next) => { try { const deleteURL = `${process.env.RERUM_API_ADDR}delete/${req.params.id}` @@ -40,16 +16,35 @@ router.delete('/:id', async (req, res, next) => { method: "DELETE", headers: { 'user-agent': 'TinyPen', + 'Origin': process.env.ORIGIN, 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}` } } - const result = await fetch(deleteURL, deleteOptions).then(resp => resp.text()) - res.status(204) - res.send(result) + await fetch(deleteURL, deleteOptions) + .then(async (resp) => { + if (resp.ok) return + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${deleteURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${deleteURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + res.status(204).end() } catch (err) { - console.log(err) - res.status(500).send(`Caught Error:${err}`) + console.error(err) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/index.js b/routes/index.js deleted file mode 100644 index bfe8888..0000000 --- a/routes/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express' -const router = express.Router() - -/* There is no home page for now */ -router.get('/', (req, res, next) => { - res.status(404).send() -}) - -export default router diff --git a/routes/overwrite.js b/routes/overwrite.js index 4b16a60..b09a42a 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -1,17 +1,20 @@ import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', checkAccessToken, async (req, res, next) => { +router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { const overwriteBody = req.body // check for @id; any value is valid if (!(overwriteBody['@id'] ?? overwriteBody.id)) { - throw Error("No record id to overwrite! (https://store.rerum.io/API.html#overwrite)") + const err = new Error("No record id to overwrite! (https://store.rerum.io/API.html#overwrite)") + err.status = 400 + throw err } const overwriteOptions = { @@ -38,30 +41,49 @@ router.put('/', checkAccessToken, async (req, res, next) => { } const overwriteURL = `${process.env.RERUM_API_ADDR}overwrite` - const response = await fetch(overwriteURL, overwriteOptions) - .then(resp=>{ - if (!resp.ok) throw resp - return resp + const rerumResponse = await fetch(overwriteURL, overwriteOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // Handle 409 conflict error for version mismatch (optimistic locking) + if (resp.status === 409) { + const conflictBody = await resp.json() + const err = new Error("Version conflict") + err.status = 409 + err.body = conflictBody + throw err + } + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${overwriteURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${overwriteURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err }) - .catch(async err => { - // Handle 409 conflict error for version mismatch - if (err.status === 409) { - const currentVersion = await err.json() - return res.status(409).json(currentVersion) - } - throw new Error(`Error in overwrite request: ${err.status} ${err.statusText}`) + .catch(err => { + if (err.status === 502 || err.status === 409) throw err + const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError }) - if(res.headersSent) return - const result = await response.json() - if(response.status === 200) { - res.setHeader("Location", result["@id"] ?? result.id) - res.status(200) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError } - res.send(result) + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(200).json(rerumResponse) } catch (err) { - console.log(err) - res.status(500).send("Caught Error:" + err) + console.error(err) + if (err.status === 409) { + return res.status(409).json(err.body) + } + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/query.js b/routes/query.js index 606df01..fcc45f4 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,8 +1,10 @@ import express from "express" +import rest from "../rest.js" + const router = express.Router() /* POST a query to the thing. */ -router.post('/', async (req, res, next) => { +router.post('/', rest.verifyJsonContentType, async (req, res, next) => { const lim = req.query.limit ?? 10 const skip = req.query.skip ?? 0 try { @@ -34,13 +36,31 @@ router.post('/', async (req, res, next) => { } } const queryURL = `${process.env.RERUM_API_ADDR}query?limit=${lim}&skip=${skip}` - const results = await fetch(queryURL, queryOptions).then(resp => resp.json()) - res.status(200) - res.send(results) + const rerumResponse = await fetch(queryURL, queryOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${queryURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${queryURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${queryURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + res.status(200).json(rerumResponse) } catch (err) { - console.log(err) - res.status(err.status ?? 500).send("Caught " + err.message) + console.error(err) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/update.js b/routes/update.js index 69008f8..a025875 100644 --- a/routes/update.js +++ b/routes/update.js @@ -1,15 +1,18 @@ import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', checkAccessToken, async (req, res, next) => { +router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { // check for @id; any value is valid if (!(req.body['@id'] ?? req.body.id)) { - throw Error("No record id to update! (https://store.rerum.io/API.html#update)") + const err = new Error("No record id to update! (https://store.rerum.io/API.html#update)") + err.status = 400 + throw err } const updateBody = JSON.stringify(req.body) @@ -24,14 +27,38 @@ router.put('/', checkAccessToken, async (req, res, next) => { } } const updateURL = `${process.env.RERUM_API_ADDR}update` - const result = await fetch(updateURL, updateOptions).then(resp => resp.json()) - res.setHeader("Location", result["@id"] ?? result.id) - res.status(200) - res.send(result) + const rerumResponse = await fetch(updateURL, updateOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + } + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(200).json(rerumResponse) } catch (err) { - console.log(err) - res.status(500).send("Caught Error:" + err) + console.error(err) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } })