import type {NextFunction, Request, Response} from 'express'; import * as markdown from './entry/markdown'; import * as giftLinks from './entry/gift-links'; import buildCanonicalUrl from './entry/canonical-url'; const debug = require('@tryghost/debug')('services:routing:controllers:entry'); const config = require('../../../../shared/config'); const urlUtils = require('../../../../shared/url-utils').default; const dataService = require('../../data'); const renderer = require('../../rendering'); // Routing context the router attaches to the matched entry's response. export interface RouterOptions { isMarkdownRequest?: boolean; context?: string[]; [key: string]: unknown; } export interface EntryResponse extends Response { routerOptions: RouterOptions; } // The resolved post/page entry from the data layer; only the fields used here are typed. export interface Entry { id: string; url: string; visibility: string; [key: string]: unknown; } /** * The request's last url param is `/edit`: redirect to the admin editor, or fall * through to a 404 when admin redirects are disabled. */ function editRedirect(res: EntryResponse, next: NextFunction, entry: Entry) { if (!config.get('admin:redirects')) { debug('is edit url but admin redirects are disabled'); return next(); } debug('redirect. is edit url'); const resourceType = res.routerOptions.context?.includes('page') ? 'page' : 'post'; return urlUtils.redirectToAdmin(302, res, `/#/editor/${resourceType}/${entry.id}`); } /** * The requested path no longer matches the entry's canonical url — happens with * date permalinks after a publish date change. */ function isPermalinkStale(req: Request, entry: Entry): boolean { return urlUtils.absoluteToRelative(entry.url, {withoutSubdirectory: true}) !== req.path; } export async function entryController(req: Request, res: EntryResponse, next: NextFunction): Promise { debug('entryController', res.routerOptions); try { // A gift view is html-only. Redirecting before the lookup keeps the // token off the read, so markdown paths can never see an unlocked entry. if (giftLinks.isGiftRequest(req) && (markdown.isMdRequest(res) || markdown.isAcceptsRequest(req))) { return giftLinks.stripGiftAndRedirect(req, res); } // The raw gift token rides the lookup as read context; the API read // verifies it against the entry and unlocks, or rejects the lookup. const giftToken = giftLinks.isGiftRequest(req) ? giftLinks.giftToken(req) : null; let lookup; try { lookup = await dataService.entryLookup(req.path, res.routerOptions, res.locals, {giftToken}); } catch (err) { if (giftLinks.isInvalidGiftTokenError(err)) { return giftLinks.stripGiftAndRedirect(req, res); } throw err; } const entry = lookup ? lookup.entry : false; if (!entry || lookup.isUnknownOption) { debug('no entry or unknown option'); return next(); } if (lookup.isEditURL) { return editRedirect(res, next, entry); } // MUST run before the permalink redirect below: a `.md` path can never // equal the entry's canonical (html) path, so the redirect would always // fire and 301 the request to html, losing the markdown intent. if (markdown.isMdRequest(res)) { return markdown.serveMdRequest(req, res, entry); } if (isPermalinkStale(req, entry)) { debug('redirect'); return urlUtils.redirect301(res, buildCanonicalUrl(req, entry)); } // MUST run after the permalink redirect above: negotiation rides on the // canonical URL, so a stale dated-permalink URL is 301'd to canonical // first, then markdown is served. if (markdown.isAcceptsRequest(req) && markdown.isPublic(entry)) { return markdown.serveAcceptsRequest(res, entry); } if (giftLinks.isGiftRequest(req)) { if (!giftToken) { return giftLinks.stripGiftAndRedirect(req, res); } // Reaching here means the lookup verified the token: the entry is // the unlocked variant. giftLinks.prepareGiftRender(res, giftToken); } return renderer.renderEntry(req, res)(entry); } catch (err) { return renderer.handleError(next)(err); } };