Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d8f3675
First pass at fixing Content-Type header processing.
thehabes Mar 24, 2026
cd98bc4
Changes while testing and reviewing
thehabes Mar 24, 2026
909f655
Changes while testing and reviewing
thehabes Mar 24, 2026
132f03f
Cleanup the DELETE stuff. DELETE requests don't do bodies.
thehabes Mar 24, 2026
7bc4635
Cleanup the DELETE stuff. DELETE requests don't do bodies.
thehabes Mar 24, 2026
0367c49
Cleanup the DELETE stuff. DELETE requests don't do bodies.
thehabes Mar 24, 2026
074396d
Changes while reviewing and testing
thehabes Mar 24, 2026
dc7f5f7
Changes while reviewing and testing
thehabes Mar 24, 2026
b660ac3
Missing, blank, or null Content-Type headers are also 415
thehabes Mar 24, 2026
4d129d4
reject content-type headers that have multiple or duplicate types
thehabes Mar 24, 2026
10f5589
refactor how Content-Type headers are checked
thehabes Mar 24, 2026
6541efc
refactor how Content-Type headers are checked
thehabes Mar 24, 2026
e3a9dc1
refactor how Content-Type headers are checked
thehabes Mar 24, 2026
7364736
no shebang
thehabes Mar 24, 2026
1ae3dba
changes while testing and reviewing
thehabes Mar 24, 2026
d36fe53
Changes while testing and reviewing
thehabes Mar 24, 2026
ab6cf16
Clean out dead index handler and router
thehabes Mar 24, 2026
29e77aa
rename
thehabes Mar 24, 2026
9f3eb77
Bring in content-type processing from lessons learned in rerum
thehabes Mar 25, 2026
53a63b5
Handle success and error responses and send them through
thehabes Mar 25, 2026
4dec674
changes during review
thehabes Mar 25, 2026
4ebe895
changes during review
thehabes Mar 25, 2026
50da757
changes during review
thehabes Mar 25, 2026
c3bda63
changes during review
thehabes Mar 25, 2026
c254e77
changes during review
thehabes Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions app.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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'] }))
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

['application/json', 'application/ld+json'] is duplicated here and again in ALLOWED_CONTENT_TYPES below. To avoid drift (e.g., updating one list but not the other), consider defining the allowed list once and reusing it for both express.json({ type: ... }) and the validation middleware.

Copilot uses AI. Check for mistakes.
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')))

/**
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions rest.js
Original file line number Diff line number Diff line change
@@ -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 }
39 changes: 32 additions & 7 deletions routes/create.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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')
}
})

Expand Down
65 changes: 30 additions & 35 deletions routes/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,48 @@ 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}`
const deleteOptions = {
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')
}
})

Expand Down
9 changes: 0 additions & 9 deletions routes/index.js

This file was deleted.

64 changes: 43 additions & 21 deletions routes/overwrite.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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')
}
})

Expand Down
32 changes: 26 additions & 6 deletions routes/query.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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')
}
})

Expand Down
Loading
Loading