diff --git a/.gitignore b/.gitignore index 7af7f04..bf8dcbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules -.env \ No newline at end of file +.env +/uploads diff --git a/API.md b/API.md new file mode 100644 index 0000000..6cbfe5f --- /dev/null +++ b/API.md @@ -0,0 +1,40 @@ +# API contract (agent reference) + +Base URL: `http://localhost:${PORT}`. Use `Authorization: Bearer ` on every endpoint except register/login. JSON uses `Content-Type: application/json`; upload routes use `multipart/form-data`. + +| Method + path | Auth | Input | Result / policy | +|---|---:|---|---| +| `POST /auth/register` | no | JSON: `name`, `username`, `email`, `password` | `201 {id}`; strict fields; see password policy below | +| `POST /auth/login` | no | JSON: `email`, `password` | `200 {id,token,expires_at}`; `401` is generic | +| `GET /posts` | yes | no body/query | `200` all posts; ownership does not limit viewing | +| `GET /posts/me` | yes | no body/query | `200` only posts with `author_id = token.user_id` | +| `GET /posts/image/:filename` | yes | no body/query | Authenticated image download | +| `POST /posts/create` | yes | multipart fields: `title`, `text`; optional file `image` | `201` post; `author_id` always comes from token | +| `PUT /posts/edit?id=` | yes + owner | multipart: optional `title`, `text`, `image`, `remove_image=true\|false` | `200` updated post; upload replaces image; `remove_image=true` clears it; image + remove is invalid | +| `DELETE /posts/delete?id=` | yes + owner | query: `id` only | `200`; deletes only matching `id AND author_id` and removes stored image | +| `POST /create_relationship?me=&them=` | yes | query: `me`, `them` only | `200`; `me` must equal token user ID | + +## Input rules + +- Any undocumented body or query field returns `400`; do not send `author_id` to create posts. +- Registration: `name` 1–100 chars; `username` 1–50, no whitespace; `email` 1–254, trimmed/lowercased, valid format, no whitespace, not disposable; `password` 1–128 and at least 12 chars with lower/upper/number/special, no username/email-local-part, common password, triple repeat, or obvious sequence. +- Login accepts only `email` and `password`; email is trimmed/lowercased; password is not trimmed or otherwise transformed (ordinary whitespace is significant). +- Passwords, hashes, salts, JWTs, and database details must never be logged or exposed. + +## Uploads + +```sh +curl -X POST http://localhost:3000/posts/create \ + -H "Authorization: Bearer $TOKEN" \ + -F title='Hello' -F text='Body' -F image=@photo.png +``` + +To edit text/title, replace the image, or remove it: + +```sh +curl -X PUT "http://localhost:3000/posts/edit?id=12" \ + -H "Authorization: Bearer $TOKEN" \ + -F text='Updated body' -F remove_image=true +``` + +Omit `image` and `remove_image` to keep the current image. Stored image URLs require the same Bearer token. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9519227 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Filing Cabinet API + +Node.js/Express API for accounts, JWT-authenticated actions, relationships, and posts. + +## Run + +```sh +npm install +npm run dev # nodemon app.js +``` + +Configure `.env` (never commit it): + +```env +PORT=3000 +JWT_SECRET= +JWT_EXPIRES_IN=86400 +UPLOAD_DIR=./uploads +MAX_UPLOAD_SIZE_BYTES=5242880 +``` + +Database and graph-database variables are also required by the relevant routes. See `.env.example`. + +## Authentication + +`POST /auth/register` and `POST /auth/login` are public. Every other route requires: + +```http +Authorization: Bearer +``` + +Login returns `{ id, token, expires_at }`. The token is HS256-signed and contains `user_id`, `iat`, and `exp`. Never log or expose tokens, passwords, password hashes, or salts. + +## API + +The compact endpoint contract is in [API.md](API.md). Unknown body/query fields are rejected with `400`; clients must send only documented fields. + +## Security and ownership + +- The API is the authoritative validator; client validation is only UX. +- Registration/login validation and password rules are defined in [AUTH_INPUT_POLICY.md](AUTH_INPUT_POLICY.md). +- Post creation always uses `author_id` from the verified JWT; clients must not send `author_id`. +- Post edit/delete require that the JWT user owns the post. +- `/posts` is visible to any authenticated user; `/posts/me` filters by JWT `user_id`. +- Relationship creation requires `me` to equal the JWT user ID. +- Uploads accept only JPEG, PNG, GIF, and WebP, one file named `image`, up to `MAX_UPLOAD_SIZE_BYTES` (default 5 MiB). Files receive random names and are stored under `UPLOAD_DIR`. +- Registration rejects disposable email addresses and weak/reused-pattern passwords. Login failures use the generic `Invalid email or password` response. +- Do not expose database errors or stack traces to clients. diff --git a/app.js b/app.js index 73bf791..3d23e01 100644 --- a/app.js +++ b/app.js @@ -1,40 +1,45 @@ -const express = require('express'); -const cors = require('cors'); +const express = require("express"); +const cors = require("cors"); const app = express(); -const dotenv = require('dotenv'); -require('dotenv').config(); +const dotenv = require("dotenv"); +const { authenticateToken } = require("./middleware/authenticate_token"); +require("dotenv").config(); -const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '') - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); +const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); // Apply configured CORS origins to every endpoint, including preflight requests. app.use(cors({ origin: allowedOrigins })); app.use(express.json()); -// Data routes -const getDataRoute = require('./features/data/GET_data'); -const postDataRoute = require('./features/data/POST_data'); -const deleteDataRoute = require('./features/data/DELETE_data'); - -app.use('/get_data', getDataRoute); -app.use('/post_data', postDataRoute); -app.use('/delete_data', deleteDataRoute); - // People routes -const CreatePersonRoute = require('./features/people/REGISTER_people'); -const LoginPersonRoute = require('./features/people/LOGIN_people'); +const CreatePersonRoute = require("./features/people/REGISTER_people"); +const LoginPersonRoute = require("./features/people/LOGIN_people"); -app.use('/auth/register', CreatePersonRoute); -app.use('/auth/login', LoginPersonRoute); +app.use("/auth/register", CreatePersonRoute); +app.use("/auth/login", LoginPersonRoute); + +// Every route below this point requires a valid JWT. Registration and login stay public. +app.use(authenticateToken); // Relation routes -const CreateRelationRoute = require('./features/relations/CREATE_friendship'); +const CreateRelationRoute = require("./features/relations/CREATE_friendship"); -app.use('/create_relationship', CreateRelationRoute) +app.use("/create_relationship", CreateRelationRoute); +// Post routes +const CreatePostRoute = require("./features/posts/CREATE_post"); +const DeletePostRoute = require("./features/posts/DELETE_post"); +const GetPostsRoute = require("./features/posts/GET_posts"); +const EditPostRoute = require("./features/posts/EDIT_post"); + +app.use("/posts", GetPostsRoute); +app.use("/posts/create", CreatePostRoute); +app.use("/posts/delete", DeletePostRoute); +app.use("/posts/edit", EditPostRoute); app.listen(process.env.PORT, () => { - console.log(`Server draait op port ${process.env.PORT}`) -}) + console.log(`Server draait op port ${process.env.PORT}`); +}); diff --git a/features/data/DELETE_data.js b/features/data/DELETE_data.js deleted file mode 100644 index 3ba2493..0000000 --- a/features/data/DELETE_data.js +++ /dev/null @@ -1,29 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const pool = require('../../db'); - - -async function delete_data(req, res) { - try { - const name = req.query.name; - const result = await pool.query('DELETE FROM data WHERE name = $1', [name]); - res.status(200).json({ - message: 'Data Deleted', - deleted: result.rows - }) - } - catch (err) { - console.error('database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('Data has not been deleted, request failed'); - } -}; - - -router.delete('/', delete_data); -module.exports = router; \ No newline at end of file diff --git a/features/data/GET_data.js b/features/data/GET_data.js deleted file mode 100644 index 002e221..0000000 --- a/features/data/GET_data.js +++ /dev/null @@ -1,26 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const pool = require('../../db'); - - -async function get_data(req, res) { - try { - const result = await pool.query('SELECT id, name, created_at FROM data'); - console.log(result.rows); - res.json(result.rows); - } - catch (err) { - console.error('[get_data] database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('No data available, request failed'); - } -}; - - -router.get('/', get_data); -module.exports = router; \ No newline at end of file diff --git a/features/data/POST_data.js b/features/data/POST_data.js deleted file mode 100644 index 67d0807..0000000 --- a/features/data/POST_data.js +++ /dev/null @@ -1,27 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const pool = require('../../db'); - - -async function post_data(req, res) { - try { - const { name } = req.body; - const { rows } = await pool.query('INSERT INTO data (name) VALUES ($1)', [name]); - console.log(rows[0]); - res.json(rows[0]); - } - catch (err) { - console.error('database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('Data was not pushed, request failed'); - } -}; - - -router.post('/', post_data); -module.exports = router; \ No newline at end of file diff --git a/features/people/LOGIN_people.js b/features/people/LOGIN_people.js index ff9768b..1f4a3a1 100644 --- a/features/people/LOGIN_people.js +++ b/features/people/LOGIN_people.js @@ -4,6 +4,8 @@ const pool = require('../../db'); const crypto = require('crypto'); const { promisify } = require('util'); const { sanitizeLoginInput } = require('../../middleware/sanitize_person_input'); +const { strictInput } = require('../../middleware/strict_input'); +const { signToken } = require('../../middleware/authenticate_token'); const scrypt = promisify(crypto.scrypt); @@ -32,7 +34,8 @@ async function login(req, res) { return; } - res.status(200).json({ id: rows[0].id }); + const { token, expiresAt } = signToken(rows[0].id); + res.status(200).json({ id: rows[0].id, token, expires_at: expiresAt }); } catch (err) { console.error('login failed', { message: err.message, code: err.code }); @@ -40,5 +43,5 @@ async function login(req, res) { } }; -router.post('/', sanitizeLoginInput, login); +router.post('/', strictInput({ body: ['email', 'password'] }), sanitizeLoginInput, login); module.exports = router; diff --git a/features/people/REGISTER_people.js b/features/people/REGISTER_people.js index e04e16b..42fb241 100644 --- a/features/people/REGISTER_people.js +++ b/features/people/REGISTER_people.js @@ -5,6 +5,7 @@ const pool = require('../../db'); var crypto = require('crypto'); const { promisify } = require('util'); const { sanitizePersonInput } = require('../../middleware/sanitize_person_input'); +const { strictInput } = require('../../middleware/strict_input'); const scrypt = promisify(crypto.scrypt); @@ -84,5 +85,5 @@ async function create_people(req, res) { }; -router.post('/', sanitizePersonInput, create_people); +router.post('/', strictInput({ body: ['name', 'username', 'email', 'password'] }), sanitizePersonInput, create_people); module.exports = router; diff --git a/features/posts/CREATE_post.js b/features/posts/CREATE_post.js new file mode 100644 index 0000000..ce37ed8 --- /dev/null +++ b/features/posts/CREATE_post.js @@ -0,0 +1,51 @@ +const express = require("express"); +const router = express.Router(); +const pool = require("../../db"); +const { strictInput } = require("../../middleware/strict_input"); +const { authenticateToken } = require("../../middleware/authenticate_token"); +const { + handlePostUpload, + imageLinkForFile, + removeUploadedFile, +} = require("../../middleware/post_upload"); + +async function create_post(req, res) { + try { + const { title, text } = req.body; + const author_id = req.user.id; + if (!title || !text) { + await removeUploadedFile(req.file); + return res.status(400).send("Title or text was not provided"); + } else { + const imageLink = imageLinkForFile(req.file); + try { + const { rows } = await pool.query( + "INSERT INTO posts (title, text, author_id, image_link) VALUES ($1, $2, $3, $4) RETURNING id, title, text, author_id, image_link", + [title, text, author_id, imageLink], + ); + res.status(201).json(rows[0]); + } catch (err) { + await removeUploadedFile(req.file); + throw err; + } + } + } catch (err) { + console.error("database query failed", { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + res.status(500).send("Data was not pushed, request failed"); + } +} + +router.post( + "/", + authenticateToken, + handlePostUpload, + strictInput({ body: ["title", "text"], cleanupUploadedFile: true }), + create_post, +); +module.exports = router; diff --git a/features/posts/DELETE_post.js b/features/posts/DELETE_post.js new file mode 100644 index 0000000..681781a --- /dev/null +++ b/features/posts/DELETE_post.js @@ -0,0 +1,40 @@ +const express = require("express"); +const router = express.Router(); +const pool = require("../../db"); +const { strictInput } = require("../../middleware/strict_input"); +const { authenticateToken } = require("../../middleware/authenticate_token"); +const { removeImageLink } = require("../../middleware/post_upload"); + +async function delete_post(req, res) { + try { + const id = req.query.id; + const result = await pool.query( + "DELETE FROM posts WHERE id = $1 AND author_id = $2 RETURNING id, image_link", + [id, req.user.id], + ); + if (result.rowCount === 0) { + return res.status(404).send("Post not found or does not belong to you"); + } + await removeImageLink(result.rows[0].image_link); + res.status(200).json({ + message: "Post Deleted", + }); + } catch (err) { + console.error("database query failed", { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + res.status(500).send("Data has not been deleted, request failed"); + } +} + +router.delete( + "/", + authenticateToken, + strictInput({ query: ["id"] }), + delete_post, +); +module.exports = router; diff --git a/features/posts/EDIT_post.js b/features/posts/EDIT_post.js new file mode 100644 index 0000000..379d959 --- /dev/null +++ b/features/posts/EDIT_post.js @@ -0,0 +1,100 @@ +const express = require("express"); +const router = express.Router(); +const pool = require("../../db"); +const { strictInput } = require("../../middleware/strict_input"); +const { authenticateToken } = require("../../middleware/authenticate_token"); +const { + handlePostUpload, + imageLinkForFile, + removeImageLink, + removeUploadedFile, +} = require("../../middleware/post_upload"); + +async function editPost(req, res) { + const id = req.query.id; + const { title, text, remove_image: removeImageValue } = req.body; + const removeImage = removeImageValue === "true"; + + if (removeImageValue !== undefined && !["true", "false"].includes(removeImageValue)) { + await removeUploadedFile(req.file); + return res.status(400).send("remove_image must be true or false"); + } + if (req.file && removeImage) { + await removeUploadedFile(req.file); + return res.status(400).send("Choose either an image or remove_image=true"); + } + if (title === "" || text === "") { + await removeUploadedFile(req.file); + return res.status(400).send("title and text cannot be empty"); + } + if (title === undefined && text === undefined && !req.file && !removeImage) { + return res.status(400).send("At least one post field must be changed"); + } + + try { + const existing = await pool.query( + "SELECT id, image_link FROM posts WHERE id = $1 AND author_id = $2", + [id, req.user.id], + ); + if (existing.rowCount === 0) { + await removeUploadedFile(req.file); + return res.status(404).send("Post not found or does not belong to you"); + } + + const oldImageLink = existing.rows[0].image_link; + const fields = []; + const values = []; + const addField = (field, value) => { + fields.push(`${field} = $${values.length + 1}`); + values.push(value); + }; + + if (title !== undefined) addField("title", title); + if (text !== undefined) addField("text", text); + if (req.file) addField("image_link", imageLinkForFile(req.file)); + if (removeImage) addField("image_link", null); + values.push(id, req.user.id); + + let updated; + try { + updated = await pool.query( + `UPDATE posts SET ${fields.join(", ")} WHERE id = $${values.length - 1} AND author_id = $${values.length} RETURNING id, title, text, author_id, image_link`, + values, + ); + } catch (err) { + await removeUploadedFile(req.file); + throw err; + } + + if (req.file || removeImage) { + await removeImageLink(oldImageLink).catch((err) => { + console.error("old post image could not be removed", { message: err.message }); + }); + } + return res.status(200).json(updated.rows[0]); + } catch (err) { + await removeUploadedFile(req.file); + console.error("database query failed", { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + return res.status(500).send("Post was not updated, request failed"); + } +} + +router.put( + "/", + authenticateToken, + handlePostUpload, + strictInput({ + query: ["id"], + body: ["title", "text", "remove_image"], + cleanupUploadedFile: true, + }), + editPost, +); + +module.exports = router; diff --git a/features/posts/GET_posts.js b/features/posts/GET_posts.js new file mode 100644 index 0000000..7c71a6a --- /dev/null +++ b/features/posts/GET_posts.js @@ -0,0 +1,56 @@ +const express = require("express"); +const router = express.Router(); +const pool = require("../../db"); +const { strictInput } = require("../../middleware/strict_input"); +const { authenticateToken } = require("../../middleware/authenticate_token"); +const { uploadDirectory } = require("../../middleware/post_upload"); +const path = require("path"); + +async function getPosts(req, res, ownOnly) { + try { + const query = ownOnly + ? "SELECT id, title, text, author_id, image_link FROM posts WHERE author_id = $1" + : "SELECT id, title, text, author_id, image_link FROM posts"; + const values = ownOnly ? [req.user.id] : []; + const { rows } = await pool.query(query, values); + res.status(200).json(rows); + } catch (err) { + console.error("database query failed", { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + res.status(500).send("Posts could not be retrieved, request failed"); + } +} + +router.get( + "/image/:filename", + authenticateToken, + strictInput(), + (req, res) => { + const filename = path.basename(req.params.filename); + if (filename !== req.params.filename) return res.status(404).end(); + res.sendFile(filename, { root: uploadDirectory }, (err) => { + if (err && !res.headersSent) res.status(err.statusCode === 404 ? 404 : 500).end(); + }); + }, +); + +router.get( + "/me", + authenticateToken, + strictInput(), + (req, res) => getPosts(req, res, true), +); + +router.get( + "/", + authenticateToken, + strictInput(), + (req, res) => getPosts(req, res, false), +); + +module.exports = router; diff --git a/features/relations/CREATE_friendship.js b/features/relations/CREATE_friendship.js index 4c57623..cd17c38 100644 --- a/features/relations/CREATE_friendship.js +++ b/features/relations/CREATE_friendship.js @@ -1,28 +1,32 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -var driver = require('../../graph_db'); - - +var driver = require("../../graph_db"); +const { strictInput } = require('../../middleware/strict_input'); async function create_friendship(req, res) { - try { - const me = req.query.me; - const them = req.query.them; - const { records } = await driver.executeQuery('MATCH (m:Person {name: $me}) MATCH (t:Person {name: $them}) CREATE (m)-[:FRIENDS_WITH]-> (t) CREATE (t)-[:FRIENDS_WITH]-> (m)', { me: me, them: them }, { database: process.env.GRAPH_DB_NAME}); - res.status(200).send('Relationship has been created'); + try { + const me = req.query.me; + const them = req.query.them; + if (String(req.user.id) !== String(me)) { + return res.status(403).send("You can only create relationships as yourself"); } - catch (err) { - console.error('database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('Relationship was not created, request failed'); - } -}; + const { records } = await driver.executeQuery( + "MATCH (m:Person {db_id: $me}) MATCH (t:Person {db_id: $them}) CREATE (m)-[:FRIENDS_WITH]-> (t) CREATE (t)-[:FRIENDS_WITH]-> (m)", + { me: me, them: them }, + { database: process.env.GRAPH_DB_NAME }, + ); + res.status(200).send("Relationship has been created"); + } catch (err) { + console.error("database query failed", { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + res.status(500).send("Relationship was not created, request failed"); + } +} - -router.post('/', create_friendship); -module.exports = router; \ No newline at end of file +router.post("/", strictInput({ query: ['me', 'them'] }), create_friendship); +module.exports = router; diff --git a/middleware/authenticate_token.js b/middleware/authenticate_token.js new file mode 100644 index 0000000..37ba595 --- /dev/null +++ b/middleware/authenticate_token.js @@ -0,0 +1,85 @@ +const crypto = require('crypto'); + +const DEFAULT_TOKEN_LIFETIME_SECONDS = 60 * 60; + +function base64url(value) { + return Buffer.from(value).toString('base64url'); +} + +function getJwtSecret() { + const secret = process.env.JWT_SECRET; + if (!secret || secret.length < 32) { + throw new Error('JWT_SECRET must be configured and contain at least 32 characters'); + } + return secret; +} + +function signToken(userId) { + const now = Math.floor(Date.now() / 1000); + const configuredLifetime = Number.parseInt(process.env.JWT_EXPIRES_IN, 10); + const lifetime = Number.isInteger(configuredLifetime) && configuredLifetime > 0 + ? configuredLifetime + : DEFAULT_TOKEN_LIFETIME_SECONDS; + const payload = { + user_id: userId, + iat: now, + exp: now + lifetime, + }; + const encodedHeader = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const encodedPayload = base64url(JSON.stringify(payload)); + const content = `${encodedHeader}.${encodedPayload}`; + const signature = crypto + .createHmac('sha256', getJwtSecret()) + .update(content) + .digest('base64url'); + + return { token: `${content}.${signature}`, expiresAt: payload.exp }; +} + +function authenticateToken(req, res, next) { + const authorization = req.get('authorization') || ''; + const match = authorization.match(/^Bearer\s+([^\s]+)$/i); + + if (!match) { + return res.status(401).send('A valid Bearer token is required'); + } + + try { + const [encodedHeader, encodedPayload, providedSignature] = match[1].split('.'); + if (!encodedHeader || !encodedPayload || !providedSignature) { + throw new Error('Malformed token'); + } + + const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString('utf8')); + const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); + if (header.alg !== 'HS256' || header.typ !== 'JWT') { + throw new Error('Unsupported token'); + } + + const expectedSignature = crypto + .createHmac('sha256', getJwtSecret()) + .update(`${encodedHeader}.${encodedPayload}`) + .digest('base64url'); + const provided = Buffer.from(providedSignature); + const expected = Buffer.from(expectedSignature); + + if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) { + throw new Error('Invalid signature'); + } + if (!Number.isInteger(payload.user_id) || payload.user_id <= 0 || + !Number.isInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) { + throw new Error('Invalid or expired token'); + } + + req.user = { id: payload.user_id }; + next(); + } catch (err) { + if (err.message.startsWith('JWT_SECRET')) { + console.error('[auth] JWT configuration error:', err.message); + return res.status(500).send('Authentication is not configured'); + } + return res.status(401).send('Invalid or expired token'); + } +} + +module.exports = { authenticateToken, signToken }; diff --git a/middleware/post_upload.js b/middleware/post_upload.js new file mode 100644 index 0000000..76b21f7 --- /dev/null +++ b/middleware/post_upload.js @@ -0,0 +1,69 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const multer = require("multer"); + +const uploadDirectory = path.resolve( + process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads"), +); +const maxFileSize = Number.parseInt(process.env.MAX_UPLOAD_SIZE_BYTES, 10) || 5 * 1024 * 1024; +const extensionByMimeType = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +}; + +fs.mkdirSync(uploadDirectory, { recursive: true }); + +const storage = multer.diskStorage({ + destination: uploadDirectory, + filename: (req, file, callback) => { + callback(null, `${crypto.randomUUID()}${extensionByMimeType[file.mimetype]}`); + }, +}); + +const uploadImage = multer({ + storage, + limits: { fileSize: maxFileSize, files: 1 }, + fileFilter: (req, file, callback) => { + if (!extensionByMimeType[file.mimetype]) { + return callback(new Error("Only JPEG, PNG, GIF, and WebP images are allowed")); + } + callback(null, true); + }, +}).single("image"); + +function handlePostUpload(req, res, next) { + uploadImage(req, res, (err) => { + if (!err) return next(); + if (err.code === "LIMIT_FILE_SIZE") { + return res.status(413).send("Image exceeds the maximum allowed size"); + } + return res.status(400).send(err.message); + }); +} + +function imageLinkForFile(file) { + return file ? `/posts/image/${file.filename}` : null; +} + +async function removeImageLink(imageLink) { + if (!imageLink || !imageLink.startsWith("/posts/image/")) return; + const filename = path.basename(imageLink); + await fs.promises.unlink(path.join(uploadDirectory, filename)).catch((err) => { + if (err.code !== "ENOENT") throw err; + }); +} + +async function removeUploadedFile(file) { + if (file) await removeImageLink(imageLinkForFile(file)); +} + +module.exports = { + handlePostUpload, + imageLinkForFile, + removeImageLink, + removeUploadedFile, + uploadDirectory, +}; diff --git a/middleware/strict_input.js b/middleware/strict_input.js new file mode 100644 index 0000000..250bf6d --- /dev/null +++ b/middleware/strict_input.js @@ -0,0 +1,27 @@ +const fs = require('fs'); + +function strictInput({ body = [], query = [], cleanupUploadedFile = false } = {}) { + const allowedBody = new Set(body); + const allowedQuery = new Set(query); + + return function validateInputShape(req, res, next) { + const bodyKeys = Object.keys(req.body || {}); + const queryKeys = Object.keys(req.query || {}); + const unexpectedBody = bodyKeys.filter((key) => !allowedBody.has(key)); + const unexpectedQuery = queryKeys.filter((key) => !allowedQuery.has(key)); + + if (unexpectedBody.length > 0 || unexpectedQuery.length > 0) { + if (cleanupUploadedFile && req.file?.path) { + fs.promises.unlink(req.file.path).catch(() => {}); + } + return res.status(400).json({ + error: 'Unexpected request field(s)', + fields: [...unexpectedBody, ...unexpectedQuery], + }); + } + + next(); + }; +} + +module.exports = { strictInput }; diff --git a/package-lock.json b/package-lock.json index b35816d..2368295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fakeout": "^1.0.65", + "multer": "^2.2.0", "neo4j-driver": "^6.2.0", "pg": "^8.22.0", "router": "^2.2.0" @@ -48,6 +49,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -178,6 +185,23 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -241,6 +265,21 @@ "fsevents": "~2.3.2" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -841,6 +880,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1186,6 +1287,20 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1417,6 +1532,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1508,6 +1631,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1524,6 +1653,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ce1edf5..dafc1c8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fakeout": "^1.0.65", + "multer": "^2.2.0", "neo4j-driver": "^6.2.0", "pg": "^8.22.0", "router": "^2.2.0"