diff --git a/bin/make_docs.ts b/bin/make_docs.ts index d414822dd6f..d4abfc97df5 100644 --- a/bin/make_docs.ts +++ b/bin/make_docs.ts @@ -57,7 +57,7 @@ createDirIfNotExists('../out/doc/api') -exec(`asciidoctor -D ../out/doc ../doc/index.adoc */**.adoc -a VERSION=${VERSION}`) +exec(`asciidoctor -D ../out/doc ../doc/index.adoc ../*/**.adoc -a VERSION=${VERSION}`) exec(`asciidoctor -D ../out/doc/api ../doc/api/*.adoc -a VERSION=${VERSION}`) copyFolderSync('../doc/public/', '../out/doc/') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05a505805a8..ff4f5cd76a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: superagent: specifier: 10.1.0 version: 10.1.0 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@4.21.0) tinycon: specifier: 0.6.8 version: 0.6.8 @@ -324,6 +327,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.2 + '@types/swagger-ui-express': + specifier: ^4.1.6 + version: 4.1.6 '@types/underscore': specifier: ^1.11.15 version: 1.11.15 @@ -1613,6 +1619,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/swagger-ui-express@4.1.6': + resolution: {integrity: sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==} + '@types/tar@6.1.13': resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} @@ -4258,6 +4267,15 @@ packages: swagger-schema-official@2.0.0-bab6bed: resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + swagger-ui-dist@5.17.14: + resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5888,6 +5906,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.7 + '@types/swagger-ui-express@4.1.6': + dependencies: + '@types/express': 4.17.21 + '@types/serve-static': 1.15.7 + '@types/tar@6.1.13': dependencies: '@types/node': 22.5.4 @@ -9000,6 +9023,13 @@ snapshots: swagger-schema-official@2.0.0-bab6bed: {} + swagger-ui-dist@5.17.14: {} + + swagger-ui-express@5.0.1(express@4.21.0): + dependencies: + express: 4.21.0 + swagger-ui-dist: 5.17.14 + symbol-tree@3.2.4: {} tabbable@6.2.0: {} diff --git a/src/ep.json b/src/ep.json index c9b26c175aa..83dfc509db8 100644 --- a/src/ep.json +++ b/src/ep.json @@ -82,6 +82,12 @@ "expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling" } }, + { + "name": "restApi", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/handler/RestAPI" + } + }, { "name": "socketio", "hooks": { diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index 5feb74eb965..b108f50f0a0 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -24,10 +24,10 @@ import {MapArrayType} from "../types/MapType"; const api = require('../db/API'); const padManager = require('../db/PadManager'); import createHTTPError from 'http-errors'; -import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; +import {Http2ServerRequest} from "node:http2"; import {publicKeyExported} from "../security/OAuth2Provider"; import {jwtVerify} from "jose"; -import {apikey} from './APIKeyHandler' +import {APIFields, apikey} from './APIKeyHandler' // a list of all functions const version:MapArrayType = {}; @@ -141,6 +141,7 @@ version['1.3.0'] = { setText: ['padID', 'text', 'authorId'], }; + // set the latest available API version here exports.latestApiVersion = '1.3.0'; @@ -148,13 +149,6 @@ exports.latestApiVersion = '1.3.0'; exports.version = version; -type APIFields = { - apikey: string; - api_key: string; - padID: string; - padName: string; - authorization: string; -} /** * Handles an HTTP API call diff --git a/src/node/handler/APIKeyHandler.ts b/src/node/handler/APIKeyHandler.ts index b4e70f6e4b0..5a00453b14d 100644 --- a/src/node/handler/APIKeyHandler.ts +++ b/src/node/handler/APIKeyHandler.ts @@ -7,6 +7,16 @@ const settings = require('../utils/Settings'); const apiHandlerLogger = log4js.getLogger('APIHandler'); + + +export type APIFields = { + apikey: string; + api_key: string; + padID: string; + padName: string; + authorization: string; +} + // ensure we have an apikey export let apikey:string|null = null; const apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || './APIKEY.txt'); diff --git a/src/node/handler/RestAPI.ts b/src/node/handler/RestAPI.ts new file mode 100644 index 00000000000..7b1b82309d7 --- /dev/null +++ b/src/node/handler/RestAPI.ts @@ -0,0 +1,1527 @@ +import {ArgsExpressType} from "../types/ArgsExpressType"; +import {MapArrayType} from "../types/MapType"; +import {IncomingForm} from "formidable"; +import {ErrorCaused} from "../types/ErrorCaused"; +import createHTTPError from "http-errors"; + +const apiHandler = require('./APIHandler') +import {serve, setup} from 'swagger-ui-express' +import express from "express"; + +const settings = require('../utils/Settings') + + +type RestAPIMapping = { + apiVersion: string; + functionName: string, + summary?: string, + operationId?: string, + requestBody?: any, + responses?: any, + tags?: string[], +} + + +const mapping = new Map> + + +const GET = "GET" +const POST = "POST" +const PUT = "PUT" +const DELETE = "DELETE" +const PATCH = "PATCH" + + +const defaultResponses = { + "200": { + "description": "ok (code 0)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "groupID": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "generic api error (code 1)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 1 + }, + "message": { + "type": "string", + "example": "error message" + }, + "data": { + "type": "object", + "example": null + } + } + } + } + } + }, + "401": { + "description": "no or wrong API key (code 4)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 4 + }, + "message": { + "type": "string", + "example": "no or wrong API key" + }, + "data": { + "type": "object", + "example": null + } + } + } + } + } + }, + "500": { + "description": "internal api error (code 2)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 2 + }, + "message": { + "type": "string", + "example": "internal error" + }, + "data": { + "type": "object", + "example": null + } + } + } + } + } + }, + "tags": [ + "group" + ], + "parameters": [] +} + +const prepareResponses = (data: { + type: string, + properties: Record, + properties?: Record, + }>, +}) => { + return { + ...defaultResponses, + 200: { + ...defaultResponses["200"], + content: { + ...defaultResponses["200"].content, + "application/json": { + schema: { + type: "object", + properties: { + ...defaultResponses["200"].content["application/json"].schema.properties, + data: data + } + } + } + } + } + } +} + + +const prepareDefinition = (mapping: Map>, address: string) => { + const authenticationMethod = settings.authenticationMethod + + + const definitions: { + "openapi": string, + "info": { + "title": string, + "description": string, + "termsOfService": string, + "contact": { + "name": string, + "url": string, + "email": string, + }, + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": string, + "name": string, + "in": string + + }, + "sso"?: { + "type": string, + "flows": { + "authorizationCode": { + "authorizationUrl": string, + "tokenUrl": string, + "scopes": { + "openid": string, + "profile": string, + "email": string, + "admin": string + } + } + } + } + }, + }, + "servers": [ + { + "url": string + } + ], + "paths": Record, + }>>, + "security": any[] + } = { + "openapi": "3.0.2", + "info": { + "title": "Etherpad API", + "description": "Etherpad is a real-time collaborative editor scalable to thousands of simultaneous real time users. It provides full data export capabilities, and runs on your server, under your control.", + "termsOfService": "https://etherpad.org/", + "contact": { + "name": "The Etherpad Foundation", + "url": "https://etherpad.org/", + "email": "", + }, + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "apikey", + "in": "query" + + }, + }, + }, + "servers": [ + { + "url": `${address}/api/2` + } + ], + "paths": {}, + "security": [] + } + + if (authenticationMethod === "apikey") { + definitions.security = [ + { + "apiKey": [] + } + ] + } else if (authenticationMethod === "sso") { + definitions.components.securitySchemes.sso = { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: settings.sso.issuer + "/oidc/auth", + tokenUrl: settings.sso.issuer + "/oidc/token", + scopes: { + openid: "openid", + profile: "profile", + email: "email", + admin: "admin" + } + } + }, + } + + definitions.security = [ + { + "sso": [] + } + ] + } + + + for (const [method, value] of mapping) { + for (const [path, mapping] of Object.entries(value)) { + const {apiVersion, functionName, summary, operationId, requestBody, responses, tags} = mapping + if (!definitions.paths[path]) { + definitions.paths[path] = {} + } + + const methodLowercased = method.toLowerCase() + + definitions.paths[path][methodLowercased] = { + summary: summary!, + operationId: operationId!, + responses, + tags: tags! + } + + if (method === GET) { + definitions.paths[path][methodLowercased].parameters = requestBody + } else { + definitions.paths[path][methodLowercased].requestBody = requestBody + } + } + } + return definitions +} + + +export const expressCreateServer = async (hookName: string, {app}: ArgsExpressType) => { + mapping.set(GET, {}) + mapping.set(POST, {}) + mapping.set(PUT, {}) + mapping.set(DELETE, {}) + mapping.set(PATCH, {}) + + // Version 1 + mapping.get(POST)!["/groups"] = { + apiVersion: '1', + functionName: 'createGroup', summary: 'Creates a new group', + operationId: 'createGroup', tags: ['group'], responses: prepareResponses({type: "object", properties: {groupID: {type: "string"}}}) + + } + mapping.get(POST)!["/groups/createIfNotExistsFor"] = { + apiVersion: '1', functionName: 'createGroupIfNotExistsFor', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + groupMapper: { + type: "string" + } + }, + required: ["groupMapper"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {groupID: {type: "string"}}}), + summary: "Creates a new group if it doesn't exist", operationId: 'createGroupIfNotExistsFor', tags: ['group'] + }; + mapping.get(GET)!["/groups/pads"] = { + apiVersion: '1', functionName: 'listPads', + summary: "Lists all pads in a group", tags: ['group'], + operationId: 'listPads', responses: prepareResponses({type: "object", properties: {padIDs: {type: "string"}}}), + requestBody: [ + { + "name": "groupID", + "in": "query", + "schema": { + "type": "string" + } + } + ] + } + mapping.get(DELETE)!["/groups"] = { + apiVersion: '1', functionName: 'deleteGroup', responses: prepareResponses({type: "object", properties: {}}), requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + groupID: { + type: "string" + } + }, + required: ["groupID"] + } + } + } + }, summary: "Deletes a group", operationId: 'deleteGroup', tags: ['group'] + } + + mapping.get(POST)!["/authors"] = { + apiVersion: '1', functionName: 'createAuthor', requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string" + } + }, + required: ["name"] + } + } + } + }, tags: ["author"] + } + + + mapping.get(POST)!["/authors/createIfNotExistsFor"] = { + apiVersion: '1', functionName: 'createAuthorIfNotExistsFor', + responses: prepareResponses({type: "object", properties: {authorID: {type: "string"}}}), + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + authorMapper: { + type: "string" + }, + name: { + type: "string" + } + }, + required: ["authorMapper", "name"] + } + } + } + }, + tags: ["author"], + } + + + mapping.get(GET)!["/authors/pads"] = { + apiVersion: '1', functionName: 'listPadsOfAuthor', + requestBody: [ + { + "name": "authorID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + responses: prepareResponses({type: "object", properties: {padIDs: {type: "array", items: {type: "string"}}}}), + tags: ["author"] + } + mapping.get(POST)!["/sessions"] = { + apiVersion: '1', functionName: 'createSession', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + groupID: { + type: "string" + }, + authorID: { + type: "string" + }, + validUntil: { + type: "string" + } + }, + required: ["groupID", "authorID", "validUntil"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {sessionID: {type: "string"}}}), + tags: ['session'] + } + + mapping.get(DELETE)!["/sessions"] = { + apiVersion: '1', functionName: 'deleteSession', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + sessionID: { + type: "string" + } + }, + required: ["sessionID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + tags: ['session'] + } + + + mapping.get(GET)!["/sessions/info"] = { + apiVersion: '1', functionName: 'getSessionInfo', + requestBody: [ + { + "name": "sessionID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + responses: prepareResponses({ + "type": "object", + "properties": { + id: { + type: "string" + }, + "groupID": { + "type": "string" + }, + "authorID": { + "type": "string" + }, + "validUntil": { + "type": "string" + } + } + }), + tags: ['session'] + } + + + mapping.get(GET)!["/sessions/group"] = { + apiVersion: '1', functionName: 'listSessionsOfGroup', summary: 'Lists all sessions in a group', + operationId: 'listSessionsOfGroup', tags: ['session'], + responses: prepareResponses({ + type: "object", "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "authorID": { + "type": "string" + }, + "groupID": { + "type": "string" + }, + "validUntil": { + "type": "integer" + } + } + } + } + } + }), + requestBody: [ + { + "name": "groupID", + "in": "query", + "schema": { + "type": "string" + } + } + ] + } + mapping.get(GET)!["/sessions/author"] = { + apiVersion: '1', functionName: 'listSessionsOfAuthor', + summary: 'Lists all sessions of an author', operationId: 'listSessionsOfAuthor', tags: ['session'], + responses: prepareResponses({ + type: "object", "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "authorID": { + "type": "string" + }, + "groupID": { + "type": "string" + }, + "validUntil": { + "type": "integer" + } + } + } + } + } + }), + requestBody: [ + { + "name": "authorID", + "in": "query", + "schema": { + "type": "string" + } + } + ] + } + + + mapping.get(GET)!["/pads/text"] = { + apiVersion: '1', functionName: 'getText', + responses: prepareResponses({type: "object", properties: {text: {type: "string"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + tags: ['pad'] + } + + + mapping.get(GET)!["/pads/html"] = { + apiVersion: '1', functionName: 'getHTML', + responses: prepareResponses({type: "object", properties: {html: {type: "string"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the HTML of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/revisions"] = { + apiVersion: '1', functionName: 'getRevisionsCount', + responses: prepareResponses({type: "object", properties: {revisions: {type: "integer"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the number of revisions of a pad', + tags: ['pad'] + } + + mapping.get(GET)!["/pads/lastEdited"] = { + apiVersion: '1', functionName: 'getLastEdited', + responses: prepareResponses({type: "object", properties: {lastEdited: {type: "integer"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the timestamp of the last revision of a pad', + tags: ['pad'] + } + + + mapping.get(DELETE)!["/pads"] = { + apiVersion: '1', functionName: 'deletePad', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + } + }, + required: ["padID"] + } + } + } + }, + summary: 'Deletes a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/readonly"] = { + apiVersion: '1', functionName: 'getReadOnlyID', + responses: prepareResponses({type: "object", properties: {readOnlyID: {type: "string"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the read only id of a pad', + tags: ['pad'] + } + + mapping.get(POST)!["/pads/publicStatus"] = { + apiVersion: '1', functionName: 'setPublicStatus', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + publicStatus: { + type: "boolean" + } + }, + required: ["padID", "publicStatus"] + } + } + } + }, + summary: 'Set the public status of a pad', + tags: ['pad'] + + } + mapping.get(GET)!["/pads/publicStatus"] = { + apiVersion: '1', functionName: 'getPublicStatus', + responses: prepareResponses({type: "object", properties: {publicStatus: {type: "boolean"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the public status of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/authors"] = { + apiVersion: '1', functionName: 'listAuthorsOfPad', + responses: prepareResponses({type: "object", properties: {authorIDs: {type: "array", items: {type: "string"}}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the authors of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/usersCount"] = { + apiVersion: '1', functionName: 'padUsersCount', + responses: prepareResponses({type: "object", properties: {padUsersCount: {type: "integer"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the number of users currently editing a pad', + tags: ['pad'] + } + + + // Version 1.1 + mapping.get(GET)!["/authors/name"] = { + apiVersion: '1.1', functionName: 'getAuthorName', + responses: prepareResponses({type: "object", properties: {authorName: {type: "string"}}}), + requestBody: [ + { + "name": "authorID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the name of an author', + tags: ['author'] + } + mapping.get(GET)!["/pads/users"] = { + apiVersion: '1.1', functionName: 'padUsers', + responses: prepareResponses({ + type: "object", properties: { + padUsers: { + type: "array", "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "colorId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + } + } + } + }), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the users currently editing a pad', + tags: ['pad'] + } + + + mapping.get(POST)!["/pads/clientsMessage"] = { + apiVersion: '1.1', functionName: 'sendClientsMessage', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + msg: { + type: "string" + } + }, + required: ["padID", "msg"] + } + } + } + }, + summary: 'Send a message to all clients of a pad', + tags: ['pad'] + } + + + mapping.get(GET)!["/groups"] = { + apiVersion: '1.1', functionName: 'listAllGroups', + responses: prepareResponses({type: "object", properties: {groupIDs: {type: "array", items: {type: "string"}}}}), + summary: 'Lists all groups', + tags: ['group'] + } + + + // Version 1.2 + mapping.get(GET)!["/checkToken"] = { + apiVersion: '1.2', functionName: 'checkToken', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: [ + { + "name": "token", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Check if a token is valid', + tags: ['token'] + + } + + // Version 1.2.1 + mapping.get(GET)!["/pads"] = { + apiVersion: '1.2.1', functionName: 'listAllPads', + summary: 'Lists all pads', + tags: ['pad'], + requestBody: [ + { + "name": "groupID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + responses: prepareResponses({type: "object", properties: {padIDs: {type: "array", items: {type: "string"}}}}) + } + + // Version 1.2.7 + mapping.get(POST)!["/pads/diff"] = { + apiVersion: '1.2.7', functionName: 'createDiffHTML', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + startRev: { + type: "integer" + }, + endRev: { + type: "integer" + } + }, + required: ["padID", "startRev", "endRev"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Creates a diff of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/chatHistory"] = { + apiVersion: '1.2.7', functionName: 'getChatHistory', + responses: prepareResponses({ + type: "object", properties: { + messages: { + type: "array", items: { + type: "object", properties: { + "text": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "time": { + "type": "integer" + } + } + } + } + } + }), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the chat history of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/chatHead"] = { + apiVersion: '1.2.7', functionName: 'getChatHead', + responses: prepareResponses({ + type: "object", properties: { + chatHead: { + type: "object", + properties: { + "text": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "time": { + "type": "integer" + } + } + } + + } + }), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the chat head of a pad', + tags: ['pad'] + + } + + // Version 1.2.8 + mapping.get(GET)!["/pads/attributePool"] = { + apiVersion: '1.2.8', functionName: 'getAttributePool', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the attribute pool of a pad', + tags: ['pad'] + } + mapping.get(GET)!["/pads/revisionChangeset"] = { + apiVersion: '1.2.8', functionName: 'getRevisionChangeset', + responses: prepareResponses({type: "object", properties: {}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "rev", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + summary: 'Get the changeset of a revision of a pad', + tags: ['pad'] + } + + // Version 1.2.9 + mapping.get(POST)!["/pads/copypad"] = { + apiVersion: '1.2.9', functionName: 'copyPad', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + sourceID: { + type: "string" + }, + destinationID: { + type: "string" + }, + force: { + type: "boolean" + } + }, + required: ["sourceID", "destinationID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Copies a pad', + tags: ['pad'] + } + + + mapping.get(POST)!["/pads/movePad"] = { + apiVersion: '1.2.9', functionName: 'movePad', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + sourceID: { + type: "string" + }, + destinationID: { + type: "string" + }, + force: { + type: "boolean" + } + }, + required: ["sourceID", "destinationID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Moves a pad', + tags: ['pad'] + } + + // Version 1.2.10 + mapping.get(POST)!["/pads/padId"] = { + apiVersion: '1.2.10', functionName: 'getPadID', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roID: { + type: "string" + } + }, + required: ["roID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Get the pad id of a pad', + tags: ['pad'] + } + + // Version 1.2.11 + mapping.get(GET)!["/savedRevisions"] = { + apiVersion: '1.2.11', functionName: 'listSavedRevisions', + responses: prepareResponses({type: "object", properties: {savedRevisions: {type: "array", items: {type: "object"}}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Lists all saved revisions of a pad', + tags: ['pad'] + } + + + mapping.get(POST)!["/savedRevisions"] = { + apiVersion: '1.2.11', functionName: 'saveRevision', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + rev: { + type: "integer" + } + }, + required: ["padID", "rev"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Saves a revision of a pad', + tags: ['pad'] + } + + mapping.get(GET)!["/savedRevisions/revisionsCount"] = { + apiVersion: '1.2.11', functionName: 'getSavedRevisionsCount', + responses: prepareResponses({type: "object", properties: {revisionsCount: {type: "integer"}}}), + requestBody: [ + { + "name": "padID", + "in": "query", + "schema": { + "type": "string" + } + } + ], + summary: 'Get the number of saved revisions of a pad', + tags: ['pad'] + } + + // Version 1.2.12 + mapping.get(PATCH)!["/chats/messages"] = { + apiVersion: '1.2.12', functionName: 'appendChatMessage', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + text: { + type: "string" + }, + authorID: { + type: "string" + }, + time: { + type: "string" + } + }, + required: ["padID", "text"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Appends a chat message to a pad', + tags: ['pad'] + } + + // Version 1.2.13 + + // Version 1.2.14 + mapping.get(GET)!["/stats"] = { + apiVersion: '1.2.14', functionName: 'getStats', + responses: prepareResponses({type: "object", properties: {stats: {type: "object"}}}), + summary: 'Get stats', + tags: ['stats'] + } + + // Version 1.2.15 + + // Version 1.3.0 + mapping.get(PATCH)!["/pads/text"] = { + apiVersion: '1.3.0', functionName: 'appendText', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + text: { + type: "string" + }, + authorID: { + type: "string" + }, + }, + required: ["padID", "text", "authorID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Appends text to a pad', + tags: ['pad'] + } + mapping.get(POST)!["/pads/copyWithoutHistory"] = { + apiVersion: '1.3.0', functionName: 'copyPadWithoutHistory', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + sourceID: { + type: "string" + }, + destinationID: { + type: "string" + }, + force: { + type: "string" + }, + authorID: { + type: "string" + } + }, + required: ["sourceID", "destinationID", "force", "authorID"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Copies a pad without its history', + tags: ['pad'] + } + mapping.get(POST)!["/pads/group"] = { + apiVersion: '1.3.0', functionName: 'createGroupPad', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + groupID: { + type: "string" + }, + padName: { + type: "string" + }, + text: { + type: "string" + }, + authorID: { + type: "string" + } + }, + required: ["groupID", "padName"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Creates a new pad in a group', + tags: ['pad'] + + } + mapping.get(POST)!["/pads"] = { + apiVersion: '1.3.0', functionName: 'createPad', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + text: { + type: "string" + }, + authorId: { + type: "string" + } + }, + required: ["padName"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Creates a new pad', + tags: ['pad'] + } + mapping.get(PATCH)!["/savedRevisions"] = { + apiVersion: '1.3.0', functionName: 'restoreRevision', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + rev: { + type: "integer" + }, + authorId: { + type: "string" + } + }, + required: ["padID", "rev", "authorId"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Restores a revision of a pad', + tags: ['pad'] + } + + + mapping.get(POST)!["/pads/html"] = { + apiVersion: '1.3.0', functionName: 'setHTML', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + html: { + type: "string" + }, + authorId: { + type: "string" + } + }, + required: ["padID", "html", "authorId"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Sets the HTML of a pad', + tags: ['pad'] + } + + mapping.get(POST)!["/pads/text"] = { + apiVersion: '1.3.0', functionName: 'setText', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + padID: { + type: "string" + }, + text: { + type: "string" + }, + authorId: { + type: "string" + } + }, + required: ["padID", "text", "authorId"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {}}), + summary: 'Sets the text of a pad', + tags: ['pad'] + } + + + app.use('/api-docs', serve); + app.get('/api-docs', setup(undefined, { + swaggerOptions: { + url: '/api-docs.json', + }, + })); + + app.use(express.json()); + + app.get('/api-docs.json', (req, res) => { + const fullUrl = req.protocol + '://' + req.get('host'); + const generatedDefinition = prepareDefinition(mapping, fullUrl) + res.json(generatedDefinition) + }) + app.use('/api/2', async (req, res, next) => { + const method = req.method + const pathToFunction = req.path + // parse fields from request + const {headers, params, query} = req; + + // read form data if method was POST + let formData: MapArrayType = {}; + if (method.toLowerCase() === 'post' || method.toLowerCase() === "delete") { + if (!req.headers['content-type'] || req.headers['content-type']!.startsWith('application/json')) { + // parse json + formData = req.body; + } else { + const form = new IncomingForm(); + formData = (await form.parse(req))[0]; + for (const k of Object.keys(formData)) { + if (formData[k] instanceof Array) { + formData[k] = formData[k][0]; + } + } + } + } + + const fields = Object.assign({}, headers, params, query, formData); + + if (mapping.has(method) && pathToFunction in mapping.get(method)!) { + const {apiVersion, functionName} = mapping.get(method)![pathToFunction]! + // pass to api handler + let response; + try { + try { + let data = await apiHandler.handle(apiVersion, functionName, fields, req, res); + + // return in common format + response = {code: 0, message: 'ok', data: data || null}; + } catch (err) { + const errCaused = err as ErrorCaused + // convert all errors to http errors + if (createHTTPError.isHttpError(err)) { + // pass http errors thrown by handler forward + throw err; + } else if (errCaused.name === 'apierror') { + // parameters were wrong and the api stopped execution, pass the error + // convert to http error + throw new createHTTPError.BadRequest(errCaused.message); + } else { + // an unknown error happened + // log it and throw internal error + console.error(errCaused.stack || errCaused.toString()); + throw new createHTTPError.InternalServerError('internal error'); + } + } + } catch (err) { + const errCaused = err as ErrorCaused + // handle http errors + // @ts-ignore + res.statusCode = errCaused.statusCode || 500; + + // convert to our json response format + // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format + switch (res.statusCode) { + case 403: // forbidden + response = {code: 4, message: errCaused.message, data: null}; + break; + case 401: // unauthorized (no or wrong api key) + response = {code: 4, message: errCaused.message, data: null}; + break; + case 404: // not found (no such function) + response = {code: 3, message: errCaused.message, data: null}; + break; + case 500: // server error (internal error) + response = {code: 2, message: errCaused.message, data: null}; + break; + case 400: // bad request (wrong parameters) + // respond with 200 OK to keep old behavior and pass tests + res.statusCode = 200; // @TODO: this is bad api design + response = {code: 1, message: errCaused.message, data: null}; + break; + default: + response = {code: 1, message: errCaused.message, data: null}; + break; + } + } + + + console.debug(`RESPONSE, ${functionName}, ${JSON.stringify(response)}`); + + // return the response data + res.json(response); + } else { + res.json({code: 1, message: 'not found'}); + } + }) +} diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index ce4a90bb4f6..ef5914e9573 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -12,6 +12,7 @@ const webaccess = require('./webaccess'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); import {build, buildSync} from 'esbuild' +import {ArgsExpressType} from "../../types/ArgsExpressType"; let ioI: { sockets: { sockets: any[]; }; } | null = null exports.socketio = (hookName: string, {io}: any) => { @@ -19,7 +20,7 @@ exports.socketio = (hookName: string, {io}: any) => { } -exports.expressPreSession = async (hookName:string, {app}:any) => { +exports.expressPreSession = async (hookName:string, {app}:ArgsExpressType) => { // This endpoint is intended to conform to: // https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html app.get('/health', (req:any, res:any) => { @@ -243,7 +244,7 @@ const convertTypescriptWatched = (content: string, cb: (output:string, hash: str }) } -exports.expressCreateServer = async (hookName: string, args: any, cb: Function) => { +exports.expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => { const padString = eejs.require('ep_etherpad-lite/templates/padBootstrap.js', { pluginModules: (() => { const pluginModules = new Set(); diff --git a/src/node/security/OAuth2Provider.ts b/src/node/security/OAuth2Provider.ts index e212113504b..76e7ed4b894 100644 --- a/src/node/security/OAuth2Provider.ts +++ b/src/node/security/OAuth2Provider.ts @@ -153,7 +153,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp }); - args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => { + args.app.post('/interaction/:uid', async (req, res, next) => { const formid = new IncomingForm(); try { // @ts-ignore @@ -226,7 +226,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp }) - args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => { + args.app.get('/interaction/:uid', async (req, res, next) => { try { const { uid, prompt, params, session, diff --git a/src/node/types/ArgsExpressType.ts b/src/node/types/ArgsExpressType.ts index 5c0675b9730..d8dc700be1c 100644 --- a/src/node/types/ArgsExpressType.ts +++ b/src/node/types/ArgsExpressType.ts @@ -1,5 +1,7 @@ +import {Express} from "express"; + export type ArgsExpressType = { - app:any, + app:Express, io: any, server:any -} \ No newline at end of file +} diff --git a/src/package.json b/src/package.json index 75281e338f8..3c0b4439597 100644 --- a/src/package.json +++ b/src/package.json @@ -69,6 +69,7 @@ "socket.io": "^4.7.5", "socket.io-client": "^4.7.5", "superagent": "10.1.0", + "swagger-ui-express": "^5.0.1", "tinycon": "0.6.8", "tsx": "4.19.1", "ueberdb2": "^5.0.2", @@ -97,6 +98,7 @@ "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.6", "@types/underscore": "^1.11.15", "@types/whatwg-mimetype": "^3.0.2", "chokidar": "^4.0.0",