├── .nvmrc ├── nodemon.json ├── routes ├── __utils__ │ ├── serialize.js │ └── path.js ├── __helpers__ │ ├── jsonify.js │ └── setup.js ├── __swagger__ │ └── definitions.js ├── __snapshots__ │ ├── attribution.integration.test.js.snap │ ├── licenses.test.js.snap │ ├── fileinfo.test.js.snap │ ├── files.test.js.snap │ └── attribution.test.js.snap ├── info.test.js ├── info.js ├── files.js ├── fileinfo.js ├── licenses.js ├── fileinfo.integration.test.js ├── licenses.test.js ├── licenses.integration.test.js ├── fileinfo.test.js ├── files.test.js ├── attribution.js ├── attribution.integration.test.js └── attribution.test.js ├── jest.config.js ├── scripts ├── gen-secret └── gen-apidoc ├── .editorconfig ├── config ├── boot.js ├── tracker.js ├── secret.js ├── server.js ├── environment.js ├── swagger.js ├── services.js ├── jest │ └── vcrNode.js ├── logging.js └── licenses │ └── portReferences.js ├── jest.setup.js ├── services ├── util │ ├── errors.js │ ├── serializers.js │ ├── translate.js │ ├── client.js │ ├── parseWikiUrl.js │ ├── client.test.js │ ├── parseWikiUrl.test.js │ └── serializers.test.js ├── __fixtures__ │ ├── imageTitlesMissing.js │ ├── templatesMissing.js │ ├── imageTitles.js │ ├── imagesInfo.js │ ├── templates.js │ ├── imageInfoWithoutAttribution.js │ ├── imageInfoWithoutArtist.js │ ├── imageInfoWithoutNormalization.js │ └── imageInfo.js ├── tracker.js ├── __snapshots__ │ └── files.test.js.snap ├── licenses.js ├── __test__ │ └── compatibleCases.js ├── tracker.test.js ├── files.js ├── licenses.test.js ├── fileData.js ├── files.integration.test.js ├── files.test.js ├── licenseStore.js ├── htmlSanitizer.js ├── fileData.test.js ├── licenseStore.test.js └── fileData.integration.test.js ├── .travis.yml ├── .env.test ├── __helpers__ ├── licenseFactory.js ├── fileDataFactory.js └── attributionFactory.js ├── .gitignore ├── console.js ├── .eslintrc.json ├── start.js ├── models ├── license.js ├── license.test.js ├── attribution.js └── attribution.test.js ├── server.js ├── package.json ├── README.md └── openapi.yaml /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.14.2 2 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["*.test.js"] 3 | } 4 | -------------------------------------------------------------------------------- /routes/__utils__/serialize.js: -------------------------------------------------------------------------------- 1 | module.exports = serializer => records => serializer.serialize(records); 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: '/jest.setup.js', 3 | testEnvironment: '/config/jest/vcrNode', 4 | }; 5 | -------------------------------------------------------------------------------- /routes/__utils__/path.js: -------------------------------------------------------------------------------- 1 | function path(prefix) { 2 | return function append(suffix) { 3 | return prefix + suffix; 4 | }; 5 | } 6 | 7 | module.exports = path; 8 | -------------------------------------------------------------------------------- /scripts/gen-secret: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const crypto = require('crypto'); 4 | 5 | const secret = crypto.randomBytes(64); 6 | process.stdout.write(secret.toString('hex')); 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /config/boot.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const path = require('path'); 3 | 4 | // Read .env file in project root if present 5 | dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); 6 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const path = require('path'); 3 | 4 | async function setup() { 5 | dotenv.config({ path: path.resolve(__dirname, '.env.test') }); 6 | } 7 | 8 | module.exports = setup; 9 | -------------------------------------------------------------------------------- /config/tracker.js: -------------------------------------------------------------------------------- 1 | const Tracker = require('../services/tracker'); 2 | 3 | const matomoUrl = process.env.MATOMO_URL; 4 | const matomoSiteId = process.env.MATOMO_SITE_ID; 5 | 6 | module.exports = new Tracker(matomoUrl, matomoSiteId); 7 | -------------------------------------------------------------------------------- /services/util/errors.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | invalidUrl: 'invalid-url', 3 | apiUnavailabe: 'api-unavailable', 4 | emptyResponse: 'empty-response', 5 | validationError: 'validation-error', 6 | licenseNotFound: 'license-not-found', 7 | }; 8 | -------------------------------------------------------------------------------- /config/secret.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | // Configure the app secret used for signing JSON Web Tokens. 4 | const secret = process.env.SECRET; 5 | assert.ok(secret, 'No application secret provided, set the "SECRET" env variable'); 6 | 7 | module.exports = secret; 8 | -------------------------------------------------------------------------------- /services/__fixtures__/imageTitlesMissing.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'Article_Title', 5 | to: 'Article Title', 6 | }, 7 | ], 8 | pages: { 9 | 848165: { 10 | pageid: 848165, 11 | ns: 0, 12 | title: 'Article Title', 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | yarn: true 4 | install: yarn install 5 | script: 6 | # Run linting and execute tests 7 | - yarn lint 8 | - yarn sequentialtest 9 | # Set up environment 10 | - cp .env.test .env 11 | # Ensure generated API specification is up-to-date 12 | - scripts/gen-apidoc > openapi.yaml && git diff --quiet --exit-code HEAD openapi.yaml 13 | -------------------------------------------------------------------------------- /config/server.js: -------------------------------------------------------------------------------- 1 | // Configure the server host. 2 | const host = 'localhost'; 3 | 4 | // Configure the TCP port to bind to. 5 | const port = parseInt(process.env.PORT, 10) || 8080; 6 | 7 | // Conditionally enable debugging output (if "DEBUG=true"). 8 | const debug = 9 | process.env.DEBUG === 'true' ? { log: '*', request: '*' } : { request: ['implementation'] }; 10 | 11 | module.exports = { host, port, debug }; 12 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | // Load application config 2 | const logging = require('./logging'); 3 | // const secret = require('./secret'); 4 | const server = require('./server'); 5 | const services = require('./services'); 6 | const swagger = require('./swagger'); 7 | const tracker = require('./tracker'); 8 | 9 | module.exports = { 10 | logging, 11 | // secret, 12 | server, 13 | services, 14 | swagger, 15 | tracker, 16 | }; 17 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Logging 2 | LOG=false 3 | 4 | # Application secret - used for signing and verifying JSON Web Tokens 5 | SECRET=shhhhhh 6 | 7 | # Service configurations 8 | SERVICES={} 9 | 10 | # Host name used in the swagger API docs 11 | API_DOCS_HOST=localhost:8080 12 | 13 | # Optional base path to prepend /docs URL of swagger API docs 14 | #API_DOCS_BASE_PATH=/api 15 | 16 | # Matomo config 17 | MATOMO_URL=http://matomo/path/matomo.php 18 | MATOMO_SITE_ID=1 19 | -------------------------------------------------------------------------------- /services/__fixtures__/templatesMissing.js: -------------------------------------------------------------------------------- 1 | // An example response where the whole "templates" section is missing. 2 | // See "services/__fixtures__/templates.js" for the expected payload. 3 | module.exports = { 4 | normalized: [ 5 | { 6 | from: 'File:Apple_Lisa.jpg', 7 | to: 'File:Apple Lisa.jpg', 8 | }, 9 | ], 10 | pages: { 11 | '18061': { 12 | pageid: 18061, 13 | ns: 6, 14 | title: 'File:Apple Lisa.jpg', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /__helpers__/licenseFactory.js: -------------------------------------------------------------------------------- 1 | const License = require('../models/license'); 2 | 3 | function licenseFactory({ 4 | id = 'cc-by-sa-3.0', 5 | name = 'CC BY-SA 3.0', 6 | groups = ['cc', 'cc4'], 7 | compatibility = [], 8 | regexp = /^(Bild-)?CC-BY-SA(-|\/)4.0(([^-]+.+|-migrated)*)?$/i, 9 | url = 'https://creativecommons.org/licenses/by-sa/3.0/legalcode', 10 | }) { 11 | return new License({ id, name, url, groups, compatibility, regexp }); 12 | } 13 | 14 | module.exports = licenseFactory; 15 | -------------------------------------------------------------------------------- /routes/__helpers__/jsonify.js: -------------------------------------------------------------------------------- 1 | // Round-trip a JavaScript value through `JSON.stringify()` and `JSON.parse()` 2 | // in order to compare it against server responses. 3 | // 4 | // Certain value types would otherwise result in failed comparisons. 5 | // Date objects for instance are serialized into strings in JSON. 6 | // However, strings in JSON are never parsed back into Date objects. 7 | 8 | function jsonify(value) { 9 | return JSON.parse(JSON.stringify(value)); 10 | } 11 | 12 | module.exports = jsonify; 13 | -------------------------------------------------------------------------------- /services/__fixtures__/imageTitles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'Article_Title', 5 | to: 'Article Title', 6 | }, 7 | ], 8 | pages: { 9 | 848165: { 10 | pageid: 848165, 11 | ns: 0, 12 | title: 'Article Title', 13 | images: [ 14 | { 15 | ns: 6, 16 | title: 'File:Graphic 01.jpg', 17 | }, 18 | { 19 | ns: 6, 20 | title: 'File:logo.svg', 21 | }, 22 | ], 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Coverage files and directory used by tools like Istanbul 8 | lib-cov 9 | coverage 10 | .nyc_output 11 | 12 | # Dependency directories 13 | node_modules/ 14 | 15 | # Optional npm cache directory 16 | .npm 17 | 18 | # Optional ESLint cache 19 | .eslintcache 20 | 21 | # Optional REPL history 22 | .node_repl_history 23 | .console_history 24 | 25 | # Yarn integrity file 26 | .yarn-integrity 27 | 28 | # Environment variables file 29 | .env 30 | 31 | # System- and editor-specific files 32 | .DS_Store 33 | .vscode 34 | -------------------------------------------------------------------------------- /scripts/gen-apidoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yaml = require('js-yaml'); 4 | 5 | require('../config/boot'); 6 | 7 | const environment = require('../config/environment'); 8 | const init = require('../server'); 9 | 10 | async function main() { 11 | const server = await init({ ...environment, logging: false }); 12 | const response = await server.inject('/swagger'); 13 | 14 | const spec = JSON.parse(response.payload); 15 | 16 | const options = { sortKeys: true }; 17 | const output = yaml.safeDump(spec, options).trim(); 18 | 19 | console.log(output); // eslint-disable-line no-console 20 | 21 | await server.stop(); 22 | } 23 | 24 | main(); 25 | -------------------------------------------------------------------------------- /__helpers__/fileDataFactory.js: -------------------------------------------------------------------------------- 1 | function fileDataFactory({ 2 | artistHtml = 'Rama & Musée Bolo', 3 | attributionHtml = 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 4 | mediaType = 'BITMAP', 5 | rawUrl = 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 6 | title = 'File:Apple_Lisa2-IMG_1517.jpg', 7 | wikiUrl = 'https://commons.wikimedia.org/', 8 | }) { 9 | return { 10 | artistHtml, 11 | attributionHtml, 12 | mediaType, 13 | rawUrl, 14 | title, 15 | wikiUrl, 16 | }; 17 | } 18 | 19 | module.exports = fileDataFactory; 20 | -------------------------------------------------------------------------------- /routes/__swagger__/definitions.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const errors = { 4 | 400: Joi.object() 5 | .description('Bad Request') 6 | .meta({ className: 'BadRequestErrorResponse' }), 7 | 401: Joi.object() 8 | .description('Unauthorized') 9 | .meta({ className: 'UnauthorizedErrorResponse' }), 10 | 422: Joi.object() 11 | .description('Unprocessable Entity') 12 | .meta({ className: 'UnprocessableEntityErrorResponse' }), 13 | 500: Joi.object() 14 | .description('Internal Server Error') 15 | .meta({ className: 'InternalServerError' }), 16 | 503: Joi.object() 17 | .description('Service Unavailable') 18 | .meta({ className: 'ServiceUnavailableErrorResponse' }), 19 | }; 20 | 21 | module.exports = { errors }; 22 | -------------------------------------------------------------------------------- /config/swagger.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../package.json'); 2 | 3 | const config = { 4 | host: process.env.API_DOCS_HOST, 5 | 6 | // Overall API description 7 | info: { 8 | description: pkg.description, 9 | title: pkg.name, 10 | version: pkg.version, 11 | }, 12 | 13 | // Specify content types 14 | consumes: ['application/json'], 15 | produces: ['application/json'], 16 | 17 | // URI schemes 18 | schemes: ['http', 'https'], 19 | 20 | // Security definitions 21 | securityDefinitions: {}, 22 | 23 | // Configuration keys specific to "hapi-swaggered" 24 | requiredTags: [], 25 | auth: false, 26 | }; 27 | 28 | if (process.env.API_DOCS_BASE_PATH) { 29 | config.basePath = process.env.API_DOCS_BASE_PATH; 30 | } 31 | 32 | module.exports = config; 33 | -------------------------------------------------------------------------------- /console.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const repl = require('repl'); 3 | const history = require('repl.history'); 4 | 5 | const pkg = require('./package.json'); 6 | 7 | require('./config/boot'); 8 | 9 | const environment = require('./config/environment'); 10 | const init = require('./server'); 11 | 12 | async function main() { 13 | const motd = `${pkg.name}, ${pkg.version}`; 14 | 15 | console.log(motd); // eslint-disable-line no-console 16 | 17 | const server = await init({ ...environment, logging: false }); 18 | 19 | const r = repl.start({}); 20 | history(r, path.resolve(__dirname, '.console_history')); 21 | 22 | Object.assign(r.context, { server, ...server.app }); 23 | 24 | r.on('exit', () => { 25 | server.stop(); 26 | }); 27 | } 28 | 29 | main(); 30 | -------------------------------------------------------------------------------- /routes/__snapshots__/attribution.integration.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`attribution routes GET /attribution/... (unmodified) returns the attribution 1`] = ` 4 | Object { 5 | "attributionHtml": "Kaldari, Foobar, CC0 1.0", 6 | "attributionPlain": "Kaldari (https://upload.wikimedia.org/wikipedia/commons/3/3a/Foobar.jpg), „Foobar“, https://creativecommons.org/publicdomain/zero/1.0/legalcode", 7 | "licenseId": "cc-zero", 8 | "licenseUrl": "https://creativecommons.org/publicdomain/zero/1.0/legalcode", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /routes/info.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | const pkg = require('../package.json'); 3 | 4 | describe('info routes', () => { 5 | let context; 6 | 7 | beforeEach(async () => { 8 | context = await setup({}); 9 | }); 10 | 11 | afterEach(async () => { 12 | await context.destroy(); 13 | }); 14 | 15 | describe('GET /info', () => { 16 | async function subject() { 17 | const options = { url: '/info', method: 'GET' }; 18 | return context.inject(options); 19 | } 20 | 21 | it('returns API status information', async () => { 22 | const response = await subject({}); 23 | expect(response.status).toBe(200); 24 | expect(response.type).toBe('application/json'); 25 | expect(response.payload.version).toBe(pkg.version); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /services/tracker.js: -------------------------------------------------------------------------------- 1 | const MatomoTracker = require('matomo-tracker'); 2 | 3 | function doNotTrack(request) { 4 | if ('dnt' in request.headers && request.headers.dnt === '1') { 5 | return true; 6 | } 7 | if ('DNT' in request.headers && request.headers.DNT === '1') { 8 | return true; 9 | } 10 | return false; 11 | } 12 | 13 | class Tracker { 14 | constructor(matomoUrl, matomoSiteId) { 15 | this.matomo = new MatomoTracker(matomoSiteId, matomoUrl); 16 | } 17 | 18 | track(request, actionName) { 19 | if (doNotTrack(request)) { 20 | return; 21 | } 22 | 23 | const url = `${request.server.info.protocol}://${request.info.host}${request.url.path}`; 24 | 25 | this.matomo.track({ 26 | url, 27 | action_name: actionName, 28 | }); 29 | } 30 | } 31 | 32 | module.exports = Tracker; 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest": true 5 | }, 6 | "globals": { 7 | "startRecording": true, 8 | "stopRecording": true 9 | }, 10 | "extends": [ 11 | "airbnb-base", 12 | "plugin:prettier/recommended", 13 | "plugin:jest/recommended" 14 | ], 15 | "rules": { 16 | "import/no-extraneous-dependencies": [ 17 | "error", 18 | { 19 | "devDependencies": [ 20 | "config/jest/*.js", 21 | "**/*.test.{js,jsx}", 22 | "**/jest.config.js", 23 | "**/jest.setup.js", 24 | "scripts/*" 25 | ] 26 | } 27 | ], 28 | "prettier/prettier": [ 29 | "error", 30 | { 31 | "printWidth": 100, 32 | "singleQuote": true, 33 | "trailingComma": "es5" 34 | } 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | // Initialize the environment 2 | require('./config/boot'); 3 | 4 | // Read server environment configuration 5 | const environment = require('./config/environment'); 6 | 7 | // Load server initialization code 8 | const init = require('./server'); 9 | 10 | async function start() { 11 | // Create a server instance. 12 | const server = await init(environment); 13 | 14 | // Abort mission on uncaught exceptions. 15 | process.once('uncaughtException', error => { 16 | server.log('error', error); 17 | process.exit(1); 18 | }); 19 | 20 | // Abort mission on unhandled promise rejections. 21 | process.on('unhandledRejection', error => { 22 | throw error; 23 | }); 24 | 25 | // Launch… 26 | await server.start(); 27 | 28 | // …and we have liftoff. 29 | server.log('info', { start: server.info }); 30 | } 31 | 32 | start(); 33 | -------------------------------------------------------------------------------- /services/util/serializers.js: -------------------------------------------------------------------------------- 1 | function license(licenseObject) { 2 | const { id: code, name, url, groups } = licenseObject; 3 | return { code, name, url, groups }; 4 | } 5 | 6 | function attribution(attributionObject) { 7 | const { id: licenseId, url: licenseUrl } = attributionObject.license || {}; 8 | return { 9 | licenseId, 10 | licenseUrl, 11 | attributionHtml: attributionObject.html ? attributionObject.html() : undefined, 12 | attributionPlain: attributionObject.plainText ? attributionObject.plainText() : undefined, 13 | }; 14 | } 15 | 16 | function fileinfo({ artistHtml, attributionHtml, mediaType }, licenseParam) { 17 | return { 18 | license: license(licenseParam), 19 | authorHtml: artistHtml, 20 | attributionHtml, 21 | mediaType, 22 | }; 23 | } 24 | 25 | module.exports = { 26 | license, 27 | attribution, 28 | fileinfo, 29 | }; 30 | -------------------------------------------------------------------------------- /models/license.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | function validationError(attribute) { 4 | return `License: Invalid "${attribute}" provided`; 5 | } 6 | 7 | function validateParams({ id, name, groups, compatibility, regexp, url }) { 8 | assert(typeof id === 'string' && id.length > 0, validationError('id')); 9 | assert(typeof name === 'string' && name.length > 0, validationError('name')); 10 | assert(Array.isArray(groups), validationError('groups')); 11 | assert(Array.isArray(compatibility), validationError('compatibility')); 12 | assert(regexp instanceof RegExp, validationError('regexp')); 13 | assert(!url || typeof url === 'string', validationError('url')); 14 | } 15 | 16 | class License { 17 | constructor(params) { 18 | validateParams(params); 19 | Object.assign(this, params); 20 | } 21 | 22 | match(value) { 23 | return this.regexp.test(value); 24 | } 25 | 26 | isInGroup(groupId) { 27 | return this.groups.includes(groupId); 28 | } 29 | } 30 | 31 | module.exports = License; 32 | -------------------------------------------------------------------------------- /routes/info.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const pkg = require('../package.json'); 4 | 5 | const prefix = require('./__utils__/path')('/info'); 6 | 7 | const routes = []; 8 | 9 | routes.push({ 10 | path: prefix(''), 11 | method: 'GET', 12 | options: { 13 | auth: false, 14 | description: 'Get information', 15 | notes: 'Get information on the API.', 16 | validate: {}, 17 | response: { 18 | schema: Joi.object({ 19 | version: Joi.string() 20 | .required() 21 | .example('1.0.0'), 22 | }) 23 | .required() 24 | .meta({ className: 'InfoShowResponse' }), 25 | }, 26 | plugins: { 27 | 'hapi-swaggered': { 28 | operationId: 'info.show', 29 | }, 30 | }, 31 | }, 32 | handler: async (request, h) => { 33 | const { tracker } = request.server.app; 34 | const { version } = pkg; 35 | tracker.track(request, 'API Info'); 36 | return h.response({ version }); 37 | }, 38 | }); 39 | 40 | module.exports = routes; 41 | -------------------------------------------------------------------------------- /config/services.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const LicenseStore = require('../services/licenseStore'); 4 | const Client = require('../services/util/client'); 5 | const Files = require('../services/files'); 6 | const FileData = require('../services/fileData'); 7 | const Licenses = require('../services/licenses'); 8 | 9 | const licenses = require('./licenses/licenses'); 10 | const portReferences = require('./licenses/portReferences'); 11 | 12 | // Read service configurations from environment. 13 | const config = JSON.parse(process.env.SERVICES); 14 | 15 | assert.ok(typeof config === 'object', 'Invalid services configuration provided'); 16 | 17 | // Create configured service instances. 18 | const client = new Client(); 19 | const licenseStore = new LicenseStore(licenses, portReferences); 20 | 21 | const services = { 22 | licenseStore, 23 | files: new Files({ client }), 24 | fileData: new FileData({ client }), 25 | licenses: new Licenses({ client, licenseStore }), 26 | }; 27 | 28 | module.exports = services; 29 | -------------------------------------------------------------------------------- /__helpers__/attributionFactory.js: -------------------------------------------------------------------------------- 1 | const licenseFactory = require('./licenseFactory'); 2 | const { Attribution } = require('../models/attribution'); 3 | 4 | function attributionFactory({ 5 | fileInfo = { 6 | rawUrl: 'https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg', 7 | title: 'File:Eisklettern kl engstligenfall.jpg', 8 | normalizedTitle: 'File:Eisklettern kl engstligenfall.jpg', 9 | artistHtml: 10 | 'Bernhard', 11 | attributionHtml: null, 12 | }, 13 | typeOfUse = 'online', 14 | languageCode = 'de', 15 | license = licenseFactory({}), 16 | modification = null, 17 | modificationAuthor = null, 18 | isEdited = false, 19 | }) { 20 | return new Attribution({ 21 | fileInfo, 22 | typeOfUse, 23 | languageCode, 24 | license, 25 | modification, 26 | modificationAuthor, 27 | isEdited, 28 | }); 29 | } 30 | 31 | module.exports = attributionFactory; 32 | -------------------------------------------------------------------------------- /services/__snapshots__/files.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Files getPageImages() with a valid wikipedia url returns a list of all images from the given article 1`] = ` 4 | Array [ 5 | Object { 6 | "descriptionUrl": "https://commons.wikimedia.org/wiki/File:Graphic_01.jpg", 7 | "fileSize": 112450, 8 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/e/ef/Graphic_01.jpg", 9 | "thumbnail": Object { 10 | "height": 300, 11 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Graphic_01.jpg/300px-Graphic_01.jpg", 12 | "width": 300, 13 | }, 14 | "title": "File:Graphic 01.jpg", 15 | }, 16 | Object { 17 | "descriptionUrl": "https://commons.wikimedia.org/wiki/File:logo.svg", 18 | "fileSize": 112450, 19 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/4/4a/logo.svg", 20 | "thumbnail": Object { 21 | "height": 300, 22 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/logo.svg/300px-logo.svg", 23 | "width": 300, 24 | }, 25 | "title": "File:logo.svg", 26 | }, 27 | ] 28 | `; 29 | -------------------------------------------------------------------------------- /config/jest/vcrNode.js: -------------------------------------------------------------------------------- 1 | const NodeEnvironment = require('jest-environment-node'); 2 | const { Polly } = require('@pollyjs/core'); 3 | const NodeHttpAdapter = require('@pollyjs/adapter-node-http'); 4 | const FSPersister = require('@pollyjs/persister-fs'); 5 | 6 | Polly.register(NodeHttpAdapter); 7 | Polly.register(FSPersister); 8 | 9 | let polly = null; 10 | 11 | async function stopRecording() { 12 | if (polly) { 13 | await polly.stop(); 14 | polly = null; 15 | } 16 | } 17 | 18 | function startRecording(recordingName) { 19 | if (polly) { 20 | throw new Error('a recording is already running'); 21 | } 22 | 23 | polly = new Polly(recordingName, { 24 | adapters: ['node-http'], 25 | persister: 'fs', 26 | persisterOptions: { 27 | fs: { 28 | recordingsDir: '__recordings__', 29 | }, 30 | }, 31 | recordIfMissing: true, 32 | }); 33 | 34 | return polly; 35 | } 36 | 37 | class VcrNode extends NodeEnvironment { 38 | async setup() { 39 | await super.setup(); 40 | this.global.startRecording = startRecording; 41 | this.global.stopRecording = stopRecording; 42 | } 43 | } 44 | 45 | module.exports = VcrNode; 46 | -------------------------------------------------------------------------------- /services/licenses.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const errors = require('./util/errors'); 4 | 5 | function normalizeTemplateTitle(template) { 6 | const { title } = template; 7 | return title.replace(/^Template:/, ''); 8 | } 9 | 10 | function formatPageTemplateTitles(response) { 11 | assert.ok(response.pages, errors.emptyResponse); 12 | const pages = Object.values(response.pages); 13 | assert.ok(pages.length === 1); 14 | const { templates = [] } = pages[0]; 15 | return templates.map(normalizeTemplateTitle); 16 | } 17 | 18 | async function getPageTemplates({ client, title, wikiUrl }) { 19 | const params = { tlnamespace: 10, tllimit: 500 }; 20 | const response = await client.getResultsFromApi([title], 'templates', wikiUrl, params); 21 | return formatPageTemplateTitles(response); 22 | } 23 | 24 | class Licences { 25 | constructor({ client, licenseStore }) { 26 | this.client = client; 27 | this.licenseStore = licenseStore; 28 | } 29 | 30 | async getLicense({ title, wikiUrl }) { 31 | const { client } = this; 32 | const templates = await getPageTemplates({ client, title, wikiUrl }); 33 | return this.licenseStore.match(templates); 34 | } 35 | } 36 | 37 | module.exports = Licences; 38 | -------------------------------------------------------------------------------- /services/util/translate.js: -------------------------------------------------------------------------------- 1 | const translations = { 2 | en: { 3 | 'pd-attribution-hint': 'marked as public domain', 4 | 'check-details': 'more details on', 5 | anonymous: 'anonymous', 6 | by: 'by', 7 | edited: 'modified', 8 | }, 9 | es: { 10 | 'pd-attribution-hint': 'marcado como dominio público', 11 | 'check-details': 'para más detalles véase', 12 | anonymous: 'anónimo', 13 | by: 'por', 14 | edited: 'modificado', 15 | }, 16 | pt: { 17 | 'pd-attribution-hint': 'marcado como domínio público', 18 | 'check-details': 'para mais detalhes, veja', 19 | anonymous: 'anónimo', 20 | by: 'por', 21 | edited: 'modificado', 22 | }, 23 | de: { 24 | 'pd-attribution-hint': 'als gemeinfrei gekennzeichnet', 25 | 'check-details': 'Details auf', 26 | anonymous: 'anonym', 27 | by: 'von', 28 | edited: 'bearbeitet', 29 | }, 30 | uk: { 31 | 'pd-attribution-hint': 'позначено як суспільне надбання', 32 | 'check-details': 'більше деталей на', 33 | anonymous: 'анонім', 34 | by: 'автор', 35 | edited: 'модифіковано', 36 | }, 37 | }; 38 | 39 | function translate(lang, key) { 40 | return translations[lang][key]; 41 | } 42 | 43 | module.exports = translate; 44 | -------------------------------------------------------------------------------- /config/logging.js: -------------------------------------------------------------------------------- 1 | const bunyan = require('bunyan'); 2 | const GoodBunyan = require('good-bunyan'); 3 | 4 | function logger(/* environment */) { 5 | // Log to stdout and Stackdriver Logging. 6 | const streams = [{ stream: process.stdout, level: 'info' }]; 7 | 8 | return bunyan.createLogger({ name: 'attribution-generator-api', streams }); 9 | } 10 | 11 | function config() { 12 | // Conditionally disable server logging. 13 | if (process.env.LOG === 'false') return null; 14 | 15 | // Configure logging via "Good" with a single "Bunyan" reporter 16 | // capable of dispatching log events to multiple streams. 17 | 18 | // Subscribe to all events. 19 | const events = { 20 | ops: '*', 21 | log: '*', 22 | error: '*', 23 | request: '*', 24 | response: '*', 25 | }; 26 | 27 | // Configure the logger instance and the default log levels for events. 28 | const options = { 29 | logger: logger(process.env.NODE_ENV), 30 | levels: { 31 | ops: 'trace', 32 | log: 'info', 33 | error: 'error', 34 | request: 'trace', 35 | response: 'info', 36 | }, 37 | }; 38 | 39 | return { 40 | reporters: { 41 | default: [ 42 | { 43 | module: GoodBunyan, 44 | args: [events, options], 45 | }, 46 | ], 47 | }, 48 | }; 49 | } 50 | 51 | module.exports = config(); 52 | -------------------------------------------------------------------------------- /routes/__helpers__/setup.js: -------------------------------------------------------------------------------- 1 | const environment = require('../../config/environment'); 2 | const init = require('../../server'); 3 | 4 | function enhance(response) { 5 | const enhanced = Object.create(response); 6 | 7 | const ctype = response.headers['content-type']; 8 | enhanced.type = ctype.split(/ *; */).shift(); 9 | enhanced.status = response.statusCode; 10 | 11 | if (enhanced.type === 'application/json') { 12 | enhanced.payload = JSON.parse(response.payload); 13 | } 14 | 15 | return enhanced; 16 | } 17 | 18 | class ServerContext { 19 | constructor(overrides = {}) { 20 | this.environment = { ...environment, ...overrides }; 21 | this.server = undefined; 22 | } 23 | 24 | async init() { 25 | this.server = await init(this.environment); 26 | } 27 | 28 | // eslint-disable-next-line class-methods-use-this 29 | async clean() { 30 | // Put any cleanup of shared state here 31 | } 32 | 33 | async inject(options) { 34 | const response = await this.server.inject(options); 35 | return enhance(response); 36 | } 37 | 38 | async destroy() { 39 | await this.server.stop(); 40 | this.server = undefined; 41 | } 42 | } 43 | 44 | async function setup(overrides = {}) { 45 | const context = new ServerContext(overrides); 46 | await context.init(); 47 | await context.clean(); 48 | return context; 49 | } 50 | 51 | module.exports = setup; 52 | -------------------------------------------------------------------------------- /routes/__snapshots__/licenses.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`license routes GET /licenses returns a list of licenses 1`] = ` 4 | Array [ 5 | Object { 6 | "code": "cc-by-sa-4.0", 7 | "groups": Array [ 8 | "cc", 9 | "cc4", 10 | ], 11 | "name": "CC BY-SA 4.0", 12 | "url": "https://creativecommons.org/licenses/by-sa/3.0/legalcode", 13 | }, 14 | Object { 15 | "code": "cc-by-sa-3.0", 16 | "groups": Array [ 17 | "cc", 18 | "cc4", 19 | ], 20 | "name": "CC BY-SA 3.0", 21 | "url": "https://creativecommons.org/licenses/by-sa/3.0/legalcode", 22 | }, 23 | ] 24 | `; 25 | 26 | exports[`license routes GET /licenses/compatible/{license} returns a list of licenses 1`] = ` 27 | Array [ 28 | Object { 29 | "code": "cc-by-sa-4.0", 30 | "groups": Array [ 31 | "cc", 32 | "cc4", 33 | ], 34 | "name": "CC BY-SA 4.0", 35 | "url": "https://creativecommons.org/licenses/by-sa/3.0/legalcode", 36 | }, 37 | Object { 38 | "code": "cc-by-sa-3.0", 39 | "groups": Array [ 40 | "cc", 41 | "cc4", 42 | ], 43 | "name": "CC BY-SA 3.0", 44 | "url": "https://creativecommons.org/licenses/by-sa/3.0/legalcode", 45 | }, 46 | ] 47 | `; 48 | 49 | exports[`license routes GET /licenses/compatible/{license} returns an empty response if no compatible licences could be found 1`] = `Array []`; 50 | -------------------------------------------------------------------------------- /services/util/client.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const Url = require('url'); 3 | 4 | const errors = require('./errors'); 5 | 6 | const defaultParams = { action: 'query', format: 'json' }; 7 | const apiPath = 'w/api.php'; 8 | 9 | function transform(data) { 10 | const { query } = data; 11 | return query; 12 | } 13 | 14 | function handleError(error) { 15 | if (!error.response) throw new Error(errors.apiUnavailabe); 16 | throw error; 17 | } 18 | 19 | async function queryApi({ client, wikiUrl, params }) { 20 | const apiUrl = Url.resolve(wikiUrl, apiPath); 21 | try { 22 | const { data } = await client.get(apiUrl, { params }); 23 | return transform(data); 24 | } catch (error) { 25 | return handleError(error); 26 | } 27 | } 28 | 29 | class Client { 30 | constructor() { 31 | this.client = axios.create({ 32 | headers: { 33 | common: { 34 | 'Content-Type': 'application/json; charset=utf-8', 35 | Accept: 'application/json', 36 | }, 37 | }, 38 | timeout: 5000, 39 | }); 40 | } 41 | 42 | getResultsFromApi(titles, prop, wikiUrl, params = {}) { 43 | const { client } = this; 44 | const titleString = titles.join('|'); 45 | const queryParams = { 46 | ...defaultParams, 47 | ...params, 48 | titles: titleString, 49 | prop, 50 | }; 51 | return queryApi({ client, wikiUrl, params: queryParams }); 52 | } 53 | } 54 | 55 | module.exports = Client; 56 | -------------------------------------------------------------------------------- /services/__fixtures__/imagesInfo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pages: { 3 | '-1': { 4 | ns: 6, 5 | title: 'File:Graphic 01.jpg', 6 | missing: '', 7 | known: '', 8 | imagerepository: 'shared', 9 | imageinfo: [ 10 | { 11 | size: 112450, 12 | width: 700, 13 | height: 701, 14 | thumburl: 15 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Graphic_01.jpg/300px-Graphic_01.jpg', 16 | thumbwidth: 300, 17 | thumbheight: 300, 18 | url: 'https://upload.wikimedia.org/wikipedia/commons/e/ef/Graphic_01.jpg', 19 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:Graphic_01.jpg', 20 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=1720256', 21 | }, 22 | ], 23 | }, 24 | '-2': { 25 | ns: 6, 26 | title: 'File:logo.svg', 27 | missing: '', 28 | known: '', 29 | imagerepository: 'shared', 30 | imageinfo: [ 31 | { 32 | size: 112450, 33 | width: 700, 34 | height: 701, 35 | thumburl: 36 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/logo.svg/300px-logo.svg', 37 | thumbwidth: 300, 38 | thumbheight: 300, 39 | url: 'https://upload.wikimedia.org/wikipedia/commons/4/4a/logo.svg', 40 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:logo.svg', 41 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=317966', 42 | }, 43 | ], 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /services/__fixtures__/templates.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'File:Apple_Lisa.jpg', 5 | to: 'File:Apple Lisa.jpg', 6 | }, 7 | ], 8 | pages: { 9 | '18061': { 10 | pageid: 18061, 11 | ns: 6, 12 | title: 'File:Apple Lisa.jpg', 13 | templates: [ 14 | { 15 | ns: 10, 16 | title: 'Template:CC-Layout', 17 | }, 18 | { 19 | ns: 10, 20 | title: 'Template:Cc-by-sa-3.0-migrated', 21 | }, 22 | { 23 | ns: 10, 24 | title: 'Template:Description', 25 | }, 26 | { 27 | ns: 10, 28 | title: 'Template:Dir', 29 | }, 30 | { 31 | ns: 10, 32 | title: 'Template:En', 33 | }, 34 | { 35 | ns: 10, 36 | title: 'Template:Es', 37 | }, 38 | { 39 | ns: 10, 40 | title: 'Template:Fr', 41 | }, 42 | { 43 | ns: 10, 44 | title: 'Template:GFDL', 45 | }, 46 | { 47 | ns: 10, 48 | title: 'Template:GNU-Layout', 49 | }, 50 | { 51 | ns: 10, 52 | title: 'Template:License migration', 53 | }, 54 | { 55 | ns: 10, 56 | title: 'Template:License migration complete', 57 | }, 58 | { 59 | ns: 10, 60 | title: 'Template:License template tag', 61 | }, 62 | { 63 | ns: 10, 64 | title: 'Template:Original upload log', 65 | }, 66 | ], 67 | }, 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /services/__test__/compatibleCases.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'cc-by-sa-3.0': ['cc-by-sa-3.0-de', 'cc-by-sa-4.0'], 3 | 'cc-by-sa-3.0-de': ['cc-by-sa-3.0', 'cc-by-sa-4.0'], 4 | 'cc-by-sa-2.5': ['cc-by-sa-3.0-de', 'cc-by-sa-3.0', 'cc-by-sa-4.0'], 5 | 'cc-by-sa-2.0': [ 6 | 'cc-by-sa-2.0-de', 7 | 'cc-by-sa-2.5', 8 | 'cc-by-sa-3.0-de', 9 | 'cc-by-sa-3.0', 10 | 'cc-by-sa-4.0', 11 | ], 12 | 'cc-by-sa-2.0-de': [ 13 | 'cc-by-sa-2.0', 14 | 'cc-by-sa-2.5', 15 | 'cc-by-sa-3.0-de', 16 | 'cc-by-sa-3.0', 17 | 'cc-by-sa-4.0', 18 | ], 19 | 'cc-by-sa-1.0': [ 20 | 'cc-by-sa-2.0', 21 | 'cc-by-sa-2.0-de', 22 | 'cc-by-sa-2.5', 23 | 'cc-by-sa-3.0-de', 24 | 'cc-by-sa-3.0', 25 | 'cc-by-sa-4.0', 26 | ], 27 | 'cc-by-4.0': ['cc-by-sa-4.0'], 28 | 'cc-by-3.0': ['cc-by-3.0-de', 'cc-by-sa-3.0-de', 'cc-by-sa-3.0'], 29 | 'cc-by-3.0-de': ['cc-by-3.0', 'cc-by-sa-3.0-de', 'cc-by-sa-3.0'], 30 | 'cc-by-2.5': [ 31 | 'cc-by-3.0-de', 32 | 'cc-by-3.0', 33 | 'cc-by-4.0', 34 | 'cc-by-sa-2.5', 35 | 'cc-by-sa-3.0-de', 36 | 'cc-by-sa-3.0', 37 | ], 38 | 'cc-by-2.0': [ 39 | 'cc-by-2.0-de', 40 | 'cc-by-2.5', 41 | 'cc-by-3.0-de', 42 | 'cc-by-3.0', 43 | 'cc-by-4.0', 44 | 'cc-by-sa-2.0-de', 45 | 'cc-by-sa-2.0', 46 | ], 47 | 'cc-by-2.0-de': [ 48 | 'cc-by-2.0', 49 | 'cc-by-2.5', 50 | 'cc-by-3.0-de', 51 | 'cc-by-3.0', 52 | 'cc-by-sa-2.0-de', 53 | 'cc-by-sa-2.0', 54 | ], 55 | 'cc-by-1.0': [ 56 | 'cc-by-2.0-de', 57 | 'cc-by-2.0', 58 | 'cc-by-2.5', 59 | 'cc-by-3.0-de', 60 | 'cc-by-3.0', 61 | 'cc-by-sa-1.0', 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /routes/__snapshots__/fileinfo.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`fileinfo routes GET /fileinfo returns a 404 response when the license cannot be retrieved 1`] = ` 4 | Object { 5 | "error": "Not Found", 6 | "message": "empty-response", 7 | "statusCode": 404, 8 | } 9 | `; 10 | 11 | exports[`fileinfo routes GET /fileinfo returns a 422 response when the identifier cannot be parsed 1`] = ` 12 | Object { 13 | "error": "Unprocessable Entity", 14 | "message": "invalid-url", 15 | "statusCode": 422, 16 | } 17 | `; 18 | 19 | exports[`fileinfo routes GET /fileinfo returns a 500 response for any generic error 1`] = ` 20 | Object { 21 | "error": "Internal Server Error", 22 | "message": "An internal server error occurred", 23 | "statusCode": 500, 24 | } 25 | `; 26 | 27 | exports[`fileinfo routes GET /fileinfo returns a 503 response when the wiki api is not reachable 1`] = ` 28 | Object { 29 | "error": "Service Unavailable", 30 | "message": "api-unavailable", 31 | "statusCode": 503, 32 | } 33 | `; 34 | 35 | exports[`fileinfo routes GET /fileinfo returns the license of a file 1`] = ` 36 | Object { 37 | "attributionHtml": "Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr", 38 | "authorHtml": "Rama & Musée Bolo", 39 | "license": Object { 40 | "code": "cc-by-sa-4.0", 41 | "groups": Array [ 42 | "cc", 43 | "cc4", 44 | ], 45 | "name": "CC BY-SA 4.0", 46 | "url": "https://creativecommons.org/licenses/by-sa/3.0/legalcode", 47 | }, 48 | "mediaType": "BITMAP", 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /models/license.test.js: -------------------------------------------------------------------------------- 1 | const License = require('./license'); 2 | 3 | describe('license', () => { 4 | const options = { 5 | id: 'cc-by-sa-4.0', 6 | name: 'CC BY-SA 4.0', 7 | groups: ['cc', 'cc4'], 8 | compatibility: [], 9 | regexp: /^(Bild-)?CC-BY-SA(-|\/)4.0(([^-]+.+|-migrated)*)?$/i, 10 | url: 'https://creativecommons.org/licenses/by-sa/4.0/legalcode', 11 | }; 12 | 13 | function newLicense(overrides = {}) { 14 | return new License({ ...options, ...overrides }); 15 | } 16 | 17 | it('initalizes', () => { 18 | newLicense(); 19 | }); 20 | 21 | describe('validations', () => { 22 | it('asserts valid id', () => { 23 | expect(() => newLicense({ id: 123 })).toThrow(); 24 | }); 25 | 26 | it('asserts valid name', () => { 27 | expect(() => newLicense({ name: '' })).toThrow(); 28 | }); 29 | 30 | it('asserts valid groups', () => { 31 | expect(() => newLicense({ groups: 'pd' })).toThrow(); 32 | }); 33 | 34 | it('asserts valid compatibility', () => { 35 | expect(() => newLicense({ compatibility: 'cc-by-sa-3.0-de' })).toThrow(); 36 | }); 37 | 38 | it('asserts valid regex', () => { 39 | expect(() => newLicense({ regexp: '(cc-zero|Bild-CC-0)' })).toThrow(); 40 | }); 41 | 42 | it('asserts valid url', () => { 43 | expect(() => newLicense({ url: 123 })).toThrow(); 44 | }); 45 | }); 46 | 47 | describe('match()', () => { 48 | const subject = newLicense(); 49 | 50 | it('matches valid license', () => { 51 | expect(subject.match('CC-BY-SA-4.0')).toBeTruthy(); 52 | }); 53 | 54 | it('mismatches invalid license', () => { 55 | expect(subject.match('Blerg')).toBeFalsy(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /services/tracker.test.js: -------------------------------------------------------------------------------- 1 | const MatomoTracker = require('matomo-tracker'); 2 | 3 | const Tracker = require('./tracker'); 4 | 5 | jest.mock('matomo-tracker'); 6 | 7 | describe('Tracker', () => { 8 | const mockTracker = { track: jest.fn() }; 9 | 10 | beforeEach(() => { 11 | MatomoTracker.mockImplementation(() => mockTracker); 12 | }); 13 | 14 | afterEach(async () => { 15 | mockTracker.track.mockClear(); 16 | }); 17 | 18 | describe('track', () => { 19 | it('Uses the request URL as the URL to be tracked', () => { 20 | const tracker = new Tracker('tracker url', 123); 21 | 22 | const request = { 23 | server: { 24 | info: { protocol: 'http' }, 25 | }, 26 | info: { host: 'host.name' }, 27 | url: { path: '/path/to/track' }, 28 | headers: {}, 29 | }; 30 | 31 | tracker.track(request, 'foobar'); 32 | 33 | expect(mockTracker.track).toHaveBeenCalledWith({ 34 | url: 'http://host.name/path/to/track', 35 | action_name: expect.any(String), 36 | }); 37 | }); 38 | 39 | it('Uses the action name to be tracked', () => { 40 | const tracker = new Tracker('tracker url', 123); 41 | 42 | const request = { 43 | server: { 44 | info: { protocol: 'http' }, 45 | }, 46 | info: { host: 'host.name' }, 47 | url: { path: '/path/to/track' }, 48 | headers: {}, 49 | }; 50 | 51 | tracker.track(request, 'Test Action'); 52 | 53 | expect(mockTracker.track).toHaveBeenCalledWith({ 54 | url: expect.any(String), 55 | action_name: 'Test Action', 56 | }); 57 | }); 58 | 59 | it('Takes Do Not Track header into account', () => { 60 | const tracker = new Tracker('tracker url', 123); 61 | 62 | const request = { 63 | headers: { dnt: '1' }, 64 | }; 65 | 66 | tracker.track(request, 'foobar'); 67 | 68 | expect(mockTracker.track).not.toHaveBeenCalled(); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /routes/__snapshots__/files.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`files routes GET /files returns 400 response if the URL is invalid 1`] = ` 4 | Object { 5 | "error": "Bad Request", 6 | "message": "Invalid request params input", 7 | "statusCode": 400, 8 | } 9 | `; 10 | 11 | exports[`files routes GET /files returns a 400 response for non http(s) urls 1`] = ` 12 | Object { 13 | "error": "Bad Request", 14 | "message": "Invalid request params input", 15 | "statusCode": 400, 16 | } 17 | `; 18 | 19 | exports[`files routes GET /files returns a 404 when called with an unencoded articleUrl 1`] = ` 20 | Object { 21 | "error": "Not Found", 22 | "message": "Not Found", 23 | "statusCode": 404, 24 | } 25 | `; 26 | 27 | exports[`files routes GET /files returns a 422 response for non-wiki urls 1`] = ` 28 | Object { 29 | "error": "Unprocessable Entity", 30 | "message": "invalid-url", 31 | "statusCode": 422, 32 | } 33 | `; 34 | 35 | exports[`files routes GET /files returns a 500 response for a generic error 1`] = ` 36 | Object { 37 | "error": "Internal Server Error", 38 | "message": "An internal server error occurred", 39 | "statusCode": 500, 40 | } 41 | `; 42 | 43 | exports[`files routes GET /files returns a 503 response when the wiki api is not reachable 1`] = ` 44 | Object { 45 | "error": "Service Unavailable", 46 | "message": "api-unavailable", 47 | "statusCode": 503, 48 | } 49 | `; 50 | 51 | exports[`files routes GET /files returns a list of all files from the given article 1`] = ` 52 | Array [ 53 | Object { 54 | "descriptionUrl": "https://commons.wikimedia.org/wiki/File:Graphic_01.jpg", 55 | "fileSize": 112450, 56 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/e/ef/Graphic_01.jpg", 57 | "thumbnail": Object { 58 | "height": 300, 59 | "rawUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Graphic_01.jpg/300px-Graphic_01.jpg", 60 | "width": 300, 61 | }, 62 | "title": "File:Graphic 01.jpg", 63 | }, 64 | ] 65 | `; 66 | -------------------------------------------------------------------------------- /services/files.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const parseWikiUrl = require('./util/parseWikiUrl'); 4 | const errors = require('./util/errors'); 5 | 6 | // unescape URL-escaped characters 7 | function formatTitle(title) { 8 | return decodeURI(title.trim()); 9 | } 10 | 11 | async function getImageTitles({ client, title, wikiUrl }) { 12 | const formattedTitle = formatTitle(title); 13 | const params = { imlimit: 500 }; 14 | const response = await client.getResultsFromApi([formattedTitle], 'images', wikiUrl, params); 15 | assert.ok(response.pages, errors.emptyResponse); 16 | const pages = Object.values(response.pages); 17 | assert.ok(pages.length === 1); 18 | const { images = [] } = pages[0]; 19 | 20 | return images.map(image => image.title); 21 | } 22 | 23 | function formatImageInfo(page) { 24 | const { title, imageinfo } = page; 25 | const { 26 | url: rawUrl, 27 | descriptionurl: descriptionUrl, 28 | size: fileSize, 29 | thumburl, 30 | thumbwidth, 31 | thumbheight, 32 | } = imageinfo[0]; 33 | const thumbnail = { rawUrl: thumburl, width: thumbwidth, height: thumbheight }; 34 | 35 | return { title, descriptionUrl, rawUrl, fileSize, thumbnail }; 36 | } 37 | 38 | async function getImageInfos({ client, titles, wikiUrl }) { 39 | const params = { iiprop: 'url|size', iiurlwidth: 300 }; 40 | const { pages } = await client.getResultsFromApi(titles, 'imageinfo', wikiUrl, params); 41 | assert.ok(pages, errors.emptyResponse); 42 | 43 | return Object.values(pages).map(formatImageInfo); 44 | } 45 | 46 | class Files { 47 | constructor({ client }) { 48 | this.client = client; 49 | } 50 | 51 | async getPageImages(url) { 52 | const input = decodeURIComponent(url); 53 | const { title, wikiUrl } = parseWikiUrl(input); 54 | const { client } = this; 55 | const titles = await getImageTitles({ client, title, wikiUrl }); 56 | if (titles.length === 0) return []; 57 | 58 | return getImageInfos({ client, titles, wikiUrl }); 59 | } 60 | } 61 | 62 | module.exports = Files; 63 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Hapi & friends 2 | const Hapi = require('hapi'); 3 | const HapiRouter = require('hapi-router'); 4 | const HapiSwagger = require('hapi-swaggered'); 5 | const HapiSwaggerUi = require('hapi-swaggered-ui'); 6 | 7 | const Good = require('good'); 8 | const Boom = require('boom'); 9 | const Inert = require('inert'); 10 | const Vision = require('vision'); 11 | 12 | async function init(environment) { 13 | const { logging, secret, server: options, services, swagger, tracker } = environment; 14 | 15 | // Create a server instance. 16 | const server = Hapi.server(options); 17 | 18 | // Extend app context. 19 | server.app.secret = secret; 20 | server.app.services = services; 21 | server.app.tracker = tracker; 22 | 23 | // Extend Hapi response toolkit and request interfaces. 24 | server.decorate('toolkit', 'error', (message, ...args) => { 25 | throw new Boom(message, ...args); 26 | }); 27 | 28 | server.decorate('toolkit', 'assert', function assert(value, err, ...args) { 29 | if (!value) this.error(err, ...args); 30 | }); 31 | 32 | // Register logging plugin. 33 | if (logging) { 34 | await server.register([ 35 | { 36 | plugin: Good, 37 | options: logging, 38 | }, 39 | ]); 40 | } 41 | 42 | const swaggerUiOptions = { 43 | title: 'Lizenzgenerator API', 44 | path: '/docs', 45 | }; 46 | if (swagger.basePath) { 47 | swaggerUiOptions.basePath = swagger.basePath; 48 | } 49 | 50 | // Register router & swagger plugins. 51 | await server.register([ 52 | Inert, 53 | Vision, 54 | { 55 | plugin: HapiSwaggerUi, 56 | options: swaggerUiOptions, 57 | }, 58 | { 59 | plugin: HapiRouter, 60 | options: { 61 | routes: 'routes/*.js', 62 | ignore: ['routes/*.test.js'], 63 | cwd: __dirname, 64 | }, 65 | }, 66 | { 67 | plugin: HapiSwagger, 68 | options: swagger, 69 | }, 70 | ]); 71 | 72 | // Prepare for server start. 73 | await server.initialize(); 74 | 75 | return server; 76 | } 77 | 78 | module.exports = init; 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "0.1.0", 4 | "name": "attribution-generator-api", 5 | "description": "Create attribution hints for images from Wikipedia and Wikimedia Commons.", 6 | "homepage": "https://github.com/wmde/attribution-generator-api", 7 | "author": "bitcrowd ", 8 | "repository": { 9 | "type": "git", 10 | "url": "git@github.com:wmde/attribution-generator-api.git" 11 | }, 12 | "license": "GPL-2.0", 13 | "main": "server.js", 14 | "scripts": { 15 | "start": "node start.js", 16 | "watch": "nodemon start.js", 17 | "debug": "ndb start.js", 18 | "console": "node --experimental-repl-await console.js", 19 | "lint": "eslint --ignore-path .gitignore . scripts/*", 20 | "test": "jest", 21 | "sequentialtest": "yarn test --runInBand --detectOpenHandles --forceExit", 22 | "debugtest": "yarn sequentialtest", 23 | "apidoc": "scripts/gen-apidoc > openapi.yaml" 24 | }, 25 | "engines": { 26 | "node": "^10.14.2" 27 | }, 28 | "dependencies": { 29 | "axios": "^0.18.0", 30 | "boom": "^7.2.0", 31 | "bunyan": "^1.8.12", 32 | "dotenv": "^6.0.0", 33 | "good": "^8.1.1", 34 | "good-bunyan": "^2.0.1", 35 | "hapi": "^17.5.4", 36 | "hapi-router": "^4.0.0", 37 | "hapi-swaggered": "^3.0.3", 38 | "hapi-swaggered-ui": "^3.0.2", 39 | "inert": "^5.1.2", 40 | "joi": "^14.3.0", 41 | "jsdom": "^13.1.0", 42 | "matomo-tracker": "^2.2.0", 43 | "repl.history": "^0.1.4", 44 | "vision": "^5.4.4" 45 | }, 46 | "devDependencies": { 47 | "@pollyjs/adapter-node-http": "^1.4.2", 48 | "@pollyjs/core": "^1.4.2", 49 | "@pollyjs/persister-fs": "^1.4.2", 50 | "eslint": "^5.10.0", 51 | "eslint-config-airbnb-base": "^13.1.0", 52 | "eslint-config-prettier": "^3.3.0", 53 | "eslint-plugin-import": "^2.14.0", 54 | "eslint-plugin-jest": "^22.1.2", 55 | "eslint-plugin-prettier": "^3.0.0", 56 | "jest": "^23.5.0", 57 | "jest-environment-node": "^23.4.0", 58 | "js-yaml": "^3.12.0", 59 | "ndb": "^1.0.25", 60 | "nodemon": "^1.18.4", 61 | "prettier": "^1.15.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /services/util/parseWikiUrl.js: -------------------------------------------------------------------------------- 1 | const errors = require('./errors'); 2 | 3 | const wikipediaRegExp = /([-a-z]{2,})(\.m)?\.wikipedia\.org\//i; 4 | const commonsRegExp = /commons(\.m)?\.wikimedia\.org\/w(iki)?\/?/i; 5 | const uploadRegExp = /upload.wikimedia\.org\/wikipedia\/([-a-z]{2,})\//i; 6 | 7 | const namePrefixes = ['#mediaviewer/', '#/media/', 'wiki/']; 8 | 9 | // Returns the tail part of `string` after `subString` 10 | // e.g. tail('John Doe', 'ohn ') == 'Doe' 11 | function tail(string, subString) { 12 | const keyLoc = string.indexOf(subString); 13 | if (keyLoc !== -1) { 14 | return string.substr(keyLoc + subString.length); 15 | } 16 | return null; 17 | } 18 | 19 | function extractName(url) { 20 | if (url.indexOf('title=') !== -1) { 21 | const matches = url.match(/title=([^&]+)/i); 22 | return matches[1]; 23 | } 24 | const sanitizedUrl = url.replace(/\?.+$/, ''); 25 | const checks = namePrefixes.map(key => tail(sanitizedUrl, key)); 26 | return checks.find(name => !!name); 27 | } 28 | 29 | function splitCommonsUrl(url) { 30 | const wikiUrl = 'https://commons.wikimedia.org/'; 31 | const title = extractName(url); 32 | return { title, wikiUrl }; 33 | } 34 | 35 | function splitUploadUrl(url) { 36 | const matches = url.match(uploadRegExp); 37 | const domain = matches[1] === 'commons' ? 'wikimedia' : 'wikipedia'; 38 | const wikiUrl = `https://${matches[1]}.${domain}.org/`; 39 | const segments = url.split('/'); 40 | const fileName = segments.includes('thumb') ? segments[segments.length - 2] : segments.pop(); 41 | const title = `File:${fileName}`; 42 | return { title, wikiUrl }; 43 | } 44 | 45 | function splitWikipediaUrl(url) { 46 | const matches = url.match(wikipediaRegExp); 47 | const wikiUrl = `https://${matches[1]}.wikipedia.org/`; 48 | const title = extractName(url); 49 | return { title, wikiUrl }; 50 | } 51 | 52 | function parse(url) { 53 | if (commonsRegExp.test(url)) { 54 | return splitCommonsUrl(url); 55 | } 56 | if (uploadRegExp.test(url)) { 57 | return splitUploadUrl(url); 58 | } 59 | if (wikipediaRegExp.test(url)) { 60 | return splitWikipediaUrl(url); 61 | } 62 | throw new Error(errors.invalidUrl); 63 | } 64 | 65 | module.exports = parse; 66 | -------------------------------------------------------------------------------- /services/licenses.test.js: -------------------------------------------------------------------------------- 1 | const Licenses = require('./licenses'); 2 | 3 | const errors = require('./util/errors'); 4 | const templatesMock = require('./__fixtures__/templates'); 5 | const templatesMissingMock = require('./__fixtures__/templatesMissing'); 6 | 7 | describe('Licenses', () => { 8 | const client = { getResultsFromApi: jest.fn() }; 9 | const licenseStore = { match: jest.fn() }; 10 | 11 | describe('getLicense()', () => { 12 | const service = new Licenses({ client, licenseStore }); 13 | const title = 'File:Apple_Lisa2-IMG_1517.jpg'; 14 | const wikiUrl = 'https://en.wikipedia.org'; 15 | const licenseMock = {}; 16 | const normalizedTemplates = [ 17 | 'CC-Layout', 18 | 'Cc-by-sa-3.0-migrated', 19 | 'Description', 20 | 'Dir', 21 | 'En', 22 | 'Es', 23 | 'Fr', 24 | 'GFDL', 25 | 'GNU-Layout', 26 | 'License migration', 27 | 'License migration complete', 28 | 'License template tag', 29 | 'Original upload log', 30 | ]; 31 | 32 | it('returns a license based on the templates for the file page', async () => { 33 | client.getResultsFromApi.mockResolvedValueOnce(templatesMock); 34 | licenseStore.match.mockImplementation(() => licenseMock); 35 | 36 | const license = await service.getLicense({ title, wikiUrl }); 37 | 38 | expect(client.getResultsFromApi).toHaveBeenCalledWith([title], 'templates', wikiUrl, { 39 | tlnamespace: 10, 40 | tllimit: 500, 41 | }); 42 | expect(licenseStore.match).toHaveBeenCalledWith(normalizedTemplates); 43 | expect(license).toEqual(licenseMock); 44 | }); 45 | 46 | it('returns null when no templates are available', async () => { 47 | client.getResultsFromApi.mockResolvedValueOnce(templatesMissingMock); 48 | licenseStore.match.mockImplementation(() => null); 49 | 50 | const license = await service.getLicense({ title, wikiUrl }); 51 | 52 | expect(licenseStore.match).toHaveBeenCalledWith([]); 53 | expect(license).toBe(null); 54 | }); 55 | 56 | it('throws an error if the response is fully empty', async () => { 57 | client.getResultsFromApi.mockResolvedValueOnce({}); 58 | await expect(service.getLicense({ title, wikiUrl })).rejects.toThrow(errors.emptyResponse); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /routes/files.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const errors = require('../services/util/errors'); 4 | const definitions = require('./__swagger__/definitions'); 5 | 6 | const routes = []; 7 | 8 | const fileSchema = Joi.object({ 9 | title: Joi.string().required(), 10 | descriptionUrl: Joi.string() 11 | .uri() 12 | .required(), 13 | rawUrl: Joi.string() 14 | .uri() 15 | .required(), 16 | fileSize: Joi.number() 17 | .integer() 18 | .required(), 19 | thumbnail: Joi.object() 20 | .required() 21 | .keys({ 22 | rawUrl: Joi.string() 23 | .uri() 24 | .required(), 25 | width: Joi.number() 26 | .integer() 27 | .required(), 28 | height: Joi.number() 29 | .integer() 30 | .required(), 31 | }), 32 | }); 33 | 34 | function handleError(h, { message }) { 35 | switch (message) { 36 | case errors.invalidUrl: 37 | return h.error(message, { statusCode: 422 }); 38 | case errors.apiUnavailabe: 39 | return h.error(message, { statusCode: 503 }); 40 | default: 41 | return h.error(message); 42 | } 43 | } 44 | 45 | routes.push({ 46 | path: '/files/{articleUrl}', 47 | method: 'GET', 48 | options: { 49 | description: 'Get all files for an article', 50 | notes: 'Retrieve all files for a given article or page url.', 51 | validate: { 52 | params: { 53 | articleUrl: Joi.string().uri({ scheme: ['http', 'https'] }), 54 | }, 55 | }, 56 | response: { 57 | schema: Joi.array().items(fileSchema), 58 | status: { 59 | 422: definitions.errors['422'], 60 | 500: definitions.errors['500'], 61 | 503: definitions.errors['503'], 62 | }, 63 | }, 64 | plugins: { 65 | 'hapi-swaggered': { 66 | operationId: 'files.index', 67 | security: [{ default: [] }], 68 | }, 69 | }, 70 | }, 71 | handler: async (request, h) => { 72 | const { files } = request.server.app.services; 73 | const { tracker } = request.server.app; 74 | const { articleUrl } = request.params; 75 | try { 76 | const response = await files.getPageImages(articleUrl); 77 | tracker.track(request, 'Files For Article'); 78 | return h.response(response); 79 | } catch (error) { 80 | return handleError(h, error); 81 | } 82 | }, 83 | }); 84 | 85 | module.exports = routes; 86 | -------------------------------------------------------------------------------- /routes/fileinfo.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const errors = require('../services/util/errors'); 4 | const definitions = require('./__swagger__/definitions'); 5 | const { fileinfo: serialize } = require('../services/util/serializers'); 6 | 7 | const routes = []; 8 | 9 | const responseSchema = Joi.object({ 10 | license: Joi.object({ 11 | code: Joi.string().required(), 12 | name: Joi.string().required(), 13 | url: Joi.string() 14 | .uri() 15 | .required(), 16 | groups: Joi.array() 17 | .required() 18 | .items(Joi.string()), 19 | }), 20 | authorHtml: Joi.string().allow(null), 21 | attributionHtml: Joi.string().allow(null), 22 | mediaType: Joi.string().required(), 23 | }); 24 | 25 | function handleError(h, { message }) { 26 | switch (message) { 27 | case errors.invalidUrl: 28 | return h.error(message, { statusCode: 422 }); 29 | case errors.emptyResponse: 30 | return h.error(message, { statusCode: 404 }); 31 | case errors.apiUnavailabe: 32 | return h.error(message, { statusCode: 503 }); 33 | default: 34 | return h.error(message); 35 | } 36 | } 37 | 38 | routes.push({ 39 | path: '/fileinfo/{fileUrlOrTitle}', 40 | method: 'GET', 41 | options: { 42 | description: 'Image license', 43 | notes: 'Returns the most liberal license for the given image', 44 | validate: { 45 | params: { 46 | fileUrlOrTitle: Joi.string(), 47 | }, 48 | }, 49 | response: { 50 | schema: responseSchema, 51 | status: { 52 | 404: definitions.errors['404'], 53 | 422: definitions.errors['422'], 54 | 500: definitions.errors['500'], 55 | 503: definitions.errors['503'], 56 | }, 57 | }, 58 | }, 59 | handler: async (request, h) => { 60 | const { fileData, licenses } = request.server.app.services; 61 | const { tracker } = request.server.app; 62 | const { fileUrlOrTitle } = request.params; 63 | try { 64 | const fileInfo = await fileData.getFileData(fileUrlOrTitle); 65 | const getLicenseParams = { title: fileInfo.title, wikiUrl: fileInfo.wikiUrl }; 66 | const license = await licenses.getLicense(getLicenseParams); 67 | const response = serialize(fileInfo, license); 68 | tracker.track(request, 'File Info'); 69 | return h.response(response); 70 | } catch (error) { 71 | return handleError(h, error); 72 | } 73 | }, 74 | }); 75 | 76 | module.exports = routes; 77 | -------------------------------------------------------------------------------- /routes/licenses.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const errors = require('../services/util/errors'); 4 | const definitions = require('./__swagger__/definitions'); 5 | const { license: serialize } = require('../services/util/serializers'); 6 | 7 | const routes = []; 8 | 9 | const licenseSchema = Joi.object({ 10 | code: Joi.string().required(), 11 | name: Joi.string().required(), 12 | url: Joi.string().required(), 13 | groups: Joi.array() 14 | .required() 15 | .items(Joi.string()), 16 | }); 17 | 18 | function handleError(h, { message }) { 19 | switch (message) { 20 | case errors.licenseNotFound: 21 | return h.error(message, { statusCode: 404 }); 22 | default: 23 | return h.error(message); 24 | } 25 | } 26 | 27 | routes.push({ 28 | path: '/licenses/compatible/{licenseId}', 29 | method: 'GET', 30 | options: { 31 | description: 'Compatible licenses', 32 | notes: 'Returns a list of licenses that are compatible to the passed license ID', 33 | validate: { 34 | params: { 35 | licenseId: Joi.string(), 36 | }, 37 | }, 38 | response: { 39 | schema: Joi.array().items(licenseSchema), 40 | status: { 41 | 404: definitions.errors['404'], 42 | 500: definitions.errors['500'], 43 | }, 44 | }, 45 | }, 46 | handler: async (request, h) => { 47 | const { licenseStore } = request.server.app.services; 48 | const { tracker } = request.server.app; 49 | const { licenseId } = request.params; 50 | try { 51 | const licenses = licenseStore.compatible(licenseId); 52 | const response = licenses.map(serialize); 53 | tracker.track(request, 'License List, Compatible'); 54 | return h.response(response); 55 | } catch (error) { 56 | return handleError(h, error); 57 | } 58 | }, 59 | }); 60 | 61 | routes.push({ 62 | path: '/licenses', 63 | method: 'GET', 64 | options: { 65 | description: 'Licenses index', 66 | notes: 'Returns a list of all licenses', 67 | validate: {}, 68 | response: { 69 | schema: Joi.array().items(licenseSchema), 70 | }, 71 | }, 72 | handler: async (request, h) => { 73 | const { licenseStore } = request.server.app.services; 74 | const { tracker } = request.server.app; 75 | const licenses = licenseStore.all(); 76 | const response = licenses.map(serialize); 77 | tracker.track(request, 'License List'); 78 | return h.response(response); 79 | }, 80 | }); 81 | 82 | module.exports = routes; 83 | -------------------------------------------------------------------------------- /services/fileData.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const parseWikiUrl = require('./util/parseWikiUrl'); 4 | const errors = require('./util/errors'); 5 | 6 | const fileRegex = /^\w+:([^/]+\.\w+)$/; 7 | const defaultWikiUrl = 'https://commons.wikimedia.org/'; 8 | 9 | function parseImageInfoResponse(response) { 10 | assert.ok(response.pages, errors.emptyResponse); 11 | const pages = Object.values(response.pages); 12 | const { to: normalizedTitle } = response.normalized ? response.normalized[0] : {}; 13 | assert.ok(pages.length === 1); 14 | const { imageinfo } = pages[0]; 15 | 16 | // when we gave an invalid URL (e.g. an article URL) 17 | if (!imageinfo) { 18 | return {}; 19 | } 20 | 21 | return { 22 | normalizedTitle, 23 | ...(imageinfo[0] || {}), 24 | }; 25 | } 26 | 27 | function parseFileTitle(title) { 28 | const matches = title.match(fileRegex); 29 | return { 30 | title: `File:${matches[1]}`, 31 | wikiUrl: defaultWikiUrl, 32 | }; 33 | } 34 | 35 | function parseIdentifier(identifier) { 36 | if (fileRegex.test(identifier)) { 37 | return parseFileTitle(identifier); 38 | } 39 | return parseWikiUrl(identifier); 40 | } 41 | 42 | async function getImageInfo({ client, title, wikiUrl }) { 43 | const params = { iiprop: 'url|extmetadata|mediatype', iilimit: 1, iiurlheight: 300 }; 44 | const response = await client.getResultsFromApi([title], 'imageinfo', wikiUrl, params); 45 | return parseImageInfoResponse(response); 46 | } 47 | 48 | class FileData { 49 | constructor({ client }) { 50 | this.client = client; 51 | } 52 | 53 | async getFileData(titleOrUrl) { 54 | const { client } = this; 55 | const identifier = decodeURIComponent(titleOrUrl); 56 | const { title, wikiUrl } = parseIdentifier(identifier); 57 | const { normalizedTitle, url, extmetadata, mediatype } = await getImageInfo({ 58 | client, 59 | title, 60 | wikiUrl, 61 | }); 62 | 63 | // when no image info could be found 64 | if (!url) { 65 | return {}; 66 | } 67 | 68 | const { title: originalTitle, wikiUrl: originalWikiUrl } = parseWikiUrl(url); 69 | const { value: artistHtml = null } = extmetadata.Artist || {}; 70 | const { value: attributionHtml = null } = extmetadata.Attribution || {}; 71 | 72 | return { 73 | title: originalTitle, 74 | normalizedTitle: normalizedTitle || originalTitle, 75 | wikiUrl: originalWikiUrl, 76 | rawUrl: url, 77 | artistHtml, 78 | attributionHtml, 79 | mediaType: mediatype, 80 | }; 81 | } 82 | } 83 | 84 | module.exports = FileData; 85 | -------------------------------------------------------------------------------- /services/files.integration.test.js: -------------------------------------------------------------------------------- 1 | const Client = require('./util/client'); 2 | const Files = require('./files'); 3 | const expectedFiles = require('./__fixtures__/expectedFiles'); 4 | 5 | describe('getPageImages()', () => { 6 | beforeAll(() => { 7 | startRecording('services/files.getPageImages()'); 8 | }); 9 | 10 | afterAll(async () => { 11 | await stopRecording(); 12 | }); 13 | 14 | const client = new Client(); 15 | const service = new Files({ client }); 16 | const urls = [ 17 | 'https://de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern', 18 | 'http://de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern', 19 | '//de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern', 20 | 'de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern', 21 | 'https://de.m.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern', 22 | 23 | 'http://de.wikipedia.org/wiki/Königsberg_in_Bayern', 24 | '//de.wikipedia.org/wiki/Königsberg_in_Bayern', 25 | 'de.wikipedia.org/wiki/Königsberg_in_Bayern', 26 | 'https://de.m.wikipedia.org/wiki/Königsberg_in_Bayern', 27 | 28 | // parameters other than title are ignored 29 | 'https://de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern?uselang=en', 30 | 'http://de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern?uselang=en', 31 | '//de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern?uselang=en', 32 | 'de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern?uselang=en', 33 | 'https://de.m.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern?uselang=en', 34 | 35 | 'https://de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern', 36 | 'http://de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern', 37 | '//de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern', 38 | 'de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern', 39 | 'https://de.m.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern', 40 | 41 | // parameters other than title are ignored 42 | 'https://de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern&uselang=de', 43 | 'http://de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern&uselang=de', 44 | '//de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern&uselang=de', 45 | 'de.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern&uselang=de', 46 | 'https://de.m.wikipedia.org/w/index.php?title=K%C3%B6nigsberg_in_Bayern&uselang=de', 47 | ]; 48 | 49 | urls.forEach(url => { 50 | it(`when requesting files for '${url}'`, async () => { 51 | const response = await service.getPageImages(url); 52 | expect(response).toEqual(expectedFiles); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /routes/fileinfo.integration.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const LicenseStore = require('../services/licenseStore'); 4 | const Client = require('../services/util/client'); 5 | const FileData = require('../services/fileData'); 6 | const Licenses = require('../services/licenses'); 7 | 8 | const licenseData = require('../config/licenses/licenses'); 9 | const portReferences = require('../config/licenses/portReferences'); 10 | 11 | describe('fileinfo routes', () => { 12 | beforeAll(() => { 13 | startRecording('routes/fileinfo'); 14 | }); 15 | 16 | afterAll(async () => { 17 | await stopRecording(); 18 | }); 19 | 20 | let context; 21 | 22 | const client = new Client(); 23 | const licenseStore = new LicenseStore(licenseData, portReferences); 24 | 25 | const fileData = new FileData({ client }); 26 | const licenses = new Licenses({ client, licenseStore }); 27 | const services = { licenseStore, fileData, licenses }; 28 | 29 | beforeEach(async () => { 30 | context = await setup({ services }); 31 | }); 32 | 33 | afterEach(async () => { 34 | await context.destroy(); 35 | }); 36 | 37 | describe('GET /fileinfo', () => { 38 | function options({ title }) { 39 | return { url: `/fileinfo/${title}`, method: 'GET' }; 40 | } 41 | 42 | async function subject(opts = {}) { 43 | return context.inject(options(opts)); 44 | } 45 | 46 | it('returns the license of a file', async () => { 47 | const response = await subject({ title: 'File:Apple_Lisa2-IMG_1517.jpg' }); 48 | 49 | expect(response.status).toBe(200); 50 | expect(response.type).toBe('application/json'); 51 | expect(response.payload).toMatchObject({ 52 | license: { 53 | code: 'cc-by-sa-2.0-ported', 54 | name: 'CC BY-SA 2.0 FR', 55 | url: 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 56 | groups: ['cc', 'cc2', 'ported', 'knownPorted'], 57 | }, 58 | authorHtml: 59 | 'Rama & Musée Bolo', 60 | attributionHtml: 61 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 62 | mediaType: 'BITMAP', 63 | }); 64 | }); 65 | 66 | it('when requesting a file where the attribution is missing', async () => { 67 | const response = await subject({ title: 'File:Helene_Fischer_2010.jpg' }); 68 | expect(response.status).toBe(200); 69 | expect(response.payload.attributionHtml).toBe(null); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /routes/licenses.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const licenseFactory = require('../__helpers__/licenseFactory'); 4 | 5 | describe('license routes', () => { 6 | let context; 7 | 8 | const licenseStore = { all: jest.fn(), compatible: jest.fn() }; 9 | const fileData = { getFileData: jest.fn() }; 10 | const licenses = { getLicense: jest.fn() }; 11 | const services = { licenseStore, fileData, licenses }; 12 | const licensesMock = [ 13 | licenseFactory({ id: 'cc-by-sa-4.0', name: 'CC BY-SA 4.0' }), 14 | licenseFactory({ id: 'cc-by-sa-3.0', name: 'CC BY-SA 3.0' }), 15 | ]; 16 | 17 | beforeEach(async () => { 18 | fileData.getFileData.mockReset(); 19 | licenses.getLicense.mockReset(); 20 | licenseStore.all.mockReset(); 21 | licenseStore.compatible.mockReset(); 22 | context = await setup({ services }); 23 | }); 24 | 25 | afterEach(async () => { 26 | await context.destroy(); 27 | }); 28 | 29 | describe('GET /licenses', () => { 30 | function options() { 31 | return { url: `/licenses`, method: 'GET' }; 32 | } 33 | 34 | async function subject() { 35 | return context.inject(options()); 36 | } 37 | 38 | beforeEach(() => { 39 | licenseStore.all.mockReturnValue(licensesMock); 40 | }); 41 | 42 | it('returns a list of licenses', async () => { 43 | const response = await subject({}); 44 | 45 | expect(response.status).toBe(200); 46 | expect(response.type).toBe('application/json'); 47 | expect(response.payload).toMatchSnapshot(); 48 | }); 49 | }); 50 | 51 | describe('GET /licenses/compatible/{license}', () => { 52 | const licenseId = 'cc-by-sa-3.0-de'; 53 | function options() { 54 | return { url: `/licenses/compatible/${licenseId}`, method: 'GET' }; 55 | } 56 | 57 | async function subject() { 58 | return context.inject(options()); 59 | } 60 | 61 | it('returns a list of licenses', async () => { 62 | licenseStore.compatible.mockReturnValue(licensesMock); 63 | const response = await subject(); 64 | 65 | expect(licenseStore.compatible).toHaveBeenCalledWith(licenseId); 66 | expect(response.status).toBe(200); 67 | expect(response.type).toBe('application/json'); 68 | expect(response.payload).toMatchSnapshot(); 69 | }); 70 | 71 | it('returns an empty response if no compatible licences could be found', async () => { 72 | licenseStore.compatible.mockReturnValue([]); 73 | const response = await subject(); 74 | 75 | expect(licenseStore.compatible).toHaveBeenCalledWith(licenseId); 76 | expect(response.status).toBe(200); 77 | expect(response.type).toBe('application/json'); 78 | expect(response.payload).toMatchSnapshot(); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Attribution Generator API 2 | 3 | An API for generating attribution hints for images from Wikipedia and Wikimedia Commons. 4 | 5 | [![Build Status](https://travis-ci.org/wmde/attribution-generator-api.svg?branch=master)](https://travis-ci.org/wmde/attribution-generator-api) 6 | 7 | ## Prerequisites 8 | 9 | You will need a few things to get started: 10 | 11 | * [`Node.js`](https://nodejs.org/en/) — we recommend installing it via [nvm](https://github.com/creationix/nvm) 12 | * [`Yarn`](https://yarnpkg.com/) 13 | 14 | 15 | 16 | ## Configuration 17 | 18 | All of the application's configuration is read from [environment variables](https://12factor.net/config). 19 | If a `.env` file is present in the current working directory, the application reads it via [`dotenv`](https://github.com/motdotla/dotenv) when starting. 20 | 21 | For a quick start in development, just copy `.env.test` and update its values: 22 | 23 | ```shell 24 | cp .env.test .env 25 | ``` 26 | 27 | In case you need to generate secure app secrets, run `scripts/gen-secret`. 28 | 29 | ## Getting started 30 | 31 | Install JavaScript dependencies: 32 | 33 | ```shell 34 | yarn install 35 | ``` 36 | 37 | To start the server and restart it on file changes: 38 | 39 | ```shell 40 | yarn watch 41 | ``` 42 | 43 | To simply start the server without restarts: 44 | 45 | ```shell 46 | yarn start 47 | ``` 48 | 49 | then connect to [localhost:8080](http://localhost:8080) (and any API endpoint in there) to use the app. 50 | 51 | To bind to a different port start the app with 52 | 53 | ```shell 54 | PORT=9000 yarn start 55 | ``` 56 | 57 | ## Development tasks 58 | 59 | We use npm scripts for development-related tasks: 60 | 61 | * Run linting: `yarn lint`, to autocorrect issues `yarn lint --fix` 62 | * Run tests: `yarn test`, to start in watch mode `yarn test --watch` 63 | * Generate / update the API documentation: `yarn run apidoc` 64 | 65 | ## Debugging 66 | 67 | You can debug this application with `ndb` by running `yarn debug`. Set break points in the pop-up window and run the test file in the terminal tab of the pop-up window. 68 | 69 | In the debug window, run the tests within the supplied terminal using: 70 | 71 | ```shell 72 | yarn debugtest 73 | ``` 74 | 75 | This lets all test run sequentially to avoid concurrency issues. 76 | 77 | It can be useful to run tests with the `DEBUG` flag enabled in order to get more information on errors: 78 | 79 | ```shell 80 | DEBUG=true yarn debugtest 81 | ``` 82 | 83 | ## Documentation 84 | 85 | You can open http://localhost:8080/docs to browse the API documentation or http://localhost:8080/swagger to see the raw `openapi.json` output to be used in other tools. 86 | 87 | ## Resources 88 | 89 | The app communicates with the Wikimedia and Wikipedia APIs: 90 | 91 | - https://commons.wikimedia.org/w/api.php 92 | - https://de.wikipedia.org/w/api.php 93 | - https://en.wikipedia.org/w/api.php 94 | 95 | 96 | -------------------------------------------------------------------------------- /routes/__snapshots__/attribution.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`attribution routes GET /attribution/... (modified) returns 500 for any generic error 1`] = ` 4 | Object { 5 | "error": "Internal Server Error", 6 | "message": "An internal server error occurred", 7 | "statusCode": 500, 8 | } 9 | `; 10 | 11 | exports[`attribution routes GET /attribution/... (modified) returns an error when the requested a license we do not know 1`] = ` 12 | Object { 13 | "error": "Not Found", 14 | "message": "license-not-found", 15 | "statusCode": 404, 16 | } 17 | `; 18 | 19 | exports[`attribution routes GET /attribution/... (modified) returns an error when the requested a typeOfUse we do not know 1`] = ` 20 | Object { 21 | "error": "Bad Request", 22 | "message": "Invalid request params input", 23 | "statusCode": 400, 24 | } 25 | `; 26 | 27 | exports[`attribution routes GET /attribution/... (modified) returns attribution information for the given file 1`] = ` 28 | Object { 29 | "attributionHtml": "Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr, Apple Lisa2-IMG 1517, cropped by the great modificator, CC BY-SA 2.5", 30 | "attributionPlain": "Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr (https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg), „Apple Lisa2-IMG 1517“, cropped by the great modificator, https://creativecommons.org/licenses/by-sa/2.5/legalcode", 31 | "licenseId": "cc-by-sa-2.5", 32 | "licenseUrl": "https://creativecommons.org/licenses/by-sa/2.5/legalcode", 33 | } 34 | `; 35 | 36 | exports[`attribution routes GET /attribution/... (unmodified) returns 500 for any generic error 1`] = ` 37 | Object { 38 | "error": "Internal Server Error", 39 | "message": "An internal server error occurred", 40 | "statusCode": 500, 41 | } 42 | `; 43 | 44 | exports[`attribution routes GET /attribution/... (unmodified) returns an error when the file responds with a license we do not know 1`] = ` 45 | Object { 46 | "error": "Unprocessable Entity", 47 | "message": "validation-error", 48 | "statusCode": 422, 49 | } 50 | `; 51 | 52 | exports[`attribution routes GET /attribution/... (unmodified) returns attribution information for the given file 1`] = ` 53 | Object { 54 | "attributionHtml": "Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr, Apple Lisa2-IMG 1517, CC BY-SA 2.5", 55 | "attributionPlain": "Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr (https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg), „Apple Lisa2-IMG 1517“, https://creativecommons.org/licenses/by-sa/2.5/legalcode", 56 | "licenseId": "cc-by-sa-2.5", 57 | "licenseUrl": "https://creativecommons.org/licenses/by-sa/2.5/legalcode", 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /services/files.test.js: -------------------------------------------------------------------------------- 1 | const Files = require('./files'); 2 | 3 | const parseWikiUrl = require('./util/parseWikiUrl'); 4 | const errors = require('./util/errors'); 5 | 6 | const imageTitles = require('./__fixtures__/imageTitles'); 7 | const imageTitlesMissing = require('./__fixtures__/imageTitlesMissing'); 8 | const imagesInfo = require('./__fixtures__/imagesInfo'); 9 | 10 | jest.mock('./util/parseWikiUrl'); 11 | 12 | describe('Files', () => { 13 | const client = { getResultsFromApi: jest.fn() }; 14 | 15 | describe('getPageImages()', () => { 16 | const url = 'https://en.wikipedia.org/wiki/Article_Title'; 17 | const title = 'Article_Title'; 18 | const wikiUrl = 'https://en.wikipedia.org'; 19 | 20 | it('passes on the error if the url cannot be parsed', async () => { 21 | parseWikiUrl.mockImplementation(() => { 22 | throw new Error(errors.invalidUrl); 23 | }); 24 | const service = new Files({ client }); 25 | await expect(service.getPageImages(url)).rejects.toThrow(errors.invalidUrl); 26 | }); 27 | 28 | describe('with a valid wikipedia url', () => { 29 | beforeEach(() => parseWikiUrl.mockReturnValue({ title, wikiUrl })); 30 | 31 | it('returns a list of all images from the given article', async () => { 32 | client.getResultsFromApi 33 | .mockResolvedValueOnce(imageTitles) 34 | .mockResolvedValueOnce(imagesInfo); 35 | 36 | const service = new Files({ client }); 37 | const files = await service.getPageImages(url); 38 | 39 | expect(client.getResultsFromApi).toHaveBeenCalledWith([title], 'images', wikiUrl, { 40 | imlimit: 500, 41 | }); 42 | expect(client.getResultsFromApi).toHaveBeenCalledWith( 43 | ['File:Graphic 01.jpg', 'File:logo.svg'], 44 | 'imageinfo', 45 | wikiUrl, 46 | { iiprop: 'url|size', iiurlwidth: 300 } 47 | ); 48 | expect(files).toMatchSnapshot(); 49 | }); 50 | 51 | it('returns an empty array if no images can be found', async () => { 52 | client.getResultsFromApi.mockResolvedValueOnce(imageTitlesMissing); 53 | 54 | const service = new Files({ client }); 55 | const files = await service.getPageImages(url); 56 | 57 | expect(client.getResultsFromApi).toHaveBeenCalledWith([title], 'images', wikiUrl, { 58 | imlimit: 500, 59 | }); 60 | expect(files).toEqual([]); 61 | }); 62 | 63 | it('returns an empty array if no image url can be found', async () => { 64 | client.getResultsFromApi 65 | .mockResolvedValueOnce(imageTitles) 66 | .mockResolvedValueOnce({ pages: {} }); 67 | 68 | const service = new Files({ client }); 69 | const files = await service.getPageImages(url); 70 | 71 | expect(client.getResultsFromApi).toHaveBeenCalledWith([title], 'images', wikiUrl, { 72 | imlimit: 500, 73 | }); 74 | expect(client.getResultsFromApi).toHaveBeenCalledWith( 75 | ['File:Graphic 01.jpg', 'File:logo.svg'], 76 | 'imageinfo', 77 | wikiUrl, 78 | { iiprop: 'url|size', iiurlwidth: 300 } 79 | ); 80 | expect(files).toEqual([]); 81 | }); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /routes/licenses.integration.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const LicenseStore = require('../services/licenseStore'); 4 | const licenseData = require('../config/licenses/licenses'); 5 | const portReferences = require('../config/licenses/portReferences'); 6 | 7 | describe('licenses routes', () => { 8 | let context; 9 | 10 | const licenseStore = new LicenseStore(licenseData, portReferences); 11 | const services = { licenseStore }; 12 | 13 | const licenseKeys = ['code', 'name', 'url', 'groups']; 14 | 15 | beforeEach(async () => { 16 | context = await setup({ services }); 17 | }); 18 | 19 | afterEach(async () => { 20 | await context.destroy(); 21 | }); 22 | 23 | describe('GET /licenses', () => { 24 | function options() { 25 | return { url: `/licenses`, method: 'GET' }; 26 | } 27 | 28 | async function subject() { 29 | return context.inject(options()); 30 | } 31 | 32 | it('returns the list of licenses', async () => { 33 | const response = await subject({}); 34 | 35 | expect(response.status).toBe(200); 36 | expect(response.type).toBe('application/json'); 37 | response.payload.forEach(license => { 38 | const keys = Object.keys(license); 39 | expect(keys).toEqual(expect.arrayContaining(licenseKeys)); 40 | licenseKeys.forEach(key => { 41 | expect(license[key]).toBeDefined(); 42 | }); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('GET /licenses/compatible/{licenseId}', () => { 48 | const defaults = { 49 | licenseId: 'cc-by-sa-3.0-de', 50 | }; 51 | 52 | const expectedLicenses = ['cc-by-sa-3.0', 'cc-by-sa-4.0']; 53 | 54 | function options(overrides) { 55 | const { licenseId } = { ...defaults, ...overrides }; 56 | return { url: `/licenses/compatible/${licenseId}`, method: 'GET' }; 57 | } 58 | 59 | async function subject(overrides = {}) { 60 | return context.inject(options(overrides)); 61 | } 62 | 63 | it('returns the list of compatible licenses', async () => { 64 | const response = await subject(); 65 | 66 | expect(response.status).toBe(200); 67 | expect(response.type).toBe('application/json'); 68 | 69 | const licenses = response.payload; 70 | expect(licenses.map(license => license.code)).toEqual( 71 | expect.arrayContaining(expectedLicenses) 72 | ); 73 | 74 | licenses.forEach(license => { 75 | const keys = Object.keys(license); 76 | expect(keys).toEqual(expect.arrayContaining(licenseKeys)); 77 | licenseKeys.forEach(key => { 78 | expect(license[key]).toBeDefined(); 79 | }); 80 | }); 81 | }); 82 | 83 | it('returns a proper error for invalid license ids', async () => { 84 | const response = await subject({ licenseId: 'flerb-florb' }); 85 | 86 | expect(response.status).toBe(404); 87 | expect(response.type).toBe('application/json'); 88 | 89 | expect(response.status).toBe(404); 90 | expect(response.type).toBe('application/json'); 91 | expect(response.payload).toMatchObject({ 92 | error: 'Not Found', 93 | message: 'license-not-found', 94 | statusCode: 404, 95 | }); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /services/util/client.test.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const errors = require('./errors'); 4 | const Client = require('./client'); 5 | 6 | jest.mock('axios'); 7 | 8 | describe('Client', () => { 9 | const axiosClient = { get: jest.fn() }; 10 | 11 | beforeEach(() => axios.create.mockReturnValue(axiosClient)); 12 | 13 | it('initializes a new axios client with defaults for header and timeout', () => { 14 | const headers = { 15 | common: { 16 | 'Content-Type': 'application/json; charset=utf-8', 17 | Accept: 'application/json', 18 | }, 19 | }; 20 | const timeout = 5000; 21 | const subject = new Client(); 22 | 23 | expect(axios.create).toHaveBeenCalledWith({ headers, timeout }); 24 | expect(subject.client).toBe(axiosClient); 25 | }); 26 | 27 | describe('getResultsFromApi()', () => { 28 | const wikiUrl = 'https://en.wikipedia.org'; 29 | const apiUrl = 'https://en.wikipedia.org/w/api.php'; 30 | const defaultParams = { action: 'query', format: 'json' }; 31 | const mockedResponse = { data: { query: { foo: 'bar' } } }; 32 | 33 | it('returns an error if the API cannot be reached', async () => { 34 | const titles = ['Def_Leppard']; 35 | const error = { request: {} }; 36 | axiosClient.get.mockImplementation(() => { 37 | throw error; 38 | }); 39 | const client = new Client(); 40 | 41 | await expect(client.getResultsFromApi(titles, 'image', wikiUrl)).rejects.toThrow( 42 | errors.apiUnavailabe 43 | ); 44 | }); 45 | 46 | it('passes on any errors from doing the request', async () => { 47 | const titles = ['Def_Leppard']; 48 | axiosClient.get.mockImplementation(() => { 49 | throw new Error(); 50 | }); 51 | const client = new Client(); 52 | 53 | await expect(client.getResultsFromApi(titles, 'image', wikiUrl)).rejects.toThrow(); 54 | }); 55 | 56 | it('allows querying for images of a page', async () => { 57 | const titleString = 'Def_Leppard'; 58 | const titles = [titleString]; 59 | const prop = 'image'; 60 | const params = { ...defaultParams, prop, titles: titleString }; 61 | 62 | axiosClient.get.mockResolvedValue(mockedResponse); 63 | 64 | const client = new Client(); 65 | const subject = await client.getResultsFromApi(titles, 'image', wikiUrl); 66 | 67 | expect(axiosClient.get).toHaveBeenCalledWith(apiUrl, { params }); 68 | expect(subject).toEqual({ foo: 'bar' }); 69 | }); 70 | 71 | it('allows querying for multiple titles with additional params', async () => { 72 | const titles = ['File:Steve_Clark.jpeg', 'File:RickAllen.JPG']; 73 | const titleString = 'File:Steve_Clark.jpeg|File:RickAllen.JPG'; 74 | const prop = 'imageInfo'; 75 | const params = { ...defaultParams, prop, titles: titleString, iiprop: 'url' }; 76 | 77 | axiosClient.get.mockResolvedValue(mockedResponse); 78 | 79 | const client = new Client(); 80 | const subject = await client.getResultsFromApi(titles, prop, wikiUrl, { 81 | titles, 82 | iiprop: 'url', 83 | }); 84 | 85 | expect(axiosClient.get).toHaveBeenCalledWith(apiUrl, { params }); 86 | expect(subject).toEqual({ foo: 'bar' }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /services/licenseStore.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const License = require('../models/license'); 3 | const errors = require('../services/util/errors'); 4 | 5 | function buildLicense(params) { 6 | const attributes = { 7 | id: params[0], 8 | name: params[1], 9 | groups: params[2], 10 | compatibility: params[3], 11 | regexp: params[4], 12 | url: params[5], 13 | }; 14 | return new License(attributes); 15 | } 16 | 17 | function normalizeLicense(string) { 18 | return string 19 | .toUpperCase() 20 | .replace(/-/g, ' ') 21 | .replace('BY SA', 'BY-SA'); 22 | } 23 | 24 | function buildPortedLicense(license, string, url) { 25 | const name = normalizeLicense(string); 26 | const groups = [...license.groups, 'knownPorted']; 27 | return new License({ ...license, name, groups, url }); 28 | } 29 | 30 | // Returns an index of licenses by license-id. 31 | // 32 | // Example: 33 | // { 34 | // 'cc-by-sa-3.0': { id: ..., name: ..., ... }, 35 | // } 36 | function buildLicensesIndex(licenses) { 37 | return licenses.reduce( 38 | (licensesIndex, license) => Object.assign(licensesIndex, { [license.id]: license }), 39 | {} 40 | ); 41 | } 42 | 43 | // Returns an index of license-ids by license name. 44 | // 45 | // Example: 46 | // { 47 | // 'CC BY-SA 3.0': ['cc-by-sa-3.0', 'cc-by-sa-3.0-ported'], 48 | // } 49 | function buildLicenseNamesIndex(licenses) { 50 | return licenses.reduce((licenseNamesIndex, license) => { 51 | const licensesWithSameName = licenseNamesIndex[license.name] || []; 52 | licensesWithSameName.push(license.id); 53 | return Object.assign(licenseNamesIndex, { [license.name]: licensesWithSameName }); 54 | }, {}); 55 | } 56 | 57 | // Returns the `license` with updated `url` from the list of portReferences 58 | // if the `licenseString` is present as key in portReferences AND the `license` 59 | // is in group 'ported'; otherwise it returns the original `license`. 60 | function selectLicense(license, licenseString, portReferences) { 61 | const url = portReferences[licenseString.toLowerCase()]; 62 | 63 | if (license.isInGroup('ported') && !!url) { 64 | return buildPortedLicense(license, licenseString, url); 65 | } 66 | return license; 67 | } 68 | 69 | class LicenseStore { 70 | constructor(licenses, portReferences) { 71 | this.licenses = licenses.map(attrs => buildLicense(attrs)); 72 | this.portReferences = portReferences; 73 | this.indices = { 74 | id: buildLicensesIndex(this.licenses), 75 | name: buildLicenseNamesIndex(this.licenses), 76 | }; 77 | } 78 | 79 | // Returns all licenses with a name and url from `config/licenses/licenses.js`. 80 | all() { 81 | return this.licenses.filter(({ name, url }) => !!name && !!url); 82 | } 83 | 84 | // Returns all compatible licenses for the passed license id. 85 | compatible(id) { 86 | const license = this.getLicenseById(id); 87 | assert.ok(license, errors.licenseNotFound); 88 | const { compatibility } = license; 89 | return compatibility.map(cid => this.getLicenseById(cid)); 90 | } 91 | 92 | // Returns the first license in the list of licenses.js that matches one of the 93 | // passed licenseStrings. 94 | match(licenseStrings) { 95 | // eslint-disable-next-line no-restricted-syntax 96 | for (const license of this.licenses) { 97 | const template = licenseStrings.find(t => license.match(t)); 98 | // eslint-disable-next-line no-continue 99 | if (typeof template === 'undefined') continue; 100 | return selectLicense(license, template, this.portReferences); 101 | } 102 | 103 | return null; 104 | } 105 | 106 | // Returns the license with the passed id. 107 | getLicenseById(id) { 108 | return this.indices.id[id] || null; 109 | } 110 | 111 | // Returns the first license with the passed name. 112 | // Ordered by occurrence in `config/licenses/licenses.js`. 113 | getLicenseByName(name) { 114 | const licenseIds = this.indices.name[name]; 115 | if (licenseIds) { 116 | return this.getLicenseById(licenseIds[0]); 117 | } 118 | return null; 119 | } 120 | } 121 | 122 | module.exports = LicenseStore; 123 | -------------------------------------------------------------------------------- /services/util/parseWikiUrl.test.js: -------------------------------------------------------------------------------- 1 | const parse = require('./parseWikiUrl'); 2 | const errors = require('./errors'); 3 | 4 | // according to https://en.wikipedia.org/wiki/Help:URL#URLs_of_Wikipedia_pages 5 | // and some upload urls 6 | const cases = [ 7 | { 8 | name: 'plain article url', 9 | url: 'https://en.wikipedia.org/wiki/Lower_Saxony', 10 | expected: { 11 | title: 'Lower_Saxony', 12 | wikiUrl: 'https://en.wikipedia.org/', 13 | }, 14 | }, 15 | { 16 | name: 'mobile plain article url', 17 | url: 'https://en.m.wikipedia.org/wiki/Lower_Saxony', 18 | expected: { 19 | title: 'Lower_Saxony', 20 | wikiUrl: 'https://en.wikipedia.org/', 21 | }, 22 | }, 23 | { 24 | name: 'parameterized article url', 25 | url: 'https://de.wikipedia.org/w/index.php?title=Pommes_frites', 26 | expected: { 27 | title: 'Pommes_frites', 28 | wikiUrl: 'https://de.wikipedia.org/', 29 | }, 30 | }, 31 | { 32 | name: 'prefixed article url', 33 | url: 'https://en.wikipedia.org/wiki/Help:URL', 34 | expected: { 35 | title: 'Help:URL', 36 | wikiUrl: 'https://en.wikipedia.org/', 37 | }, 38 | }, 39 | { 40 | name: 'parameterized & prefixed article url', 41 | url: 'https://en.wikipedia.org/w/index.php?title=Help:URL', 42 | expected: { 43 | title: 'Help:URL', 44 | wikiUrl: 'https://en.wikipedia.org/', 45 | }, 46 | }, 47 | { 48 | name: 'url with #media fragment', 49 | url: 'https://nl.wikipedia.org/wiki/Friet#/media/File:Fries_2.jpg', 50 | expected: { 51 | title: 'File:Fries_2.jpg', 52 | wikiUrl: 'https://nl.wikipedia.org/', 53 | }, 54 | }, 55 | { 56 | name: 'url with #mediaviewer fragment', 57 | url: 'https://en.wikipedia.org/wiki/Soil#mediaviewer/File:SoilTexture_USDA.png', 58 | expected: { 59 | title: 'File:SoilTexture_USDA.png', 60 | wikiUrl: 'https://en.wikipedia.org/', 61 | }, 62 | }, 63 | { 64 | name: 'upload url', 65 | url: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 66 | expected: { 67 | title: 'File:Helene_Fischer_2010.jpg', 68 | wikiUrl: 'https://commons.wikimedia.org/', 69 | }, 70 | }, 71 | { 72 | name: 'upload url without protocol prefix', 73 | url: 'upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 74 | expected: { 75 | title: 'File:Helene_Fischer_2010.jpg', 76 | wikiUrl: 'https://commons.wikimedia.org/', 77 | }, 78 | }, 79 | { 80 | name: 'upload url thumbnail', 81 | url: 82 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Helene_Fischer_2010.jpg/171px-Helene_Fischer_2010.jpg', 83 | expected: { 84 | title: 'File:Helene_Fischer_2010.jpg', 85 | wikiUrl: 'https://commons.wikimedia.org/', 86 | }, 87 | }, 88 | { 89 | name: 'commons thumbnail', 90 | url: 'https://commons.m.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 91 | expected: { 92 | title: 'File:Helene_Fischer_2010.jpg', 93 | wikiUrl: 'https://commons.wikimedia.org/', 94 | }, 95 | }, 96 | ]; 97 | 98 | describe('parse()', () => { 99 | cases.forEach(({ name, url, expected }) => { 100 | it(`extracts title and wikiUrl from ${name}`, () => { 101 | expect(parse(url)).toEqual(expected); 102 | }); 103 | }); 104 | 105 | it('throws an exception for for non-wiki url', () => { 106 | const url = 'https://en.pokepedia.org/wiki/Lower_Saxony'; 107 | expect(() => parse(url)).toThrow(errors.invalidUrl); 108 | }); 109 | 110 | it('throws an exception for empty urls', () => { 111 | const url = ''; 112 | expect(() => parse(url)).toThrow(errors.invalidUrl); 113 | }); 114 | 115 | it('throws an exception for null urls', () => { 116 | const url = null; 117 | expect(() => parse(url)).toThrow(errors.invalidUrl); 118 | }); 119 | 120 | it('throws an exception for undefined urls', () => { 121 | const url = undefined; 122 | expect(() => parse(url)).toThrow(errors.invalidUrl); 123 | }); 124 | 125 | it('throws an exception for invalid urls', () => { 126 | const url = '%'; 127 | expect(() => parse(url)).toThrow(errors.invalidUrl); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /services/htmlSanitizer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* We allow param-reassigning in helper functions in this file, as eslint 3 | * fails to detect we clone each input-node and only work on the cloned node. 4 | * So, we're already doing what eslint intends us to do. 5 | */ 6 | 7 | const { JSDOM: JSDom } = require('jsdom'); 8 | 9 | function convertToNode(html) { 10 | const dom = new JSDom(`
${html.trim()}
`); 11 | return dom.window.document.body.children[0]; 12 | } 13 | 14 | function sanitizeUrls(node) { 15 | const container = node.cloneNode(true); 16 | 17 | [].slice.call(container.getElementsByTagName('a')).forEach(link => { 18 | if (link.href.indexOf('/w/index.php?title=User:') >= 0) { 19 | link.href = link.href.replace( 20 | /^.*?\/w\/index\.php\?title=([^&]+).*$/, 21 | 'https://commons.wikimedia.org/wiki/$1' 22 | ); 23 | } else if (link.href.indexOf('/wiki/User:') === 0) { 24 | link.href = `https://commons.wikimedia.org${link.href}`; 25 | } else if (link.href.indexOf('//') === 0) { 26 | link.href = `https:${link.href}`; 27 | } 28 | 29 | const linkAttributes = link.attributes; 30 | for (let i = linkAttributes.length - 1; i >= 0; i -= 1) { 31 | if (linkAttributes[i].name !== 'href') { 32 | linkAttributes.removeNamedItem(linkAttributes[i].name); 33 | } 34 | } 35 | }); 36 | 37 | return container; 38 | } 39 | 40 | function flattenVcardDivs(node) { 41 | const container = node.cloneNode(true); 42 | 43 | [].slice.call(container.querySelectorAll('div.vcard')).forEach(vcard => { 44 | const creator = vcard.querySelector('span#creator'); 45 | if (creator) { 46 | vcard.innerHTML = creator.innerHTML; 47 | } 48 | }); 49 | 50 | return container; 51 | } 52 | 53 | function removeUselessWrappingNodes(node) { 54 | const container = node.cloneNode(true); 55 | 56 | if (container.childNodes.length === 1) { 57 | const child = container.childNodes[0]; 58 | 59 | if (child.nodeName !== 'A' && child.nodeName !== '#text') { 60 | return child; 61 | } 62 | } 63 | 64 | return container; 65 | } 66 | 67 | function removeTalkLink(node) { 68 | const container = node.cloneNode(true); 69 | const childNodes = [].slice.call(container.childNodes); 70 | const talkLinkIndex = childNodes.findIndex( 71 | child => child.nodeName === 'A' && child.text === 'talk' 72 | ); 73 | if (talkLinkIndex >= 1 && talkLinkIndex < container.childNodes.length - 1) { 74 | container.removeChild(childNodes[talkLinkIndex - 1]); 75 | container.removeChild(childNodes[talkLinkIndex]); 76 | container.removeChild(childNodes[talkLinkIndex + 1]); 77 | } 78 | return container; 79 | } 80 | 81 | function removeUnwantedHtmlTags(node) { 82 | const container = node.cloneNode(true); 83 | 84 | // leaves links and textNodes intact 85 | // replaces all other elements with their innerHTML content 86 | const childHtml = [].slice.call(container.childNodes).map(child => { 87 | if (child.nodeName === 'A') { 88 | return child.outerHTML; 89 | } 90 | if (child.nodeName === '#text') { 91 | return child.textContent; 92 | } 93 | return child.innerHTML; 94 | }); 95 | 96 | container.innerHTML = childHtml.join(''); 97 | return container; 98 | } 99 | 100 | function removeUnwantedWhiteSpace(node) { 101 | const container = node.cloneNode(true); 102 | container.innerHTML = container.innerHTML 103 | .replace(' ', ' ') 104 | .replace(/\s+/g, ' ') 105 | .trim(); 106 | return container; 107 | } 108 | 109 | // This file follows the original Lizenshinweisgenerator `_scrapeSummaryField` 110 | // HTML sanitization strategy. 111 | // Takes a HTML string and returns a sanitized HTML string. 112 | class HtmlSanitizer { 113 | constructor(html) { 114 | this.html = html; 115 | } 116 | 117 | sanitize() { 118 | let node = convertToNode(this.html); 119 | 120 | node = sanitizeUrls(node); 121 | node = flattenVcardDivs(node); 122 | node = removeUselessWrappingNodes(node); 123 | node = removeTalkLink(node); 124 | node = removeUnwantedHtmlTags(node); 125 | node = removeUnwantedWhiteSpace(node); 126 | 127 | return node.innerHTML; 128 | } 129 | } 130 | 131 | module.exports = HtmlSanitizer; 132 | -------------------------------------------------------------------------------- /routes/fileinfo.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const errors = require('../services/util/errors'); 4 | const licenseFactory = require('../__helpers__/licenseFactory'); 5 | const fileDataFactory = require('../__helpers__/fileDataFactory'); 6 | 7 | describe('fileinfo routes', () => { 8 | let context; 9 | 10 | const licenseStore = { all: jest.fn(), compatible: jest.fn() }; 11 | const fileData = { getFileData: jest.fn() }; 12 | const licenses = { getLicense: jest.fn() }; 13 | const services = { licenseStore, fileData, licenses }; 14 | 15 | beforeEach(async () => { 16 | fileData.getFileData.mockReset(); 17 | licenses.getLicense.mockReset(); 18 | licenseStore.all.mockReset(); 19 | licenseStore.compatible.mockReset(); 20 | context = await setup({ services }); 21 | }); 22 | 23 | afterEach(async () => { 24 | await context.destroy(); 25 | }); 26 | 27 | describe('GET /fileinfo', () => { 28 | const title = 'File:Apple_Lisa2-IMG_1517.jpg'; 29 | const wikiUrl = 'https://en.wikipedia.org'; 30 | const mediaType = 'BITMAP'; 31 | const mockFileData = fileDataFactory({ title, wikiUrl, mediaType }); 32 | const license = licenseFactory({ id: 'cc-by-sa-4.0', name: 'CC BY-SA 4.0' }); 33 | 34 | function options() { 35 | return { url: `/fileinfo/${title}`, method: 'GET' }; 36 | } 37 | 38 | async function subject() { 39 | return context.inject(options()); 40 | } 41 | 42 | it('returns the license of a file', async () => { 43 | fileData.getFileData.mockReturnValue(mockFileData); 44 | licenses.getLicense.mockReturnValue(license); 45 | 46 | const response = await subject({}); 47 | 48 | expect(fileData.getFileData).toHaveBeenCalledWith(title); 49 | expect(licenses.getLicense).toHaveBeenCalledWith({ title, wikiUrl }); 50 | expect(response.status).toBe(200); 51 | expect(response.type).toBe('application/json'); 52 | expect(response.payload).toMatchSnapshot(); 53 | }); 54 | 55 | it('returns a 404 response when the license cannot be retrieved', async () => { 56 | fileData.getFileData.mockImplementation(() => { 57 | throw new Error(errors.emptyResponse); 58 | }); 59 | 60 | const response = await subject({}); 61 | 62 | expect(fileData.getFileData).toHaveBeenCalledWith(title); 63 | expect(licenses.getLicense).not.toHaveBeenCalled(); 64 | expect(response.status).toBe(404); 65 | expect(response.type).toBe('application/json'); 66 | expect(response.payload).toMatchSnapshot(); 67 | }); 68 | 69 | it('returns a 422 response when the identifier cannot be parsed', async () => { 70 | fileData.getFileData.mockImplementation(() => { 71 | throw new Error(errors.invalidUrl); 72 | }); 73 | 74 | const response = await subject({}); 75 | 76 | expect(fileData.getFileData).toHaveBeenCalledWith(title); 77 | expect(licenses.getLicense).not.toHaveBeenCalled(); 78 | expect(response.status).toBe(422); 79 | expect(response.type).toBe('application/json'); 80 | expect(response.payload).toMatchSnapshot(); 81 | }); 82 | 83 | it('returns a 500 response for any generic error', async () => { 84 | fileData.getFileData.mockImplementation(() => { 85 | throw new Error('some error'); 86 | }); 87 | 88 | const response = await subject({}); 89 | 90 | expect(fileData.getFileData).toHaveBeenCalledWith(title); 91 | expect(licenses.getLicense).not.toHaveBeenCalled(); 92 | expect(response.status).toBe(500); 93 | expect(response.type).toBe('application/json'); 94 | expect(response.payload).toMatchSnapshot(); 95 | }); 96 | 97 | it('returns a 503 response when the wiki api is not reachable', async () => { 98 | fileData.getFileData.mockImplementation(() => { 99 | throw new Error(errors.apiUnavailabe); 100 | }); 101 | 102 | const response = await subject({}); 103 | 104 | expect(fileData.getFileData).toHaveBeenCalledWith(title); 105 | expect(licenses.getLicense).not.toHaveBeenCalled(); 106 | expect(response.status).toBe(503); 107 | expect(response.type).toBe('application/json'); 108 | expect(response.payload).toMatchSnapshot(); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /services/util/serializers.test.js: -------------------------------------------------------------------------------- 1 | const serializers = require('./serializers'); 2 | const licenseFactory = require('../../__helpers__/licenseFactory'); 3 | const attributionFactory = require('../../__helpers__/attributionFactory'); 4 | const fileDataFactory = require('../../__helpers__/fileDataFactory'); 5 | 6 | describe('license()', () => { 7 | const { license: serialize } = serializers; 8 | const license = licenseFactory({ 9 | id: 'cc-by-sa-4.0', 10 | name: 'CC BY-SA 4.0', 11 | groups: ['cc', 'cc4'], 12 | url: 'https://example.org/licenses/by-sa/4.0', 13 | }); 14 | 15 | it('serializes a license object into the specified format', () => { 16 | expect(serialize(license)).toEqual({ 17 | code: 'cc-by-sa-4.0', 18 | name: 'CC BY-SA 4.0', 19 | url: 'https://example.org/licenses/by-sa/4.0', 20 | groups: ['cc', 'cc4'], 21 | }); 22 | }); 23 | 24 | it('does not fail if the object misses the required keys', () => { 25 | expect(serialize({})).toEqual({ 26 | code: undefined, 27 | name: undefined, 28 | url: undefined, 29 | groups: undefined, 30 | }); 31 | }); 32 | }); 33 | 34 | describe('attribution()', () => { 35 | const { attribution: serialize } = serializers; 36 | const license = licenseFactory({ 37 | id: 'licenseId', 38 | name: 'licenseName', 39 | groups: [], 40 | url: 'licenseUrl', 41 | }); 42 | const attribution = attributionFactory({ 43 | license, 44 | fileInfo: { 45 | rawUrl: 'file_url', 46 | normalizedTitle: 'file_title', 47 | artistHtml: 'artist', 48 | }, 49 | }); 50 | 51 | it('serializes a attribution object into the specified format', () => { 52 | expect(serialize(attribution)).toEqual({ 53 | licenseId: 'licenseId', 54 | licenseUrl: 'licenseUrl', 55 | attributionHtml: 56 | 'artist, file_title, licenseName', 57 | attributionPlain: 'artist (file_url), „file_title“, licenseUrl', 58 | }); 59 | }); 60 | 61 | it('does not fail if the object misses the required keys', () => { 62 | expect(serialize({})).toEqual({ 63 | licenseId: undefined, 64 | licenseUrl: undefined, 65 | attributionHtml: undefined, 66 | attributionPlain: undefined, 67 | }); 68 | }); 69 | }); 70 | 71 | describe('fileinfo()', () => { 72 | const { fileinfo: serialize } = serializers; 73 | const fileData = fileDataFactory({}); 74 | const license = licenseFactory({}); 75 | 76 | it('serializes', () => { 77 | const serialized = serialize(fileData, license); 78 | expect(serialized).toEqual({ 79 | license: { 80 | code: 'cc-by-sa-3.0', 81 | groups: ['cc', 'cc4'], 82 | name: 'CC BY-SA 3.0', 83 | url: 'https://creativecommons.org/licenses/by-sa/3.0/legalcode', 84 | }, 85 | attributionHtml: 86 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 87 | authorHtml: 88 | 'Rama & Musée Bolo', 89 | mediaType: 'BITMAP', 90 | }); 91 | }); 92 | 93 | it('does not fail if the fileData object misses the required keys', () => { 94 | const serialized = serialize({}, license); 95 | expect(serialized).toEqual({ 96 | license: { 97 | code: 'cc-by-sa-3.0', 98 | groups: ['cc', 'cc4'], 99 | name: 'CC BY-SA 3.0', 100 | url: 'https://creativecommons.org/licenses/by-sa/3.0/legalcode', 101 | }, 102 | attributionHtml: undefined, 103 | authorHtml: undefined, 104 | mediaType: undefined, 105 | }); 106 | }); 107 | 108 | it('does not fail if the license object misses the required keys', () => { 109 | const serialized = serialize(fileData, {}); 110 | expect(serialized).toEqual({ 111 | license: { 112 | code: undefined, 113 | groups: undefined, 114 | name: undefined, 115 | url: undefined, 116 | }, 117 | attributionHtml: 118 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 119 | authorHtml: 120 | 'Rama & Musée Bolo', 121 | mediaType: 'BITMAP', 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /services/__fixtures__/imageInfoWithoutAttribution.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'File:Apple_Lisa2-IMG_1517.jpg', 5 | to: 'File:Apple Lisa2-IMG 1517.jpg', 6 | }, 7 | ], 8 | pages: { 9 | '-1': { 10 | ns: 6, 11 | title: 'File:Apple Lisa2-IMG 1517.jpg', 12 | missing: '', 13 | known: '', 14 | imagerepository: 'shared', 15 | imageinfo: [ 16 | { 17 | thumburl: 18 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Apple_Lisa2-IMG_1517.jpg/300px-Apple_Lisa2-IMG_1517.jpg', 19 | thumbwidth: 300, 20 | thumbheight: 300, 21 | url: 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 22 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:Apple_Lisa2-IMG_1517.jpg', 23 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=17540984', 24 | extmetadata: { 25 | DateTime: { 26 | value: '2011-12-01 14:17:15', 27 | source: 'mediawiki-metadata', 28 | hidden: '', 29 | }, 30 | ObjectName: { 31 | value: 'Apple Lisa2-IMG 1517', 32 | source: 'mediawiki-metadata', 33 | hidden: '', 34 | }, 35 | CommonsMetadataExtension: { 36 | value: 1.2, 37 | source: 'extension', 38 | hidden: '', 39 | }, 40 | Categories: { 41 | value: 42 | 'All media supported by Wikimedia CH|Apple Lisa|CeCILL|Musée Bolo|Self-published work|Supported by Wikimedia CH', 43 | source: 'commons-categories', 44 | hidden: '', 45 | }, 46 | Assessments: { 47 | value: '', 48 | source: 'commons-categories', 49 | hidden: '', 50 | }, 51 | ImageDescription: { 52 | value: 53 | 'The making of this document was supported by Wikimedia CH. (Submit your project!)
\nFor all the files concerned, please see the category Supported by Wikimedia CH.', 54 | source: 'commons-desc-page', 55 | }, 56 | DateTimeOriginal: { 57 | value: '', 58 | source: 'commons-desc-page', 59 | }, 60 | Credit: { 61 | value: 'Own work', 62 | source: 'commons-desc-page', 63 | hidden: '', 64 | }, 65 | Artist: { 66 | value: 67 | 'Rama & Musée Bolo', 68 | source: 'commons-desc-page', 69 | }, 70 | Permission: { 71 | value: 'You may select the license of your choice.', 72 | source: 'commons-desc-page', 73 | hidden: '', 74 | }, 75 | LicenseShortName: { 76 | value: 'CC BY-SA 2.0 fr', 77 | source: 'commons-desc-page', 78 | hidden: '', 79 | }, 80 | UsageTerms: { 81 | value: 'Creative Commons Attribution-Share Alike 2.0 fr', 82 | source: 'commons-desc-page', 83 | hidden: '', 84 | }, 85 | AttributionRequired: { 86 | value: 'true', 87 | source: 'commons-desc-page', 88 | hidden: '', 89 | }, 90 | LicenseUrl: { 91 | value: 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 92 | source: 'commons-desc-page', 93 | hidden: '', 94 | }, 95 | Copyrighted: { 96 | value: 'True', 97 | source: 'commons-desc-page', 98 | hidden: '', 99 | }, 100 | Restrictions: { 101 | value: '', 102 | source: 'commons-desc-page', 103 | hidden: '', 104 | }, 105 | License: { 106 | value: 'cc-by-sa-2.0-fr', 107 | source: 'commons-templates', 108 | hidden: '', 109 | }, 110 | }, 111 | }, 112 | ], 113 | }, 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /services/__fixtures__/imageInfoWithoutArtist.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'File:Apple_Lisa2-IMG_1517.jpg', 5 | to: 'File:Apple Lisa2-IMG 1517.jpg', 6 | }, 7 | ], 8 | pages: { 9 | '-1': { 10 | ns: 6, 11 | title: 'File:Apple Lisa2-IMG 1517.jpg', 12 | missing: '', 13 | known: '', 14 | imagerepository: 'shared', 15 | imageinfo: [ 16 | { 17 | thumburl: 18 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Apple_Lisa2-IMG_1517.jpg/300px-Apple_Lisa2-IMG_1517.jpg', 19 | thumbwidth: 300, 20 | thumbheight: 300, 21 | url: 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 22 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:Apple_Lisa2-IMG_1517.jpg', 23 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=17540984', 24 | extmetadata: { 25 | DateTime: { 26 | value: '2011-12-01 14:17:15', 27 | source: 'mediawiki-metadata', 28 | hidden: '', 29 | }, 30 | ObjectName: { 31 | value: 'Apple Lisa2-IMG 1517', 32 | source: 'mediawiki-metadata', 33 | hidden: '', 34 | }, 35 | CommonsMetadataExtension: { 36 | value: 1.2, 37 | source: 'extension', 38 | hidden: '', 39 | }, 40 | Categories: { 41 | value: 42 | 'All media supported by Wikimedia CH|Apple Lisa|CeCILL|Musée Bolo|Self-published work|Supported by Wikimedia CH', 43 | source: 'commons-categories', 44 | hidden: '', 45 | }, 46 | Assessments: { 47 | value: '', 48 | source: 'commons-categories', 49 | hidden: '', 50 | }, 51 | ImageDescription: { 52 | value: 53 | 'The making of this document was supported by Wikimedia CH. (Submit your project!)
\nFor all the files concerned, please see the category Supported by Wikimedia CH.', 54 | source: 'commons-desc-page', 55 | }, 56 | DateTimeOriginal: { 57 | value: '', 58 | source: 'commons-desc-page', 59 | }, 60 | Credit: { 61 | value: 'Own work', 62 | source: 'commons-desc-page', 63 | hidden: '', 64 | }, 65 | Permission: { 66 | value: 'You may select the license of your choice.', 67 | source: 'commons-desc-page', 68 | hidden: '', 69 | }, 70 | LicenseShortName: { 71 | value: 'CC BY-SA 2.0 fr', 72 | source: 'commons-desc-page', 73 | hidden: '', 74 | }, 75 | UsageTerms: { 76 | value: 'Creative Commons Attribution-Share Alike 2.0 fr', 77 | source: 'commons-desc-page', 78 | hidden: '', 79 | }, 80 | AttributionRequired: { 81 | value: 'true', 82 | source: 'commons-desc-page', 83 | hidden: '', 84 | }, 85 | Attribution: { 86 | value: 87 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 88 | source: 'commons-desc-page', 89 | hidden: '', 90 | }, 91 | LicenseUrl: { 92 | value: 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 93 | source: 'commons-desc-page', 94 | hidden: '', 95 | }, 96 | Copyrighted: { 97 | value: 'True', 98 | source: 'commons-desc-page', 99 | hidden: '', 100 | }, 101 | Restrictions: { 102 | value: '', 103 | source: 'commons-desc-page', 104 | hidden: '', 105 | }, 106 | License: { 107 | value: 'cc-by-sa-2.0-fr', 108 | source: 'commons-templates', 109 | hidden: '', 110 | }, 111 | }, 112 | }, 113 | ], 114 | }, 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /services/__fixtures__/imageInfoWithoutNormalization.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pages: { 3 | '-1': { 4 | ns: 6, 5 | title: 'File:Apple Lisa2-IMG 1517.jpg', 6 | missing: '', 7 | known: '', 8 | imagerepository: 'shared', 9 | imageinfo: [ 10 | { 11 | thumburl: 12 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Apple_Lisa2-IMG_1517.jpg/300px-Apple_Lisa2-IMG_1517.jpg', 13 | thumbwidth: 300, 14 | thumbheight: 300, 15 | url: 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 16 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:Apple_Lisa2-IMG_1517.jpg', 17 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=17540984', 18 | extmetadata: { 19 | DateTime: { 20 | value: '2011-12-01 14:17:15', 21 | source: 'mediawiki-metadata', 22 | hidden: '', 23 | }, 24 | ObjectName: { 25 | value: 'Apple Lisa2-IMG 1517', 26 | source: 'mediawiki-metadata', 27 | hidden: '', 28 | }, 29 | CommonsMetadataExtension: { 30 | value: 1.2, 31 | source: 'extension', 32 | hidden: '', 33 | }, 34 | Categories: { 35 | value: 36 | 'All media supported by Wikimedia CH|Apple Lisa|CeCILL|Musée Bolo|Self-published work|Supported by Wikimedia CH', 37 | source: 'commons-categories', 38 | hidden: '', 39 | }, 40 | Assessments: { 41 | value: '', 42 | source: 'commons-categories', 43 | hidden: '', 44 | }, 45 | ImageDescription: { 46 | value: 47 | 'The making of this document was supported by Wikimedia CH. (Submit your project!)
\nFor all the files concerned, please see the category Supported by Wikimedia CH.', 48 | source: 'commons-desc-page', 49 | }, 50 | DateTimeOriginal: { 51 | value: '', 52 | source: 'commons-desc-page', 53 | }, 54 | Credit: { 55 | value: 'Own work', 56 | source: 'commons-desc-page', 57 | hidden: '', 58 | }, 59 | Artist: { 60 | value: 61 | 'Rama & Musée Bolo', 62 | source: 'commons-desc-page', 63 | }, 64 | Permission: { 65 | value: 'You may select the license of your choice.', 66 | source: 'commons-desc-page', 67 | hidden: '', 68 | }, 69 | LicenseShortName: { 70 | value: 'CC BY-SA 2.0 fr', 71 | source: 'commons-desc-page', 72 | hidden: '', 73 | }, 74 | UsageTerms: { 75 | value: 'Creative Commons Attribution-Share Alike 2.0 fr', 76 | source: 'commons-desc-page', 77 | hidden: '', 78 | }, 79 | AttributionRequired: { 80 | value: 'true', 81 | source: 'commons-desc-page', 82 | hidden: '', 83 | }, 84 | Attribution: { 85 | value: 86 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 87 | source: 'commons-desc-page', 88 | hidden: '', 89 | }, 90 | LicenseUrl: { 91 | value: 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 92 | source: 'commons-desc-page', 93 | hidden: '', 94 | }, 95 | Copyrighted: { 96 | value: 'True', 97 | source: 'commons-desc-page', 98 | hidden: '', 99 | }, 100 | Restrictions: { 101 | value: '', 102 | source: 'commons-desc-page', 103 | hidden: '', 104 | }, 105 | License: { 106 | value: 'cc-by-sa-2.0-fr', 107 | source: 'commons-templates', 108 | hidden: '', 109 | }, 110 | }, 111 | }, 112 | ], 113 | }, 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /routes/files.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const errors = require('../services/util/errors'); 4 | 5 | describe('files routes', () => { 6 | const files = { getPageImages: jest.fn() }; 7 | const services = { files }; 8 | const filesMock = [ 9 | { 10 | descriptionUrl: 'https://commons.wikimedia.org/wiki/File:Graphic_01.jpg', 11 | fileSize: 112450, 12 | rawUrl: 'https://upload.wikimedia.org/wikipedia/commons/e/ef/Graphic_01.jpg', 13 | thumbnail: { 14 | height: 300, 15 | rawUrl: 16 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Graphic_01.jpg/300px-Graphic_01.jpg', 17 | width: 300, 18 | }, 19 | title: 'File:Graphic 01.jpg', 20 | }, 21 | ]; 22 | 23 | let context; 24 | 25 | beforeEach(async () => { 26 | files.getPageImages.mockReset(); 27 | context = await setup({ services }); 28 | }); 29 | 30 | afterEach(async () => { 31 | await context.destroy(); 32 | }); 33 | 34 | describe('GET /files', () => { 35 | const articleUrl = 'https://en.wikipedia.org/wiki/Wikimedia_Foundation'; 36 | const encodedArticleUrl = encodeURIComponent(articleUrl); 37 | 38 | async function subject(options = {}) { 39 | const defaults = { method: 'GET' }; 40 | return context.inject({ ...defaults, ...options }); 41 | } 42 | 43 | it('returns a list of all files from the given article', async () => { 44 | files.getPageImages.mockResolvedValue(filesMock); 45 | 46 | const response = await subject({ url: `/files/${encodedArticleUrl}` }); 47 | 48 | expect(files.getPageImages).toHaveBeenCalledWith(articleUrl); 49 | expect(response.status).toBe(200); 50 | expect(response.type).toBe('application/json'); 51 | expect(response.payload).toMatchSnapshot(); 52 | }); 53 | 54 | it('returns a 400 response for non http(s) urls', async () => { 55 | const sftpUrl = 'sftp://en.wikipedia.org/wiki/Wikimedia_Foundation'; 56 | const encodedSftpUrl = encodeURIComponent(sftpUrl); 57 | const response = await subject({ url: `/files/${encodedSftpUrl}` }); 58 | 59 | expect(files.getPageImages).not.toHaveBeenCalled(); 60 | expect(response.status).toBe(400); 61 | expect(response.type).toBe('application/json'); 62 | expect(response.payload).toMatchSnapshot(); 63 | }); 64 | 65 | it('returns 400 response if the URL is invalid', async () => { 66 | const response = await subject({ url: '/files/something-invalid' }); 67 | 68 | expect(files.getPageImages).not.toHaveBeenCalled(); 69 | expect(response.status).toBe(400); 70 | expect(response.type).toBe('application/json'); 71 | expect(response.payload).toMatchSnapshot(); 72 | }); 73 | 74 | it('returns a 422 response for non-wiki urls', async () => { 75 | files.getPageImages.mockImplementation(() => { 76 | throw new Error(errors.invalidUrl); 77 | }); 78 | 79 | const response = await subject({ url: `/files/${encodedArticleUrl}` }); 80 | 81 | expect(files.getPageImages).toHaveBeenCalledWith(articleUrl); 82 | expect(response.status).toBe(422); 83 | expect(response.type).toBe('application/json'); 84 | expect(response.payload).toMatchSnapshot(); 85 | }); 86 | 87 | it('returns a 500 response for a generic error', async () => { 88 | files.getPageImages.mockImplementation(() => { 89 | throw new Error('just some error'); 90 | }); 91 | 92 | const response = await subject({ url: `/files/${encodedArticleUrl}` }); 93 | 94 | expect(files.getPageImages).toHaveBeenCalledWith(articleUrl); 95 | expect(response.status).toBe(500); 96 | expect(response.type).toBe('application/json'); 97 | expect(response.payload).toMatchSnapshot(); 98 | }); 99 | 100 | it('returns a 503 response when the wiki api is not reachable', async () => { 101 | files.getPageImages.mockImplementation(() => { 102 | throw new Error(errors.apiUnavailabe); 103 | }); 104 | 105 | const response = await subject({ url: `/files/${encodedArticleUrl}` }); 106 | 107 | expect(files.getPageImages).toHaveBeenCalledWith(articleUrl); 108 | expect(response.status).toBe(503); 109 | expect(response.type).toBe('application/json'); 110 | expect(response.payload).toMatchSnapshot(); 111 | }); 112 | 113 | it('returns a 404 when called with an unencoded articleUrl', async () => { 114 | const rawArticleUrl = 'https://en.wikipedia.org/wiki/Wikimedia_Foundation'; 115 | 116 | const response = await subject({ url: `/files/${rawArticleUrl}` }); 117 | 118 | expect(files.getPageImages).not.toHaveBeenCalled(); 119 | expect(response.status).toBe(404); 120 | expect(response.type).toBe('application/json'); 121 | expect(response.payload).toMatchSnapshot(); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /routes/attribution.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const assert = require('assert'); 3 | 4 | const { knownLanguages, knownTypesOfUse, Attribution } = require('../models/attribution'); 5 | const errors = require('../services/util/errors'); 6 | const { attribution: serialize } = require('../services/util/serializers'); 7 | const definitions = require('./__swagger__/definitions'); 8 | const prefix = require('./__utils__/path')('/attribution'); 9 | 10 | const routes = []; 11 | 12 | const attributionSchema = Joi.object() 13 | .required() 14 | .keys({ 15 | attributionHtml: Joi.string().required(), 16 | attributionPlain: Joi.string().required(), 17 | licenseId: Joi.string().required(), 18 | licenseUrl: Joi.string() 19 | .uri() 20 | .required(), 21 | }) 22 | .meta({ className: 'AttributionShowResponse' }); 23 | 24 | function handleError(h, { message }) { 25 | switch (message) { 26 | case errors.licenseNotFound: 27 | return h.error(message, { statusCode: 404 }); 28 | case errors.invalidUrl: 29 | case errors.validationError: 30 | return h.error(message, { statusCode: 422 }); 31 | case errors.apiUnavailabe: 32 | return h.error(message, { statusCode: 503 }); 33 | default: 34 | return h.error(message); 35 | } 36 | } 37 | 38 | routes.push({ 39 | path: prefix('/{languageCode}/{file}/{typeOfUse}/unmodified'), 40 | method: 'GET', 41 | options: { 42 | description: 'Generate attribution', 43 | notes: 'Generate attribution hints for the given file.', 44 | validate: { 45 | params: { 46 | languageCode: Joi.string().valid(knownLanguages), 47 | file: Joi.string(), 48 | typeOfUse: Joi.string().valid(knownTypesOfUse), 49 | }, 50 | }, 51 | response: { 52 | schema: attributionSchema, 53 | status: { 54 | 400: definitions.errors['400'], 55 | 422: definitions.errors['422'], 56 | 500: definitions.errors['500'], 57 | 503: definitions.errors['503'], 58 | }, 59 | }, 60 | plugins: { 61 | 'hapi-swaggered': { 62 | operationId: 'attribution.unmodified.show', 63 | security: [{ default: [] }], 64 | }, 65 | }, 66 | }, 67 | handler: async (request, h) => { 68 | const { fileData, licenses } = request.server.app.services; 69 | const { tracker } = request.server.app; 70 | const { file } = request.params; 71 | try { 72 | const fileInfo = await fileData.getFileData(file); 73 | const license = await licenses.getLicense(fileInfo); 74 | const attribution = new Attribution({ 75 | isEdited: false, 76 | fileInfo, 77 | license, 78 | ...request.params, 79 | }); 80 | tracker.track(request, 'Attribution'); 81 | return h.response(serialize(attribution)); 82 | } catch (error) { 83 | return handleError(h, error); 84 | } 85 | }, 86 | }); 87 | 88 | routes.push({ 89 | path: prefix( 90 | '/{languageCode}/{file}/{typeOfUse}/modified/{modification}/{modificationAuthor}/{licenseId}' 91 | ), 92 | method: 'GET', 93 | options: { 94 | description: 'Generate attribution for a modified work', 95 | notes: 'Generate attribution hints for the given file if that file was modified.', 96 | validate: { 97 | params: { 98 | languageCode: Joi.string().valid(knownLanguages), 99 | file: Joi.string(), 100 | typeOfUse: Joi.string().valid(knownTypesOfUse), 101 | modification: Joi.string(), 102 | modificationAuthor: Joi.string(), 103 | licenseId: Joi.string(), 104 | }, 105 | }, 106 | response: { 107 | schema: attributionSchema, 108 | status: { 109 | 400: definitions.errors['400'], 110 | 422: definitions.errors['422'], 111 | 500: definitions.errors['500'], 112 | 503: definitions.errors['503'], 113 | }, 114 | }, 115 | plugins: { 116 | 'hapi-swaggered': { 117 | operationId: 'attribution.modified.show', 118 | security: [{ default: [] }], 119 | }, 120 | }, 121 | }, 122 | handler: async (request, h) => { 123 | const { fileData, licenseStore } = request.server.app.services; 124 | const { tracker } = request.server.app; 125 | const { file, licenseId } = request.params; 126 | try { 127 | const fileInfo = await fileData.getFileData(file); 128 | const license = licenseStore.getLicenseById(licenseId); 129 | assert.ok(license, errors.licenseNotFound); 130 | 131 | const attribution = new Attribution({ 132 | isEdited: true, 133 | fileInfo, 134 | license, 135 | ...request.params, 136 | }); 137 | tracker.track(request, 'Attribution'); 138 | return h.response(serialize(attribution)); 139 | } catch (error) { 140 | return handleError(h, error); 141 | } 142 | }, 143 | }); 144 | 145 | module.exports = routes; 146 | -------------------------------------------------------------------------------- /services/__fixtures__/imageInfo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalized: [ 3 | { 4 | from: 'File:Apple_Lisa2-IMG_1517.jpg', 5 | to: 'File:Apple Lisa2-IMG 1517.jpg', 6 | }, 7 | ], 8 | pages: { 9 | '-1': { 10 | ns: 6, 11 | title: 'File:Apple Lisa2-IMG 1517.jpg', 12 | missing: '', 13 | known: '', 14 | imagerepository: 'shared', 15 | imageinfo: [ 16 | { 17 | thumburl: 18 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Apple_Lisa2-IMG_1517.jpg/300px-Apple_Lisa2-IMG_1517.jpg', 19 | thumbwidth: 300, 20 | thumbheight: 300, 21 | url: 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 22 | descriptionurl: 'https://commons.wikimedia.org/wiki/File:Apple_Lisa2-IMG_1517.jpg', 23 | descriptionshorturl: 'https://commons.wikimedia.org/w/index.php?curid=17540984', 24 | extmetadata: { 25 | DateTime: { 26 | value: '2011-12-01 14:17:15', 27 | source: 'mediawiki-metadata', 28 | hidden: '', 29 | }, 30 | ObjectName: { 31 | value: 'Apple Lisa2-IMG 1517', 32 | source: 'mediawiki-metadata', 33 | hidden: '', 34 | }, 35 | CommonsMetadataExtension: { 36 | value: 1.2, 37 | source: 'extension', 38 | hidden: '', 39 | }, 40 | Categories: { 41 | value: 42 | 'All media supported by Wikimedia CH|Apple Lisa|CeCILL|Musée Bolo|Self-published work|Supported by Wikimedia CH', 43 | source: 'commons-categories', 44 | hidden: '', 45 | }, 46 | Assessments: { 47 | value: '', 48 | source: 'commons-categories', 49 | hidden: '', 50 | }, 51 | ImageDescription: { 52 | value: 53 | 'The making of this document was supported by Wikimedia CH. (Submit your project!)
\nFor all the files concerned, please see the category Supported by Wikimedia CH.', 54 | source: 'commons-desc-page', 55 | }, 56 | DateTimeOriginal: { 57 | value: '', 58 | source: 'commons-desc-page', 59 | }, 60 | Credit: { 61 | value: 'Own work', 62 | source: 'commons-desc-page', 63 | hidden: '', 64 | }, 65 | Artist: { 66 | value: 67 | 'Rama & Musée Bolo', 68 | source: 'commons-desc-page', 69 | }, 70 | Permission: { 71 | value: 'You may select the license of your choice.', 72 | source: 'commons-desc-page', 73 | hidden: '', 74 | }, 75 | LicenseShortName: { 76 | value: 'CC BY-SA 2.0 fr', 77 | source: 'commons-desc-page', 78 | hidden: '', 79 | }, 80 | UsageTerms: { 81 | value: 'Creative Commons Attribution-Share Alike 2.0 fr', 82 | source: 'commons-desc-page', 83 | hidden: '', 84 | }, 85 | AttributionRequired: { 86 | value: 'true', 87 | source: 'commons-desc-page', 88 | hidden: '', 89 | }, 90 | Attribution: { 91 | value: 92 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 93 | source: 'commons-desc-page', 94 | hidden: '', 95 | }, 96 | LicenseUrl: { 97 | value: 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 98 | source: 'commons-desc-page', 99 | hidden: '', 100 | }, 101 | Copyrighted: { 102 | value: 'True', 103 | source: 'commons-desc-page', 104 | hidden: '', 105 | }, 106 | Restrictions: { 107 | value: '', 108 | source: 'commons-desc-page', 109 | hidden: '', 110 | }, 111 | License: { 112 | value: 'cc-by-sa-2.0-fr', 113 | source: 'commons-templates', 114 | hidden: '', 115 | }, 116 | }, 117 | }, 118 | ], 119 | }, 120 | }, 121 | }; 122 | -------------------------------------------------------------------------------- /services/fileData.test.js: -------------------------------------------------------------------------------- 1 | const FileData = require('./fileData'); 2 | 3 | const errors = require('../services/util/errors'); 4 | const imageInfoMock = require('./__fixtures__/imageInfo'); 5 | const imageInfoWithoutArtistMock = require('./__fixtures__/imageInfoWithoutArtist'); 6 | const imageInfoWithoutAttributionMock = require('./__fixtures__/imageInfoWithoutAttribution'); 7 | const imageInfoWithoutNormalizationMock = require('./__fixtures__/imageInfoWithoutNormalization'); 8 | 9 | describe('FileData', () => { 10 | const client = { getResultsFromApi: jest.fn() }; 11 | 12 | describe('getFileData', () => { 13 | const title = 'File:Apple_Lisa2-IMG_1517.jpg'; 14 | const normalizedTitle = 'File:Apple Lisa2-IMG 1517.jpg'; 15 | const wikiUrl = 'https://commons.wikimedia.org/'; 16 | const artistHtml = 17 | 'Rama & Musée Bolo'; 18 | const attributionHtml = 19 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr'; 20 | const rawUrl = 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg'; 21 | 22 | describe('when passing a valid url', () => { 23 | const url = 'https://en.wikipedia.org/wiki/Apple_Lisa#/media/File:Apple_Lisa.jpg'; 24 | 25 | it('retrieves the original file data', async () => { 26 | client.getResultsFromApi.mockResolvedValueOnce(imageInfoMock); 27 | 28 | const service = new FileData({ client }); 29 | const fileData = await service.getFileData(url); 30 | 31 | expect(client.getResultsFromApi).toHaveBeenCalledWith( 32 | ['File:Apple_Lisa.jpg'], 33 | 'imageinfo', 34 | 'https://en.wikipedia.org/', 35 | { 36 | iiprop: 'url|extmetadata|mediatype', 37 | iilimit: 1, 38 | iiurlheight: 300, 39 | } 40 | ); 41 | expect(fileData).toEqual({ 42 | title, 43 | normalizedTitle, 44 | rawUrl, 45 | wikiUrl, 46 | artistHtml, 47 | attributionHtml, 48 | }); 49 | }); 50 | 51 | it('skips the artist information if not available for the file', async () => { 52 | client.getResultsFromApi.mockResolvedValueOnce(imageInfoWithoutArtistMock); 53 | 54 | const service = new FileData({ client }); 55 | const fileData = await service.getFileData(url); 56 | 57 | expect(fileData).toEqual({ 58 | title, 59 | normalizedTitle, 60 | rawUrl, 61 | wikiUrl, 62 | artistHtml: null, 63 | attributionHtml, 64 | }); 65 | }); 66 | 67 | it('skips the attribution information if not available for the file', async () => { 68 | client.getResultsFromApi.mockResolvedValueOnce(imageInfoWithoutAttributionMock); 69 | 70 | const service = new FileData({ client }); 71 | const fileData = await service.getFileData(url); 72 | 73 | expect(fileData).toEqual({ 74 | title, 75 | normalizedTitle, 76 | rawUrl, 77 | wikiUrl, 78 | artistHtml, 79 | attributionHtml: null, 80 | }); 81 | }); 82 | 83 | it("returns the title if the title didn't need normalization", async () => { 84 | client.getResultsFromApi.mockResolvedValueOnce(imageInfoWithoutNormalizationMock); 85 | 86 | const service = new FileData({ client }); 87 | const fileData = await service.getFileData(url); 88 | 89 | expect(fileData).toEqual({ 90 | title, 91 | normalizedTitle: title, 92 | rawUrl, 93 | wikiUrl, 94 | artistHtml, 95 | attributionHtml, 96 | }); 97 | }); 98 | 99 | it('throws an error when the imageinfo response is empty', async () => { 100 | client.getResultsFromApi.mockResolvedValueOnce({}); 101 | 102 | const service = new FileData({ client }); 103 | 104 | await expect(service.getFileData(url)).rejects.toThrow(errors.emptyResponse); 105 | }); 106 | }); 107 | 108 | describe('when passing only a title', () => { 109 | it('retrieves the original file data defaulting to the commons API', async () => { 110 | client.getResultsFromApi.mockResolvedValueOnce(imageInfoMock); 111 | 112 | const service = new FileData({ client }); 113 | const fileData = await service.getFileData(title); 114 | 115 | expect(client.getResultsFromApi).toHaveBeenCalledWith([title], 'imageinfo', wikiUrl, { 116 | iiprop: 'url|extmetadata|mediatype', 117 | iilimit: 1, 118 | iiurlheight: 300, 119 | }); 120 | expect(fileData).toEqual({ 121 | title, 122 | normalizedTitle, 123 | rawUrl, 124 | wikiUrl, 125 | artistHtml, 126 | attributionHtml, 127 | }); 128 | }); 129 | 130 | it('throws an exception when the title has the wrong format', async () => { 131 | const service = new FileData({ client }); 132 | const badTitle = 'Apple_Lisa2-IMG_1517.jpg'; 133 | 134 | await expect(service.getFileData(badTitle)).rejects.toThrow(errors.invalidUrl); 135 | }); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /routes/attribution.integration.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | 3 | const LicenseStore = require('../services/licenseStore'); 4 | const Client = require('../services/util/client'); 5 | const FileData = require('../services/fileData'); 6 | const Licenses = require('../services/licenses'); 7 | 8 | const licenseData = require('../config/licenses/licenses'); 9 | const portReferences = require('../config/licenses/portReferences'); 10 | 11 | describe('attribution routes', () => { 12 | beforeAll(() => { 13 | startRecording('routes/attribution'); 14 | }); 15 | 16 | afterAll(async () => { 17 | await stopRecording(); 18 | }); 19 | 20 | let context; 21 | 22 | const client = new Client(); 23 | const licenseStore = new LicenseStore(licenseData, portReferences); 24 | 25 | const fileData = new FileData({ client }); 26 | const licenses = new Licenses({ client, licenseStore }); 27 | const services = { licenseStore, fileData, licenses }; 28 | 29 | beforeEach(async () => { 30 | context = await setup({ services }); 31 | }); 32 | 33 | afterEach(async () => { 34 | await context.destroy(); 35 | }); 36 | 37 | describe('GET /attribution/... (unmodified)', () => { 38 | const defaults = { 39 | languageCode: 'en', 40 | file: 'File:Foobar.jpg', 41 | typeOfUse: 'online', 42 | }; 43 | 44 | function options(overrides = {}) { 45 | const { languageCode, file, typeOfUse } = { ...defaults, ...overrides }; 46 | const url = `/attribution/${languageCode}/${file}/${typeOfUse}/unmodified`; 47 | return { url, method: 'GET' }; 48 | } 49 | 50 | async function subject(overrides) { 51 | return context.inject(options(overrides)); 52 | } 53 | 54 | it('returns the attribution', async () => { 55 | const response = await subject({}); 56 | 57 | expect(response.status).toBe(200); 58 | expect(response.type).toBe('application/json'); 59 | expect(response.payload).toMatchSnapshot(); 60 | }); 61 | 62 | it('returns a proper error for unknown typeOfUse', async () => { 63 | const response = await subject({ typeOfUse: 'does-not-exist' }); 64 | 65 | expect(response.status).toBe(400); 66 | expect(response.type).toBe('application/json'); 67 | expect(response.payload).toMatchObject({ 68 | error: 'Bad Request', 69 | message: 'Invalid request params input', 70 | statusCode: 400, 71 | }); 72 | }); 73 | 74 | it('returns a proper error for weird file urls', async () => { 75 | const response = await subject({ file: 'does-not-exist' }); 76 | 77 | expect(response.status).toBe(422); 78 | expect(response.type).toBe('application/json'); 79 | expect(response.payload).toMatchObject({ 80 | error: 'Unprocessable Entity', 81 | message: 'invalid-url', 82 | statusCode: 422, 83 | }); 84 | }); 85 | }); 86 | 87 | describe('GET /attribution/... (modified)', () => { 88 | const defaults = { 89 | languageCode: 'en', 90 | file: 'File:Foobar.jpg', 91 | typeOfUse: 'online', 92 | modification: 'cropped', 93 | creator: 'the great modificator', 94 | licenseId: 'cc-zero', 95 | }; 96 | 97 | const attribution = { 98 | licenseId: 'cc-zero', 99 | licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/legalcode', 100 | attributionHtml: 101 | 'Kaldari, Foobar, cropped by the great modificator, CC0 1.0', 102 | attributionPlain: 103 | 'Kaldari (https://upload.wikimedia.org/wikipedia/commons/3/3a/Foobar.jpg), „Foobar“, cropped by the great modificator, https://creativecommons.org/publicdomain/zero/1.0/legalcode', 104 | }; 105 | 106 | function options(overrides = {}) { 107 | const params = { ...defaults, ...overrides }; 108 | const url = `/attribution/${params.languageCode}/${params.file}/${ 109 | params.typeOfUse 110 | }/modified/${params.modification}/${params.creator}/${params.licenseId}`; 111 | return { url, method: 'GET' }; 112 | } 113 | 114 | async function subject(overrides) { 115 | return context.inject(options(overrides)); 116 | } 117 | 118 | it('returns the attribution', async () => { 119 | const response = await subject({}); 120 | 121 | expect(response.status).toBe(200); 122 | expect(response.type).toBe('application/json'); 123 | expect(response.payload).toMatchObject(attribution); 124 | }); 125 | 126 | it('returns a proper error for unknown typeOfUse', async () => { 127 | const response = await subject({ typeOfUse: 'does-not-exist' }); 128 | 129 | expect(response.status).toBe(400); 130 | expect(response.type).toBe('application/json'); 131 | expect(response.payload).toMatchObject({ 132 | error: 'Bad Request', 133 | message: 'Invalid request params input', 134 | statusCode: 400, 135 | }); 136 | }); 137 | 138 | it('returns a proper error for weird file urls', async () => { 139 | const response = await subject({ file: 'does-not-exist' }); 140 | 141 | expect(response.status).toBe(422); 142 | expect(response.type).toBe('application/json'); 143 | expect(response.payload).toMatchObject({ 144 | error: 'Unprocessable Entity', 145 | message: 'invalid-url', 146 | statusCode: 422, 147 | }); 148 | }); 149 | 150 | it('returns a proper error for an unknown licenseId', async () => { 151 | const response = await subject({ licenseId: 'does-not-exist' }); 152 | 153 | expect(response.status).toBe(404); 154 | expect(response.type).toBe('application/json'); 155 | expect(response.payload).toMatchObject({ 156 | error: 'Not Found', 157 | message: 'license-not-found', 158 | statusCode: 404, 159 | }); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /services/licenseStore.test.js: -------------------------------------------------------------------------------- 1 | const LicenseStore = require('./licenseStore'); 2 | const License = require('../models/license'); 3 | const licenses = require('../config/licenses/licenses'); 4 | const portReferences = require('../config/licenses/portReferences'); 5 | const compatibleCases = require('./__test__/compatibleCases'); 6 | const errors = require('../services/util/errors'); 7 | 8 | describe('licenseStore', () => { 9 | const subject = new LicenseStore(licenses, portReferences); 10 | 11 | describe('all()', () => { 12 | const expectedLicenseParams = { 13 | compatibility: [], 14 | groups: ['cc', 'cc4'], 15 | id: 'cc-by-sa-4.0', 16 | name: 'CC BY-SA 4.0', 17 | regexp: /^(Bild-)?CC-BY-SA(-|\/)4.0(([^-]+.+|-migrated)*)?$/i, 18 | url: 'https://creativecommons.org/licenses/by-sa/4.0/legalcode', 19 | }; 20 | 21 | it('returns licenses', () => { 22 | const allLicenses = subject.all(); 23 | expect(allLicenses.length).toBeGreaterThan(600); 24 | expect(allLicenses[0]).toEqual(new License(expectedLicenseParams)); 25 | }); 26 | 27 | it('filters out licenses that do not contain a name or url', () => { 28 | const allLicenses = subject.all(); 29 | expect(allLicenses.every(license => !!license.name)).toBeTruthy(); 30 | expect(allLicenses.every(license => !!license.url)).toBeTruthy(); 31 | }); 32 | }); 33 | 34 | describe('match()', () => { 35 | it('detects "Public Domain"', () => { 36 | const license = subject.match(['PD-self']); 37 | expect(license.name).toBe('Public Domain'); 38 | }); 39 | 40 | it('detects "Copyrighted free use"', () => { 41 | const license = subject.match(['Copyrighted free use']); 42 | expect(license.name).toBe('Public Domain'); 43 | }); 44 | 45 | it('detects "CC BY 1.0"', () => { 46 | const license = subject.match(['Cc-by-1.0']); 47 | expect(license.name).toBe('CC BY 1.0'); 48 | }); 49 | 50 | it('detects "CC BY 2.0"', () => { 51 | const license = subject.match(['Cc-by-2.0']); 52 | expect(license.name).toBe('CC BY 2.0'); 53 | }); 54 | 55 | it('detects "CC BY 2.5"', () => { 56 | const license = subject.match(['Cc-by-2.5']); 57 | expect(license.name).toBe('CC BY 2.5'); 58 | }); 59 | 60 | it('detects "CC BY SA 2.5"', () => { 61 | const license = subject.match(['Cc-by-sa-2.5']); 62 | expect(license.name).toBe('CC BY-SA 2.5'); 63 | }); 64 | 65 | it('detects "CC BY SA 2.0"', () => { 66 | const license = subject.match(['Cc-by-sa-2.0']); 67 | expect(license.name).toBe('CC BY-SA 2.0'); 68 | }); 69 | 70 | it('detects "CC BY SA 1.0"', () => { 71 | expect(subject.match(['Cc-by-sa-1.0'])).toHaveProperty('name', 'CC BY-SA 1.0'); 72 | expect(subject.match(['cc-by-sa'])).toHaveProperty('name', 'CC BY-SA 1.0'); 73 | }); 74 | 75 | it('detects ported "CC BY-SA 1.0 NL"', () => { 76 | const license = subject.match(['Cc-by-sa-1.0-nl']); 77 | expect(license.id).toBe('cc-by-sa-1.0-ported'); 78 | expect(license.name).toBe('CC BY-SA 1.0 NL'); 79 | }); 80 | 81 | it('detects ported "CC BY 1.0"', () => { 82 | const license = subject.match(['Cc-by-1.0-nl']); 83 | expect(license.id).toBe('cc-by-1.0-ported'); 84 | expect(license.name).toBe('CC BY 1.0 NL'); 85 | }); 86 | 87 | it('detects ported "CC BY 2.0"', () => { 88 | const license = subject.match(['Cc-by-2.0-at']); 89 | expect(license.id).toBe('cc-by-2.0-ported'); 90 | expect(license.name).toBe('CC BY 2.0 AT'); 91 | }); 92 | 93 | it('detects ported "CC BY 2.5"', () => { 94 | const license = subject.match(['Cc-by-2.5-au']); 95 | expect(license.id).toBe('cc-by-2.5-ported'); 96 | expect(license.name).toBe('CC BY 2.5 AU'); 97 | }); 98 | 99 | it('detects ported "CC BY 3.0"', () => { 100 | const license = subject.match(['Cc-by-3.0-hk']); 101 | expect(license.id).toBe('cc-by-3.0-ported'); 102 | expect(license.name).toBe('CC BY 3.0 HK'); 103 | }); 104 | 105 | it('detects ported "CC BY-SA 2.0"', () => { 106 | const license = subject.match(['Cc-by-sa-2.0-uk']); 107 | expect(license.id).toBe('cc-by-sa-2.0-ported'); 108 | expect(license.name).toBe('CC BY-SA 2.0 UK'); 109 | }); 110 | 111 | it('detects ported "CC BY-SA 2.5"', () => { 112 | const license = subject.match(['Cc-by-sa-2.5-it']); 113 | expect(license.id).toBe('cc-by-sa-2.5-ported'); 114 | expect(license.name).toBe('CC BY-SA 2.5 IT'); 115 | }); 116 | 117 | it('detects ported "CC BY-SA 3.0"', () => { 118 | const license = subject.match(['Cc-by-sa-3.0-tw']); 119 | expect(license.id).toBe('cc-by-sa-3.0-ported'); 120 | expect(license.name).toBe('CC BY-SA 3.0 TW'); 121 | }); 122 | 123 | it('accepts multiple input strings', () => { 124 | const license = subject.match(['Cc-by-sa-2.0-de', 'Cc-by-sa-2.5']); 125 | expect(license.id).toBe('cc-by-sa-2.5'); 126 | expect(license.name).toBe('CC BY-SA 2.5'); 127 | }); 128 | }); 129 | 130 | describe('compatible()', () => { 131 | const expectedKeys = ['id', 'name', 'groups', 'compatibility', 'regexp', 'url']; 132 | 133 | it('returns an array of licenses', () => { 134 | const compatible = subject.compatible('cc-by-sa-3.0'); 135 | compatible.forEach(license => { 136 | expect(Object.keys(license)).toEqual(expectedKeys); 137 | }); 138 | }); 139 | 140 | it('finds compatible licenses for "cc-by-sa-3.0"', () => { 141 | const compatible = subject.compatible('cc-by-sa-3.0'); 142 | expect(compatible.map(license => license.id)).toEqual(['cc-by-sa-3.0-de', 'cc-by-sa-4.0']); 143 | }); 144 | 145 | it('finds compatible licenses for "cc-by-sa-4.0"', () => { 146 | const compatible = subject.compatible('cc-by-sa-4.0'); 147 | expect(compatible).toEqual([]); 148 | }); 149 | 150 | it('throws error for an invalid license id', () => { 151 | expect(() => subject.compatible('xx-by-sa-3.0')).toThrow(errors.licenseNotFound); 152 | }); 153 | 154 | it('finds no compatible license for ported license', () => { 155 | const compatible = subject.compatible('cc-by-sa-3.0-ported'); 156 | expect(compatible).toEqual([]); 157 | }); 158 | 159 | Object.keys(compatibleCases).forEach(id => { 160 | const expected = compatibleCases[id]; 161 | const license = subject.getLicenseById(id); 162 | 163 | it(`finds compatible licenses for "${license.id}"`, () => { 164 | const compatible = subject.compatible(license.id); 165 | expect(compatible).toHaveLength(expected.length); 166 | const ids = compatible.map(l => l.id); 167 | expect(ids).toEqual(expect.arrayContaining(expected)); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('getLicenseByName()', () => { 173 | it('returns the first license with that name', () => { 174 | const license = subject.getLicenseByName('CC BY-SA 3.0'); 175 | expect(license.id).toEqual('cc-by-sa-3.0'); 176 | expect(license.name).toEqual('CC BY-SA 3.0'); 177 | }); 178 | 179 | it('returns no license for invalid license name', () => { 180 | const license = subject.getLicenseByName('XX BY-SA 3.0'); 181 | expect(license).toBeNull(); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /routes/attribution.test.js: -------------------------------------------------------------------------------- 1 | const setup = require('./__helpers__/setup'); 2 | const licenseFactory = require('../__helpers__/licenseFactory'); 3 | 4 | describe('attribution routes', () => { 5 | const services = { 6 | fileData: { getFileData: jest.fn() }, 7 | licenses: { getLicense: jest.fn() }, 8 | licenseStore: { getLicenseById: jest.fn() }, 9 | }; 10 | const fileInfoMock = { 11 | title: 'File:Apple_Lisa2-IMG_1517.jpg', 12 | normalizedTitle: 'File:Apple Lisa2-IMG 1517.jpg', 13 | rawUrl: 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Apple_Lisa2-IMG_1517.jpg', 14 | wikiUrl: 'https://commons.wikimedia.org/', 15 | artistHtml: 16 | 'Rama & Musée Bolo', 17 | attributionHtml: 18 | 'Photograph by Rama, Wikimedia Commons, Cc-by-sa-2.0-fr', 19 | }; 20 | const license = licenseFactory({ 21 | id: 'cc-by-sa-2.5', 22 | name: 'CC BY-SA 2.5', 23 | groups: ['cc', 'cc2.5', 'ccby'], 24 | url: 'https://creativecommons.org/licenses/by-sa/2.5/legalcode', 25 | }); 26 | 27 | let context; 28 | 29 | beforeEach(async () => { 30 | context = await setup({ services }); 31 | }); 32 | 33 | afterEach(async () => { 34 | await context.destroy(); 35 | }); 36 | 37 | describe('GET /attribution/... (unmodified)', () => { 38 | const file = 'File:Foobar.jpg'; 39 | const languageCode = 'en'; 40 | const typeOfUse = 'online'; 41 | 42 | const defaults = { 43 | languageCode, 44 | file, 45 | typeOfUse, 46 | }; 47 | 48 | function options(overrides = {}) { 49 | const params = { ...defaults, ...overrides }; 50 | const url = `/attribution/${params.languageCode}/${params.file}/${ 51 | params.typeOfUse 52 | }/unmodified`; 53 | return { method: 'GET', url }; 54 | } 55 | 56 | async function subject(overrides = {}) { 57 | return context.inject(options(overrides)); 58 | } 59 | 60 | beforeEach(() => { 61 | services.fileData.getFileData.mockResolvedValue(fileInfoMock); 62 | services.licenses.getLicense.mockResolvedValue(license); 63 | }); 64 | 65 | afterEach(() => { 66 | services.fileData.getFileData.mockReset(); 67 | services.licenses.getLicense.mockReset(); 68 | }); 69 | 70 | it('returns attribution information for the given file', async () => { 71 | const response = await subject(); 72 | 73 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 74 | expect(services.licenses.getLicense).toHaveBeenCalledWith(fileInfoMock); 75 | 76 | expect(response.status).toBe(200); 77 | expect(response.type).toBe('application/json'); 78 | expect(response.payload).toMatchSnapshot(); 79 | }); 80 | 81 | it('returns 500 for any generic error', async () => { 82 | services.fileData.getFileData.mockImplementation(() => { 83 | throw new Error('some error'); 84 | }); 85 | const response = await subject(); 86 | 87 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 88 | expect(services.licenses.getLicense).not.toHaveBeenCalled(); 89 | expect(response.status).toBe(500); 90 | expect(response.type).toBe('application/json'); 91 | expect(response.payload).toMatchSnapshot(); 92 | }); 93 | 94 | it('returns an error when the file responds with a license we do not know', async () => { 95 | services.licenses.getLicense.mockImplementation(() => undefined); 96 | const response = await subject(); 97 | 98 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 99 | expect(services.licenses.getLicense).toHaveBeenCalledWith(fileInfoMock); 100 | expect(response.status).toBe(422); 101 | expect(response.type).toBe('application/json'); 102 | expect(response.payload).toMatchSnapshot(); 103 | }); 104 | }); 105 | 106 | describe('GET /attribution/... (modified)', () => { 107 | const file = 'File:Foobar.jpg'; 108 | const languageCode = 'en'; 109 | const typeOfUse = 'online'; 110 | const modification = 'cropped'; 111 | const modificationAuthor = 'the great modificator'; 112 | const licenseId = 'cc-by-sa-2.5'; 113 | 114 | const defaults = { 115 | languageCode, 116 | file, 117 | typeOfUse, 118 | modification, 119 | modificationAuthor, 120 | licenseId, 121 | }; 122 | 123 | function options(overrides = {}) { 124 | const params = { ...defaults, ...overrides }; 125 | const url = `/attribution/${params.languageCode}/${params.file}/${ 126 | params.typeOfUse 127 | }/modified/${params.modification}/${params.modificationAuthor}/${params.licenseId}`; 128 | return { method: 'GET', url }; 129 | } 130 | 131 | async function subject(overrides = {}) { 132 | return context.inject(options(overrides)); 133 | } 134 | 135 | beforeEach(() => { 136 | services.fileData.getFileData.mockResolvedValue(fileInfoMock); 137 | services.licenseStore.getLicenseById.mockImplementation(() => license); 138 | }); 139 | 140 | afterEach(() => { 141 | services.fileData.getFileData.mockReset(); 142 | services.licenseStore.getLicenseById.mockReset(); 143 | }); 144 | 145 | it('returns attribution information for the given file', async () => { 146 | const response = await subject(); 147 | 148 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 149 | expect(services.licenseStore.getLicenseById).toHaveBeenCalledWith(licenseId); 150 | 151 | expect(response.status).toBe(200); 152 | expect(response.type).toBe('application/json'); 153 | expect(response.payload).toMatchSnapshot(); 154 | }); 155 | 156 | it('returns 500 for any generic error', async () => { 157 | services.fileData.getFileData.mockImplementation(() => { 158 | throw new Error('some error'); 159 | }); 160 | const response = await subject(); 161 | 162 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 163 | expect(services.licenseStore.getLicenseById).not.toHaveBeenCalledWith(); 164 | expect(response.status).toBe(500); 165 | expect(response.type).toBe('application/json'); 166 | expect(response.payload).toMatchSnapshot(); 167 | }); 168 | 169 | it('returns an error when the requested a license we do not know', async () => { 170 | services.licenseStore.getLicenseById.mockImplementation(() => undefined); 171 | const response = await subject(); 172 | 173 | expect(services.fileData.getFileData).toHaveBeenCalledWith(file); 174 | expect(services.licenseStore.getLicenseById).toHaveBeenCalledWith(licenseId); 175 | expect(response.status).toBe(404); 176 | expect(response.type).toBe('application/json'); 177 | expect(response.payload).toMatchSnapshot(); 178 | }); 179 | 180 | it('returns an error when the requested a typeOfUse we do not know', async () => { 181 | const response = await subject({ typeOfUse: 'for the lulz' }); 182 | 183 | expect(services.fileData.getFileData).not.toHaveBeenCalledWith(); 184 | expect(services.licenseStore.getLicenseById).not.toHaveBeenCalledWith(); 185 | expect(response.status).toBe(400); 186 | expect(response.type).toBe('application/json'); 187 | expect(response.payload).toMatchSnapshot(); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /models/attribution.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Most of this file was copied as-is (keeping function names intact) 3 | * and refactored only slightly to make comparisons between this app and the 4 | * original Lizenhinweisgenerator easier. 5 | */ 6 | 7 | const assert = require('assert'); 8 | const { JSDOM: JSDom } = require('jsdom'); 9 | 10 | const License = require('./license'); 11 | const HtmlSaniziter = require('../services/htmlSanitizer'); 12 | const t = require('../services/util/translate'); 13 | const errors = require('../services/util/errors'); 14 | 15 | const knownLanguages = ['en', 'es', 'pt', 'de', 'uk']; 16 | const knownTypesOfUse = ['online', 'offline']; 17 | 18 | function isStringPresent(string) { 19 | return typeof string === 'string' && string.length > 0; 20 | } 21 | 22 | function validateParams({ 23 | fileInfo, 24 | typeOfUse, 25 | languageCode, 26 | license, 27 | modification, 28 | modificationAuthor, 29 | isEdited, 30 | }) { 31 | assert(isStringPresent(fileInfo.rawUrl), errors.validationError); 32 | assert(isStringPresent(fileInfo.normalizedTitle), errors.validationError); 33 | assert(knownTypesOfUse.includes(typeOfUse), errors.validationError); 34 | assert(knownLanguages.includes(languageCode), errors.validationError); 35 | assert([true, false].includes(isEdited), errors.validationError); 36 | assert(!fileInfo.artistHtml || isStringPresent(fileInfo.artistHtml), errors.validationError); 37 | assert( 38 | !fileInfo.attributionHtml || isStringPresent(fileInfo.attributionHtml), 39 | errors.validationError 40 | ); 41 | assert(!modification || isStringPresent(modification), errors.validationError); 42 | assert(!modificationAuthor || isStringPresent(modificationAuthor), errors.validationError); 43 | assert(license instanceof License, errors.validationError); 44 | } 45 | 46 | function extractTextFromHtml(html) { 47 | const dom = new JSDom(html); 48 | return dom.window.document.body.textContent; 49 | } 50 | 51 | // takes a file identifier string like "File:something.jpg" and returns a 52 | // file name from it ("something"). 53 | function fileNameFromIdentifier(identifier) { 54 | const { 55 | groups: { name }, 56 | } = /^(?:.+:)?(?.*?)(?:\.[^.]+)?$/.exec(identifier); 57 | return name; 58 | } 59 | 60 | function getEditingAttribution(self) { 61 | if (!self.isEdited) { 62 | return ''; 63 | } 64 | 65 | const change = self.modification || t(self.languageCode, 'edited'); 66 | 67 | if (isStringPresent(self.modificationAuthor)) { 68 | const creator = `${t(self.languageCode, 'by')} ${self.modificationAuthor}`; 69 | return `${change} ${creator}`; 70 | } 71 | 72 | return change; 73 | } 74 | 75 | function getAttributionText(self) { 76 | if (!isStringPresent(self.attributionHtml)) { 77 | return ''; 78 | } 79 | return extractTextFromHtml(self.attributionHtml).trim(); 80 | } 81 | 82 | function getArtistText(self) { 83 | // note: Lizenzgenerator supports the case that artistHtml is empty 84 | // by asking the user for input. This should be implemented here, 85 | // but we don't get the necessary info in the /attribution endpoint. 86 | // example image: https://en.wikipedia.org/wiki/File:Sodexo.svg 87 | const text = extractTextFromHtml(self.artistHtml || ''); 88 | if (text.length === 0) { 89 | return t(self.languageCode, 'anonymous'); 90 | } 91 | return text; 92 | } 93 | 94 | function getAuthorAttributionText(self) { 95 | return getAttributionText(self) || getArtistText(self); 96 | } 97 | 98 | function getAttributionHtml(self) { 99 | const attributionText = self.attributionHtml && extractTextFromHtml(self.attributionHtml).trim(); 100 | 101 | if (!isStringPresent(attributionText)) { 102 | return ''; 103 | } 104 | if (self.typeOfUse === 'offline') { 105 | return attributionText; 106 | } 107 | return self.attributionHtml; 108 | } 109 | 110 | function getArtistHtml(self) { 111 | // note: Lizenzgenerator supports the case that artistHtml is empty 112 | // by asking the user for input. This should be implemented here, 113 | // but we don't get the necessary info in the /attribution endpoint. 114 | // example image: https://en.wikipedia.org/wiki/File:Sodexo.svg 115 | const html = self.artistHtml || ''; 116 | if (html.length === 0) { 117 | return t(self.languageCode, 'anonymous'); 118 | } 119 | return html; 120 | } 121 | 122 | function getAuthorAttributionHtml(self) { 123 | return getAttributionHtml(self) || getArtistHtml(self); 124 | } 125 | 126 | function sanitizeHtml(html) { 127 | const sanitizer = new HtmlSaniziter(html); 128 | return sanitizer.sanitize(); 129 | } 130 | 131 | function getPrintAttribution(self) { 132 | let attribution = `${getAuthorAttributionText(self)} (${self.fileUrl})`; 133 | 134 | if (!self.license.isInGroup('cc4')) { 135 | attribution += `, „${fileNameFromIdentifier(self.fileTitle)}“`; 136 | } 137 | const editingAttribution = getEditingAttribution(self); 138 | if (editingAttribution) { 139 | attribution += `, ${editingAttribution}`; 140 | } 141 | 142 | const { url } = self.license; 143 | if (url) { 144 | attribution += ', '; 145 | if (!editingAttribution && self.license.isInGroup('pd')) { 146 | attribution += `${t(self.languageCode, 'pd-attribution-hint')}, ${t( 147 | self.languageCode, 148 | 'check-details' 149 | )} Wikimedia Commons: `; 150 | } 151 | attribution += url; 152 | } 153 | 154 | return attribution; 155 | } 156 | 157 | function getHtmlLicense(self) { 158 | const { url } = self.license; 159 | if (url) { 160 | return `${self.license.name}`; 161 | } 162 | 163 | return self.license.name; 164 | } 165 | 166 | function getHtmlAttribution(self) { 167 | let attributionLink; 168 | let editingAttribution = getEditingAttribution(self); 169 | 170 | if (!editingAttribution && self.license.isInGroup('pd')) { 171 | attributionLink = `, ${t(self.languageCode, 'pd-attribution-hint')}`; 172 | if (self.license.url) { 173 | attributionLink += `, ${t(self.languageCode, 'check-details')} Wikimedia Commons`; 176 | } 177 | } else { 178 | attributionLink = `, ${getHtmlLicense(self)}`; 179 | } 180 | 181 | if (editingAttribution) { 182 | editingAttribution = `, ${editingAttribution}`; 183 | } 184 | 185 | return ( 186 | `${getAuthorAttributionHtml(self)}, ` + 187 | `${fileNameFromIdentifier( 188 | self.fileTitle 189 | )}${editingAttribution}${attributionLink}` 190 | ); 191 | } 192 | 193 | function getAttributionAsTextWithLinks(self) { 194 | let urlSnippet = ''; 195 | const { url } = self.license; 196 | if (url) { 197 | urlSnippet = `, ${url}`; 198 | } 199 | 200 | let editingAttribution = getEditingAttribution(self); 201 | if (editingAttribution) { 202 | editingAttribution = `, ${editingAttribution}`; 203 | } 204 | 205 | return `${getAuthorAttributionText(self)} (${self.fileUrl}), „${fileNameFromIdentifier( 206 | self.fileTitle 207 | )}“${editingAttribution}${urlSnippet}`; 208 | } 209 | 210 | class Attribution { 211 | constructor(params) { 212 | validateParams(params); 213 | const { 214 | normalizedTitle: fileTitle, 215 | rawUrl: fileUrl, 216 | artistHtml, 217 | attributionHtml, 218 | } = params.fileInfo; 219 | 220 | Object.assign(this, { 221 | ...params, 222 | fileTitle, 223 | fileUrl, 224 | artistHtml: sanitizeHtml(artistHtml || ''), 225 | attributionHtml: sanitizeHtml(attributionHtml || ''), 226 | }); 227 | } 228 | 229 | html() { 230 | const { typeOfUse } = this; 231 | return typeOfUse === 'offline' ? getPrintAttribution(this) : getHtmlAttribution(this); 232 | } 233 | 234 | plainText() { 235 | const { typeOfUse, license } = this; 236 | if (typeOfUse === 'offline' || !license.isInGroup('cc4')) { 237 | return getPrintAttribution(this); 238 | } 239 | return getAttributionAsTextWithLinks(this); 240 | } 241 | } 242 | 243 | module.exports = { 244 | Attribution, 245 | knownLanguages, 246 | knownTypesOfUse, 247 | }; 248 | -------------------------------------------------------------------------------- /services/fileData.integration.test.js: -------------------------------------------------------------------------------- 1 | const Client = require('../services/util/client'); 2 | const FileData = require('./fileData'); 3 | 4 | describe('FileData', () => { 5 | const client = new Client(); 6 | const fileData = new FileData({ client }); 7 | 8 | beforeAll(() => { 9 | startRecording('services/fileData.getFileData()'); 10 | }); 11 | 12 | afterAll(async () => { 13 | await stopRecording(); 14 | }); 15 | 16 | // We got a list of example url that we need to understand and match. 17 | // This spec calls all these examples and expects they work against a known 18 | // good state (snapshop). 19 | const inputs = [ 20 | // the following are expected to be “normalized” to https://commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg 21 | [ 22 | { 23 | title: 'File:Helene_Fischer_2010.jpg', 24 | normalizedTitle: 'File:Helene Fischer 2010.jpg', 25 | wikiUrl: 'https://commons.wikimedia.org/', 26 | rawUrl: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 27 | artistHtml: 28 | 'Fleyx24', 29 | attributionHtml: null, 30 | mediaType: 'BITMAP', 31 | }, 32 | [ 33 | 'File:Helene_Fischer_2010.jpg', 34 | 'Datei:Helene_Fischer_2010.jpg', 35 | 'Fichier:Helene_Fischer_2010.jpg', 36 | 37 | 'https://commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 38 | 'https://commons.m.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 39 | 'http://commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 40 | '//commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 41 | 'commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg', 42 | 43 | 'https://commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg', 44 | 'https://commons.m.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg', 45 | 'http://commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg', 46 | '//commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg', 47 | 'commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg', 48 | 49 | 'https://upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 50 | 'http://upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 51 | '//upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 52 | 'upload.wikimedia.org/wikipedia/commons/8/84/Helene_Fischer_2010.jpg', 53 | 54 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Helene_Fischer_2010.jpg/171px-Helene_Fischer_2010.jpg', 55 | 'http://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Helene_Fischer_2010.jpg/171px-Helene_Fischer_2010.jpg', 56 | '//upload.wikimedia.org/wikipedia/commons/thumb/8/84/Helene_Fischer_2010.jpg/171px-Helene_Fischer_2010.jpg', 57 | 'upload.wikimedia.org/wikipedia/commons/thumb/8/84/Helene_Fischer_2010.jpg/171px-Helene_Fischer_2010.jpg', 58 | 59 | 'https://commons.wikimedia.org/wiki/Helene_Fischer#/media/File:Helene_Fischer_2010.jpg', 60 | 'https://commons.m.wikimedia.org/wiki/Helene_Fischer#/media/File:Helene_Fischer_2010.jpg', 61 | 'http://commons.wikimedia.org/wiki/Helene_Fischer#/media/File:Helene_Fischer_2010.jpg', 62 | '//commons.wikimedia.org/wiki/Helene_Fischer#/media/File:Helene_Fischer_2010.jpg', 63 | 'commons.wikimedia.org/wiki/Helene_Fischer#/media/File:Helene_Fischer_2010.jpg', 64 | 65 | // parameters other than title are ignored 66 | 'https://commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg&uselang=de', 67 | 'https://commons.m.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg&uselang=de', 68 | 'http//commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg&uselang=de', 69 | '//commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg&uselang=de', 70 | 'commons.wikimedia.org/w/index.php?title=File:Helene_Fischer_2010.jpg&uselang=de', 71 | 72 | // parameters other than title are ignored 73 | 'https://commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg?foo=bar', 74 | 'https://commons.m.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg?foo=bar', 75 | 'http://commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg?foo=bar', 76 | '//commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg?foo=bar', 77 | 'commons.wikimedia.org/wiki/File:Helene_Fischer_2010.jpg?foo=bar', 78 | ], 79 | ], 80 | // the following are expected to be “normalized” to https://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg 81 | [ 82 | { 83 | title: 'File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 84 | normalizedTitle: 'Datei:1 FC Bamberg - 1 FC Nürnberg 1901.jpg', 85 | wikiUrl: 'https://de.wikipedia.org/', 86 | rawUrl: 87 | 'https://upload.wikimedia.org/wikipedia/de/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 88 | artistHtml: '

unbekannt\n

', 89 | attributionHtml: null, 90 | mediaType: 'BITMAP', 91 | }, 92 | [ 93 | 'https://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_Nürnberg_1901.jpg', 94 | 95 | 'https://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 96 | 'https://de.m.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 97 | 'http://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 98 | '//de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 99 | 'de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 100 | 101 | 'https://de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 102 | 'https://de.m.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 103 | 'http://de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 104 | '//de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 105 | 'de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 106 | 107 | 'https://upload.wikimedia.org/wikipedia/de/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 108 | 'http://upload.wikimedia.org/wikipedia/de/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 109 | '//upload.wikimedia.org/wikipedia/de/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 110 | 'upload.wikimedia.org/wikipedia/de/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 111 | 112 | 'https://upload.wikimedia.org/wikipedia/de/thumb/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg/320px-1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 113 | 'http://upload.wikimedia.org/wikipedia/de/thumb/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg/320px-1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 114 | '//upload.wikimedia.org/wikipedia/de/thumb/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg/320px-1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 115 | 'upload.wikimedia.org/wikipedia/de/thumb/f/fb/1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg/320px-1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 116 | 117 | 'https://de.wikipedia.org/wiki/S%C3%BCddeutsche_Fu%C3%9Fballmeisterschaft_1901/02#/media/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 118 | 'https://de.m.wikipedia.org/wiki/S%C3%BCddeutsche_Fu%C3%9Fballmeisterschaft_1901/02#/media/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 119 | 'http://de.wikipedia.org/wiki/S%C3%BCddeutsche_Fu%C3%9Fballmeisterschaft_1901/02#/media/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 120 | '//de.wikipedia.org/wiki/S%C3%BCddeutsche_Fu%C3%9Fballmeisterschaft_1901/02#/media/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 121 | 'de.wikipedia.org/wiki/S%C3%BCddeutsche_Fu%C3%9Fballmeisterschaft_1901/02#/media/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg', 122 | 123 | // 'parameters other than title are ignored 124 | 'https://de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg&uselang=de', 125 | 'https://de.m.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg&uselang=de', 126 | 'http://de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg&uselang=de', 127 | '//de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg&uselang=de', 128 | 'de.wikipedia.org/w/index.php?title=File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg&uselang=de', 129 | 130 | // parameters other than title are ignored 131 | 'https://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg?foo=bar', 132 | 'https://de.m.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg?foo=bar', 133 | 'http://de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg?foo=bar', 134 | '//de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg?foo=bar', 135 | 'de.wikipedia.org/wiki/File:1_FC_Bamberg_-_1_FC_N%C3%BCrnberg_1901.jpg?foo=bar', 136 | ], 137 | ], 138 | [ 139 | // we give an article-url (not a file URL) and should give "no result" in all cases 140 | {}, 141 | ['https://de.wikipedia.org/wiki/K%C3%B6nigsberg_in_Bayern'], 142 | ], 143 | ]; 144 | 145 | inputs.forEach(normalizeExpectation => { 146 | const [expectedResult, inputUrls] = normalizeExpectation; 147 | 148 | describe(`for an input URL which normalizes to ${expectedResult.wikiUrl}`, () => { 149 | inputUrls.forEach(input => { 150 | it(`requesting info for ${input}`, async () => { 151 | const result = await fileData.getFileData(input); 152 | expect(result).toEqual(expectedResult); 153 | }); 154 | }); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | AttributionShowResponse: 3 | properties: 4 | attributionHtml: 5 | type: string 6 | attributionPlain: 7 | type: string 8 | licenseId: 9 | type: string 10 | licenseUrl: 11 | type: string 12 | required: 13 | - attributionHtml 14 | - attributionPlain 15 | - licenseId 16 | - licenseUrl 17 | BadRequestErrorResponse: 18 | description: Bad Request 19 | properties: {} 20 | CodeNameUrlGroupsModel: 21 | properties: 22 | code: 23 | type: string 24 | groups: 25 | items: 26 | type: string 27 | type: array 28 | name: 29 | type: string 30 | url: 31 | type: string 32 | required: 33 | - code 34 | - name 35 | - url 36 | - groups 37 | InfoShowResponse: 38 | properties: 39 | version: 40 | example: 41 | value: 1.0.0 42 | type: string 43 | required: 44 | - version 45 | InternalServerError: 46 | description: Internal Server Error 47 | properties: {} 48 | LicenseAuthorHtmlAttributionHtmlMediaTypeModel: 49 | properties: 50 | attributionHtml: 51 | type: string 52 | authorHtml: 53 | type: string 54 | license: 55 | $ref: '#/definitions/CodeNameUrlGroupsModel' 56 | mediaType: 57 | type: string 58 | required: 59 | - mediaType 60 | RawUrlWidthHeightModel: 61 | properties: 62 | height: 63 | type: integer 64 | rawUrl: 65 | type: string 66 | width: 67 | type: integer 68 | required: 69 | - rawUrl 70 | - width 71 | - height 72 | ServiceUnavailableErrorResponse: 73 | description: Service Unavailable 74 | properties: {} 75 | TitleDescriptionUrlRawUrlFileSizeThumbnailModel: 76 | properties: 77 | descriptionUrl: 78 | type: string 79 | fileSize: 80 | type: integer 81 | rawUrl: 82 | type: string 83 | thumbnail: 84 | $ref: '#/definitions/RawUrlWidthHeightModel' 85 | title: 86 | type: string 87 | required: 88 | - title 89 | - descriptionUrl 90 | - rawUrl 91 | - fileSize 92 | - thumbnail 93 | UnprocessableEntityErrorResponse: 94 | description: Unprocessable Entity 95 | properties: {} 96 | host: 'localhost:8080' 97 | info: 98 | description: Create attribution hints for images from Wikipedia and Wikimedia Commons. 99 | title: attribution-generator-api 100 | version: 0.1.0 101 | paths: 102 | '/attribution/{languageCode}/{file}/{typeOfUse}/modified/{modification}/{modificationAuthor}/{licenseId}': 103 | get: 104 | description: Generate attribution hints for the given file if that file was modified. 105 | operationId: attribution.modified.show 106 | parameters: 107 | - in: path 108 | name: languageCode 109 | required: true 110 | type: string 111 | - in: path 112 | name: file 113 | required: true 114 | type: string 115 | - in: path 116 | name: typeOfUse 117 | required: true 118 | type: string 119 | - in: path 120 | name: modification 121 | required: true 122 | type: string 123 | - in: path 124 | name: modificationAuthor 125 | required: true 126 | type: string 127 | - in: path 128 | name: licenseId 129 | required: true 130 | type: string 131 | produces: 132 | - application/json 133 | responses: 134 | '400': 135 | description: Bad Request 136 | schema: 137 | $ref: '#/definitions/BadRequestErrorResponse' 138 | '422': 139 | description: Unprocessable Entity 140 | schema: 141 | $ref: '#/definitions/UnprocessableEntityErrorResponse' 142 | '500': 143 | description: Internal Server Error 144 | schema: 145 | $ref: '#/definitions/InternalServerError' 146 | '503': 147 | description: Service Unavailable 148 | schema: 149 | $ref: '#/definitions/ServiceUnavailableErrorResponse' 150 | default: 151 | description: '' 152 | schema: 153 | $ref: '#/definitions/AttributionShowResponse' 154 | security: 155 | - default: [] 156 | summary: Generate attribution for a modified work 157 | tags: 158 | - attribution 159 | '/attribution/{languageCode}/{file}/{typeOfUse}/unmodified': 160 | get: 161 | description: Generate attribution hints for the given file. 162 | operationId: attribution.unmodified.show 163 | parameters: 164 | - in: path 165 | name: languageCode 166 | required: true 167 | type: string 168 | - in: path 169 | name: file 170 | required: true 171 | type: string 172 | - in: path 173 | name: typeOfUse 174 | required: true 175 | type: string 176 | produces: 177 | - application/json 178 | responses: 179 | '400': 180 | description: Bad Request 181 | schema: 182 | $ref: '#/definitions/BadRequestErrorResponse' 183 | '422': 184 | description: Unprocessable Entity 185 | schema: 186 | $ref: '#/definitions/UnprocessableEntityErrorResponse' 187 | '500': 188 | description: Internal Server Error 189 | schema: 190 | $ref: '#/definitions/InternalServerError' 191 | '503': 192 | description: Service Unavailable 193 | schema: 194 | $ref: '#/definitions/ServiceUnavailableErrorResponse' 195 | default: 196 | description: '' 197 | schema: 198 | $ref: '#/definitions/AttributionShowResponse' 199 | security: 200 | - default: [] 201 | summary: Generate attribution 202 | tags: 203 | - attribution 204 | /docs: 205 | get: 206 | produces: 207 | - application/json 208 | responses: 209 | default: 210 | description: '' 211 | tags: 212 | - docs 213 | /docs/index.html: 214 | get: 215 | produces: 216 | - application/json 217 | responses: 218 | default: 219 | description: '' 220 | tags: 221 | - docs 222 | '/docs/{path}': 223 | get: 224 | produces: 225 | - application/json 226 | responses: 227 | default: 228 | description: '' 229 | tags: 230 | - docs 231 | '/fileinfo/{fileUrlOrTitle}': 232 | get: 233 | description: Returns the most liberal license for the given image 234 | parameters: 235 | - in: path 236 | name: fileUrlOrTitle 237 | required: true 238 | type: string 239 | produces: 240 | - application/json 241 | responses: 242 | '422': 243 | description: Unprocessable Entity 244 | schema: 245 | $ref: '#/definitions/UnprocessableEntityErrorResponse' 246 | '500': 247 | description: Internal Server Error 248 | schema: 249 | $ref: '#/definitions/InternalServerError' 250 | '503': 251 | description: Service Unavailable 252 | schema: 253 | $ref: '#/definitions/ServiceUnavailableErrorResponse' 254 | default: 255 | description: '' 256 | schema: 257 | $ref: '#/definitions/LicenseAuthorHtmlAttributionHtmlMediaTypeModel' 258 | summary: Image license 259 | tags: 260 | - fileinfo 261 | '/files/{articleUrl}': 262 | get: 263 | description: Retrieve all files for a given article or page url. 264 | operationId: files.index 265 | parameters: 266 | - in: path 267 | name: articleUrl 268 | required: true 269 | type: string 270 | produces: 271 | - application/json 272 | responses: 273 | '422': 274 | description: Unprocessable Entity 275 | schema: 276 | $ref: '#/definitions/UnprocessableEntityErrorResponse' 277 | '500': 278 | description: Internal Server Error 279 | schema: 280 | $ref: '#/definitions/InternalServerError' 281 | '503': 282 | description: Service Unavailable 283 | schema: 284 | $ref: '#/definitions/ServiceUnavailableErrorResponse' 285 | default: 286 | description: '' 287 | schema: 288 | items: 289 | $ref: '#/definitions/TitleDescriptionUrlRawUrlFileSizeThumbnailModel' 290 | type: array 291 | security: 292 | - default: [] 293 | summary: Get all files for an article 294 | tags: 295 | - files 296 | /info: 297 | get: 298 | description: Get information on the API. 299 | operationId: info.show 300 | produces: 301 | - application/json 302 | responses: 303 | default: 304 | description: '' 305 | schema: 306 | $ref: '#/definitions/InfoShowResponse' 307 | summary: Get information 308 | tags: 309 | - info 310 | /licenses: 311 | get: 312 | description: Returns a list of all licenses 313 | produces: 314 | - application/json 315 | responses: 316 | default: 317 | description: '' 318 | schema: 319 | items: 320 | $ref: '#/definitions/CodeNameUrlGroupsModel' 321 | type: array 322 | summary: Licenses index 323 | tags: 324 | - licenses 325 | '/licenses/compatible/{licenseId}': 326 | get: 327 | description: Returns a list of licenses that are compatible to the passed license ID 328 | parameters: 329 | - in: path 330 | name: licenseId 331 | required: true 332 | type: string 333 | produces: 334 | - application/json 335 | responses: 336 | '500': 337 | description: Internal Server Error 338 | schema: 339 | $ref: '#/definitions/InternalServerError' 340 | default: 341 | description: '' 342 | schema: 343 | items: 344 | $ref: '#/definitions/CodeNameUrlGroupsModel' 345 | type: array 346 | summary: Compatible licenses 347 | tags: 348 | - licenses 349 | /swagger: 350 | get: 351 | parameters: 352 | - in: query 353 | name: tags 354 | required: false 355 | type: string 356 | produces: 357 | - application/json 358 | responses: 359 | default: 360 | description: '' 361 | tags: 362 | - swagger 363 | schemes: 364 | - http 365 | - https 366 | securityDefinitions: {} 367 | swagger: '2.0' 368 | tags: [] 369 | -------------------------------------------------------------------------------- /config/licenses/portReferences.js: -------------------------------------------------------------------------------- 1 | // Map of lowercase license names to URLs. 2 | // 3 | // Ported from: https://github.com/wmde/Lizenzhinweisgenerator/blob/0df1805ef1b362b56c287b7035b2bbf66e6642a1/js/config.json 4 | // 5 | module.exports = { 6 | 'cc-by-1.0-fi': 'https://creativecommons.org/licenses/by/1.0/fi/deed.en', 7 | 'cc-by-1.0-il': 'https://creativecommons.org/licenses/by/1.0/il/deed.en', 8 | 'cc-by-1.0-nl': 'https://creativecommons.org/licenses/by/1.0/nl/deed.en', 9 | 'cc-by-2.0-at': 'https://creativecommons.org/licenses/by/2.0/at/deed.en', 10 | 'cc-by-2.0-au': 'https://creativecommons.org/licenses/by/2.0/au/deed.en', 11 | 'cc-by-2.0-be': 'https://creativecommons.org/licenses/by/2.0/be/deed.en', 12 | 'cc-by-2.0-br': 'https://creativecommons.org/licenses/by/2.0/br/deed.en', 13 | 'cc-by-2.0-ca': 'https://creativecommons.org/licenses/by/2.0/ca/deed.en', 14 | 'cc-by-2.0-cl': 'https://creativecommons.org/licenses/by/2.0/cl/deed.en', 15 | 'cc-by-2.0-de': 'https://creativecommons.org/licenses/by/2.0/de/deed.en', 16 | 'cc-by-2.0-es': 'https://creativecommons.org/licenses/by/2.0/es/deed.en', 17 | 'cc-by-2.0-fr': 'https://creativecommons.org/licenses/by/2.0/fr/deed.en', 18 | 'cc-by-2.0-hr': 'https://creativecommons.org/licenses/by/2.0/hr/deed.en', 19 | 'cc-by-2.0-it': 'https://creativecommons.org/licenses/by/2.0/it/deed.en', 20 | 'cc-by-2.0-jp': 'https://creativecommons.org/licenses/by/2.0/jp/deed.en', 21 | 'cc-by-2.0-kr': 'https://creativecommons.org/licenses/by/2.0/kr/deed.en', 22 | 'cc-by-2.0-nl': 'https://creativecommons.org/licenses/by/2.0/nl/deed.en', 23 | 'cc-by-2.0-pl': 'https://creativecommons.org/licenses/by/2.0/pl/deed.en', 24 | 'cc-by-2.0-tw': 'https://creativecommons.org/licenses/by/2.0/tw/deed.en', 25 | 'cc-by-2.0-uk': 'https://creativecommons.org/licenses/by/2.0/uk/deed.en', 26 | 'cc-by-2.0-za': 'https://creativecommons.org/licenses/by/2.0/za/deed.en', 27 | 'cc-by-2.1-au': 'https://creativecommons.org/licenses/by/2.1/au/deed.en', 28 | 'cc-by-2.1-es': 'https://creativecommons.org/licenses/by/2.1/es/deed.en', 29 | 'cc-by-2.1-jp': 'https://creativecommons.org/licenses/by/2.1/jp/deed.en', 30 | 'cc-by-2.5-ar': 'https://creativecommons.org/licenses/by/2.5/ar/deed.en', 31 | 'cc-by-2.5-au': 'https://creativecommons.org/licenses/by/2.5/au/deed.en', 32 | 'cc-by-2.5-bg': 'https://creativecommons.org/licenses/by/2.5/bg/deed.en', 33 | 'cc-by-2.5-br': 'https://creativecommons.org/licenses/by/2.5/br/deed.en', 34 | 'cc-by-2.5-ca': 'https://creativecommons.org/licenses/by/2.5/ca/deed.en', 35 | 'cc-by-2.5-ch': 'https://creativecommons.org/licenses/by/2.5/ch/deed.en', 36 | 'cc-by-2.5-cn': 'https://creativecommons.org/licenses/by/2.5/cn/deed.en', 37 | 'cc-by-2.5-co': 'https://creativecommons.org/licenses/by/2.5/co/deed.en', 38 | 'cc-by-2.5-dk': 'https://creativecommons.org/licenses/by/2.5/dk/deed.en', 39 | 'cc-by-2.5-es': 'https://creativecommons.org/licenses/by/2.5/es/deed.en', 40 | 'cc-by-2.5-hr': 'https://creativecommons.org/licenses/by/2.5/hr/deed.en', 41 | 'cc-by-2.5-hu': 'https://creativecommons.org/licenses/by/2.5/hu/deed.en', 42 | 'cc-by-2.5-il': 'https://creativecommons.org/licenses/by/2.5/il/deed.en', 43 | 'cc-by-2.5-in': 'https://creativecommons.org/licenses/by/2.5/in/deed.en', 44 | 'cc-by-2.5-it': 'https://creativecommons.org/licenses/by/2.5/it/deed.en', 45 | 'cc-by-2.5-mk': 'https://creativecommons.org/licenses/by/2.5/mk/deed.en', 46 | 'cc-by-2.5-mt': 'https://creativecommons.org/licenses/by/2.5/mt/deed.en', 47 | 'cc-by-2.5-mx': 'https://creativecommons.org/licenses/by/2.5/mx/deed.en', 48 | 'cc-by-2.5-my': 'https://creativecommons.org/licenses/by/2.5/my/deed.en', 49 | 'cc-by-2.5-nl': 'https://creativecommons.org/licenses/by/2.5/nl/deed.en', 50 | 'cc-by-2.5-pe': 'https://creativecommons.org/licenses/by/2.5/pe/deed.en', 51 | 'cc-by-2.5-pl': 'https://creativecommons.org/licenses/by/2.5/pl/deed.en', 52 | 'cc-by-2.5-pt': 'https://creativecommons.org/licenses/by/2.5/pt/deed.en', 53 | 'cc-by-2.5-scotland': 'https://creativecommons.org/licenses/by/2.5/scotland/deed.en', 54 | 'cc-by-2.5-se': 'https://creativecommons.org/licenses/by/2.5/se/deed.en', 55 | 'cc-by-2.5-si': 'https://creativecommons.org/licenses/by/2.5/si/deed.en', 56 | 'cc-by-2.5-tw': 'https://creativecommons.org/licenses/by/2.5/tw/deed.en', 57 | 'cc-by-2.5-za': 'https://creativecommons.org/licenses/by/2.5/za/deed.en', 58 | 'cc-by-3.0-at': 'https://creativecommons.org/licenses/by/3.0/at/deed.en', 59 | 'cc-by-3.0-au': 'https://creativecommons.org/licenses/by/3.0/au/deed.en', 60 | 'cc-by-3.0-bg': 'https://creativecommons.org/', 61 | 'cc-by-3.0-br': 'https://creativecommons.org/licenses/by/3.0/br/deed.en', 62 | 'cc-by-3.0-ch': 'https://creativecommons.org/licenses/by/3.0/ch/deed.en', 63 | 'cc-by-3.0-cl': 'https://creativecommons.org/licenses/by/3.0/cl/deed.en', 64 | 'cc-by-3.0-cn': 'https://creativecommons.org/licenses/by/3.0/cn/deed.en', 65 | 'cc-by-3.0-cr': 'https://creativecommons.org/licenses/by/3.0/cr/deed.en', 66 | 'cc-by-3.0-cz': 'https://creativecommons.org/licenses/by/3.0/cz/deed.en', 67 | 'cc-by-3.0-de': 'https://creativecommons.org/licenses/by/3.0/de/deed.en', 68 | 'cc-by-3.0-ec': 'https://creativecommons.org/licenses/by/3.0/ec/deed.en', 69 | 'cc-by-3.0-ee': 'https://creativecommons.org/licenses/by/3.0/ee/deed.en', 70 | 'cc-by-3.0-eg': 'https://creativecommons.org/licenses/by/3.0/eg/deed.en', 71 | 'cc-by-3.0-es': 'https://creativecommons.org/licenses/by/3.0/es/deed.en', 72 | 'cc-by-3.0-fr': 'https://creativecommons.org/licenses/by/3.0/fr/deed.en', 73 | 'cc-by-3.0-gr': 'https://creativecommons.org/licenses/by/3.0/gr/deed.en', 74 | 'cc-by-3.0-gt': 'https://creativecommons.org/licenses/by/3.0/gt/deed.en', 75 | 'cc-by-3.0-hk': 'https://creativecommons.org/licenses/by/3.0/hk/deed.en', 76 | 'cc-by-3.0-hr': 'https://creativecommons.org/licenses/by/3.0/hr/deed.en', 77 | 'cc-by-3.0-ie': 'https://creativecommons.org/licenses/by/3.0/ie/deed.en', 78 | 'cc-by-3.0-igo': 'https://creativecommons.org/licenses/by/3.0/igo/deed.en', 79 | 'cc-by-3.0-it': 'https://creativecommons.org/licenses/by/3.0/it/deed.en', 80 | 'cc-by-3.0-lu': 'https://creativecommons.org/licenses/by/3.0/lu/deed.en', 81 | 'cc-by-3.0-nl': 'https://creativecommons.org/licenses/by/3.0/nl/deed.en', 82 | 'cc-by-3.0-no': 'https://creativecommons.org/licenses/by/3.0/no/deed.en', 83 | 'cc-by-3.0-nz': 'https://creativecommons.org/licenses/by/3.0/nz/deed.en', 84 | 'cc-by-3.0-ph': 'https://creativecommons.org/licenses/by/3.0/ph/deed.en', 85 | 'cc-by-3.0-pl': 'https://creativecommons.org/licenses/by/3.0/pl/deed.en', 86 | 'cc-by-3.0-pr': 'https://creativecommons.org/licenses/by/3.0/pr/deed.en', 87 | 'cc-by-3.0-pt': 'https://creativecommons.org/licenses/by/3.0/pt/deed.en', 88 | 'cc-by-3.0-ro': 'https://creativecommons.org/licenses/by/3.0/ro/deed.en', 89 | 'cc-by-3.0-rs': 'https://creativecommons.org/licenses/by/3.0/rs/deed.en', 90 | 'cc-by-3.0-sg': 'https://creativecommons.org/licenses/by/3.0/sg/deed.en', 91 | 'cc-by-3.0-th': 'https://creativecommons.org/licenses/by/3.0/th/deed.en', 92 | 'cc-by-3.0-tw': 'https://creativecommons.org/licenses/by/3.0/tw/deed.en', 93 | 'cc-by-3.0-ug': 'https://creativecommons.org/licenses/by/3.0/ug/deed.en', 94 | 'cc-by-3.0-us': 'https://creativecommons.org/licenses/by/3.0/us/deed.en', 95 | 'cc-by-3.0-vn': 'https://creativecommons.org/licenses/by/3.0/vn/deed.en', 96 | 'cc-by-3.0-za': 'https://creativecommons.org/', 97 | 'cc-by-sa-1.0-fi': 'https://creativecommons.org/licenses/by-sa/1.0/fi/deed.en', 98 | 'cc-by-sa-1.0-il': 'https://creativecommons.org/licenses/by-sa/1.0/il/deed.en', 99 | 'cc-by-sa-1.0-nl': 'https://creativecommons.org/licenses/by-sa/1.0/nl/deed.en', 100 | 'cc-by-sa-2.0-at': 'https://creativecommons.org/licenses/by-sa/2.0/at/deed.en', 101 | 'cc-by-sa-2.0-au': 'https://creativecommons.org/licenses/by-sa/2.0/au/deed.en', 102 | 'cc-by-sa-2.0-be': 'https://creativecommons.org/licenses/by-sa/2.0/be/deed.en', 103 | 'cc-by-sa-2.0-br': 'https://creativecommons.org/licenses/by-sa/2.0/br/deed.en', 104 | 'cc-by-sa-2.0-ca': 'https://creativecommons.org/licenses/by-sa/2.0/ca/deed.en', 105 | 'cc-by-sa-2.0-cl': 'https://creativecommons.org/licenses/by-sa/2.0/cl/deed.en', 106 | 'cc-by-sa-2.0-de': 'https://creativecommons.org/licenses/by-sa/2.0/de/deed.en', 107 | 'cc-by-sa-2.0-es': 'https://creativecommons.org/licenses/by-sa/2.0/es/deed.en', 108 | 'cc-by-sa-2.0-fr': 'https://creativecommons.org/licenses/by-sa/2.0/fr/deed.en', 109 | 'cc-by-sa-2.0-hr': 'https://creativecommons.org/licenses/by-sa/2.0/hr/deed.en', 110 | 'cc-by-sa-2.0-it': 'https://creativecommons.org/licenses/by-sa/2.0/it/deed.en', 111 | 'cc-by-sa-2.0-jp': 'https://creativecommons.org/licenses/by-sa/2.0/jp/deed.en', 112 | 'cc-by-sa-2.0-kr': 'https://creativecommons.org/licenses/by-sa/2.0/kr/deed.en', 113 | 'cc-by-sa-2.0-nl': 'https://creativecommons.org/licenses/by-sa/2.0/nl/deed.en', 114 | 'cc-by-sa-2.0-pl': 'https://creativecommons.org/licenses/by-sa/2.0/pl/deed.en', 115 | 'cc-by-sa-2.0-tw': 'https://creativecommons.org/licenses/by-sa/2.0/tw/deed.en', 116 | 'cc-by-sa-2.0-uk': 'https://creativecommons.org/licenses/by-sa/2.0/uk/deed.en', 117 | 'cc-by-sa-2.0-za': 'https://creativecommons.org/licenses/by-sa/2.0/za/deed.en', 118 | 'cc-by-sa-2.1-au': 'https://creativecommons.org/licenses/by-sa/2.1/au/deed.en', 119 | 'cc-by-sa-2.1-es': 'https://creativecommons.org/licenses/by-sa/2.1/es/deed.en', 120 | 'cc-by-sa-2.1-jp': 'https://creativecommons.org/licenses/by-sa/2.1/jp/deed.en', 121 | 'cc-by-sa-2.5-ar': 'https://creativecommons.org/licenses/by-sa/2.5/ar/deed.en', 122 | 'cc-by-sa-2.5-au': 'https://creativecommons.org/licenses/by-sa/2.5/au/deed.en', 123 | 'cc-by-sa-2.5-bg': 'https://creativecommons.org/licenses/by-sa/2.5/bg/deed.en', 124 | 'cc-by-sa-2.5-br': 'https://creativecommons.org/licenses/by-sa/2.5/br/deed.en', 125 | 'cc-by-sa-2.5-ca': 'https://creativecommons.org/licenses/by-sa/2.5/ca/deed.en', 126 | 'cc-by-sa-2.5-ch': 'https://creativecommons.org/licenses/by-sa/2.5/ch/deed.en', 127 | 'cc-by-sa-2.5-cn': 'https://creativecommons.org/licenses/by-sa/2.5/cn/deed.en', 128 | 'cc-by-sa-2.5-co': 'https://creativecommons.org/licenses/by-sa/2.5/co/deed.en', 129 | 'cc-by-sa-2.5-dk': 'https://creativecommons.org/licenses/by-sa/2.5/dk/deed.en', 130 | 'cc-by-sa-2.5-es': 'https://creativecommons.org/licenses/by-sa/2.5/es/deed.en', 131 | 'cc-by-sa-2.5-hr': 'https://creativecommons.org/licenses/by-sa/2.5/hr/deed.en', 132 | 'cc-by-sa-2.5-hu': 'https://creativecommons.org/licenses/by-sa/2.5/hu/deed.en', 133 | 'cc-by-sa-2.5-il': 'https://creativecommons.org/licenses/by-sa/2.5/il/deed.en', 134 | 'cc-by-sa-2.5-in': 'https://creativecommons.org/licenses/by-sa/2.5/in/deed.en', 135 | 'cc-by-sa-2.5-it': 'https://creativecommons.org/licenses/by-sa/2.5/it/deed.en', 136 | 'cc-by-sa-2.5-mk': 'https://creativecommons.org/licenses/by-sa/2.5/mk/deed.en', 137 | 'cc-by-sa-2.5-mt': 'https://creativecommons.org/licenses/by-sa/2.5/mt/deed.en', 138 | 'cc-by-sa-2.5-mx': 'https://creativecommons.org/licenses/by-sa/2.5/mx/deed.en', 139 | 'cc-by-sa-2.5-my': 'https://creativecommons.org/licenses/by-sa/2.5/my/deed.en', 140 | 'cc-by-sa-2.5-nl': 'https://creativecommons.org/licenses/by-sa/2.5/nl/deed.en', 141 | 'cc-by-sa-2.5-pe': 'https://creativecommons.org/licenses/by-sa/2.5/pe/deed.en', 142 | 'cc-by-sa-2.5-pl': 'https://creativecommons.org/licenses/by-sa/2.5/pl/deed.en', 143 | 'cc-by-sa-2.5-pt': 'https://creativecommons.org/licenses/by-sa/2.5/pt/deed.en', 144 | 'cc-by-sa-2.5-scotland': 'https://creativecommons.org/licenses/by-sa/2.5/scotland/deed.en', 145 | 'cc-by-sa-2.5-se': 'https://creativecommons.org/licenses/by-sa/2.5/se/deed.en', 146 | 'cc-by-sa-2.5-si': 'https://creativecommons.org/licenses/by-sa/2.5/si/deed.en', 147 | 'cc-by-sa-2.5-tw': 'https://creativecommons.org/licenses/by-sa/2.5/tw/deed.en', 148 | 'cc-by-sa-2.5-za': 'https://creativecommons.org/licenses/by-sa/2.5/za/deed.en', 149 | 'cc-by-sa-3.0-at': 'https://creativecommons.org/licenses/by-sa/3.0/at/deed.en', 150 | 'cc-by-sa-3.0-au': 'https://creativecommons.org/licenses/by-sa/3.0/au/deed.en', 151 | 'cc-by-sa-3.0-bg': 'https://creativecommons.org/', 152 | 'cc-by-sa-3.0-bn': 'https://creativecommons.org/', 153 | 'cc-by-sa-3.0-br': 'https://creativecommons.org/licenses/by-sa/3.0/br/deed.en', 154 | 'cc-by-sa-3.0-ch': 'https://creativecommons.org/licenses/by-sa/3.0/ch/deed.en', 155 | 'cc-by-sa-3.0-cl': 'https://creativecommons.org/licenses/by-sa/3.0/cl/deed.en', 156 | 'cc-by-sa-3.0-cn': 'https://creativecommons.org/licenses/by-sa/3.0/cn/deed.en', 157 | 'cc-by-sa-3.0-cr': 'https://creativecommons.org/licenses/by-sa/3.0/cr/deed.en', 158 | 'cc-by-sa-3.0-cz': 'https://creativecommons.org/licenses/by-sa/3.0/cz/deed.en', 159 | 'cc-by-sa-3.0-de': 'https://creativecommons.org/licenses/by-sa/3.0/de/deed.en', 160 | 'cc-by-sa-3.0-ec': 'https://creativecommons.org/licenses/by-sa/3.0/ec/deed.en', 161 | 'cc-by-sa-3.0-ee': 'https://creativecommons.org/licenses/by-sa/3.0/ee/deed.en', 162 | 'cc-by-sa-3.0-eg': 'https://creativecommons.org/licenses/by-sa/3.0/eg/deed.en', 163 | 'cc-by-sa-3.0-es': 'https://creativecommons.org/licenses/by-sa/3.0/es/deed.en', 164 | 'cc-by-sa-3.0-fr': 'https://creativecommons.org/licenses/by-sa/3.0/fr/deed.en', 165 | 'cc-by-sa-3.0-gr': 'https://creativecommons.org/licenses/by-sa/3.0/gr/deed.en', 166 | 'cc-by-sa-3.0-gt': 'https://creativecommons.org/licenses/by-sa/3.0/gt/deed.en', 167 | 'cc-by-sa-3.0-heirs': 'https://creativecommons.org/licenses/by-sa/3.0/deed.en', 168 | 'cc-by-sa-3.0-hk': 'https://creativecommons.org/licenses/by-sa/3.0/hk/deed.en', 169 | 'cc-by-sa-3.0-hr': 'https://creativecommons.org/licenses/by-sa/3.0/hr/deed.en', 170 | 'cc-by-sa-3.0-ie': 'https://creativecommons.org/licenses/by-sa/3.0/ie/deed.en', 171 | 'cc-by-sa-3.0-igo': 'https://creativecommons.org/licenses/by-sa/3.0/igo/deed.en', 172 | 'cc-by-sa-3.0-in': 'https://creativecommons.org/', 173 | 'cc-by-sa-3.0-it': 'https://creativecommons.org/licenses/by-sa/3.0/it/deed.en', 174 | 'cc-by-sa-3.0-lu': 'https://creativecommons.org/licenses/by-sa/3.0/lu/deed.en', 175 | 'cc-by-sa-3.0-nl': 'https://creativecommons.org/licenses/by-sa/3.0/nl/deed.en', 176 | 'cc-by-sa-3.0-no': 'https://creativecommons.org/licenses/by-sa/3.0/no/deed.en', 177 | 'cc-by-sa-3.0-nz': 'https://creativecommons.org/licenses/by-sa/3.0/nz/deed.en', 178 | 'cc-by-sa-3.0-ph': 'https://creativecommons.org/licenses/by-sa/3.0/ph/deed.en', 179 | 'cc-by-sa-3.0-pl': 'https://creativecommons.org/licenses/by-sa/3.0/pl/deed.en', 180 | 'cc-by-sa-3.0-pr': 'https://creativecommons.org/licenses/by-sa/3.0/pr/deed.en', 181 | 'cc-by-sa-3.0-pt': 'https://creativecommons.org/licenses/by-sa/3.0/pt/deed.en', 182 | 'cc-by-sa-3.0-ro': 'https://creativecommons.org/licenses/by-sa/3.0/ro/deed.en', 183 | 'cc-by-sa-3.0-rs': 'https://creativecommons.org/licenses/by-sa/3.0/rs/deed.en', 184 | 'cc-by-sa-3.0-sg': 'https://creativecommons.org/licenses/by-sa/3.0/sg/deed.en', 185 | 'cc-by-sa-3.0-th': 'https://creativecommons.org/licenses/by-sa/3.0/th/deed.en', 186 | 'cc-by-sa-3.0-tw': 'https://creativecommons.org/licenses/by-sa/3.0/tw/deed.en', 187 | 'cc-by-sa-3.0-ug': 'https://creativecommons.org/licenses/by-sa/3.0/ug/deed.en', 188 | 'cc-by-sa-3.0-us': 'https://creativecommons.org/licenses/by-sa/3.0/us/deed.en', 189 | 'cc-by-sa-3.0-vn': 'https://creativecommons.org/licenses/by-sa/3.0/vn/deed.en', 190 | 'cc-by-sa-3.0-za': 'https://creativecommons.org/', 191 | }; 192 | -------------------------------------------------------------------------------- /models/attribution.test.js: -------------------------------------------------------------------------------- 1 | const { Attribution } = require('./attribution'); 2 | const licenseFactory = require('../__helpers__/licenseFactory'); 3 | 4 | describe('attribution', () => { 5 | const exampleCC4License = licenseFactory({ 6 | name: 'CC BY-SA 4.0', 7 | groups: ['cc', 'cc4'], 8 | url: 'https://creativecommons.org/licenses/by-sa/4.0/legalcode', 9 | }); 10 | 11 | const exampleCC2License = licenseFactory({ 12 | name: 'CC BY-SA 2.5', 13 | groups: ['cc', 'cc2.5', 'ccby'], 14 | url: 'https://creativecommons.org/licenses/by-sa/2.5/legalcode', 15 | }); 16 | 17 | const examplePublicDomainLicense = licenseFactory({ 18 | name: 'Public Domain', 19 | groups: ['pd'], 20 | url: 'https://commons.wikimedia.org/wiki/Template:PD-1923', 21 | }); 22 | 23 | const options = { 24 | fileInfo: { 25 | rawUrl: 'https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg', 26 | title: 'File:Eisklettern kl engstligenfall.jpg', 27 | normalizedTitle: 'File:Eisklettern kl engstligenfall.jpg', 28 | artistHtml: 29 | 'Bernhard', 30 | attributionHtml: null, 31 | }, 32 | typeOfUse: 'online', 33 | languageCode: 'de', 34 | license: exampleCC2License, 35 | modification: null, 36 | modificationAuthor: null, 37 | isEdited: false, 38 | }; 39 | 40 | function newAttribution(overrides = {}) { 41 | return new Attribution({ ...options, ...overrides }); 42 | } 43 | 44 | it('initalizes', () => { 45 | newAttribution(); 46 | }); 47 | 48 | describe('validations', () => { 49 | it('asserts valid fileInfo.rawUrl', () => { 50 | expect(() => 51 | newAttribution({ fileInfo: { rawUrl: 123, normalizedTtle: 'title' } }) 52 | ).toThrow(); 53 | }); 54 | 55 | it('asserts valid fileInfo.title', () => { 56 | expect(() => newAttribution({ fileInfo: { rawUrl: 'url', normalizedTitle: '' } })).toThrow(); 57 | }); 58 | 59 | it('asserts valid typeOfUse', () => { 60 | expect(() => newAttribution({ typeOfUse: 'print' })).toThrow(); 61 | }); 62 | 63 | it('asserts valid languageCode', () => { 64 | expect(() => newAttribution({ languageCode: 'yo' })).toThrow(); 65 | }); 66 | 67 | it('asserts valid fileInfo.artistHtml', () => { 68 | expect(() => newAttribution({ fileInfo: { artistHtml: 123 } })).toThrow(); 69 | }); 70 | 71 | it('asserts valid fileInfo.attributionHtml', () => { 72 | expect(() => newAttribution({ fileInfo: { attributionHtml: 123 } })).toThrow(); 73 | }); 74 | 75 | it('asserts valid license', () => { 76 | expect(() => newAttribution({ license: 'public domain' })).toThrow(); 77 | }); 78 | 79 | it('asserts valid modification', () => { 80 | expect(() => newAttribution({ modification: 123 })).toThrow(); 81 | }); 82 | 83 | it('asserts valid modificationAuthor', () => { 84 | expect(() => newAttribution({ modificationAuthor: 123 })).toThrow(); 85 | }); 86 | }); 87 | 88 | describe('when initialized with defaults', () => { 89 | it('generates an html attribution', () => { 90 | expect(newAttribution().html()).toEqual( 91 | 'Bernhard, Eisklettern kl engstligenfall, CC BY-SA 2.5' 92 | ); 93 | }); 94 | 95 | it('generates a plain text attribution', () => { 96 | expect(newAttribution().plainText()).toEqual( 97 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 98 | ); 99 | }); 100 | }); 101 | 102 | describe('when typeOfUse is offline', () => { 103 | describe('with the cc2 license', () => { 104 | it('generates an html attribution', () => { 105 | expect(newAttribution({ typeOfUse: 'offline' }).html()).toEqual( 106 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 107 | ); 108 | }); 109 | 110 | it('generates a plain text attribution', () => { 111 | expect(newAttribution({ typeOfUse: 'offline' }).plainText()).toEqual( 112 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 113 | ); 114 | }); 115 | }); 116 | 117 | describe('when we use a cc4 license', () => { 118 | const subject = newAttribution({ license: exampleCC4License, typeOfUse: 'offline' }); 119 | 120 | it('generates an html attribution', () => { 121 | expect(subject.html()).toEqual( 122 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), https://creativecommons.org/licenses/by-sa/4.0/legalcode' 123 | ); 124 | }); 125 | 126 | it('generates a plain text attribution', () => { 127 | expect(subject.plainText()).toEqual( 128 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), https://creativecommons.org/licenses/by-sa/4.0/legalcode' 129 | ); 130 | }); 131 | }); 132 | }); 133 | 134 | describe('when we use a cc4 license', () => { 135 | const subject = newAttribution({ license: exampleCC4License }); 136 | 137 | it('generates an html attribution', () => { 138 | expect(subject.html()).toEqual( 139 | 'Bernhard, Eisklettern kl engstligenfall, CC BY-SA 4.0' 140 | ); 141 | }); 142 | 143 | it('generates a plain text attribution', () => { 144 | expect(subject.plainText()).toEqual( 145 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/4.0/legalcode' 146 | ); 147 | }); 148 | }); 149 | 150 | describe('when we use a public domain license', () => { 151 | const subject = newAttribution({ license: examplePublicDomainLicense }); 152 | 153 | it('generates an html attribution', () => { 154 | expect(subject.html()).toEqual( 155 | 'Bernhard, Eisklettern kl engstligenfall, als gemeinfrei gekennzeichnet, Details auf Wikimedia Commons' 156 | ); 157 | }); 158 | 159 | it('generates a plain text attribution', () => { 160 | expect(subject.plainText()).toEqual( 161 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, als gemeinfrei gekennzeichnet, Details auf Wikimedia Commons: https://commons.wikimedia.org/wiki/Template:PD-1923' 162 | ); 163 | }); 164 | }); 165 | 166 | describe('when attributionHtml is present', () => { 167 | const subject = newAttribution({ 168 | fileInfo: { 169 | rawUrl: 'https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg', 170 | normalizedTitle: 'File:Eisklettern kl engstligenfall.jpg', 171 | artistHtml: 'artistHtml', 172 | attributionHtml: 173 | 'Rhorn at the English language Wikipedia', 174 | }, 175 | }); 176 | 177 | it('generates an html attribution', () => { 178 | expect(subject.html()).toEqual( 179 | 'Rhorn at the English language Wikipedia, Eisklettern kl engstligenfall, CC BY-SA 2.5' 180 | ); 181 | }); 182 | 183 | it('generates a plain text attribution', () => { 184 | expect(subject.plainText()).toEqual( 185 | 'Rhorn at the English language Wikipedia (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 186 | ); 187 | }); 188 | }); 189 | 190 | describe('when neither, attributionHtml nor artistHtml is present', () => { 191 | const subject = newAttribution({ 192 | fileInfo: { 193 | rawUrl: 'https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg', 194 | normalizedTitle: 'File:Eisklettern kl engstligenfall.jpg', 195 | }, 196 | }); 197 | 198 | it('generates an html attribution', () => { 199 | expect(subject.html()).toEqual( 200 | 'anonym, Eisklettern kl engstligenfall, CC BY-SA 2.5' 201 | ); 202 | }); 203 | 204 | it('generates a plain text attribution', () => { 205 | expect(subject.plainText()).toEqual( 206 | 'anonym (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 207 | ); 208 | }); 209 | }); 210 | 211 | describe('when requesting the locale "en"', () => { 212 | const subject = newAttribution({ languageCode: 'en' }); 213 | 214 | it('generates an html attribution', () => { 215 | expect(subject.html()).toEqual( 216 | 'Bernhard, Eisklettern kl engstligenfall, CC BY-SA 2.5' 217 | ); 218 | }); 219 | it('generates a plain text attribution', () => { 220 | expect(subject.plainText()).toEqual( 221 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 222 | ); 223 | }); 224 | }); 225 | 226 | describe('when it was edited', () => { 227 | it('generates an html attribution', () => { 228 | expect(newAttribution({ isEdited: true }).html()).toEqual( 229 | 'Bernhard, Eisklettern kl engstligenfall, bearbeitet, CC BY-SA 2.5' 230 | ); 231 | }); 232 | 233 | it('generates a plain text attribution', () => { 234 | expect(newAttribution({ isEdited: true }).plainText()).toEqual( 235 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, bearbeitet, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 236 | ); 237 | }); 238 | 239 | describe('when requesting the locale "en"', () => { 240 | const subject = newAttribution({ languageCode: 'en', isEdited: true }); 241 | 242 | it('generates an html attribution', () => { 243 | expect(subject.html()).toEqual( 244 | 'Bernhard, Eisklettern kl engstligenfall, modified, CC BY-SA 2.5' 245 | ); 246 | }); 247 | 248 | it('generates a plain text attribution', () => { 249 | expect(subject.plainText()).toEqual( 250 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, modified, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 251 | ); 252 | }); 253 | }); 254 | 255 | describe('when there is a modification', () => { 256 | const subject = newAttribution({ isEdited: true, modification: 'cropped' }); 257 | 258 | it('generates an html attribution', () => { 259 | expect(subject.html()).toEqual( 260 | 'Bernhard, Eisklettern kl engstligenfall, cropped, CC BY-SA 2.5' 261 | ); 262 | }); 263 | 264 | it('generates a plain text attribution', () => { 265 | expect(subject.plainText()).toEqual( 266 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, cropped, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 267 | ); 268 | }); 269 | }); 270 | 271 | describe('when there is a modificationAuthor', () => { 272 | const subject = newAttribution({ 273 | isEdited: true, 274 | modificationAuthor: 'the great Modificator', 275 | }); 276 | 277 | it('generates an html attribution', () => { 278 | expect(subject.html()).toEqual( 279 | 'Bernhard, Eisklettern kl engstligenfall, bearbeitet von the great Modificator, CC BY-SA 2.5' 280 | ); 281 | }); 282 | 283 | it('generates a plain text attribution', () => { 284 | expect(subject.plainText()).toEqual( 285 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, bearbeitet von the great Modificator, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 286 | ); 287 | }); 288 | }); 289 | 290 | describe('when there is both, a modification and modificationAuthor', () => { 291 | const subject = newAttribution({ 292 | isEdited: true, 293 | modification: 'cropped', 294 | modificationAuthor: 'the great Modificator', 295 | }); 296 | 297 | it('generates an html attribution', () => { 298 | expect(subject.html()).toEqual( 299 | 'Bernhard, Eisklettern kl engstligenfall, cropped von the great Modificator, CC BY-SA 2.5' 300 | ); 301 | }); 302 | 303 | it('generates a plain text attribution', () => { 304 | expect(subject.plainText()).toEqual( 305 | 'Bernhard (https://commons.wikimedia.org/wiki/File:Eisklettern_kl_engstligenfall.jpg), „Eisklettern kl engstligenfall“, cropped von the great Modificator, https://creativecommons.org/licenses/by-sa/2.5/legalcode' 306 | ); 307 | }); 308 | }); 309 | }); 310 | }); 311 | --------------------------------------------------------------------------------