Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 80 additions & 18 deletions app/apiDocSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,52 @@ function fieldSchema(field: any): any {
return {type: 'string', format: 'binary'};
}
const mapped = TYPE_MAP[(field.type || '').toLowerCase()];
const base: any = mapped ? {type: mapped} : {};
const base: any = mapped ? {type: mapped} : {};
if (field.allowedValues?.length) {
base.enum = field.allowedValues.map((value: string) => value.replace(/^\"|\"$/g, ''));
}
if (field.defaultValue !== undefined) {
base.default = field.defaultValue;
}
if (field.isArray) {
return {type: 'array', items: base};
}
return base;
}

function objectSchema(fields: any[]): any {
const properties: any = {};
const required: string[] = [];
for (const field of fields) {
const schema = fieldSchema(field);
const description = stripHtml(field.description);
if (description) {
schema.description = description;
}
properties[field.field] = schema;
if (!field.optional) {
required.push(field.field);
}
}
return {type: 'object', properties, ...(required.length ? {required} : {})};
}

const OPERATION_SCOPES: Record<string, string[]> = {
AssetCreate: ['assets:write'],
AssetGet: ['assets:read-private'],
AssetBatchCreate: ['asset-batches:write'],
AssetBatchGet: ['asset-batches:write'],
AssetBatchComplete: ['asset-batches:write'],
OperationGet: ['operations:read'],
OperationCancel: ['operations:read']
};

const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];

// Build an OpenAPI 3 document from the parsed apiDoc data: paths, path params,
// request bodies (multipart when a file field is present, else JSON), bearer
// security when an Authorization header is documented, and summaries/tags.
export function buildOpenApiFromApiDoc(version: string, docsStorageId?: string): any | null {
export function buildOpenApiFromApiDoc(version: string, docsStorageId?: string, publicApiBaseUrl?: string): any | null {
const data = getApiDocData();
if (!data) {
return null;
Expand All @@ -91,9 +124,12 @@ export function buildOpenApiFromApiDoc(version: string, docsStorageId?: string):
return `{${name}}`;
});

const operation: any = {responses: {'200': {description: 'OK'}}};
const operation: any = {responses: {}};
if (endpoint.name) {
operation.operationId = endpoint.name;
if (OPERATION_SCOPES[endpoint.name]) {
operation['x-required-scopes'] = OPERATION_SCOPES[endpoint.name];
}
}
if (endpoint.title) {
operation.summary = endpoint.title;
Expand All @@ -112,21 +148,30 @@ export function buildOpenApiFromApiDoc(version: string, docsStorageId?: string):
if (headerFields.some((h: any) => h.field === 'Authorization')) {
operation.security = [{bearerAuth: []}];
}
for (const field of headerFields.filter((item: any) => item.field !== 'Authorization')) {
operation.parameters = operation.parameters || [];
operation.parameters.push({name: field.field, in: 'header', required: !field.optional, schema: fieldSchema(field), description: stripHtml(field.description)});
}
const body = endpoint.body || [];
if (body.length && method !== 'get' && method !== 'head') {
const isMultipart = body.some((b: any) => b.field === 'file' || (b.type || '').toLowerCase() === 'file');
const properties: any = {};
for (const field of body) {
const schema = fieldSchema(field);
const fieldDescription = stripHtml(field.description);
if (fieldDescription) {
schema.description = fieldDescription;
}
properties[field.field] = schema;
}
const contentType = isMultipart ? 'multipart/form-data' : 'application/json';
operation.requestBody = {content: {[contentType]: {schema: {type: 'object', properties}}}};
operation.requestBody = {required: body.some((field: any) => !field.optional), content: {[contentType]: {schema: objectSchema(body)}}};
}
const successGroups = endpoint.success?.fields || {};
for (const [status, fields] of Object.entries(successGroups)) {
operation.responses[status] = {description: status === '201' ? 'Created' : 'Success', content: {'application/json': {schema: objectSchema(fields as any[])}}};
}
const errorGroups = endpoint.error?.fields || {};
for (const status of Object.keys(errorGroups)) {
operation.responses[status] = {description: 'API problem', content: {'application/problem+json': {schema: {$ref: '#/components/schemas/ApiProblem'}}}};
}
if (!Object.keys(operation.responses).length) {
operation.responses['200'] = {description: 'OK'};
}
if (operation.security) {
operation.responses['403'] ||= {description: 'Insufficient scope', content: {'application/problem+json': {schema: {$ref: '#/components/schemas/ApiProblem'}}}};
}

paths[specPath] = paths[specPath] || {};
paths[specPath][method] = operation;
Expand All @@ -139,12 +184,29 @@ export function buildOpenApiFromApiDoc(version: string, docsStorageId?: string):
version,
description: "Generated from the node's apiDoc annotations. The full human reference is also published to IPFS on each boot (see x-docs-ipfs).",
},
servers: [
{url: `/${version}`, description: 'Direct node'},
{url: `/api/${version}`, description: 'Behind the bundled nginx reverse proxy'},
],
servers: getOpenApiServers(version, publicApiBaseUrl),
'x-docs-ipfs': docsStorageId ? `/ipfs/${docsStorageId}` : null,
components: {securitySchemes: {bearerAuth: {type: 'http', scheme: 'bearer'}}},
components: {
securitySchemes: {bearerAuth: {type: 'http', scheme: 'bearer'}},
schemas: {ApiProblem: {
type: 'object',
required: ['type', 'title', 'status', 'code', 'requestId'],
properties: {
type: {type: 'string'}, title: {type: 'string'}, status: {type: 'integer'},
code: {type: 'string'}, detail: {type: 'string'}, requestId: {type: 'string'}
}
}}
},
paths,
};
}

function getOpenApiServers(version: string, publicApiBaseUrl?: string) {
if (publicApiBaseUrl) {
return [{url: publicApiBaseUrl, description: 'Advertised public API'}];
}
return [
{url: `/${version}`, description: 'Direct node'},
{url: `/api/${version}`, description: 'Behind the bundled nginx reverse proxy'},
];
}
13 changes: 12 additions & 1 deletion app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@

//TODO: move communicator and fileCatalog to improve
const modulePacks = {
'main': ['drivers', 'database', 'api', 'accountStorage', 'communicator', 'storage', 'content', 'staticId', 'asyncOperation', 'privateGroup', 'group', 'chat', 'fileCatalog', 'entityJsonManifest', 'imageComposition', 'remoteGroup'],
'main': ['drivers', 'database', 'api', 'accountStorage', 'communicator', 'storage', 'content', 'staticId', 'asyncOperation', 'asset', 'privateGroup', 'group', 'chat', 'fileCatalog', 'entityJsonManifest', 'imageComposition', 'remoteGroup'],
'improve': ['groupCategory', 'invite', 'staticSiteGenerator', 'rss', 'activityPub', 'autoActions', 'pin', 'foreignAccounts', 'ethereumAuthorization', 'storageSpace', 'gateway'],
'socNet': ['socNetAccount', 'socNetImport', 'bluesky', 'telegramClient', 'twitterClient', 'tgContentBot']
};

//TODO: refactor modules config
export default {
domain: process.env.DOMAIN || '',
apiConfig: {
publicUrl: process.env.GEESOME_PUBLIC_URL || getPublicUrlFromDomainEnv(process.env.DOMAIN),
publicBasePath: normalizeApiBasePath(process.env.GEESOME_API_BASE_PATH || '/api/v1'),
deploymentVersion: process.env.GEESOME_VERSION || process.env.npm_package_version || 'unknown',
maxUploadBytes: process.env.GEESOME_MAX_UPLOAD_BYTES || 2000 * 1024 * 1024
},
databaseModule: 'sql',
databaseConfig: {

Expand Down Expand Up @@ -134,3 +140,8 @@ function getPublicUrlFromDomainEnv(domain): string {
return '';
}
}

function normalizeApiBasePath(value): string {
const path = `/${String(value || '').trim().replace(/^\/+|\/+$/g, '')}`;
return path === '/' ? '/api/v1' : path;
}
23 changes: 22 additions & 1 deletion app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import IGeesomePrivateGroupModule from "./modules/privateGroup/interface.js";
import IGeesomeChatModule from "./modules/chat/interface.js";
import IGeesomeImageCompositionModule from "./modules/imageComposition/interface.js";
import IGeesomeApiModule from "./modules/api/interface.js";
import IGeesomeAssetModule from "./modules/asset/interface.js";
import {IGeesomeApp, IUserInput} from "./interface.js";
import {GeesomeEmitter} from "./events.js";
import {
Expand All @@ -50,6 +51,7 @@ import config from './config.js';
import {startMemoryProfiler} from './memoryProfiler.js';
import type {MemoryProfilerHandle} from './memoryProfiler.js';
import {cleanupAndRethrow, cleanupResource} from './resourceCleanup.js';
import {getScopePermissions, normalizeIntegrationScopes, serializeApiKey} from './modules/api/integrationScopes.js';
const {pick, merge, isUndefined, startsWith, reverse, clone, extend, isString} = _;
const log = debug('geesome:app');
const apiKeyListParams: IListParamsOptions = {
Expand Down Expand Up @@ -137,6 +139,7 @@ function getModule(config, appPass) {
drivers: IGeesomeDriversModule,
api: IGeesomeApiModule,
asyncOperation: IGeesomeAsyncOperationModule,
asset: IGeesomeAssetModule,
staticId: IGeesomeStaticIdModule,
invite: IGeesomeInviteModule,
group: IGeesomeGroupModule,
Expand Down Expand Up @@ -377,6 +380,18 @@ function getModule(config, appPass) {
data.userId = userId;
data.valueHash = generated.uuid;

if (data.scopes) {
const scopes = normalizeIntegrationScopes(data.scopes);
const permissions = getScopePermissions(scopes);
for (const permission of permissions) {
if (!await this.isUserCan(userId, permission)) {
throw new Error('not_permitted');
}
}
data.scopes = JSON.stringify(scopes);
data.permissions = JSON.stringify(permissions);
}

if (!data.permissions) {
data.permissions = JSON.stringify(await this.ms.database.getCorePermissions(userId).then(list => list.map(i => i.name)));
} else if (Array.isArray(data.permissions)) {
Expand Down Expand Up @@ -420,6 +435,12 @@ function getModule(config, appPass) {
if (!this.isApiKeyActive(keyObj)) {
return {user: null, apiKey: null};
}
const now = new Date();
const lastUsedAt = keyObj.lastUsedAt ? new Date(keyObj.lastUsedAt).getTime() : 0;
if (!lastUsedAt || now.getTime() - lastUsedAt >= 5 * 60 * 1000) {
await this.ms.database.updateApiKey(keyObj.id, {lastUsedAt: now});
keyObj.lastUsedAt = now;
}
return {
user: await this.ms.database.getUser(keyObj.userId),
apiKey: keyObj,
Expand All @@ -438,7 +459,7 @@ function getModule(config, appPass) {
listParams = helpers.prepareListParams(listParams, apiKeyListParams);
await this.checkUserCan(userId, CorePermissionName.UserApiKeyManagement);
return {
list: await this.ms.database.getApiKeysByUser(userId, isDisabled, search, listParams),
list: (await this.ms.database.getApiKeysByUser(userId, isDisabled, search, listParams)).map(serializeApiKey),
total: await this.ms.database.getApiKeysCountByUser(userId, isDisabled, search)
};
}
Expand Down
3 changes: 3 additions & 0 deletions app/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import IGeesomePrivateGroupModule from "./modules/privateGroup/interface.js";
import IGeesomeChatModule from "./modules/chat/interface.js";
import IGeesomeImageCompositionModule from "./modules/imageComposition/interface.js";
import IGeesomeApiModule from "./modules/api/interface.js";
import IGeesomeAssetModule from "./modules/asset/interface.js";
import {GeesomeEmitter} from "./events.js";
import {
CorePermissionName,
Expand All @@ -47,6 +48,7 @@ export interface IGeesomeApp {
api: IGeesomeApiModule;
content: IGeesomeContentModule,
asyncOperation: IGeesomeAsyncOperationModule;
asset: IGeesomeAssetModule;
staticId: IGeesomeStaticIdModule;
invite: IGeesomeInviteModule;
group: IGeesomeGroupModule;
Expand Down Expand Up @@ -281,6 +283,7 @@ export interface IUserApiKeyInput {
title?: string;
type?: string;
permissions?: string;
scopes?: string[] | string;
expiredOn?: Date | string;
isDisabled?: boolean;
}
Expand Down
18 changes: 17 additions & 1 deletion app/modules/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import IGeesomeApiModule from "./interface.js";
import {CorePermissionName} from "../database/interface.js";
import {IGeesomeApp} from "../../interface.js";
import {sendBadGatewayOnStorageRouteError, sendForbiddenOnAuthRouteError} from "./routeErrorHelpers.js";
import {serializeApiKey} from './integrationScopes.js';
const {isNumber} = _;
const log = debug('geesome:api:routes');

Expand Down Expand Up @@ -259,7 +260,22 @@ export default (app: IGeesomeApp, module: IGeesomeApiModule) => {
* @apiInterface (../database/interface.ts) {IUserApiKey} apiSuccess
*/
module.onAuthorizedGet('user/api-key/current', async (req, res) => {
res.send(req.apiKey);
res.send(serializeApiKey(req.apiKey));
});

/**
* @api {get} /v1/integrations/credentials/current Inspect current integration credential
* @apiName IntegrationCredentialCurrent
* @apiGroup UserApiKey
* @apiUse ApiKey
* @apiSuccess {Number} id API-key identifier.
* @apiSuccess {String[]} scopes Granted integration scopes.
* @apiSuccess {Date} [expiresAt] Expiration time.
* @apiSuccess {Date} [lastUsedAt] Last recorded use time.
* @apiSuccess {Boolean} revoked Revocation state.
*/
module.onAuthorizedGet('integrations/credentials/current', async (req, res) => {
res.send(serializeApiKey(req.apiKey));
});

/**
Expand Down
Loading