diff --git a/README_PLUGINS.md b/README_PLUGINS.md new file mode 100644 index 00000000..696ec63d --- /dev/null +++ b/README_PLUGINS.md @@ -0,0 +1,116 @@ +# Tdrive - How to enable plugins + +## Edit the /backend/node/config/{env}.json file to add the plugin, here is an example with onlyoffice: + +```json +{ + "applications": { + "grid": [ + { + "name": "Tmail", + "logo": "/public/img/grid/tmail.png", + "url": "https://tmail.linagora.com/" + } + ], + "plugins": [ + { + "id": "tdrive_onlyoffice", + "internal_domain": "http://plugins_onlyoffice:5000/", + "external_prefix": "/plugins/onlyoffice/", + "api": { + "private_key": "apisecret" + }, + "display": { + "tdrive": { + "version": 1, + "files": { + "editor": { + "preview_url": "/plugins/onlyoffice/?preview=1", + "edition_url": "/plugins/onlyoffice/", + "empty_files": [ + { + "url": "/plugins/onlyoffice/assets/empty.docx", + "filename": "Untitled.docx", + "name": "ONLYOFFICE Word Document" + }, + { + "url": "/plugins/onlyoffice/assets/empty.xlsx", + "filename": "Untitled.xlsx", + "name": "ONLYOFFICE Excel Document" + }, + { + "url": "/plugins/onlyoffice/assets/empty.pptx", + "filename": "Untitled.pptx", + "name": "ONLYOFFICE PowerPoint Document" + } + ], + "extensions": [ + "xlsx", + "pptx", + "docx", + "xls", + "ppt", + "doc", + "odt", + "ods", + "odp", + "txt", + "html", + "csv" + ] + } + } + } + }, + "identity": { + "code": "only_office", + "name": "Only Office", + "icon": "/plugins/onlyoffice/assets/logo.png", + "description": null, + "website": "http://twake.app/", + "categories": [], + "compatibility": ["tdrive"] + } + } + ] + } +} +``` + +#### Explanation + +- `grid` is the list of applications that will be displayed in the grid on top bar, here an example with tmail +- `plugins` is the list of plugins for tdrive, here an example with onlyoffice. +- `plugins.id` is mandatory and must be unique to each plusing +- `plugins.internal_domain` (only if installed inside the same docker and same domain) is the internal domain of the plugin, it must be the same as the one in the docker-compose.yml file +- `plugins.external_prefix` (only if installed outside of the docker) is the external prefix of the plugin. When the frontend will call https://tdrive.app/plugins/onlyoffice/ it will be redirected to the internal_domain. + +## Add and run the docker container for the plugin + +For instance with onlyoffice, you can find the plugin here: https://github.com/linagora/Twake-Plugin-Onlyoffice + +In the docker-compose.yml you can add the following: + +```yml +plugins_onlyoffice: + image: onlyoffice-connector + restart: unless-stopped + environment: + - SERVER_PORT=5000 + - SERVER_PREFIX=/plugins/onlyoffice/ + - CREDENTIALS_ENDPOINT=http://node/ + - ONLY_OFFICE_SERVER=https://onlyoffice.server.come/ + - CREDENTIALS_ID=tdrive_onlyoffice + - CREDENTIALS_SECRET=apisecret +``` + +Note: + +- Make sure the network is shared with node +- Make sure the service name matches the internal_domain in the config file + +## Run everything + +When starting the docker-compose, the plugin will be available on https://[frontend]/plugins/onlyoffice/ you can test everything works by opening the url https://[frontend]/plugins/onlyoffice/assets/logo.png . + +If the logo displays, then open TDrive, and see if you can create a new OnlyOffice Spreadsheet when clicking on "New". diff --git a/tdrive/backend/node/config/default.json b/tdrive/backend/node/config/default.json index 27622995..2daeb9c4 100644 --- a/tdrive/backend/node/config/default.json +++ b/tdrive/backend/node/config/default.json @@ -2,24 +2,12 @@ "general": { "help_url": false, "pricing_plan_url": "", - "app_download_url": "https://tdrive.app/download", + "app_download_url": "https://twake.app/download", "mobile": { - "mobile_redirect": "mobile.tdrive.app", + "mobile_redirect": "mobile.twake.app", "mobile_appstore": "https://apps.apple.com/fr/app/tdrive/id1588764852?l=en", "mobile_googleplay": "https://play.google.com/store/apps/details?id=com.tdrive.tdrive&gl=FR" }, - "app_grid": [ - { - "name": "Tmail", - "logo": "/public/img/grid/tmail.png", - "url": "https://tmail.linagora.com/" - }, - { - "name": "Tdrive", - "logo": "/public/img/grid/tdrive.png", - "url": "https://tdrive.app/" - } - ], "accounts": { "_type": "remote", "type": "internal", @@ -139,9 +127,6 @@ "path": "/storage/" } }, - "plugins": { - "server": "plugins:3100" - }, "knowledge-graph": { "endpoint": "http://host-gateway:8888", "callback_token": "secret", @@ -151,7 +136,79 @@ "email-pusher": { "endpoint": "https://api.smtp2go.com/v3/email/send", "api_key": "secret", - "sender": "noreply@tdrive.app" + "sender": "noreply@twake.app" + }, + "applications": { + "grid": [ + { + "name": "Tmail", + "logo": "/public/img/grid/tmail.png", + "url": "https://tmail.linagora.com/" + }, + { + "name": "Tdrive", + "logo": "/public/img/grid/tdrive.png", + "url": "https://twake.app/" + } + ], + "plugins": [ + { + "id": "tdrive_onlyoffice", + "internal_domain": "http://plugins_onlyoffice:5000/", + "external_prefix": "/plugins/onlyoffice/", + "display": { + "tdrive": { + "version": 1, + "files": { + "editor": { + "preview_url": "/plugins/onlyoffice/?preview=1", + "edition_url": "/plugins/onlyoffice/", + "empty_files": [ + { + "url": "/plugins/onlyoffice/assets/empty.docx", + "filename": "Untitled.docx", + "name": "ONLYOFFICE Word Document" + }, + { + "url": "/plugins/onlyoffice/assets/empty.xlsx", + "filename": "Untitled.xlsx", + "name": "ONLYOFFICE Excel Document" + }, + { + "url": "/plugins/onlyoffice/assets/empty.pptx", + "filename": "Untitled.pptx", + "name": "ONLYOFFICE PowerPoint Document" + } + ], + "extensions": [ + "xlsx", + "pptx", + "docx", + "xls", + "ppt", + "doc", + "odt", + "ods", + "odp", + "txt", + "html", + "csv" + ] + } + } + } + }, + "identity": { + "code": "only_office", + "name": "Only Office", + "icon": "/plugins/onlyoffice/assets/logo.png", + "description": null, + "website": "http://twake.app/", + "categories": [], + "compatibility": ["tdrive"] + } + } + ] }, "services": [ "auth", @@ -181,6 +238,8 @@ "knowledge-graph-web", "email-pusher", "documents", + "applications", + "applications-api", "tags" ] } diff --git a/tdrive/backend/node/src/services/applications-api/index.ts b/tdrive/backend/node/src/services/applications-api/index.ts new file mode 100644 index 00000000..995cb4b8 --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/index.ts @@ -0,0 +1,42 @@ +import { Prefix, TdriveService } from "../../core/platform/framework"; +import WebServerAPI from "../../core/platform/services/webserver/provider"; +import web from "./web/index"; +import FastProxy from "fast-proxy"; +import globalResolver from "../global-resolver"; + +@Prefix("/api") +export default class ApplicationsApiService extends TdriveService { + version = "1"; + name = "applicationsapi"; + + public async doInit(): Promise { + const fastify = this.context.getProvider("webserver").getServer(); + fastify.register((instance, _opts, next) => { + web(instance, { prefix: this.prefix }); + next(); + }); + + //Redirect requests from /plugins/* to the plugin server (if installed) + const apps = await globalResolver.services.applications.marketplaceApps.list(null); + for (const app of apps) { + const domain = app.internal_domain; + const prefix = app.external_prefix; + if (domain && prefix) { + const { proxy, close } = FastProxy({ + base: domain, + }); + fastify.addHook("onClose", close); + fastify.all("/" + prefix.replace(/(\/$|^\/)/, "") + "/*", (req, rep) => { + proxy(req.raw, rep.raw, req.url, {}); + }); + } + } + + return this; + } + + // TODO: remove + api(): undefined { + return undefined; + } +} diff --git a/tdrive/backend/node/src/services/applications-api/types.ts b/tdrive/backend/node/src/services/applications-api/types.ts new file mode 100644 index 00000000..9919e15d --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/types.ts @@ -0,0 +1,13 @@ +import { Channel } from "../channels/entities"; +import { Message } from "../messages/entities/messages"; +import { Thread } from "../messages/entities/threads"; + +export type HookType = { + type: "message"; + application_id: string; + company_id: string; + + channel?: Channel; + thread: Thread; + message: Message; +}; diff --git a/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts new file mode 100644 index 00000000..afbf0f85 --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts @@ -0,0 +1,181 @@ +import { FastifyInstance, FastifyReply, FastifyRequest, HTTPMethods } from "fastify"; +import { ApplicationObject } from "../../../applications/entities/application"; +import { + ApplicationApiExecutionContext, + ApplicationLoginRequest, + ApplicationLoginResponse, + ConfigureRequest, +} from "../types"; +import { ResourceGetResponse } from "../../../../utils/types"; +import { CrudException } from "../../../../core/platform/framework/api/crud-service"; +import { localEventBus } from "../../../../core/platform/framework/event-bus"; +import { + RealtimeApplicationEvent, + RealtimeBaseBusEvent, +} from "../../../../core/platform/services/realtime/types"; +import gr from "../../../global-resolver"; +import _ from "lodash"; +import { v4 } from "uuid"; + +export class ApplicationsApiController { + async token( + request: FastifyRequest<{ Body: ApplicationLoginRequest }>, + ): Promise> { + const context = getExecutionContext(request); + + if (!request.body.id || !request.body.secret) { + throw CrudException.forbidden("Application not found"); + } + + const app = await gr.services.applications.marketplaceApps.get( + { + id: request.body.id, + }, + context, + ); + + if (!app) { + throw CrudException.forbidden("Application not found"); + } + + if (!app.api.private_key || app.api.private_key !== request.body.secret) { + throw CrudException.forbidden("Secret key is not valid"); + } + + return { + resource: { + access_token: gr.platformServices.auth.generateJWT(request.body.id, null, { + track: false, + provider_id: "", + application_id: request.body.id, + }), + }, + }; + } + + async me( + request: FastifyRequest, + _reply: FastifyReply, + ): Promise> { + const context = getExecutionContext(request); + + const entity = await gr.services.applications.marketplaceApps.get( + { + id: context.application_id, + }, + context, + ); + if (!entity) { + throw CrudException.notFound("Application not found"); + } + + return { resource: entity.getApplicationObject() }; + } + + async configure( + request: FastifyRequest<{ Body: ConfigureRequest }>, + _reply: FastifyReply, + ): Promise> { + const app_id = request.currentUser.application_id; + const context = getExecutionContext(request); + const application = await gr.services.applications.marketplaceApps.get({ id: app_id }, context); + + if (!application) { + throw CrudException.forbidden("Application not found"); + } + + const body = request.body; + + const data = { + action: "configure", + application: { + id: app_id, + identity: application.identity, + }, + form: body.form, + connection_id: body.connection_id, + hidden_data: {}, + configurator_id: v4(), + }; + + localEventBus.publish("realtime:event", { + room: "/me/" + body.user_id, + type: "application", + data, + } as RealtimeBaseBusEvent); + + return { status: "ok" }; + } + + async proxy( + request: FastifyRequest<{ Params: { company_id: string; service: string; version: string } }>, + reply: FastifyReply, + fastify: FastifyInstance, + ): Promise { + // Check the application has access to this company + const company_id = request.params.company_id; + const companyApplication = gr.services.applications.companyApps.get({ + company_id, + application_id: request.currentUser.application_id, + id: undefined, + }); + if (!companyApplication) { + throw CrudException.forbidden("This application is not installed in the requested company"); + } + + const context = getExecutionContext(request); + const app = await gr.services.applications.marketplaceApps.get( + { + id: request.currentUser.application_id, + }, + context, + ); + + // Check call can be done from this IP + if ( + app.api.allowed_ips.trim() && + app.api.allowed_ips !== "*" && + !_.includes( + app.api.allowed_ips + .split(",") + .map(a => a.trim()) + .filter(a => a), + request.ip, + ) + ) { + throw CrudException.forbidden( + `This application is not allowed to access from this IP (${request.ip})`, + ); + } + + //TODO Check application access rights (write, read, remove for each micro services) + const _access = app.access; + + //TODO save some statistics about API usage for application and per companies + + const route = request.url.replace("/api/", "/internal/services/"); + + fastify.inject( + { + method: request.method as HTTPMethods, + url: route, + payload: request.body as any, + headers: _.pick(request.headers, "authorization"), + }, + (err, response) => { + reply.headers(response.headers); + reply.send(response.payload); + }, + ); + } +} + +function getExecutionContext(request: FastifyRequest): ApplicationApiExecutionContext { + return { + application_id: request.currentUser?.application_id, + user: request.currentUser, + url: request.url, + method: request.routerMethod, + transport: "http", + }; +} diff --git a/tdrive/backend/node/src/services/applications-api/web/index.ts b/tdrive/backend/node/src/services/applications-api/web/index.ts new file mode 100644 index 00000000..de7176c9 --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/web/index.ts @@ -0,0 +1,10 @@ +import { FastifyInstance, FastifyRegisterOptions } from "fastify"; +import routes from "./routes"; + +export default ( + fastify: FastifyInstance, + options: FastifyRegisterOptions<{ prefix: string }>, +): void => { + fastify.log.debug("Configuring /internal/services/applications/v1 routes"); + fastify.register(routes, options); +}; diff --git a/tdrive/backend/node/src/services/applications-api/web/routes.ts b/tdrive/backend/node/src/services/applications-api/web/routes.ts new file mode 100644 index 00000000..3af81dbb --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/web/routes.ts @@ -0,0 +1,54 @@ +import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify"; + +import { ApplicationsApiController } from "./controllers"; +import { ApplicationApiBaseRequest } from "./types"; +import { logger as log } from "../../../core/platform/framework"; +import { configureRequestSchema } from "./schemas"; + +const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => { + const controller = new ApplicationsApiController(); + + const checkApplication = async (request: FastifyRequest<{ Body: ApplicationApiBaseRequest }>) => { + if (!request.currentUser.application_id) { + log.debug(request.currentUser); + throw fastify.httpErrors.forbidden("You should log in as application"); + } + }; + + //Authenticate the application + fastify.route({ + method: "POST", + url: "/console/v1/login", + handler: controller.token.bind(controller), + }); + + //Get myself as an application + fastify.route({ + method: "GET", + url: "/console/v1/me", + preValidation: [fastify.authenticate], + preHandler: [checkApplication], + handler: controller.me.bind(controller), + }); + + //Open a configuration popup on the client side + fastify.route({ + method: "POST", + url: "/console/v1/configure", + preValidation: [fastify.authenticate], + schema: configureRequestSchema, + handler: controller.configure.bind(controller), + }); + + //Get myself as an application + fastify.route({ + method: ["POST", "GET", "DELETE", "PUT"], + url: "/:service/:version/companies/:company_id/*", + preValidation: [fastify.authenticate], + handler: (request, reply) => controller.proxy.bind(controller)(request, reply, fastify), + }); + + next(); +}; + +export default routes; diff --git a/tdrive/backend/node/src/services/applications-api/web/schemas.ts b/tdrive/backend/node/src/services/applications-api/web/schemas.ts new file mode 100644 index 00000000..1ee1b370 --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/web/schemas.ts @@ -0,0 +1,16 @@ +export const applicationsSchema = { + type: "object", + properties: {}, +}; + +export const configureRequestSchema = { + body: { + type: "object", + properties: { + user_id: { type: "string" }, + connection_id: { type: "string" }, + form: {}, + }, + required: ["user_id", "connection_id"], + }, +}; diff --git a/tdrive/backend/node/src/services/applications-api/web/types.ts b/tdrive/backend/node/src/services/applications-api/web/types.ts new file mode 100644 index 00000000..04556d9e --- /dev/null +++ b/tdrive/backend/node/src/services/applications-api/web/types.ts @@ -0,0 +1,23 @@ +import { AccessToken } from "../../../utils/types"; +import { ExecutionContext } from "../../../core/platform/framework/api/crud-service"; + +export interface ApplicationApiBaseRequest { + id: string; + secret: string; +} + +export type ApplicationLoginRequest = ApplicationApiBaseRequest; + +export interface ApplicationLoginResponse { + access_token: AccessToken; +} + +export interface ApplicationApiExecutionContext extends ExecutionContext { + application_id: string; +} + +export interface ConfigureRequest { + user_id: string; + connection_id: string; + form?: any; +} diff --git a/tdrive/backend/node/src/services/applications/entities/application.search.ts b/tdrive/backend/node/src/services/applications/entities/application.search.ts deleted file mode 100644 index 669e0245..00000000 --- a/tdrive/backend/node/src/services/applications/entities/application.search.ts +++ /dev/null @@ -1,33 +0,0 @@ -import Application, { TYPE } from "./application"; - -export default { - index: TYPE, - source: (entity: Application) => { - return { - company_id: entity.company_id, - name: entity.identity.name, - description: entity.identity.description, - categories: entity.identity.categories, - compatibility: entity.identity.compatibility, - published: entity.publication.published, - created_at: entity.stats.created_at, - }; - }, - mongoMapping: { - text: { - name: "text", - description: "text", - }, - }, - esMapping: { - properties: { - company_id: { type: "keyword" }, - name: { type: "text", index_prefixes: { min_chars: 1 } }, - description: { type: "text" }, - categories: { type: "keyword" }, - compatibility: { type: "keyword" }, - published: { type: "boolean" }, - created_at: { type: "number" }, - }, - }, -}; diff --git a/tdrive/backend/node/src/services/applications/entities/application.ts b/tdrive/backend/node/src/services/applications/entities/application.ts index 1cd1c793..c8c788a7 100644 --- a/tdrive/backend/node/src/services/applications/entities/application.ts +++ b/tdrive/backend/node/src/services/applications/entities/application.ts @@ -1,82 +1,54 @@ -import { Type } from "class-transformer"; -import _, { merge } from "lodash"; -import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; -import search from "./application.search"; +import _ from "lodash"; -export const TYPE = "applications"; - -@Entity(TYPE, { - primaryKey: ["id"], - type: TYPE, - search, -}) -export default class Application { - @Type(() => String) - @Column("id", "timeuuid", { generator: "timeuuid" }) +export default interface Application { id: string; - - @Type(() => String) - @Column("group_id", "timeuuid") + internal_domain?: string; + external_prefix?: string; company_id: string; - - @Column("is_default", "boolean") is_default: boolean; - - @Column("identity", "json") identity: ApplicationIdentity; - - //This information is private to the application, make sure not to disclose it - @Column("api", "encoded_json") api: ApplicationApi; - - @Column("access", "json") access: ApplicationAccess; - - @Column("display", "json") display: ApplicationDisplay; - - @Column("publication", "json") publication: ApplicationPublication; - - @Column("stats", "json") stats: ApplicationStatistics; - - getPublicObject(): PublicApplicationObject { - const i = _.pick( - this, - "id", - "company_id", - "is_default", - "identity", - "access", - "display", - "publication", - "stats", - ); - - i.is_default = !!i.is_default; - return i; - } - - getApplicationObject(): ApplicationObject { - const i = _.pick( - this, - "id", - "company_id", - "is_default", - "identity", - "access", - "display", - "publication", - "stats", - "api", - ); - - i.is_default = !!i.is_default; - return i; - } } +export const getPublicObject = (e: Application): PublicApplicationObject => { + const i = _.pick( + e, + "id", + "company_id", + "is_default", + "identity", + "access", + "display", + "publication", + "stats", + ); + + i.is_default = !!i.is_default; + return i; +}; + +export const getApplicationObject = (e: Application): ApplicationObject => { + const i = _.pick( + e, + "id", + "company_id", + "is_default", + "identity", + "access", + "display", + "publication", + "stats", + "api", + ); + + i.is_default = !!i.is_default; + return i; +}; + export type PublicApplicationObject = Pick< Application, "id" | "company_id" | "is_default" | "identity" | "access" | "display" | "publication" | "stats" @@ -95,12 +67,6 @@ export type ApplicationObject = Pick< | "api" >; -export type ApplicationPrimaryKey = { id: string }; - -export function getInstance(message: Application): Application { - return merge(new Application(), message); -} - export type ApplicationIdentity = { code: string; name: string; @@ -108,7 +74,7 @@ export type ApplicationIdentity = { description: string; website: string; categories: string[]; - compatibility: "tdrive"[]; + compatibility: "twake"[]; repository?: string; }; @@ -145,7 +111,7 @@ export type ApplicationAccess = { }; export type ApplicationDisplay = { - tdrive: { + twake: { files?: { editor?: { preview_url: string; //Open a preview inline (iframe) diff --git a/tdrive/backend/node/src/services/applications/entities/company-application.ts b/tdrive/backend/node/src/services/applications/entities/company-application.ts index e3d85e55..c9cc5b56 100644 --- a/tdrive/backend/node/src/services/applications/entities/company-application.ts +++ b/tdrive/backend/node/src/services/applications/entities/company-application.ts @@ -1,32 +1,9 @@ -import { Type } from "class-transformer"; -import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; import { PublicApplicationObject } from "./application"; -export const TYPE = "group_app"; - -@Entity(TYPE, { - primaryKey: [["group_id"], "app_id", "id"], - type: TYPE, -}) export default class CompanyApplication { - @Type(() => String) - @Column("group_id", "timeuuid") company_id: string; - - @Type(() => String) - @Column("app_id", "timeuuid") application_id: string; - - @Type(() => String) - @Column("id", "timeuuid", { generator: "timeuuid" }) id: string; - - @Column("created_at", "number") - created_at: number; - - @Type(() => String) - @Column("created_by", "string") - created_by: string; //Will be the default delegated user when doing actions on Tdrive } export type CompanyApplicationPrimaryKey = Pick< diff --git a/tdrive/backend/node/src/services/applications/services/applications.ts b/tdrive/backend/node/src/services/applications/services/applications.ts index b480fd4f..8cb8b5ef 100644 --- a/tdrive/backend/node/src/services/applications/services/applications.ts +++ b/tdrive/backend/node/src/services/applications/services/applications.ts @@ -1,140 +1,20 @@ -import Application, { - ApplicationPrimaryKey, - getInstance as getApplicationInstance, - PublicApplicationObject, - TYPE, -} from "../entities/application"; -import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; -import { Initializable, logger, TdriveServiceProvider } from "../../../core/platform/framework"; -import { - DeleteResult, - ExecutionContext, - ListResult, - OperationType, - Pagination, - SaveResult, -} from "../../../core/platform/framework/api/crud-service"; -import SearchRepository from "../../../core/platform/services/search/repository"; -import assert from "assert"; - -import gr from "../../global-resolver"; +import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework"; +import { ExecutionContext } from "../../../core/platform/framework/api/crud-service"; +import Application from "../entities/application"; +import config from "config"; export class ApplicationServiceImpl implements TdriveServiceProvider, Initializable { version: "1"; - repository: Repository; - searchRepository: SearchRepository; async init(): Promise { - try { - this.searchRepository = gr.platformServices.search.getRepository( - TYPE, - Application, - ); - this.repository = await gr.database.getRepository(TYPE, Application); - } catch (err) { - console.log(err); - logger.error("Error while initializing applications service"); - } - return this; } - async get(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { - return await this.repository.findOne(pk, {}, context); + async get(id: string, _: ExecutionContext): Promise { + return (await this.list(_)).find((app: Application) => app.id === id); } - async list( - pagination: Pagination, - options?: { search?: string }, - context?: ExecutionContext, - ): Promise> { - let entities: ListResult; - if (options.search) { - entities = await this.searchRepository.search( - {}, - { - pagination, - $text: { - $search: options.search, - }, - }, - context, - ); - } else { - entities = await this.repository.find({}, { pagination }, context); - } - entities.filterEntities(app => app.publication.published); - - const applications = entities - .getEntities() - .filter(app => app) - .map(app => app.getPublicObject()); - return new ListResult(entities.type, applications, entities.nextPage); - } - - async listUnpublished(context: ExecutionContext): Promise { - const entities = await this.repository.find({}, {}, context); - entities.filterEntities(app => !app.publication.published); - return entities.getEntities(); - } - - async listDefaults(context: ExecutionContext): Promise> { - const entities = []; - - let page: Pagination = { limitStr: "100" }; - do { - const applicationListResult = await this.repository.find({}, { pagination: page }, context); - page = applicationListResult.nextPage as Pagination; - applicationListResult.filterEntities(app => app.publication.published && app.is_default); - - for (const application of applicationListResult.getEntities()) { - if (application) entities.push(application.getPublicObject()); - } - } while (page.page_token); - - return new ListResult(TYPE, entities); - } - - async save( - item: Application, - options?: SaveOptions, - context?: ExecutionContext, - ): Promise> { - assert(item.company_id, "company_id is not defined"); - - try { - const entity = getApplicationInstance(item); - await this.repository.save(entity, context); - return new SaveResult("application", entity, OperationType.UPDATE); - } catch (e) { - throw e; - } - } - - async delete( - pk: ApplicationPrimaryKey, - context?: ExecutionContext, - ): Promise> { - const entity = await this.get(pk, context); - await this.repository.remove(entity, context); - return new DeleteResult("application", entity, true); - } - - async publish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { - const entity = await this.get(pk, context); - if (!entity) { - throw new Error("Entity not found"); - } - entity.publication.published = true; - await this.repository.save(entity, context); - } - - async unpublish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { - const entity = await this.get(pk, context); - if (!entity) { - throw new Error("Entity not found"); - } - entity.publication.published = false; - await this.repository.save(entity, context); + async list(_: ExecutionContext): Promise { + return config.get("applications.plugins") || []; } } diff --git a/tdrive/backend/node/src/services/applications/services/company-applications.ts b/tdrive/backend/node/src/services/applications/services/company-applications.ts index 4df059ec..9ab5ae43 100644 --- a/tdrive/backend/node/src/services/applications/services/company-applications.ts +++ b/tdrive/backend/node/src/services/applications/services/company-applications.ts @@ -1,175 +1,60 @@ -import CompanyApplication, { - CompanyApplicationPrimaryKey, - CompanyApplicationWithApplication, - TYPE, -} from "../entities/company-application"; -import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; +import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework"; import { - Initializable, - logger, - RealtimeDeleted, - RealtimeSaved, - TdriveServiceProvider, -} from "../../../core/platform/framework"; -import { - DeleteResult, ListResult, - OperationType, Paginable, Pagination, - SaveResult, } from "../../../core/platform/framework/api/crud-service"; -import { CompanyExecutionContext } from "../web/types"; -import { getCompanyApplicationRoom } from "../realtime"; import gr from "../../global-resolver"; +import { + CompanyApplicationPrimaryKey, + CompanyApplicationWithApplication, +} from "../entities/company-application"; +import { CompanyExecutionContext } from "../web/types"; export class CompanyApplicationServiceImpl implements TdriveServiceProvider, Initializable { version: "1"; - repository: Repository; async init(): Promise { - try { - this.repository = await gr.database.getRepository( - TYPE, - CompanyApplication, - ); - } catch (err) { - console.log(err); - logger.error("Error while initializing applications service"); - } return this; } - // TODO: remove logic from context async get( pk: Pick & { id?: string }, context?: CompanyExecutionContext, ): Promise { - const companyApplication = await this.repository.findOne( - { - group_id: context ? context.company.id : pk.company_id, - app_id: pk.application_id, - }, - {}, - context, - ); - const application = await gr.services.applications.marketplaceApps.get( - { - id: pk.application_id, - }, + pk.application_id, context, ); return { - ...companyApplication, + ...{ + id: pk.application_id, + company_id: pk.company_id, + application_id: pk.application_id, + }, application: application, }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - @RealtimeSaved((companyApplication, _context) => { - return [ - { - room: getCompanyApplicationRoom(companyApplication.id), - resource: companyApplication, - }, - ]; - }) - async save( - item: Pick, - _?: SaveOptions, - context?: CompanyExecutionContext, - ): Promise> { - if (!context?.user?.id && !context?.user?.server_request) { - throw new Error("Only an user of a company can add an application to a company."); - } - - let operation = OperationType.UPDATE; - let companyApplication = await this.repository.findOne( - { - group_id: context?.company.id, - app_id: item.application_id, - }, - {}, - context, - ); - if (!companyApplication) { - operation = OperationType.CREATE; - - companyApplication = new CompanyApplication(); - companyApplication.company_id = context.company.id; - companyApplication.application_id = item.application_id; - companyApplication.created_at = new Date().getTime(); - companyApplication.created_by = context?.user?.id || ""; - - await this.repository.save(companyApplication, context); - } - - return new SaveResult(TYPE, companyApplication, operation); - } - - async initWithDefaultApplications( - companyId: string, - context: CompanyExecutionContext, - ): Promise { - const defaultApps = await gr.services.applications.marketplaceApps.listDefaults(context); - for (const defaultApp of defaultApps.getEntities()) { - await this.save({ company_id: companyId, application_id: defaultApp.id }, {}, context); - } - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - @RealtimeDeleted((companyApplication, _context) => { - return [ - { - room: getCompanyApplicationRoom(companyApplication.id), - resource: companyApplication, - }, - ]; - }) - async delete( - pk: CompanyApplicationPrimaryKey, - context?: CompanyExecutionContext, - ): Promise> { - const companyApplication = await this.repository.findOne( - { - group_id: context.company.id, - app_id: pk.application_id, - }, - {}, - context, - ); - - let deleted = false; - if (companyApplication) { - this.repository.remove(companyApplication, context); - deleted = true; - } - - return new DeleteResult(TYPE, companyApplication, deleted); - } - async list( - pagination: Paginable, - options?: ListOptions, + _pagination: Paginable, + _options?: ListOptions, context?: CompanyExecutionContext, ): Promise> { - const companyApplications = await this.repository.find( - { - group_id: context.company.id, - }, - { pagination: Pagination.fromPaginable(pagination) }, - context, + const companyApplications = (await gr.services.applications.marketplaceApps.list(context)).map( + app => ({ + id: app.id, + company_id: context.company.id, + application_id: app.id, + }), ); const applications = []; - for (const companyApplication of companyApplications.getEntities()) { + for (const companyApplication of companyApplications) { const application = await gr.services.applications.marketplaceApps.get( - { - id: companyApplication.application_id, - }, + companyApplication.application_id, context, ); if (application) @@ -179,10 +64,6 @@ export class CompanyApplicationServiceImpl implements TdriveServiceProvider, Ini }); } - return new ListResult( - TYPE, - applications, - companyApplications.nextPage, - ); + return new ListResult("applications", applications, null); } } diff --git a/tdrive/backend/node/src/services/applications/services/hooks.ts b/tdrive/backend/node/src/services/applications/services/hooks.ts index 036d7157..07e1fcfe 100644 --- a/tdrive/backend/node/src/services/applications/services/hooks.ts +++ b/tdrive/backend/node/src/services/applications/services/hooks.ts @@ -32,7 +32,7 @@ export class ApplicationHooksService implements TdriveServiceProvider, Initializ workspace_id: string, context: ExecutionContext, ): Promise { - const app = await gr.services.applications.marketplaceApps.get({ id: application_id }, context); + const app = await gr.services.applications.marketplaceApps.get(application_id, context); if (!app) { throw CrudException.notFound("Application not found"); } diff --git a/tdrive/backend/node/src/services/applications/web/controllers/applications.ts b/tdrive/backend/node/src/services/applications/web/controllers/applications.ts index 11cbaa2d..ad4ec4a6 100644 --- a/tdrive/backend/node/src/services/applications/web/controllers/applications.ts +++ b/tdrive/backend/node/src/services/applications/web/controllers/applications.ts @@ -1,30 +1,24 @@ import { FastifyReply, FastifyRequest } from "fastify"; +import { + CrudException, + ExecutionContext, +} from "../../../../core/platform/framework/api/crud-service"; import { CrudController } from "../../../../core/platform/services/webserver/types"; import { - PaginationQueryParameters, ResourceCreateResponse, ResourceDeleteResponse, ResourceGetResponse, ResourceListResponse, ResourceUpdateResponse, } from "../../../../utils/types"; -import Application, { +import gr from "../../../global-resolver"; +import { ApplicationObject, PublicApplicationObject, + getApplicationObject, + getPublicObject, } from "../../entities/application"; -import { - CrudException, - ExecutionContext, - Pagination, -} from "../../../../core/platform/framework/api/crud-service"; -import _ from "lodash"; -import { randomBytes } from "crypto"; import { ApplicationEventRequestBody } from "../types"; -import { logger as log } from "../../../../core/platform/framework"; -import { hasCompanyAdminLevel } from "../../../../utils/company"; -import gr from "../../../global-resolver"; -import config from "../../../../core/config"; -import axios from "axios"; export class ApplicationController implements @@ -41,9 +35,7 @@ export class ApplicationController const context = getExecutionContext(request); const entity = await gr.services.applications.marketplaceApps.get( - { - id: request.params.application_id, - }, + request.params.application_id, context, ); @@ -55,173 +47,7 @@ export class ApplicationController const isAdmin = companyUser && companyUser.role == "admin"; return { - resource: isAdmin ? entity.getApplicationObject() : entity.getPublicObject(), - }; - } - - async list( - request: FastifyRequest<{ - Querystring: PaginationQueryParameters & { search: string }; - }>, - ): Promise> { - const entities = await gr.services.applications.marketplaceApps.list(new Pagination(), { - search: request.query.search, - }); - return { - resources: entities.getEntities(), - next_page_token: entities.nextPage.page_token, - }; - } - - async save( - request: FastifyRequest<{ - Params: { application_id: string }; - Body: { resource: Application }; - }>, - _reply: FastifyReply, - ): Promise> { - const context = getExecutionContext(request); - - try { - const app = request.body.resource; - const now = new Date().getTime(); - const pluginsEndpoint = config.get("plugins.api"); - - let entity: Application; - - if (request.params.application_id) { - entity = await gr.services.applications.marketplaceApps.get( - { - id: request.params.application_id, - }, - context, - ); - - if (!entity) { - throw CrudException.notFound("Application not found"); - } - - entity.publication.requested = app.publication.requested; - if (app.publication.requested === false) { - entity.publication.published = false; - } - - if (entity.publication.published) { - if ( - !_.isEqual( - _.pick(entity, "identity", "api", "access", "display"), - _.pick(app, "identity", "api", "access", "display"), - ) - ) { - throw CrudException.badRequest( - "You can't update applications details while it published", - ); - } - } - - entity.identity = app.identity; - entity.api.hooks_url = app.api.hooks_url; - entity.api.allowed_ips = app.api.allowed_ips; - entity.access = app.access; - entity.display = app.display; - - entity.stats.updated_at = now; - entity.stats.version++; - - const res = await gr.services.applications.marketplaceApps.save(entity); - entity = res.entity; - } else { - // INSERT - - app.is_default = false; - app.publication.published = false; - app.api.private_key = randomBytes(32).toString("base64"); - - app.stats = { - created_at: now, - updated_at: now, - version: 0, - }; - - const res = await gr.services.applications.marketplaceApps.save(app); - entity = res.entity; - } - - // SYNC PLUGINS - if (app.identity.repository) { - try { - axios - .post( - `${pluginsEndpoint}/add`, - { - gitRepo: app.identity.repository, - pluginId: entity.getApplicationObject().id, - pluginSecret: entity.getApplicationObject().api.private_key, - }, - { - headers: { - "Content-Type": "application/json", - }, - }, - ) - .then(response => { - log.info(response.data); - }) - .catch(error => { - log.error(error); - }); - } catch (error) { - console.error(error); - } - } - - return { - resource: entity.getApplicationObject(), - }; - } catch (e) { - log.error(e); - throw e; - } - } - - async delete( - request: FastifyRequest<{ Params: { application_id: string } }>, - reply: FastifyReply, - ): Promise { - const context = getExecutionContext(request); - - const application = await gr.services.applications.marketplaceApps.get( - { - id: request.params.application_id, - }, - context, - ); - - const compUser = await gr.services.companies.getCompanyUser( - { id: application.company_id }, - { id: context.user.id }, - ); - if (!compUser || !hasCompanyAdminLevel(compUser.role)) { - throw CrudException.forbidden("You don't have the rights to delete this application"); - } - - const deleteResult = await gr.services.applications.marketplaceApps.delete( - { - id: request.params.application_id, - }, - context, - ); - - if (deleteResult.deleted) { - reply.code(204); - - return { - status: "success", - }; - } - - return { - status: "error", + resource: isAdmin ? getApplicationObject(entity) : getPublicObject(entity), }; } @@ -237,9 +63,7 @@ export class ApplicationController const content = request.body.data; const applicationEntity = await gr.services.applications.marketplaceApps.get( - { - id: request.params.application_id, - }, + request.params.application_id, context, ); diff --git a/tdrive/backend/node/src/services/applications/web/controllers/company-applications.ts b/tdrive/backend/node/src/services/applications/web/controllers/company-applications.ts index 26542e39..11ee5fee 100644 --- a/tdrive/backend/node/src/services/applications/web/controllers/company-applications.ts +++ b/tdrive/backend/node/src/services/applications/web/controllers/company-applications.ts @@ -62,45 +62,6 @@ export class CompanyApplicationController ) || [], }; } - - async save( - request: FastifyRequest<{ - Params: { company_id: string; application_id: string }; - Body: PublicApplicationObject; - }>, - ): Promise> { - const context = getCompanyExecutionContext(request); - - const resource = await gr.services.applications.companyApps.save( - { application_id: request.params.application_id, company_id: context.company.id }, - {}, - context, - ); - - const app = await gr.services.applications.companyApps.get(resource.entity); - - return { - resource: app.application, - }; - } - - async delete( - request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>, - _reply: FastifyReply, - ): Promise { - const context = getCompanyExecutionContext(request); - const resource = await gr.services.applications.companyApps.delete( - { - application_id: request.params.application_id, - company_id: context.company.id, - id: undefined, - }, - context, - ); - return { - status: resource.deleted ? "success" : "error", - }; - } } function getCompanyExecutionContext( diff --git a/tdrive/backend/node/src/services/applications/web/routes.ts b/tdrive/backend/node/src/services/applications/web/routes.ts index 3d5835c7..da0e0445 100644 --- a/tdrive/backend/node/src/services/applications/web/routes.ts +++ b/tdrive/backend/node/src/services/applications/web/routes.ts @@ -26,9 +26,7 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) if (request.params.application_id) { const application = await gr.services.applications.marketplaceApps.get( - { - id: request.params.application_id, - }, + request.params.application_id, undefined, ); @@ -61,15 +59,6 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) * Marketplace of applications */ - //Get and search list of applications in the marketplace - fastify.route({ - method: "GET", - url: `${applicationsUrl}`, - preValidation: [fastify.authenticate], - // schema: applicationGetSchema, - handler: applicationController.list.bind(applicationController), - }); - //Get a single application in the marketplace fastify.route({ method: "GET", @@ -79,35 +68,6 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: applicationController.get.bind(applicationController), }); - //Create application (must be my company application and I must be company admin) - fastify.route({ - method: "POST", - url: `${applicationsUrl}`, - preHandler: [adminCheck], - preValidation: [fastify.authenticate], - schema: applicationPostSchema, - handler: applicationController.save.bind(applicationController), - }); - - //Edit application (must be my company application and I must be company admin) - fastify.route({ - method: "POST", - url: `${applicationsUrl}/:application_id`, - preHandler: [adminCheck], - preValidation: [fastify.authenticate], - schema: applicationPostSchema, - handler: applicationController.save.bind(applicationController), - }); - - // Delete application (must be my company application and I must be company admin) - fastify.route({ - method: "DELETE", - url: `${applicationsUrl}/:application_id`, - preHandler: [adminCheck], - preValidation: [fastify.authenticate], - handler: applicationController.delete.bind(applicationController), - }); - /** * Company applications collection * Company-wide available applications @@ -130,22 +90,6 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: companyApplicationController.get.bind(companyApplicationController), }); - //Remove an application from a company - fastify.route({ - method: "DELETE", - url: `${companyApplicationsUrl}/:application_id`, - preValidation: [fastify.authenticate], - handler: companyApplicationController.delete.bind(companyApplicationController), - }); - - //Add an application to the company - fastify.route({ - method: "POST", - url: `${companyApplicationsUrl}/:application_id`, - preValidation: [fastify.authenticate], - handler: companyApplicationController.save.bind(companyApplicationController), - }); - //Application event triggered by a user fastify.route({ method: "POST", diff --git a/tdrive/backend/node/src/services/general/web/routes.ts b/tdrive/backend/node/src/services/general/web/routes.ts index 6bb7d211..9722be7b 100644 --- a/tdrive/backend/node/src/services/general/web/routes.ts +++ b/tdrive/backend/node/src/services/general/web/routes.ts @@ -3,6 +3,7 @@ import _ from "lodash"; import { languages } from "../languages"; import { Languages, ServerConfiguration } from "../types"; import version from "../../../version"; +import config from "config"; const routes: FastifyPluginCallback<{ configuration: ServerConfiguration["configuration"] }> = ( fastify: FastifyInstance, @@ -24,8 +25,9 @@ const routes: FastifyPluginCallback<{ configuration: ServerConfiguration["config "pricing_plan_url", "mobile", "app_download_url", - "app_grid", + ), + app_grid: config.get("applications.grid") || [], accounts: { type: accounts.type, remote: _.pick(