diff --git a/src/modules/lexical/lexical.service.js b/src/modules/lexical/lexical.service.js index d8c3137..4e81654 100644 --- a/src/modules/lexical/lexical.service.js +++ b/src/modules/lexical/lexical.service.js @@ -4,10 +4,11 @@ import { bioConfig } from './ppsl-cd-lexical-shared/src/editors/Bio/config.js' import { defaultTheme, readOnlyTheme } from './ppsl-cd-lexical-shared/src/editors/theme.js' import { entityConfig } from './ppsl-cd-lexical-shared/src/editors/Entity/config.js' import { $isEntityContainerNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityContainer/node.js' -import { $isEntityImageNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityImage/node.js' +import { $isEntityImageNode, EntityImageNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityImage/node.js' import { $isEntityShortDescriptionNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityShortDescription/node.js' import { $isEntityLongDescriptionNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityLongDescription/node.js' import { EntityMentionNode } from './ppsl-cd-lexical-shared/src/editors/plugins/EntityMention/node.js' +import { getIfImageIsNSFW } from '../wikimedia-commons/nsfwFilter.controller.js' const { $getRoot, @@ -98,6 +99,9 @@ function validateEntityEditor (stringifiedJSON) { sanitizeNode(entityEditor, root) // Make sure last child is entity-container + /** + * @type {lexical.ElementNode} + */ const entityContainer = root.getLastChild() if (!$isEntityContainerNode(entityContainer)) { throw new Error(`First child is "${entityContainer.getType()}" and not "entity-container".`) @@ -107,8 +111,11 @@ function validateEntityEditor (stringifiedJSON) { if (root.getChildrenSize() > 1) { throw new Error('Root has too many children.') } - + // Make sure first child of entity-container is entity-image + /** + * @type {EntityImageNode} + */ const entityImage = entityContainer.getFirstChild() if (!$isEntityImageNode(entityImage)) { throw new Error(`First child of "entity-container" is "${entityImage.getType()}" and not "entity-image".`) @@ -128,6 +135,14 @@ function validateEntityEditor (stringifiedJSON) { throw new Error(`Last child of "entity-container" is "${entityImage.getType()}" and not "entity-long-description".`) } + // Make sure the inserted image is not NSFW + const entityNSFWImageURL = new URL(entityImage.getSrc()) + const fileName = `File:${entityNSFWImageURL.pathname.split('/').pop().replace(/_/g, ' ')}` + const request = {query:{image:fileName}} + if (getIfImageIsNSFW(request, null)) { + throw new Error(`Entity Image is NSFW "${entityImage.getSrc()}".`) + } + onlyTextNodes(entityEditor, entityLongDescription.getChildren()) resolve(entityEditor) diff --git a/src/modules/wikimedia-commons/nsfwFilter.controller.js b/src/modules/wikimedia-commons/nsfwFilter.controller.js new file mode 100644 index 0000000..b076dba --- /dev/null +++ b/src/modules/wikimedia-commons/nsfwFilter.controller.js @@ -0,0 +1,35 @@ +import { wikibaseGetEntities, wikidataSparqlQueryDepicts } from './nsfwFilter.service.js' + +/** + * https://commons.m.wikimedia.org/wiki/MediaWiki:Gadget-NSFW.js + * @param {Fastify.Request} request + * @param {Fastify.Reply} reply + */ +export async function getIfImageIsNSFW (request, reply) { + const { image } = request.query + const data = await wikibaseGetEntities(image) + if (data.entities === undefined) { + return + } + const imagesData = [] + for (const pageMid in data.entities) { + const entity = data.entities[pageMid] + if (entity.statements === undefined || entity.statements.P180 === undefined) { + continue + } + imagesData.push({ + depicts: entity.statements.P180.map(function (value) { + return value.mainsnak.datavalue.value.id + }) + }) + } + let topics = imagesData.map(function (imageData) { + return imageData.depicts + }).flat() + topics = topics.filter(function (value, index, self) { + return self.indexOf(value) === index + }) + const sparqlQueryResults = await wikidataSparqlQueryDepicts(topics) + + return !!sparqlQueryResults.results.bindings.length +} diff --git a/src/modules/wikimedia-commons/nsfwFilter.route.js b/src/modules/wikimedia-commons/nsfwFilter.route.js new file mode 100644 index 0000000..a6e7905 --- /dev/null +++ b/src/modules/wikimedia-commons/nsfwFilter.route.js @@ -0,0 +1,18 @@ +import { getIfImageIsNSFW } from './nsfwFilter.controller.js' +import { $ref } from './nsfwFilter.schema.js' + +/** + * @param {Fastify.Instance} fastify + */ +export default async function nsfwFilterRoutes (fastify) { + fastify.get('/nsfwFilter', { + schema: { + querystring: $ref('nsfwInput') + /** + response: { + 200: $ref('ResponseSchema') + } + */ + } + }, getIfImageIsNSFW) +} diff --git a/src/modules/wikimedia-commons/nsfwFilter.schema.js b/src/modules/wikimedia-commons/nsfwFilter.schema.js new file mode 100644 index 0000000..7f59968 --- /dev/null +++ b/src/modules/wikimedia-commons/nsfwFilter.schema.js @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { buildJsonSchemas } from 'fastify-zod' + +export const nsfwInput = z.object({ + image: z.string() +}) + +// Build + +export const { schemas: nsfwFilterSchemas, $ref } = buildJsonSchemas({ + nsfwInput +}, { $id: 'nsfwFilter' }) + +/** + * @typedef {z.infer} NSFWInputSchema + */ diff --git a/src/modules/wikimedia-commons/nsfwFilter.service.js b/src/modules/wikimedia-commons/nsfwFilter.service.js new file mode 100644 index 0000000..434d940 --- /dev/null +++ b/src/modules/wikimedia-commons/nsfwFilter.service.js @@ -0,0 +1,54 @@ +const headers = new Headers({ 'user-agent': 'ppsl-cd' }) +const WIKIMEDIA_API_ENDPOINT = 'https://commons.wikimedia.org/w/api.php' +const nsfwTopics = [ + 'Q291', // pornography + 'Q496', // feces + 'Q608', // human sexual activity + 'Q5880', // vagina + 'Q5887', // orgasm + 'Q9103', // breast + 'Q10791', // nudity + 'Q10816', // sex toy + 'Q40446', // nude + 'Q42165', // buttocks + 'Q124490', // violence + 'Q133993', // erection + 'Q174471', // scrotum + 'Q181001', // erotica + 'Q188641', // nipple + 'Q650891', // glans penis + 'Q673203', // foreskin + 'Q843533', // areola + 'Q844482', // killing + 'Q1058795', // body fluid + 'Q1406501', // labia + 'Q2148678', // sexual penetration + 'Q2192288', // vulva + 'Q3258546', // human anus + 'Q4620674', // sex organ + 'Q11722446' // mons pubis +] + +export async function wikibaseGetEntities (imageTitle) { + const url = new URL(WIKIMEDIA_API_ENDPOINT) + url.searchParams.append('action', 'wbgetentities') + url.searchParams.append('props', 'info|claims') + url.searchParams.append('sites', 'commonswiki') + url.searchParams.append('titles', imageTitle) + url.searchParams.append('format', 'json') + const request = await fetch(url) + return await request.json() +} + +export async function wikidataSparqlQueryDepicts (topics) { + const sparqlQuery = `SELECT ?depicts WHERE { + ?depicts wdt:P31*/wdt:P279* ?nsfw . + VALUES ?depicts {wd:${topics.join(' wd:')}} + VALUES ?nsfw {wd:${nsfwTopics.join(' wd:')}} + }` + const url = new URL('https://query.wikidata.org/sparql') + url.searchParams.append('format', 'json') + url.searchParams.append('query', sparqlQuery) + const request = await fetch(url, { headers }) + return await request.json() +} diff --git a/src/routes.js b/src/routes.js index eaaffd0..f961f83 100644 --- a/src/routes.js +++ b/src/routes.js @@ -3,6 +3,7 @@ import fp from 'fastify-plugin' import userRoutes from './modules/user/user.route.js' import postRoutes from './modules/post/post.route.js' import lexicalRoutes from './modules/lexical/lexical.route.js' +import nsfwFilterRoutes from './modules/wikimedia-commons/nsfwFilter.route.js' /** * @type {import('fastify').FastifyPluginAsync} @@ -11,6 +12,7 @@ const routes = fp(async (fastify) => { fastify.register(userRoutes, { prefix: 'api/users' }) fastify.register(postRoutes, { prefix: 'api/posts' }) fastify.register(lexicalRoutes, { prefix: 'api/lexical' }) + fastify.register(nsfwFilterRoutes, { prefix: 'api' }) }) export default routes diff --git a/src/schemas.js b/src/schemas.js index 0977e7d..eb69197 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -2,12 +2,13 @@ import fp from 'fastify-plugin' import { userSchemas } from './modules/user/user.schema.js' import { postSchemas } from './modules/post/post.schema.js' +import { nsfwFilterSchemas } from './modules/wikimedia-commons/nsfwFilter.schema.js' /** * @type {import('fastify').FastifyPluginAsync} */ const schemas = fp(async (fastify) => { - for (const schema of [...userSchemas, ...postSchemas]) { + for (const schema of [...userSchemas, ...postSchemas, ...nsfwFilterSchemas]) { await fastify.addSchema(schema) } })