src/server/helpers/middleware.ts
/*
* Copyright (C) 2015 Ben Ockmore
* 2015-2016 Sean Burke
* 2021 Akash Gupta
* 2022 Ansh Goyal
* 2023 David Kellner
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import * as commonUtils from '../../common/helpers/utils';
import * as error from '../../common/helpers/error';
import * as utils from '../helpers/utils';
import type {Response as $Response, NextFunction, Request} from 'express';
import {ENTITY_TYPES, getRelationshipTargetBBIDByTypeId} from '../../client/helpers/entity';
import {getWikipediaExtract, selectWikipediaPage} from './wikimedia';
import _ from 'lodash';
import {getAcceptedLanguageCodes} from './i18n';
import {getReviewsFromCB} from './critiquebrainz';
import {getWikidataId} from '../../common/helpers/wikimedia';
import log from 'log';
interface $Request extends Request {
user: any
}
function makeLoader(modelName, propName, sortFunc?, relations = []) {
return async function loaderFunc(req: $Request, res: $Response, next: NextFunction) {
try {
const {orm}: any = req.app.locals;
const model = orm[modelName];
const results = await model.fetchAll({withRelated: [...relations]});
const resultsSerial = results.toJSON();
res.locals[propName] =
sortFunc ? resultsSerial.sort(sortFunc) : resultsSerial;
}
catch (err) {
return next(err);
}
next();
return null;
};
}
export const loadAuthorTypes = makeLoader('AuthorType', 'authorTypes');
export const loadEditionFormats = makeLoader('EditionFormat', 'editionFormats');
export const loadEditionStatuses =
makeLoader('EditionStatus', 'editionStatuses');
export const loadIdentifierTypes =
makeLoader('IdentifierType', 'identifierTypes');
export const loadEditionGroupTypes =
makeLoader('EditionGroupType', 'editionGroupTypes');
export const loadPublisherTypes = makeLoader('PublisherType', 'publisherTypes');
export const loadWorkTypes = makeLoader('WorkType', 'workTypes');
export const loadSeriesOrderingTypes =
makeLoader('SeriesOrderingType', 'seriesOrderingTypes');
export const loadRelationshipTypes =
makeLoader('RelationshipType', 'relationshipTypes', null, ['attributeTypes']);
export const loadParentRelationshipTypes =
makeLoader('RelationshipType', 'parentTypes');
export const loadParentIdentifierTypes =
makeLoader('IdentifierType', 'parentTypes');
export const loadRelationshipAttributeTypes =
makeLoader('RelationshipAttributeType', 'attributeTypes');
export const loadGenders =
makeLoader('Gender', 'genders', (a, b) => a.id > b.id);
export const loadLanguages = makeLoader('Language', 'languages', (a, b) => {
if (a.frequency !== b.frequency) {
return b.frequency - a.frequency;
}
return a.name.localeCompare(b.name);
});
export function loadSeriesItems(req: $Request, res: $Response, next: NextFunction) {
try {
const {entity} = res.locals;
if (entity.dataId) {
const {relationships} = entity;
// Extract the series items from relationships
const seriesItems = _.remove(relationships, (relationship: any) => relationship.typeId > 69 && relationship.typeId < 75);
if (entity.seriesOrderingType.label === 'Manual') {
seriesItems.sort(commonUtils.sortRelationshipOrdinal('position'));
}
else {
seriesItems.sort(commonUtils.sortRelationshipOrdinal('number'));
}
const formattedSeriesItems = seriesItems.map((item) => (
{...item.source, displayNumber: true,
number: item.number,
position: item.position}
));
res.locals.entity.seriesItems = formattedSeriesItems;
}
else {
res.locals.entity.seriesItems = [];
}
return next();
}
catch (err) {
return next(err);
}
}
export async function loadWorkTableAuthors(req: $Request, res: $Response, next: NextFunction) {
const {orm}: any = req.app.locals;
const {entity} = res.locals;
try {
const workBBIDs = getRelationshipTargetBBIDByTypeId(entity, 10);
const authorsData = await orm.func.work.loadAuthorNames(orm, workBBIDs);
const authorsDataGroupedByWorkBBID = _.groupBy(authorsData, 'workbbid');
entity.authorsData = authorsDataGroupedByWorkBBID;
}
catch (err) {
return next(err);
}
return next();
}
/**
* Add the relationships on entity object.
*
* @param {Object} entity - The entity to load the relationships for.
* @param {Object} relationshipSet - The RelationshipSet model.
* @param {Object} orm - The ORM instance.
*/
export async function addRelationships(entity, relationshipSet, orm) {
async function getEntityWithAlias(relEntity) {
const redirectBbid = await orm.func.entity.recursivelyGetRedirectBBID(orm, relEntity.bbid, null);
const model = commonUtils.getEntityModelByType(orm, relEntity.type);
return model.forge({bbid: redirectBbid})
.fetch({require: false, withRelated: ['defaultAlias'].concat(utils.getAdditionalRelations(relEntity.type))});
}
entity.relationships = relationshipSet ?
relationshipSet.related('relationships').toJSON() : [];
utils.attachAttributes(entity.relationships);
/**
* Source and target are generic Entity objects, so until we have
* a good way of polymorphically fetching the right specific entity,
* we need to fetch default alias in a somewhat sketchier way.
*/
const relationshipPromises = entity.relationships.map(async (relationship) => {
const [source, target] = await Promise.all([getEntityWithAlias(relationship.source), getEntityWithAlias(relationship.target)]);
relationship.source = source.toJSON();
relationship.target = target.toJSON();
return relationship;
});
const relationships = await Promise.all(relationshipPromises);
return relationships;
}
export async function loadEntityRelationships(req: $Request, res: $Response, next: NextFunction) {
const {orm}: any = req.app.locals;
const {RelationshipSet} = orm;
const {entity} = res.locals;
try {
if (!entity) {
throw new error.SiteError('Failed to load entity');
}
const relationshipSet = await RelationshipSet.forge({id: entity.relationshipSetId})
.fetch({
require: false,
withRelated: [
'relationships.source',
'relationships.target',
'relationships.type.attributeTypes',
'relationships.attributeSet.relationshipAttributes.value',
'relationships.attributeSet.relationshipAttributes.type'
]
});
await addRelationships(entity, relationshipSet, orm);
}
catch (err) {
return next(err);
}
return next();
}
export async function loadWikipediaExtract(req: $Request, res: $Response, next: NextFunction) {
const {entity} = res.locals;
if (!entity) {
return next(new error.SiteError('Failed to load entity'));
}
const wikidataId = getWikidataId(entity);
if (!wikidataId) {
return next();
}
// try to use the user's browser languages, fallback to English and alias languages
const browserLanguages = getAcceptedLanguageCodes(req);
const aliasLanguages = commonUtils.getAliasLanguageCodes(entity);
const preferredLanguages = browserLanguages.concat('en', aliasLanguages);
try {
// only pre-load Wikipedia extracts which are already cached
const article = await selectWikipediaPage(wikidataId, {forceCache: true, preferredLanguages});
if (article) {
const extract = await getWikipediaExtract(article, {forceCache: true});
if (extract) {
res.locals.wikipediaExtract = {article, ...extract};
}
}
}
catch (err) {
log.warning(`Failed to pre-load Wikipedia extract for ${wikidataId}: ${err.message}`);
}
return next();
}
export function checkValidRevisionId(req: $Request, res: $Response, next: NextFunction, id: string) {
const idToNumber = _.toNumber(id);
if (!_.isInteger(idToNumber) || (_.isInteger(idToNumber) && idToNumber <= 0)) {
return next(new error.BadRequestError(`Invalid revision id: ${req.params.id}`, req));
}
return next();
}
export function checkValidTypeId(req: $Request, res: $Response, next: NextFunction, id: string) {
const idToNumber = _.toNumber(id);
if (!_.isInteger(idToNumber) || idToNumber <= 0) {
return next(new error.BadRequestError(`Invalid Type id: ${req.params.id}`, req));
}
return next();
}
export function checkValidEntityType(req: $Request, res: $Response, next: NextFunction, entityType: string) {
const entityTypes = ENTITY_TYPES.map(entity => _.snakeCase(entity));
if (!_.includes(entityTypes, entityType)) {
return next(new error.BadRequestError(`Invalid Entity Type: ${_.startCase(entityType)}`, req));
}
return next();
}
export async function redirectedBbid(req: $Request, res: $Response, next: NextFunction, bbid: string) {
if (!commonUtils.isValidBBID(bbid)) {
return next(new error.BadRequestError(`Invalid bbid: ${req.params.bbid}`, req));
}
const {orm}: any = req.app.locals;
try {
const redirectBbid = await orm.func.entity.recursivelyGetRedirectBBID(orm, bbid);
if (redirectBbid !== bbid) {
// res.location(`${req.baseUrl}/${redirectBbid}`);
return res.redirect(301, `${req.baseUrl}${req.path.replace(bbid, redirectBbid)}`);
}
}
catch (err) {
return next(err);
}
return next();
}
export function makeEntityLoader(modelName: string, additionalRels: Array<string>, errMessage: string) {
const relations = [
'aliasSet.aliases.language',
'annotation.lastRevision',
'defaultAlias',
'disambiguation',
'identifierSet.identifiers.type',
'relationshipSet.relationships.type',
'revision.revision',
'collections.owner'
].concat(additionalRels);
return async (req: $Request, res: $Response, next: NextFunction, bbid: string) => {
const {orm}: any = req.app.locals;
if (commonUtils.isValidBBID(bbid)) {
try {
const entity = await orm.func.entity.getEntity(orm, modelName, bbid, relations);
if (!entity.dataId) {
entity.deleted = true;
entity.parentAlias = await orm.func.entity.getEntityParentAlias(
orm, modelName, bbid
);
}
if (entity.collections) {
entity.collections = entity.collections.filter(collection => collection.public === true ||
parseInt(collection.ownerId, 10) === parseInt(req.user?.id, 10));
}
const reviews = await getReviewsFromCB(bbid, entity.type);
entity.reviews = reviews;
res.locals.entity = entity;
return next();
}
catch (err) {
return next(new error.NotFoundError(errMessage, req));
}
}
else {
return next(new error.BadRequestError('Invalid BBID', req));
}
};
}
export function makeCollectionLoader() {
return async (req, res, next, collectionId) => {
const {UserCollection} = req.app.locals.orm;
if (commonUtils.isValidBBID(collectionId)) {
try {
const collection = await new UserCollection({id: collectionId}).fetch({
require: true,
withRelated: ['collaborators.collaborator', 'items', 'owner']
});
const collectionJSON = collection.toJSON();
// reshaping collaborators such that it can be used in EntitySearchFieldOption
collectionJSON.collaborators = collectionJSON.collaborators.map((collaborator) => ({
id: collaborator.collaborator.id,
text: collaborator.collaborator.name
}));
res.locals.collection = collectionJSON;
return next();
}
catch (err) {
return next(new error.NotFoundError('Collection Not Found', req));
}
}
else {
return next(new error.BadRequestError('Invalid Collection ID', req));
}
};
}
export async function validateCollectionParams(req, res, next) {
const {collaborators = [], name, entityType} = req.body;
const {orm} = req.app.locals;
const {Editor} = orm;
if (!_.trim(name).length) {
return next(new error.BadRequestError('Invalid collection name: Empty string not allowed', req));
}
const entityTypes = _.keys(commonUtils.getEntityModels(orm));
if (!entityTypes.includes(_.upperFirst(_.camelCase(entityType)))) {
return next(new error.BadRequestError(`Invalid entity type: ${entityType} does not exist`, req));
}
const collaboratorIds = collaborators.map(collaborator => collaborator.id);
for (let i = 0; i < collaboratorIds.length; i++) {
const collaboratorId = collaboratorIds[i];
if (!(/^\d+$/).test(collaboratorId)) {
return next(new error.BadRequestError(`Invalid collaborator id: ${collaboratorId} not valid`, req));
}
}
const editors = await new Editor().where('id', 'in', collaboratorIds).fetchAll({require: false});
const editorsJSON = editors.toJSON();
for (let i = 0; i < collaboratorIds.length; i++) {
const collaboratorId = collaboratorIds[i];
if (!editorsJSON.find(editor => editor.id === collaboratorId)) {
return next(new error.NotFoundError(`Collaborator ${collaboratorId} does not exist`, req));
}
}
return next();
}
export async function validateBBIDsForCollectionAdd(req, res, next) {
const {Entity} = req.app.locals.orm;
const {bbids = []} = req.body;
if (!bbids.length) {
return next(new error.BadRequestError('BBIDs array is empty'));
}
const {collection} = res.locals;
const collectionType = collection.entityType;
for (let i = 0; i < bbids.length; i++) {
const bbid = bbids[i];
if (!commonUtils.isValidBBID(bbid)) {
return next(new error.BadRequestError(`Invalid BBID ${bbid}`, req));
}
}
const entities = await new Entity().where('bbid', 'in', bbids).fetchAll({require: false});
const entitiesJSON = entities.toJSON();
for (let i = 0; i < bbids.length; i++) {
const bbid = bbids[i];
const entity = entitiesJSON.find(currEntity => currEntity.bbid === bbid);
if (!entity) {
return next(new error.NotFoundError(`${collectionType} ${bbid} does not exist`, req));
}
if (_.lowerCase(entity.type) !== _.lowerCase(collectionType)) {
return next(new error.BadRequestError(`Cannot add an entity of type ${entity.type} to a collection of type ${collectionType}`));
}
}
return next();
}
export function validateBBIDsForCollectionRemove(req, res, next) {
const {bbids = []} = req.body;
if (!bbids.length) {
return next(new error.BadRequestError('BBIDs array is empty'));
}
const {collection} = res.locals;
for (let i = 0; i < bbids.length; i++) {
const bbid = bbids[i];
if (!commonUtils.isValidBBID(bbid)) {
return next(new error.BadRequestError(`Invalid BBID ${bbid}`, req));
}
}
for (let i = 0; i < bbids.length; i++) {
const bbid = bbids[i];
const isBbidInCollection = collection.items.find(item => item.bbid === bbid);
if (!isBbidInCollection) {
return next(new error.BadRequestError(`Entity ${bbid} is not in collection ${collection.id}`, req));
}
}
return next();
}
export function validateCollaboratorIdsForCollectionRemove(req, res, next) {
const {collaboratorIds = []} = req.body;
if (!collaboratorIds.length) {
return next(new error.BadRequestError('CollaboratorIds array is empty'));
}
const {collection} = res.locals;
for (let i = 0; i < collaboratorIds.length; i++) {
const collaboratorId = collaboratorIds[i];
const isCollaborator = collection.collaborators.find(collaborator => collaborator.id === collaboratorId);
if (!isCollaborator) {
return next(new error.BadRequestError(`User ${collaboratorId} is not a collaborator of collection ${collection.id}`, req));
}
}
return next();
}