diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 3bc18b3..8f1207a 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -5,16 +5,12 @@ // here are unchanged from when this file held all 110 admin routes. // // Already extracted: users, account, invites, auth/providers, moderation, -// bot-activity, activity. -// Still here: shard, dashboard, site-mode, posts, uploads, wiki, pages, -// settings, discord-bot, email, uo-link. +// bot-activity, activity, posts, uploads, wiki, pages. +// Still here: shard, dashboard, site-mode, settings, discord-bot, email, +// uo-link. // This file disappears when the last group moves. const express = require('express') -const path = require('path') -const fs = require('fs') -const crypto = require('crypto') -const multer = require('multer') const { body, param } = require('express-validator') const ctrl = require('./admin.controller') @@ -23,7 +19,6 @@ const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') const shardOps = require('./shardOps.controller') const selfShard = require('../player/shard.controller') -const pagesCtrl = require('./pages.controller') const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') @@ -238,40 +233,6 @@ adminRouter.get( shardOps.listHouses, ) -// ── Image uploads (screenshots/gallery) ─────────────────────────────── -const UPLOAD_DIR = - process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads') -fs.mkdirSync(UPLOAD_DIR, { recursive: true }) - -// Whitelisted image mimetypes → the extension we store the file under. The -// stored extension is derived from this map (keyed by the accepted mimetype), -// never from originalname — so a spoofed `Content-Type: image/png` paired with -// `originalname: x.html` can never land an executable .html file in /uploads. -const MIME_EXT = { - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'image/avif': '.avif', -} - -const storage = multer.diskStorage({ - destination: (req, file, cb) => cb(null, UPLOAD_DIR), - filename: (req, file, cb) => { - const ext = MIME_EXT[file.mimetype] || '' - cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`) - }, -}) -const upload = multer({ - storage, - limits: { fileSize: 8 * 1024 * 1024 }, - fileFilter: (req, file, cb) => { - // Single source of truth: only mimetypes we can map to a safe extension pass. - if (MIME_EXT[file.mimetype]) cb(null, true) - else cb(new Error('Only image uploads are allowed')) - }, -}) - // ── Dashboard & site mode ───────────────────────────────────────────── adminRouter.get( '/dashboard', @@ -299,431 +260,6 @@ adminRouter.put( ctrl.setSiteMode, ) -// ── Posts (news / five-on-friday / newsletter / screenshots) ────────── -adminRouter.get( - '/posts', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'List all posts (including unpublished)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['category'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Optional category filter.' } - /* #swagger.responses[200] = { description: 'Posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listPosts, -) -adminRouter.post( - '/posts', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Create a post' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('category').isString().notEmpty(), - body('title').isString().trim().notEmpty().isLength({ max: 200 }), - validate, - ctrl.createPost, -) -adminRouter.post( - '/posts/upload', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Upload a post image (multipart)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */ - /* #swagger.responses[201] = { description: 'Stored image URL', content: { "application/json": { schema: { type: "object", properties: { image_url: { type: "string", example: "/uploads/1700000000-abcd.png" } } } } } } */ - /* #swagger.responses[400] = { description: 'No image / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - upload.single('image'), - ctrl.uploadImage, -) -// Generalized upload (rich-text editors). Same multer middleware; returns { url }. -adminRouter.post( - '/uploads', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Upload an image for rich-text editors (multipart)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */ - /* #swagger.responses[201] = { description: 'Stored file URL', content: { "application/json": { schema: { $ref: "#/components/schemas/UploadResponse" } } } } */ - /* #swagger.responses[400] = { description: 'No file / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - upload.single('image'), - ctrl.uploadFile, -) -adminRouter.get( - '/posts/:id', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Get a post by id' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.getPost, -) -adminRouter.put( - '/posts/:id', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Update a post' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.updatePost, -) -adminRouter.patch( - '/posts/:id/publish', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Publish / unpublish a post' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('published').isBoolean(), - validate, - ctrl.publishPost, -) -adminRouter.delete( - '/posts/:id', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Delete a post' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.deletePost, -) -adminRouter.get( - '/posts/:id/announce', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Get the announcement pipeline status for a post' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.getAnnounceStatus, -) -adminRouter.post( - '/posts/:id/announce/retry', - // #swagger.tags = ['Admin · Posts'] - // #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */ - /* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('leg').isIn(['towncrier', 'discord']), - validate, - ctrl.retryAnnounceLeg, -) - -// ── Wiki categories (static paths registered before /wiki/:slug) ─────── -adminRouter.get( - '/wiki/categories', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'List wiki categories' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listWikiCategories, -) -adminRouter.post( - '/wiki/categories', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Create a wiki category' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('slug').matches(/^[a-z0-9-]+$/), - body('title').isString().trim().notEmpty().isLength({ max: 200 }), - body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), - body('sort_order').optional().isInt(), - validate, - ctrl.createWikiCategory, -) -adminRouter.put( - '/wiki/categories/:id', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Update a wiki category' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('slug').optional().matches(/^[a-z0-9-]+$/), - body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }), - body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), - body('sort_order').optional().isInt(), - validate, - ctrl.updateWikiCategory, -) -adminRouter.delete( - '/wiki/categories/:id', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Delete a wiki category' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.deleteWikiCategory, -) - -// ── Wiki tags ────────────────────────────────────────────────────────── -adminRouter.get( - '/wiki/tags', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'List wiki tags' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listWikiTags, -) - -// ── Wiki pages ───────────────────────────────────────────────────────── -adminRouter.get( - '/wiki', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'List all wiki pages (including unpublished)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listWiki, -) -adminRouter.post( - '/wiki', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Create a wiki page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPageCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('slug').matches(/^[a-z0-9-]+$/), - body('title').isString().trim().notEmpty().isLength({ max: 200 }), - body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), - body('category_id').optional({ values: 'null' }).isInt(), - body('published').optional().isBoolean(), - body('tags').optional().isArray(), - validate, - ctrl.createWiki, -) -adminRouter.get( - '/wiki/:slug', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Get a wiki page by slug' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.responses[200] = { description: 'The wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.getWiki, -) -adminRouter.put( - '/wiki/:slug', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Update a wiki page (creates a revision)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { allOf: [ { $ref: "#/components/schemas/WikiPageCreateRequest" }, { type: "object", properties: { change_note: { type: "string", maxLength: 280 } } } ] } } } } */ - /* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }), - body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), - body('category_id').optional({ values: 'null' }).isInt(), - body('published').optional().isBoolean(), - body('tags').optional().isArray(), - body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }), - validate, - ctrl.updateWiki, -) -adminRouter.patch( - '/wiki/:slug/publish', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Publish / unpublish a wiki page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('published').isBoolean(), - validate, - ctrl.publishWiki, -) -adminRouter.get( - '/wiki/:slug/revisions', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'List revisions of a wiki page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.responses[200] = { description: 'Revisions', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listWikiRevisions, -) -adminRouter.get( - '/wiki/:slug/revisions/:id', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Get a single wiki revision' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id.' } - /* #swagger.responses[200] = { description: 'The revision', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.getWikiRevision, -) -adminRouter.post( - '/wiki/:slug/revisions/:id/restore', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Restore a wiki page to a revision' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id to restore.' } - /* #swagger.responses[200] = { description: 'Restored wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.restoreWikiRevision, -) -adminRouter.delete( - '/wiki/:slug', - // #swagger.tags = ['Admin · Wiki'] - // #swagger.summary = 'Delete a wiki page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.deleteWiki, -) - -// ── CMS Pages (block-based page builder) ────────────────────────────── -adminRouter.get( - '/pages', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'List all CMS pages (summaries)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - pagesCtrl.listPages, -) -adminRouter.post( - '/pages', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Create a CMS page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */ - /* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('slug').isString().trim().notEmpty(), - body('title').isString().trim().notEmpty().isLength({ max: 200 }), - validate, - pagesCtrl.createPage, -) -adminRouter.get( - '/pages/:id', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Get a CMS page by id (full, incl. blocks)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } - /* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - pagesCtrl.getPage, -) -adminRouter.patch( - '/pages/:id', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)' - // #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - pagesCtrl.updatePage, -) -adminRouter.delete( - '/pages/:id', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Delete a CMS page (blocked if protected)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ - /* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - pagesCtrl.deletePage, -) -adminRouter.post( - '/pages/:id/unprotect', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Disable page protection (password step-up re-auth)' - // #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */ - /* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('password').isString().notEmpty(), - validate, - pagesCtrl.unprotectPage, -) -adminRouter.post( - '/pages/:id/preview', - // #swagger.tags = ['Admin · Pages'] - // #swagger.summary = 'Mint a 1h draft-preview link for a page' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } - /* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - pagesCtrl.createPreview, -) - // ── Settings ────────────────────────────────────────────────────────── adminRouter.get( '/settings', diff --git a/server/src/router/v1/admin/imageUpload.js b/server/src/router/v1/admin/imageUpload.js new file mode 100644 index 0000000..0bd97c7 --- /dev/null +++ b/server/src/router/v1/admin/imageUpload.js @@ -0,0 +1,49 @@ +// Shared multer middleware for the two admin image-upload routes: +// POST /admin/posts/upload (posts.router.js) and POST /admin/uploads +// (uploads.router.js). It lived inline in admin.routes.js while both routes did; +// the PR 3 split put them in different files, so the config moved here rather +// than being duplicated — one upload directory, one mimetype allowlist. +// +// Kept in this directory on purpose: UPLOAD_DIR is resolved relative to +// __dirname, so moving the file to another folder would silently repoint the +// upload directory. + +const path = require('path') +const fs = require('fs') +const crypto = require('crypto') +const multer = require('multer') + +const UPLOAD_DIR = + process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads') +fs.mkdirSync(UPLOAD_DIR, { recursive: true }) + +// Whitelisted image mimetypes → the extension we store the file under. The +// stored extension is derived from this map (keyed by the accepted mimetype), +// never from originalname — so a spoofed `Content-Type: image/png` paired with +// `originalname: x.html` can never land an executable .html file in /uploads. +const MIME_EXT = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/avif': '.avif', +} + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOAD_DIR), + filename: (req, file, cb) => { + const ext = MIME_EXT[file.mimetype] || '' + cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`) + }, +}) +const upload = multer({ + storage, + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + // Single source of truth: only mimetypes we can map to a safe extension pass. + if (MIME_EXT[file.mimetype]) cb(null, true) + else cb(new Error('Only image uploads are allowed')) + }, +}) + +module.exports = { upload, UPLOAD_DIR, MIME_EXT } diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 2ac006a..a72b182 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -20,6 +20,10 @@ const authProvidersRouter = require('./authProviders.router') const moderationRouter = require('./moderation.router') const botActivityRouter = require('./botActivity.router') const activityRouter = require('./activity.router') +const postsRouter = require('./posts.router') +const uploadsRouter = require('./uploads.router') +const wikiRouter = require('./wiki.router') +const pagesRouter = require('./pages.router') const residualRouter = require('./admin.routes') const adminRouter = express.Router() @@ -46,6 +50,12 @@ adminRouter.use('/auth', authProvidersRouter) adminRouter.use('/moderation', moderationRouter) adminRouter.use('/bot-activity', botActivityRouter) adminRouter.use('/activity', activityRouter) +// Content, all editor-tier (no gate beyond staffOnly above). /uploads is the +// rich-text editors' generalized upload; /posts owns its own /posts/upload. +adminRouter.use('/posts', postsRouter) +adminRouter.use('/uploads', uploadsRouter) +adminRouter.use('/wiki', wikiRouter) +adminRouter.use('/pages', pagesRouter) // Everything not yet extracted, at the group root. Mounted last, but none of the // prefixes above appear in it, so nothing here depends on the ordering. diff --git a/server/src/router/v1/admin/pages.router.js b/server/src/router/v1/admin/pages.router.js new file mode 100644 index 0000000..2ef6d4e --- /dev/null +++ b/server/src/router/v1/admin/pages.router.js @@ -0,0 +1,114 @@ +// Admin · Pages — the block-based CMS page builder: drafts, protection, and +// short-lived preview links. +// +// Mounted at /api/v1/admin/pages by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. No extra gate — editors build pages. Note +// that protection is *not* a role gate: POST /:id/unprotect re-verifies the +// caller's password server-side (see pages.controller.js). +// +// Unrelated to /admin/shard/pages, which is the in-game help-page (support) +// queue and stays with the shard capability. +// +// Handlers live in pages.controller.js; this PR re-wires routes, not logic. + +const express = require('express') +const { body, param } = require('express-validator') + +const pagesCtrl = require('./pages.controller') +const validate = require('../../../middleware/validate') + +const pagesRouter = express.Router() + +pagesRouter.get( + '/', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'List all CMS pages (summaries)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + pagesCtrl.listPages, +) +pagesRouter.post( + '/', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Create a CMS page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */ + /* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('slug').isString().trim().notEmpty(), + body('title').isString().trim().notEmpty().isLength({ max: 200 }), + validate, + pagesCtrl.createPage, +) +pagesRouter.get( + '/:id', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Get a CMS page by id (full, incl. blocks)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } + /* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + pagesCtrl.getPage, +) +pagesRouter.patch( + '/:id', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)' + // #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + pagesCtrl.updatePage, +) +pagesRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Delete a CMS page (blocked if protected)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ + /* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + pagesCtrl.deletePage, +) +pagesRouter.post( + '/:id/unprotect', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Disable page protection (password step-up re-auth)' + // #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */ + /* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('password').isString().notEmpty(), + validate, + pagesCtrl.unprotectPage, +) +pagesRouter.post( + '/:id/preview', + // #swagger.tags = ['Admin · Pages'] + // #swagger.summary = 'Mint a 1h draft-preview link for a page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' } + /* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + pagesCtrl.createPreview, +) + +module.exports = pagesRouter diff --git a/server/src/router/v1/admin/posts.router.js b/server/src/router/v1/admin/posts.router.js new file mode 100644 index 0000000..e201502 --- /dev/null +++ b/server/src/router/v1/admin/posts.router.js @@ -0,0 +1,139 @@ +// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus +// the announcement pipeline (town crier + Discord) status and retry. +// +// Mounted at /api/v1/admin/posts by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the +// editor tier's whole job, so admin, editor and moderator all reach these. +// +// Handlers still live in admin.controller.js; this PR re-wires routes, not logic. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./admin.controller') +const { upload } = require('./imageUpload') +const validate = require('../../../middleware/validate') + +const postsRouter = express.Router() + +postsRouter.get( + '/', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'List all posts (including unpublished)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['category'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Optional category filter.' } + /* #swagger.responses[200] = { description: 'Posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listPosts, +) +postsRouter.post( + '/', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Create a post' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('category').isString().notEmpty(), + body('title').isString().trim().notEmpty().isLength({ max: 200 }), + validate, + ctrl.createPost, +) +postsRouter.post( + '/upload', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Upload a post image (multipart)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */ + /* #swagger.responses[201] = { description: 'Stored image URL', content: { "application/json": { schema: { type: "object", properties: { image_url: { type: "string", example: "/uploads/1700000000-abcd.png" } } } } } } */ + /* #swagger.responses[400] = { description: 'No image / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + upload.single('image'), + ctrl.uploadImage, +) +postsRouter.get( + '/:id', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Get a post by id' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.getPost, +) +postsRouter.put( + '/:id', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Update a post' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.updatePost, +) +postsRouter.patch( + '/:id/publish', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Publish / unpublish a post' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('published').isBoolean(), + validate, + ctrl.publishPost, +) +postsRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Delete a post' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.deletePost, +) +postsRouter.get( + '/:id/announce', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Get the announcement pipeline status for a post' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.getAnnounceStatus, +) +postsRouter.post( + '/:id/announce/retry', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */ + /* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('leg').isIn(['towncrier', 'discord']), + validate, + ctrl.retryAnnounceLeg, +) + +module.exports = postsRouter diff --git a/server/src/router/v1/admin/uploads.router.js b/server/src/router/v1/admin/uploads.router.js new file mode 100644 index 0000000..b10bb75 --- /dev/null +++ b/server/src/router/v1/admin/uploads.router.js @@ -0,0 +1,33 @@ +// Admin · Uploads — the generalized image upload used by the rich-text editors +// (wiki, CMS pages). Returns { url }, where the posts-specific sibling +// POST /admin/posts/upload returns { image_url }; both write to the same +// directory through the shared multer config in imageUpload.js. +// +// Mounted at /api/v1/admin/uploads by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. No extra gate — same editor tier as posts. +// +// The swagger tag stays 'Admin · Posts', matching the committed spec. Retagging +// it would be a real OpenAPI diff, not a route move, so it does not belong in a +// split PR whose acceptance criterion is a byte-identical spec. + +const express = require('express') + +const ctrl = require('./admin.controller') +const { upload } = require('./imageUpload') + +const uploadsRouter = express.Router() + +uploadsRouter.post( + '/', + // #swagger.tags = ['Admin · Posts'] + // #swagger.summary = 'Upload an image for rich-text editors (multipart)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */ + /* #swagger.responses[201] = { description: 'Stored file URL', content: { "application/json": { schema: { $ref: "#/components/schemas/UploadResponse" } } } } */ + /* #swagger.responses[400] = { description: 'No file / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + upload.single('image'), + ctrl.uploadFile, +) + +module.exports = uploadsRouter diff --git a/server/src/router/v1/admin/wiki.router.js b/server/src/router/v1/admin/wiki.router.js new file mode 100644 index 0000000..474664d --- /dev/null +++ b/server/src/router/v1/admin/wiki.router.js @@ -0,0 +1,220 @@ +// Admin · Wiki — wiki pages with revision history, plus the category and tag +// vocabularies they draw on. +// +// Mounted at /api/v1/admin/wiki by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. No extra gate — editors own the wiki. +// +// Handlers still live in admin.controller.js; this PR re-wires routes, not logic. +// +// ORDER IS LOAD-BEARING: the static /categories and /tags paths must stay ahead +// of /:slug, or `GET /admin/wiki/categories` would be dispatched as a page whose +// slug is "categories". The route manifest sorts its entries, so it cannot catch +// a reordering here — keep the declaration order below as it is. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./admin.controller') +const validate = require('../../../middleware/validate') + +const wikiRouter = express.Router() + +// ── Wiki categories (static paths registered before /:slug) ──────────── +wikiRouter.get( + '/categories', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'List wiki categories' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listWikiCategories, +) +wikiRouter.post( + '/categories', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Create a wiki category' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('slug').matches(/^[a-z0-9-]+$/), + body('title').isString().trim().notEmpty().isLength({ max: 200 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), + body('sort_order').optional().isInt(), + validate, + ctrl.createWikiCategory, +) +wikiRouter.put( + '/categories/:id', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Update a wiki category' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('slug').optional().matches(/^[a-z0-9-]+$/), + body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), + body('sort_order').optional().isInt(), + validate, + ctrl.updateWikiCategory, +) +wikiRouter.delete( + '/categories/:id', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Delete a wiki category' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.deleteWikiCategory, +) + +// ── Wiki tags ────────────────────────────────────────────────────────── +wikiRouter.get( + '/tags', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'List wiki tags' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listWikiTags, +) + +// ── Wiki pages ───────────────────────────────────────────────────────── +wikiRouter.get( + '/', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'List all wiki pages (including unpublished)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listWiki, +) +wikiRouter.post( + '/', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Create a wiki page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPageCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('slug').matches(/^[a-z0-9-]+$/), + body('title').isString().trim().notEmpty().isLength({ max: 200 }), + body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), + body('category_id').optional({ values: 'null' }).isInt(), + body('published').optional().isBoolean(), + body('tags').optional().isArray(), + validate, + ctrl.createWiki, +) +wikiRouter.get( + '/:slug', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Get a wiki page by slug' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + /* #swagger.responses[200] = { description: 'The wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getWiki, +) +wikiRouter.put( + '/:slug', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Update a wiki page (creates a revision)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { allOf: [ { $ref: "#/components/schemas/WikiPageCreateRequest" }, { type: "object", properties: { change_note: { type: "string", maxLength: 280 } } } ] } } } } */ + /* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }), + body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), + body('category_id').optional({ values: 'null' }).isInt(), + body('published').optional().isBoolean(), + body('tags').optional().isArray(), + body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }), + validate, + ctrl.updateWiki, +) +wikiRouter.patch( + '/:slug/publish', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Publish / unpublish a wiki page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('published').isBoolean(), + validate, + ctrl.publishWiki, +) +wikiRouter.get( + '/:slug/revisions', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'List revisions of a wiki page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + /* #swagger.responses[200] = { description: 'Revisions', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listWikiRevisions, +) +wikiRouter.get( + '/:slug/revisions/:id', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Get a single wiki revision' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id.' } + /* #swagger.responses[200] = { description: 'The revision', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.getWikiRevision, +) +wikiRouter.post( + '/:slug/revisions/:id/restore', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Restore a wiki page to a revision' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id to restore.' } + /* #swagger.responses[200] = { description: 'Restored wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.restoreWikiRevision, +) +wikiRouter.delete( + '/:slug', + // #swagger.tags = ['Admin · Wiki'] + // #swagger.summary = 'Delete a wiki page' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.deleteWiki, +) + +module.exports = wikiRouter