📁 Changed TDrive root folder (#16)

📁 Changed TDrive root folder
This commit is contained in:
Montassar Ghanmy
2023-04-11 10:04:29 +01:00
committed by GitHub
parent 3b3f67af07
commit e0615fa867
10548 changed files with 48 additions and 48 deletions
@@ -0,0 +1,33 @@
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" },
},
},
};
@@ -0,0 +1,212 @@
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";
export const TYPE = "applications";
@Entity(TYPE, {
primaryKey: ["id"],
type: TYPE,
search,
})
export default class Application {
@Type(() => String)
@Column("id", "timeuuid", { generator: "timeuuid" })
id: string;
@Type(() => String)
@Column("group_id", "timeuuid")
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 type PublicApplicationObject = Pick<
Application,
"id" | "company_id" | "is_default" | "identity" | "access" | "display" | "publication" | "stats"
>;
export type ApplicationObject = Pick<
Application,
| "id"
| "company_id"
| "is_default"
| "identity"
| "access"
| "display"
| "publication"
| "stats"
| "api"
>;
export type ApplicationPrimaryKey = { id: string };
export function getInstance(message: Application): Application {
return merge(new Application(), message);
}
export type ApplicationIdentity = {
code: string;
name: string;
icon: string;
description: string;
website: string;
categories: string[];
compatibility: "tdrive"[];
repository?: string;
};
export type ApplicationPublication = {
published: boolean; //Publication accepted // RO
requested: boolean; //Publication requested
};
export type ApplicationStatistics = {
created_at: number; // RO
updated_at: number; // RO
version: number; // RO
};
export type ApplicationApi = {
hooks_url: string;
allowed_ips: string;
private_key: string; // RO
};
type ApplicationScopes =
| "files"
| "applications"
| "workspaces"
| "users"
| "messages"
| "channels";
export type ApplicationAccess = {
read: ApplicationScopes[];
write: ApplicationScopes[];
delete: ApplicationScopes[];
hooks: ApplicationScopes[];
};
export type ApplicationDisplay = {
tdrive: {
files?: {
editor?: {
preview_url: string; //Open a preview inline (iframe)
edition_url: string; //Url to edit the file (full screen)
extensions?: string[]; //Main extensions app can read
// if file was created by the app, then the app is able to edit with or without extension
empty_files?: {
url: string; // "https://[...]/empty.docx";
filename: string; // "Untitled.docx";
name: string; // "Word Document";
}[];
};
actions?: //List of action that can apply on a file
{
name: string;
id: string;
}[];
};
//Chat plugin
chat?: {
input?:
| true
| {
icon?: string; //If defined replace original icon url of your app
type?: "file" | "call"; //To add in existing apps folder / default icon
};
commands?: {
command: string; // my_app mycommand
description: string;
}[];
actions?: //List of action that can apply on a message
{
name: string;
id: string;
}[];
};
//Allow app to appear as a bot user in direct chat
direct?:
| true
| {
name?: string;
icon?: string; //If defined replace original icon url of your app
};
//Display app as a standalone application in a tab
tab?:
| {
url: string;
}
| true;
//Display app as a standalone application on the left bar
standalone?:
| {
url: string;
}
| true;
//Define where the app can be configured from
configuration?: ("global" | "channel")[];
};
};
@@ -0,0 +1,40 @@
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<
CompanyApplication,
"company_id" | "application_id" | "id"
>;
export class CompanyApplicationWithApplication extends CompanyApplication {
//Not in database but attached to this object
application?: PublicApplicationObject;
}
@@ -0,0 +1,24 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/applications/v1")
export default class ApplicationsService extends TdriveService<undefined> {
version = "1";
name = "applications";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -0,0 +1,13 @@
import { WebsocketMetadata } from "../../utils/types";
export function getCompanyApplicationRooms(companyId: string): WebsocketMetadata[] {
return [
{
room: getCompanyApplicationRoom(companyId),
},
];
}
export function getCompanyApplicationRoom(companyApplicationId: string): string {
return `/company-application/${companyApplicationId}`;
}
@@ -0,0 +1,140 @@
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";
export class ApplicationServiceImpl implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<Application>;
searchRepository: SearchRepository<Application>;
async init(): Promise<this> {
try {
this.searchRepository = gr.platformServices.search.getRepository<Application>(
TYPE,
Application,
);
this.repository = await gr.database.getRepository<Application>(TYPE, Application);
} catch (err) {
console.log(err);
logger.error("Error while initializing applications service");
}
return this;
}
async get(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise<Application> {
return await this.repository.findOne(pk, {}, context);
}
async list(
pagination: Pagination,
options?: { search?: string },
context?: ExecutionContext,
): Promise<ListResult<PublicApplicationObject>> {
let entities: ListResult<Application>;
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<Application[]> {
const entities = await this.repository.find({}, {}, context);
entities.filterEntities(app => !app.publication.published);
return entities.getEntities();
}
async listDefaults(context: ExecutionContext): Promise<ListResult<PublicApplicationObject>> {
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<SaveOptions>(
item: Application,
options?: SaveOptions,
context?: ExecutionContext,
): Promise<SaveResult<Application>> {
assert(item.company_id, "company_id is not defined");
try {
const entity = getApplicationInstance(item);
await this.repository.save(entity, context);
return new SaveResult<Application>("application", entity, OperationType.UPDATE);
} catch (e) {
throw e;
}
}
async delete(
pk: ApplicationPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<Application>> {
const entity = await this.get(pk, context);
await this.repository.remove(entity, context);
return new DeleteResult<Application>("application", entity, true);
}
async publish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise<void> {
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<void> {
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);
}
}
@@ -0,0 +1,188 @@
import CompanyApplication, {
CompanyApplicationPrimaryKey,
CompanyApplicationWithApplication,
TYPE,
} from "../entities/company-application";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
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";
export class CompanyApplicationServiceImpl implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<CompanyApplication>;
async init(): Promise<this> {
try {
this.repository = await gr.database.getRepository<CompanyApplication>(
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<CompanyApplicationPrimaryKey, "company_id" | "application_id"> & { id?: string },
context?: CompanyExecutionContext,
): Promise<CompanyApplicationWithApplication> {
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,
},
context,
);
return {
...companyApplication,
application: application,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@RealtimeSaved<CompanyApplication>((companyApplication, _context) => {
return [
{
room: getCompanyApplicationRoom(companyApplication.id),
resource: companyApplication,
},
];
})
async save<SaveOptions>(
item: Pick<CompanyApplicationPrimaryKey, "company_id" | "application_id">,
_?: SaveOptions,
context?: CompanyExecutionContext,
): Promise<SaveResult<CompanyApplication>> {
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<void> {
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>((companyApplication, _context) => {
return [
{
room: getCompanyApplicationRoom(companyApplication.id),
resource: companyApplication,
},
];
})
async delete(
pk: CompanyApplicationPrimaryKey,
context?: CompanyExecutionContext,
): Promise<DeleteResult<CompanyApplication>> {
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<ListOptions>(
pagination: Paginable,
options?: ListOptions,
context?: CompanyExecutionContext,
): Promise<ListResult<CompanyApplicationWithApplication>> {
const companyApplications = await this.repository.find(
{
group_id: context.company.id,
},
{ pagination: Pagination.fromPaginable(pagination) },
context,
);
const applications = [];
for (const companyApplication of companyApplications.getEntities()) {
const application = await gr.services.applications.marketplaceApps.get(
{
id: companyApplication.application_id,
},
context,
);
if (application)
applications.push({
...companyApplication,
application: application,
});
}
return new ListResult<CompanyApplicationWithApplication>(
TYPE,
applications,
companyApplications.nextPage,
);
}
}
@@ -0,0 +1,96 @@
import Application from "../entities/application";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import {
Initializable,
logger as log,
TdriveServiceProvider,
} from "../../../core/platform/framework";
import { CrudException, ExecutionContext } from "../../../core/platform/framework/api/crud-service";
import SearchRepository from "../../../core/platform/services/search/repository";
import axios from "axios";
import * as crypto from "crypto";
import { isObject } from "lodash";
import gr from "../../global-resolver";
export class ApplicationHooksService implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<Application>;
searchRepository: SearchRepository<Application>;
async init() {
return this;
}
async notifyApp(
application_id: string,
connection_id: string,
user_id: string,
type: string,
name: string,
content: any,
company_id: string,
workspace_id: string,
context: ExecutionContext,
): Promise<void> {
const app = await gr.services.applications.marketplaceApps.get({ id: application_id }, context);
if (!app) {
throw CrudException.notFound("Application not found");
}
if (!app.api.hooks_url) {
throw CrudException.badRequest("Application hooks_url is not defined");
}
const payload = {
type,
name,
content,
connection_id: connection_id,
user_id: user_id,
company_id,
workspace_id,
};
const signature = crypto
.createHmac("sha256", app.api.private_key)
.update(JSON.stringify(payload))
.digest("hex");
return await axios
.post(app.api.hooks_url, payload, {
headers: {
"Content-Type": "application/json",
"X-Tdrive-Signature": signature,
},
})
.then(({ data }) => data)
.catch(e => {
log.error(e.message);
const r = e.response;
if (!r) {
throw CrudException.badGateway("Can't connect remote application");
}
let msg = r.data;
if (isObject(msg)) {
// parse typical responses
if (r.data.message) {
msg = r.data.message;
} else if (r.data.error) {
msg = r.data.error;
} else {
msg = JSON.stringify(r.data);
}
}
if (r.status == 403) {
throw CrudException.forbidden(msg);
} else {
throw CrudException.badRequest(msg);
}
});
}
}
@@ -0,0 +1,296 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { CrudController } from "../../../../core/platform/services/webserver/types";
import {
PaginationQueryParameters,
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import Application, {
ApplicationObject,
PublicApplicationObject,
} 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
CrudController<
ResourceGetResponse<PublicApplicationObject>,
ResourceUpdateResponse<PublicApplicationObject>,
ResourceListResponse<PublicApplicationObject>,
ResourceDeleteResponse
>
{
async get(
request: FastifyRequest<{ Params: { application_id: string } }>,
): Promise<ResourceGetResponse<ApplicationObject | PublicApplicationObject>> {
const context = getExecutionContext(request);
const entity = await gr.services.applications.marketplaceApps.get(
{
id: request.params.application_id,
},
context,
);
const companyUser = await gr.services.companies.getCompanyUser(
{ id: entity.company_id },
{ id: context.user.id },
);
const isAdmin = companyUser && companyUser.role == "admin";
return {
resource: isAdmin ? entity.getApplicationObject() : entity.getPublicObject(),
};
}
async list(
request: FastifyRequest<{
Querystring: PaginationQueryParameters & { search: string };
}>,
): Promise<ResourceListResponse<PublicApplicationObject>> {
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<ResourceGetResponse<ApplicationObject | PublicApplicationObject>> {
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<ResourceDeleteResponse> {
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",
};
}
async event(
request: FastifyRequest<{
Body: ApplicationEventRequestBody;
Params: { application_id: string };
}>,
_reply: FastifyReply,
): Promise<ResourceCreateResponse<any>> {
const context = getExecutionContext(request);
const content = request.body.data;
const applicationEntity = await gr.services.applications.marketplaceApps.get(
{
id: request.params.application_id,
},
context,
);
if (!applicationEntity) {
throw CrudException.notFound("Application not found");
}
const companyUser = gr.services.companies.getCompanyUser(
{ id: request.body.company_id },
{ id: context.user.id },
);
if (!companyUser) {
throw CrudException.badRequest(
"You cannot send event to an application from another company",
);
}
const applicationInCompany = await gr.services.applications.companyApps.get({
company_id: request.body.company_id,
application_id: request.params.application_id,
id: undefined,
});
if (!applicationInCompany) {
throw CrudException.badRequest("Application isn't installed in this company");
}
const hookResponse = await gr.services.applications.hooks.notifyApp(
request.params.application_id,
request.body.connection_id,
context.user.id,
request.body.type,
request.body.name,
content,
request.body.company_id,
request.body.workspace_id,
context,
);
return {
resource: hookResponse,
};
}
}
function getExecutionContext(request: FastifyRequest): ExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
transport: "http",
};
}
@@ -0,0 +1,119 @@
import { FastifyReply, FastifyRequest } from "fastify";
import {
PaginationQueryParameters,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import { PublicApplicationObject } from "../../entities/application";
import { CompanyExecutionContext } from "../types";
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { getCompanyApplicationRooms } from "../../realtime";
import gr from "../../../global-resolver";
export class CompanyApplicationController
implements
CrudController<
ResourceGetResponse<PublicApplicationObject>,
ResourceUpdateResponse<PublicApplicationObject>,
ResourceListResponse<PublicApplicationObject>,
ResourceDeleteResponse
>
{
async get(
request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>,
): Promise<ResourceGetResponse<PublicApplicationObject>> {
const context = getCompanyExecutionContext(request);
const resource = await gr.services.applications.companyApps.get(
{
application_id: request.params.application_id,
company_id: context.company.id,
id: undefined,
},
context,
);
return {
resource: resource?.application,
};
}
async list(
request: FastifyRequest<{
Params: { company_id: string };
Querystring: PaginationQueryParameters & { search: string };
}>,
): Promise<ResourceListResponse<PublicApplicationObject>> {
const context = getCompanyExecutionContext(request);
const resources = await gr.services.applications.companyApps.list(
request.query,
{ search: request.query.search },
context,
);
return {
resources: resources.getEntities().map(ca => ca.application),
next_page_token: resources.nextPage.page_token,
websockets:
gr.platformServices.realtime.sign(
getCompanyApplicationRooms(request.params.company_id),
context.user.id,
) || [],
};
}
async save(
request: FastifyRequest<{
Params: { company_id: string; application_id: string };
Body: PublicApplicationObject;
}>,
): Promise<ResourceGetResponse<PublicApplicationObject>> {
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<ResourceDeleteResponse> {
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(
request: FastifyRequest<{
Params: { company_id: string };
}>,
): CompanyExecutionContext {
return {
user: request.currentUser,
company: { id: request.params.company_id },
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
};
}
@@ -0,0 +1 @@
export * from "./applications";
@@ -0,0 +1,12 @@
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);
};
@@ -0,0 +1,161 @@
import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify";
import { ApplicationController } from "./controllers/applications";
import { CompanyApplicationController } from "./controllers/company-applications";
import Application from "../entities/application";
import { applicationEventHookSchema, applicationPostSchema } from "./schemas";
import { logger as log } from "../../../core/platform/framework";
import { checkUserBelongsToCompany, hasCompanyAdminLevel } from "../../../utils/company";
import gr from "../../global-resolver";
const applicationsUrl = "/applications";
const companyApplicationsUrl = "/companies/:company_id/applications";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const applicationController = new ApplicationController();
const companyApplicationController = new CompanyApplicationController();
const adminCheck = async (
request: FastifyRequest<{
Body: { resource: Application };
Params: { application_id: string };
}>,
) => {
try {
let companyId: string = request.body?.resource?.company_id;
if (request.params.application_id) {
const application = await gr.services.applications.marketplaceApps.get(
{
id: request.params.application_id,
},
undefined,
);
if (!application) {
throw fastify.httpErrors.notFound("Application is not defined");
}
companyId = application.company_id;
}
const userId = request.currentUser.id;
if (!companyId) {
throw fastify.httpErrors.forbidden(`Company ${companyId} not found`);
}
const companyUser = await checkUserBelongsToCompany(userId, companyId);
if (!hasCompanyAdminLevel(companyUser.role)) {
throw fastify.httpErrors.forbidden("You must be an admin of this company");
}
} catch (e) {
log.error(e);
throw e;
}
};
/**
* Applications collection
* 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",
url: `${applicationsUrl}/:application_id`,
preValidation: [fastify.authenticate],
// schema: applicationGetSchema,
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
* (must be my company application and I must be company admin)
*/
//Get list of applications for a company
fastify.route({
method: "GET",
url: `${companyApplicationsUrl}`,
preValidation: [fastify.authenticate],
handler: companyApplicationController.list.bind(companyApplicationController),
});
//Get one application of a company
fastify.route({
method: "GET",
url: `${companyApplicationsUrl}/:application_id`,
preValidation: [fastify.authenticate],
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",
url: `${applicationsUrl}/:application_id/event`,
preValidation: [fastify.authenticate],
schema: applicationEventHookSchema,
handler: applicationController.event.bind(applicationController),
});
next();
};
export default routes;
@@ -0,0 +1,129 @@
export const applicationsSchema = {
type: "object",
properties: {},
};
const applicationIdentity = {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
icon: { type: "string" },
description: { type: "string" },
website: { type: "string" },
categories: { type: "array", items: { type: "string" } },
compatibility: { type: "array", items: { type: "string" } },
},
required: ["code", "name", "icon", "description", "website", "categories", "compatibility"],
};
const applicationAccess = {
type: "object",
properties: {
read: { type: "array", items: { type: "string" } },
write: { type: "array", items: { type: "string" } },
delete: { type: "array", items: { type: "string" } },
hooks: { type: "array", items: { type: "string" } },
},
required: ["read", "write", "delete", "hooks"],
};
const requestApplicationPublication = {
type: "object",
properties: {
requested: { type: "boolean" },
},
required: ["requested"],
};
const responseApplicationPublication = {
type: "object",
properties: {
published: { type: "boolean" },
requested: { type: "boolean" },
},
required: ["requested", "published"],
};
const applicationStats = {
type: "object",
properties: {
created_at: { type: "number" },
updated_at: { type: "number" },
version: { type: "number" },
},
required: ["created_at", "updated_at", "version"],
};
const apiObject = {
type: "object",
properties: {
hooks_url: { type: "string" },
allowed_ips: { type: "string" },
private_key: { type: "string" },
},
required: ["hooks_url", "allowed_ips"],
};
const requestApplicationObject = {
type: "object",
properties: {
company_id: { type: "string" },
identity: applicationIdentity,
access: applicationAccess,
display: {},
api: apiObject,
publication: requestApplicationPublication,
},
required: ["company_id", "identity", "access", "display", "api", "publication"],
additionalProperties: false,
};
const responseApplicationObject = {
type: "object",
properties: {
id: { type: "string" },
is_default: { type: "boolean" },
company_id: { type: "string" },
identity: applicationIdentity,
access: applicationAccess,
display: {},
publication: responseApplicationPublication,
api: apiObject,
stats: applicationStats,
},
required: [
"id",
"is_default",
"company_id",
"identity",
"access",
"display",
"publication",
"stats",
],
additionalProperties: false,
};
export const applicationPostSchema = {
body: { type: "object", properties: { resource: requestApplicationObject } },
response: {
"2xx": {
resource: responseApplicationObject,
},
},
};
export const applicationEventHookSchema = {
body: {
type: "object",
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
type: { type: "string" },
name: { type: "string" },
content: {},
},
required: ["company_id", "workspace_id", "type", "content"],
},
};
@@ -0,0 +1,15 @@
import { ExecutionContext } from "../../../core/platform/framework/api/crud-service";
export interface CompanyExecutionContext extends ExecutionContext {
company: { id: string };
}
export interface ApplicationEventRequestBody {
company_id: string;
workspace_id: string;
connection_id: string;
type: string;
name?: string;
content: any;
data: any;
}
@@ -0,0 +1,59 @@
import { Type } from "class-transformer";
import { Channel } from ".";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelStats } from "../services/channel/types";
import { ChannelType } from "../types";
@Entity("channel_activity", {
primaryKey: [["company_id", "workspace_id"], "channel_id"],
type: "channel_activity",
})
export class ChannelActivity {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
@Column("last_activity", "number")
last_activity: number;
@Column("stats", "encoded_json")
stats: ChannelStats;
@Column("last_message", "encoded_json")
last_message:
| null
| Record<string, unknown>
| {
date: number;
sender: string;
sender_name: string;
title: string;
text: string;
};
/* Not stored in database */
channel: Channel;
public getChannelPrimaryKey(): Channel {
if (this.channel) {
return this.channel;
}
const channel = new Channel();
channel.id = this.channel_id;
channel.workspace_id = this.workspace_id;
channel.company_id = this.company_id;
return channel;
}
}
@@ -0,0 +1,35 @@
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "channel_counters";
@Entity(TYPE, {
primaryKey: [["id"], "counter_type", "company_id", "workspace_id"],
type: TYPE,
})
export class ChannelCounterEntity {
@Column("id", "uuid")
id: string;
@Column("counter_type", "string")
counter_type: string;
@Column("company_id", "uuid")
company_id: string;
@Column("workspace_id", "string")
workspace_id: string;
@Column("value", "counter")
value: number;
}
export type ChannelCounterPrimaryKey = Pick<
ChannelCounterEntity,
"id" | "counter_type" | "company_id" | "workspace_id"
>;
export enum ChannelUserCounterType {
MEMBERS = "members",
MESSAGES = "messages",
GUESTS = "guests",
}
@@ -0,0 +1,34 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
@Entity("channel_member_read_cursors", {
primaryKey: [["company_id"], "channel_id", "user_id"],
type: "channel_member_read_cursors",
})
export class ChannelMemberReadCursors {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("read_section", "encoded_json")
read_section: ReadSection;
}
export type ReadSection = [string, string];
@@ -0,0 +1,138 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelMemberNotificationLevel, ChannelMemberType } from "../types";
import { UserObject } from "../../../services/user/web/types";
/**
* Defines the member <-> channel link and member settings in channel
* Table name is `user_channels`
*/
@Entity("user_channels", {
primaryKey: [["company_id", "workspace_id"], "user_id", "channel_id"],
type: "user_channels",
})
export class ChannelMember {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
* Null for direct channels
*/
@Type(() => String)
@Column("workspace_id", "string")
workspace_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("type", "string")
type: ChannelMemberType = ChannelMemberType.MEMBER;
/**
* Timestamp in secconds
*/
@Column("last_access", "number")
last_access: number;
/**
* When last updated
*/
@Column("last_increment", "number")
last_increment: number;
/**
* Member marked this channel as favorite?
*/
@Column("favorite", "boolean")
favorite = false;
/**
* Member defined the notification level for the channel
* Defaults to all
*/
@Column("notification_level", "string")
notification_level: ChannelMemberNotificationLevel = ChannelMemberNotificationLevel.ALL;
/**
* Member expiration in channel (only for guests)
*/
@Column("expiration", "number")
expiration: boolean | number;
/**
* Every collection entity must have an id, here it is the user_id
*/
@Type(() => String)
@Column("id", "string")
id: string;
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
}
export type ChannelMemberPrimaryKey = Pick<
ChannelMember,
"channel_id" | "company_id" | "user_id" | "workspace_id"
>;
export function getChannelMemberInstance(member: Partial<ChannelMember>): ChannelMember {
return merge(new ChannelMember(), member);
}
export type ChannelMemberWithUser = ChannelMember & { user: UserObject };
/**
* The channel_members table allows to get all the members of a channel.
* Table name is `channel_members`
*/
@Entity("channel_members", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "user_id"],
type: "channel_members",
})
export class MemberOfChannel {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
@Type(() => String)
@Column("workspace_id", "string")
workspace_id: string;
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("type", "string")
type: ChannelMemberType = ChannelMemberType.MEMBER;
}
export function getMemberOfChannelInstance(member: Partial<MemberOfChannel>): MemberOfChannel {
return merge(new MemberOfChannel(), member);
}
@@ -0,0 +1,46 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
@Entity("channel_pending_emails", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "email"],
type: "channel_pending_emails",
})
export class ChannelPendingEmails {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("workspace_id", "uuid")
workspace_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
/**
* Pending email
*/
@Type(() => String)
@Column("email", "string")
email: string;
}
export type ChannelPendingEmailsPrimaryKey = Pick<
ChannelPendingEmails,
"channel_id" | "company_id" | "workspace_id" | "email"
>;
export function getInstance(pendingEmail: ChannelPendingEmails): ChannelPendingEmails {
return merge(new ChannelPendingEmails(), pendingEmail);
}
@@ -0,0 +1,24 @@
import { Channel } from "./channel";
export default {
index: "channels",
source: (entity: Channel) => {
return {
workspace_id: entity.workspace_id,
company_id: entity.company_id,
name: entity.channel_group + " " + entity.name,
};
},
mongoMapping: {
text: {
name: "text",
},
},
esMapping: {
properties: {
name: { type: "text", index_prefixes: { min_chars: 1 } },
workspace_id: { type: "keyword" },
company_id: { type: "keyword" },
},
},
};
@@ -0,0 +1,108 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType, ChannelVisibility } from "../types";
import { ChannelMember } from "./channel-member";
import { UserObject } from "../../user/web/types";
import { merge } from "lodash";
import search from "./channel.search";
import { ChannelActivity } from "./channel-activity";
@Entity("channels", {
primaryKey: [["company_id", "workspace_id"], "id"],
type: "channels",
search,
})
export class Channel {
// uuid-v4
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Column("name", "encoded_string")
name: string;
@Column("icon", "encoded_string")
icon: string;
@Column("description", "encoded_string")
description: string;
@Column("channel_group", "encoded_string")
channel_group: string;
@Column("is_readonly", "boolean")
is_readonly = false;
@Column("visibility", "encoded_string")
visibility: ChannelVisibility;
@Column("is_default", "boolean")
is_default = false;
@Column("archived", "boolean")
archived = false;
@Column("archivation_date", "number")
archivation_date: number;
// uuid
@Column("owner", "uuid")
@Type(() => String)
owner: string;
//This is only used for direct channels
@Column("members", "encoded_json")
members: string[] = [];
@Column("connectors", "encoded_json")
connectors: string[] = []; //list of app-ids
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
static isPrivateChannel(channel: Channel): boolean {
return channel.visibility === ChannelVisibility.PRIVATE;
}
static isPublicChannel(channel: Channel): boolean {
return channel.visibility === ChannelVisibility.PUBLIC;
}
static isDirectChannel(channel: Channel): boolean {
return (
channel.visibility === ChannelVisibility.DIRECT ||
channel.workspace_id === ChannelVisibility.DIRECT
);
}
static isDefaultChannel(channel: Channel): boolean {
return channel?.is_default;
}
}
export class UserChannel extends Channel {
user_member: ChannelMember;
last_activity: ChannelActivity["last_activity"];
last_message: ChannelActivity["last_message"];
}
export class UsersIncludedChannel extends Channel {
users: UserObject[];
}
export function getInstance(channel: Partial<Channel>): Channel {
return merge(new Channel(), channel);
}
export type ChannelPrimaryKey = Pick<Channel, "company_id" | "workspace_id" | "id">;
@@ -0,0 +1,34 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType } from "../types";
@Entity("default_channels", {
primaryKey: [["company_id", "workspace_id"], "channel_id"],
type: "default_channels",
})
export class DefaultChannel {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
}
export type DefaultChannelPrimaryKey = Pick<
DefaultChannel,
"channel_id" | "company_id" | "workspace_id"
>;
export function getInstance(channel: DefaultChannel): DefaultChannel {
return merge(new DefaultChannel(), channel);
}
@@ -0,0 +1,47 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
/**
* Direct Channel information.
* A direct channel is a channel in a company composed of a defined set of users.
*/
@Entity("direct_channels", {
primaryKey: [["company_id"], "users", "channel_id"],
type: "direct_channels",
})
export class DirectChannel {
/**
* The company identifier of this channel
* uuid-v4
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* The link to Channel.id
* uuid-v4
*/
@Type(() => String)
@Column("channel_id", "string")
channel_id: string;
/**
* CSV list of ordered user ids
*/
@Column("users", "string")
users: string;
static getUsersAsString(users: string[] = []): string {
return users.sort().join(",");
}
static getUsersFromString(identifier: string = ""): string[] {
return identifier.split(",");
}
}
export function getInstance(channel: DirectChannel): DirectChannel {
return merge(new DirectChannel(), channel);
}
@@ -0,0 +1,16 @@
export * from "./channel";
export * from "./member";
export * from "./tab";
export * from "./channel-member";
export * from "./channel-member-read-cursors";
export {
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
getInstance as getChannelPendingEmailsInstance,
} from "./channel-pending-emails";
export {
DefaultChannel,
DefaultChannelPrimaryKey,
getInstance as getDefaultChannelInstance,
} from "./default-channel";
@@ -0,0 +1,6 @@
import { Type } from "class-transformer";
export class Member {
@Type(() => String)
id: string;
}
@@ -0,0 +1,55 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType } from "../types";
@Entity("channel_tabs", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "id"],
type: "channel_tabs",
})
export class ChannelTab {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
// uuid-v4
@Type(() => String)
@Column("id", "string", { generator: "uuid" })
id: string;
@Column("name", "encoded_string")
name: string;
@Column("configuration", "encoded_json")
configuration: string;
@Column("application_id", "encoded_string")
application_id: string;
@Column("owner", "encoded_string")
owner: string;
@Column("col_order", "encoded_string")
order: string;
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
}
export type ChannelTabPrimaryKey = Pick<
ChannelTab,
"company_id" | "workspace_id" | "channel_id" | "id"
>;
@@ -0,0 +1,23 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/channels/v1")
export default class ChannelService extends TdriveService<undefined> {
version = "1";
name = "channels";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -0,0 +1,223 @@
import { getLogger, Initializable } from "../../../../../core/platform/framework";
import { localEventBus } from "../../../../../core/platform/framework/event-bus";
import { Channel as ChannelEntity, ChannelMember } from "../../../entities/";
import { ResourceEventsPayload } from "../../../../../utils/types";
import { ActivityObjectType, ActivityPublishedType } from "./types";
import _, { sortBy } from "lodash";
import { MessageQueueServiceAPI } from "../../../../../core/platform/services/message-queue/api";
import { ChannelParameters } from "../../../web/types";
import { ChannelVisibility } from "../../../types";
const logger = getLogger("channel.activities");
export default class Activities implements Initializable {
messageQueue: MessageQueueServiceAPI;
async init(): Promise<this> {
const channelConnectorCreatedEvent = "channel:connector:created";
const channelConnectorDeletedEvent = "channel:connector:deleted";
const channelMemberCreatedEvent = "channel:member:created";
const channelMemberDeletedEvent = "channel:member:deleted";
const channelTabCreatedEvent = "channel:tab:created";
const channelTabDeletedEvent = "channel:tab:deleted";
const channelUpdatedEvent = "channel:updated";
localEventBus.subscribe<ResourceEventsPayload>(channelMemberCreatedEvent, data => {
if (data.channel.visibility === ChannelVisibility.DIRECT) {
return;
}
//Fixme: We don't show activity from user himself,
// in the future when we aggregate activities then we could keep it,
// but for now it is polution in the channel
if ((data.resourcesAfter[0] as ChannelMember).user_id === data.actor.id) {
return;
}
this.notify(
channelMemberCreatedEvent,
{
type: "channel:activity:member:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [
// data.actor.id !== data.member.user_id ? "invite" : "join"
{ type: "member", resource: data.resourcesAfter[0] },
],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelMemberDeletedEvent, data => {
if (data.channel.visibility === ChannelVisibility.DIRECT) {
return;
}
//Do not notify when user leave the channel by themselves
if (data.resourcesBefore[0].id === data.actor.id) {
return;
}
this.notify(
channelMemberDeletedEvent,
{
type: "channel:activity:member:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "member", resource: data.resourcesBefore[0] }],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelUpdatedEvent, data => {
const interestedChanges = ["channel_group", "name", "description", "visibility", "icon"];
const channelChanged = !_.isEqual(
_.pick(data.resourcesBefore[0], interestedChanges),
_.pick(data.resourcesAfter[0], interestedChanges),
);
const connectorsBefore: Partial<ChannelEntity> = _.pick(data.resourcesBefore[0], [
"connectors",
]);
const connectorsAfter: Partial<ChannelEntity> = _.pick(data.resourcesAfter[0], [
"connectors",
]);
const isConnectorCreated: boolean =
connectorsBefore?.connectors.length < connectorsAfter?.connectors.length;
const channelConnectorsChanged = !_.isEqual(
{ connectors: sortBy(connectorsBefore.connectors) },
{ connectors: sortBy(connectorsAfter.connectors) },
);
if (channelConnectorsChanged) {
return localEventBus.publish(
isConnectorCreated ? "channel:connector:created" : "channel:connector:deleted",
data,
);
}
if (channelChanged) {
return this.notify(
channelUpdatedEvent,
{
type: "channel:activity:updated",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "diff",
previous: { type: "channel", resource: data.resourcesBefore[0] },
next: { type: "channel", resource: data.resourcesAfter[0] },
},
},
data.channel,
);
}
});
localEventBus.subscribe<ResourceEventsPayload>(channelTabCreatedEvent, data =>
this.notify(
channelTabCreatedEvent,
{
type: "channel:activity:tab:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [{ type: "tab", resource: data.resourcesAfter[0] }],
},
},
data.channelParameters,
),
);
localEventBus.subscribe<ResourceEventsPayload>(channelTabDeletedEvent, data =>
this.notify(
channelTabDeletedEvent,
{
type: "channel:activity:tab:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "tab", resource: data.resourcesAfter[0] }],
},
},
data.channelParameters,
),
);
localEventBus.subscribe<ResourceEventsPayload>(channelConnectorCreatedEvent, data => {
return this.notify(
channelConnectorCreatedEvent,
{
type: "channel:activity:connector:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [{ type: "connector", resource: data.resourcesAfter[0] }],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelConnectorDeletedEvent, data => {
this.notify(
channelConnectorDeletedEvent,
{
type: "channel:activity:connector:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "connector", resource: data.resourcesBefore[0] }],
},
},
data.channel,
);
});
return this;
}
async notify(
event: string,
data: ActivityObjectType,
channel: ChannelEntity | ChannelParameters,
): Promise<void> {
logger.debug(`Activities - New ${event} event %o`, data);
try {
await this.messageQueue.publish<ActivityPublishedType>("channel:activity_message", {
data: {
channel_id: channel.id,
workspace_id: channel.workspace_id,
company_id: channel.company_id,
activity: data,
},
});
} catch (err) {
logger.warn({ err }, `Activities - Error while publishing to ${event}`);
}
}
}
@@ -0,0 +1,29 @@
import { Channel, User } from "../../../../../utils/types";
import { ChannelMember } from "../../../../../services/channels/entities";
export type ActorType = { type: string; id: User["id"] };
export type ResourceObjectType = {
type: string;
resource: Channel | User | ChannelMember | [];
};
export type ContextType = {
type: "add" | "remove" | "diff";
array?: ResourceObjectType[];
previous?: ResourceObjectType;
next?: ResourceObjectType;
};
export type ActivityObjectType = {
type: string;
actor: ActorType;
context: ContextType;
};
export type ActivityPublishedType = {
channel_id: Channel["id"];
workspace_id: Channel["workspace_id"];
company_id: Channel["company_id"];
activity: ActivityObjectType;
};
@@ -0,0 +1,113 @@
import { Channel, getDefaultChannelInstance } from "../../../entities";
import { getLogger, Initializable } from "../../../../../core/platform/framework";
import { localEventBus } from "../../../../../core/platform/framework/event-bus";
import { ResourceEventsPayload } from "../../../../../utils/types";
import DefaultChannelServiceImpl from "./service";
const logger = getLogger("channel:default:listener");
export default class DefaultChannelListener implements Initializable {
constructor(private service: DefaultChannelServiceImpl) {}
async init(): Promise<this> {
localEventBus.subscribe<ResourceEventsPayload>(
"channel:created",
this.onChannelCreated.bind(this),
);
localEventBus.subscribe<ResourceEventsPayload>(
"channel:updated",
this.onChannelUpdated.bind(this),
);
localEventBus.subscribe<ResourceEventsPayload>(
"channel:deleted",
this.onChannelDeleted.bind(this),
);
return this;
}
onChannelDeleted(event: ResourceEventsPayload): void {
logger.debug("Channel has been deleted %o", event.channel);
if (!event.channel || !Channel.isDefaultChannel(event.channel)) {
return;
}
this.service
.delete(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be deleted", event.channel.id);
});
}
onChannelUpdated(event: ResourceEventsPayload): void {
logger.debug("Channel has been updated %o", event.channel);
if (!event.channel || !event.resourcesBefore || !event.resourcesBefore.length) {
return;
}
const channelUpdatedToDefault =
!Channel.isDefaultChannel(event.resourcesBefore[0] as Channel) &&
Channel.isDefaultChannel(event.channel);
const channelIsNotDefaultAnymore =
Channel.isDefaultChannel(event.resourcesBefore[0] as Channel) &&
!Channel.isDefaultChannel(event.channel);
if (channelUpdatedToDefault) {
logger.debug("Channel %s has been switched to 'default'", event.channel.id);
this.service
.create(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
undefined,
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be updated", event.channel.id);
});
}
if (channelIsNotDefaultAnymore) {
logger.debug("Channel %s has been switched to !'default'", event.channel.id);
this.service
.delete(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be removed", event.channel.id);
});
}
}
onChannelCreated(event: ResourceEventsPayload): void {
if (!event.channel || !Channel.isDefaultChannel(event.channel)) {
return;
}
logger.debug("Channel %s has been created with 'default' attribute", event.channel.id);
this.service
.create(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
undefined,
)
.catch((err: Error) => {
logger.warn({ err }, "Default channel %id can not be created", event.channel.id);
});
}
}
@@ -0,0 +1,260 @@
import { concat, EMPTY, from, Observable } from "rxjs";
import { filter, mergeMap, toArray } from "rxjs/operators";
import Repository from "../../../../../core/platform/services/database/services/orm/repository/repository";
import {
Channel,
ChannelMember,
DefaultChannel,
DefaultChannelPrimaryKey,
} from "../../../entities";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
Paginable,
SaveResult,
UpdateResult,
} from "../../../../../core/platform/framework/api/crud-service";
import { ChannelExecutionContext } from "../../../types";
import DefaultChannelListener from "./listener";
import { getLogger } from "../../../../../core/platform/framework";
import { User } from "../../../../../utils/types";
import WorkspaceUser from "../../../../workspaces/entities/workspace_user";
import gr from "../../../../global-resolver";
const logger = getLogger("channel.default");
export default class DefaultChannelServiceImpl {
version: "1";
repository: Repository<DefaultChannel>;
listener: DefaultChannelListener;
async init(): Promise<this> {
this.repository = await gr.database.getRepository("default_channels", DefaultChannel);
this.listener = new DefaultChannelListener(this);
await this.listener.init();
return this;
}
async create(
channel: DefaultChannel,
context: ExecutionContext,
): Promise<CreateResult<DefaultChannel>> {
await this.repository.save(channel, context);
// Once a default channel has been successfully created, we have to add all the workspace users as member of the channel
// There are several ways to do it: Directly or using pubsub
// Since member service is a channel sub service we can do it directly
// Another good way to be sure that the user is in default channels is to have an endpoint which ensures this...
this.onCreated(channel);
return new CreateResult<DefaultChannel>("default_channel", channel);
}
get(pk: DefaultChannelPrimaryKey, context: ExecutionContext): Promise<DefaultChannel> {
return this.repository.findOne(pk, {}, context);
}
update(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pk: DefaultChannelPrimaryKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: DefaultChannel,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<UpdateResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
save?<SaveOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: DefaultChannel,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: SaveOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<SaveResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
async delete(
pk: DefaultChannelPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<DefaultChannel>> {
const defaultChannel = await this.get(pk, context);
if (!defaultChannel) {
throw CrudException.notFound("Default channel has not been found");
}
await this.repository.remove(defaultChannel, context);
return new DeleteResult("default_channel", defaultChannel, true);
}
list<ListOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pagination: Paginable,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: ListOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<ListResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
/**
* @param channel
*/
onCreated(channel: DefaultChannel): void {
logger.debug("Default channel %s: Adding workspace users as members", channel.channel_id);
const members: ChannelMember[] = [];
const subscription = this.addWorkspaceUsersToChannel(channel).subscribe({
next: member => {
logger.debug(
"Default Channel %s: User %s has been added: %s",
channel.channel_id,
member.user.userId,
member.added,
);
member.added && members.push(member.member);
},
error: (err: Error) => {
logger.error({ err }, "Default Channel %s: Error while adding users", channel.channel_id);
},
complete: () => {
logger.debug(
"Default Channel %s: Workspace users have been added: %o",
channel.channel_id,
members.map(m => m.user_id),
);
subscription.unsubscribe();
},
});
}
addWorkspaceUsersToChannel(
channel: DefaultChannelPrimaryKey,
): Observable<{ user?: WorkspaceUser; member?: ChannelMember; added: boolean; err?: Error }> {
const workspaceUsers$ = gr.services.workspaces.getAllUsers$({
workspaceId: channel.workspace_id,
});
return workspaceUsers$.pipe(
// filter out external workspace users
filter(wsUser => !wsUser.isExternal),
mergeMap(wsUser =>
from(
gr.services.channels.members
.addUserToChannels({ id: wsUser.userId }, [
{
company_id: channel.company_id,
workspace_id: channel.workspace_id,
id: channel.channel_id,
} as Channel,
])
.then(result => ({
user: wsUser,
member: result.getEntities()[0].member,
added: result.getEntities()[0].added,
err: result.getEntities()[0].err,
}))
.catch(err => {
return { user: wsUser, added: false, err };
}),
),
),
);
}
async addUserToDefaultChannels(
user: User,
workspace: Required<Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<Array<{ channel: Channel; member?: ChannelMember; err?: Error; added: boolean }>> {
if (!user || !workspace) {
throw new Error("Can not add user to default channel (user or workspaces are empty)");
}
try {
const companyUser = await gr.services.companies.getCompanyUser(
{ id: workspace.company_id },
user,
);
if (!companyUser) {
return [];
}
// Do not add guest in default channels
if (companyUser.role === "guest") {
logger.debug(
"CompanyUser role is %s, not added in workspace %s",
companyUser.role,
workspace.workspace_id,
);
return [];
}
const channels = await this.getDefaultChannels({
company_id: workspace.company_id,
workspace_id: workspace.workspace_id,
});
if (!channels || !channels.length) {
logger.debug("No default channels in workspace %s", workspace.workspace_id);
return [];
}
logger.info("Adding user %s to channels %s", user, JSON.stringify(channels));
const regChannels = channels.map(c => ({ id: c.channel_id, ...c } as any as Channel));
return (
await gr.services.channels.members.addUserToChannels(user, regChannels)
).getEntities();
} catch (err) {
logger.error({ err }, "Error while adding user for default channels");
throw new Error("Error while adding user for default channels");
}
}
getDefaultChannels(
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination?: Paginable,
context?: ExecutionContext,
): Promise<DefaultChannel[]> {
return this.getDefaultChannels$(workspace, pagination, context).pipe(toArray()).toPromise();
}
getDefaultChannels$(
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination?: Paginable,
context?: ExecutionContext,
): Observable<DefaultChannel> {
const list = (
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination: Paginable,
) => {
return this.repository.find(
workspace,
{
pagination: { limitStr: pagination?.limitStr, page_token: pagination?.page_token },
},
context,
);
};
return from(list(workspace, pagination)).pipe(
mergeMap(channels => {
const items$ = from(channels.getEntities());
const next$ = channels?.nextPage?.page_token
? this.getDefaultChannels$(workspace, channels.nextPage)
: EMPTY;
return concat(items$, next$);
}),
);
}
}
@@ -0,0 +1,180 @@
import Repository from "../../../../../core/platform/services/database/services/orm/repository/repository";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../../core/platform/framework/api/crud-service";
import { ChannelExecutionContext } from "../../../types";
import { getLogger } from "../../../../../core/platform/framework";
import {
Channel,
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
ChannelPrimaryKey,
getChannelPendingEmailsInstance,
} from "../../../entities";
import { ChannelPendingEmailsListQueryParameters } from "../../../web/types";
import { plainToClass } from "class-transformer";
import { NewUserInWorkspaceNotification } from "../types";
import gr from "../../../../global-resolver";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const logger = getLogger("channel.pending_emails");
export default class ChannelPendingEmailServiceImpl {
version: "1";
repository: Repository<ChannelPendingEmails>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository(
"channel_pending_emails",
ChannelPendingEmails,
);
return this;
}
async create(
pendingEmail: ChannelPendingEmails,
context: ExecutionContext,
): Promise<CreateResult<ChannelPendingEmails>> {
await this.repository.save(pendingEmail, context);
// Once a channel pending email has been successfully created, we have to add him to all channels of the company that he was invited
//this.onCreated(pendingEmail);
return new CreateResult<ChannelPendingEmails>("channel_pending_emails", pendingEmail);
}
get(
pk: ChannelPendingEmailsPrimaryKey,
context: ExecutionContext,
): Promise<ChannelPendingEmails> {
return this.repository.findOne(pk, {}, context);
}
update(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pk: ChannelPendingEmailsPrimaryKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: ChannelPendingEmails,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<UpdateResult<ChannelPendingEmails>> {
throw new Error("Method not implemented.");
}
save?<SaveOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: ChannelPendingEmails,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: SaveOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<SaveResult<ChannelPendingEmails>> {
throw new Error("Method not implemented.");
}
async delete(
pk: ChannelPendingEmailsPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<ChannelPendingEmails>> {
const pendingEmail = await this.get(pk, context);
if (!pendingEmail) {
throw CrudException.notFound("Channel pendingEmail has not been found");
}
await this.repository.remove(pendingEmail, context);
return new DeleteResult("channel_pending_emails", pendingEmail, true);
}
async list<ListOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pagination: Pagination,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: ListOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<ListResult<ChannelPendingEmails>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
};
const result = await this.repository.find(pk, { pagination }, context);
return new ListResult<ChannelPendingEmails>(
"channel_pending_emails",
result
.getEntities()
.map(pendingMember =>
plainToClass(ChannelPendingEmails, { email: pendingMember.email, ...pendingMember }),
),
result.nextPage,
);
}
findPendingEmails(
pk: ChannelPendingEmailsListQueryParameters,
context: ExecutionContext,
): Promise<ListResult<ChannelPendingEmails>> {
return this.repository.find(pk, {}, context);
}
async proccessPendingEmails(
user: NewUserInWorkspaceNotification,
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
context: ExecutionContext,
): Promise<void> {
// Get user object
const userObj = await gr.services.users.get({
id: user.user_id,
});
// All pending emails in workspace
const allPendingEmailsInWorkspace = await this.repository.find(workspace, {}, context);
// Filter pending emails in workspace with user object email
allPendingEmailsInWorkspace.filterEntities(({ email }) => email === userObj.email_canonical);
// Add user to all channel that he is invited then delete pending email entity
allPendingEmailsInWorkspace
.getEntities()
.forEach(async ({ workspace_id, channel_id, company_id, email }) => {
// Add user to channel
const list = await gr.services.channels.members.addUserToChannels(userObj, [
{
workspace_id,
company_id,
id: channel_id,
} as Channel,
]);
// If added to channel, delete pending email
const isAddedToChannel = list.getEntities()[0].added;
if (isAddedToChannel) {
await this.delete(
getChannelPendingEmailsInstance({
workspace_id,
company_id,
email,
channel_id,
}),
);
}
});
}
onCreated(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pendingEmail: ChannelPendingEmails,
): void {
throw new Error("Method not implemented.");
}
}
@@ -0,0 +1,52 @@
import { User, WebsocketMetadata, Workspace } from "../../../../utils/types";
import { Channel } from "../../entities";
import { WorkspaceExecutionContext } from "../../types";
import { isDirectChannel } from "../../utils";
export function getWebsocketInformation(channel: Channel): WebsocketMetadata {
return {
name: channel.name,
room: getChannelPath(channel),
};
}
export function getWorkspaceRooms(workspace: Workspace, user: User): WebsocketMetadata[] {
return isDirectChannel(workspace)
? [{ room: getDirectChannelRoomName(workspace, user.id) }]
: [{ room: getPublicRoomName(workspace) }, { room: getPrivateRoomName(workspace, user) }];
}
export function getDirectChannelRoomName(workspace: Workspace, userId: string): string {
return `/companies/${workspace.company_id}/workspaces/direct/channels?type=direct&user=${userId}`;
}
export function getDirectChannelRooms(workspace: Workspace, channel: Channel): string[] {
return (channel.members || []).map(member => getDirectChannelRoomName(workspace, member));
}
export function getPrivateRoomName(workspace: Workspace, user: User): string {
return `/companies/${workspace.company_id}/workspaces/${workspace.workspace_id}/channels?type=private&user=${user.id}`;
}
export function getPublicRoomName(workspace: Workspace): string {
return `/companies/${workspace?.company_id}/workspaces/${workspace?.workspace_id}/channels?type=public`;
}
export function getRoomName(channel: Channel, context?: WorkspaceExecutionContext): string[] {
return isDirectChannel(channel)
? getDirectChannelRooms(context.workspace, channel)
: [getPublicRoomName(context.workspace)];
}
export function getChannelPath(
channel: Pick<Channel, "id">,
context?: WorkspaceExecutionContext,
): string {
return `${getChannelsPath(context?.workspace)}/${channel.id}`;
}
export function getChannelsPath(workspace?: Workspace): string {
return workspace
? `/companies/${workspace.company_id}/workspaces/${workspace.workspace_id}/channels`
: "/channels";
}
@@ -0,0 +1,987 @@
import _, { cloneDeep, find } from "lodash";
import { diff } from "deep-object-diff";
import {
getLogger,
RealtimeDeleted,
RealtimeSaved,
RealtimeUpdated,
} from "../../../../core/platform/framework";
import {
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../core/platform/framework/api/crud-service";
import { ChannelActivityMessage, ChannelObject, SearchChannelOptions } from "./types";
import {
Channel,
ChannelMember,
ChannelPrimaryKey,
DefaultChannel,
UserChannel,
UsersIncludedChannel,
} from "../../entities";
import { getChannelPath, getRoomName } from "./realtime";
import { ChannelType, ChannelVisibility, WorkspaceExecutionContext } from "../../types";
import { isWorkspaceAdmin as userIsWorkspaceAdmin } from "../../../../utils/workspace";
import { ResourceEventsPayload, User } from "../../../../utils/types";
import { pick } from "../../../../utils/pick";
import {
DirectChannel,
getInstance as getDirectChannelInstance,
} from "../../entities/direct-channel";
import { ChannelListOptions, ChannelSaveOptions } from "../../web/types";
import { isDirectChannel } from "../../utils";
import { ResourcePath } from "../../../../core/platform/services/realtime/types";
import Repository, {
FindFilter,
} from "../../../../core/platform/services/database/services/orm/repository/repository";
import { ChannelActivity } from "../../entities/channel-activity";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import DefaultChannelServiceImpl from "./default/service";
import { formatUser } from "../../../../utils/users";
import gr from "../../../global-resolver";
import {
KnowledgeGraphEvents,
KnowledgeGraphGenericEventPayload,
} from "../../../../core/platform/services/knowledge-graph/types";
import { ChannelUserCounterType } from "../../entities/channel-counters";
import { Readable } from "stream";
import sharp from "sharp";
const logger = getLogger("channel.service");
export class ChannelServiceImpl {
version: "1";
activityRepository: Repository<ChannelActivity>;
channelRepository: Repository<Channel>;
directChannelRepository: Repository<DirectChannel>;
defaultChannelService: DefaultChannelServiceImpl;
async init(): Promise<this> {
this.defaultChannelService = new DefaultChannelServiceImpl();
try {
this.activityRepository = await gr.database.getRepository(
"channel_activity",
ChannelActivity,
);
this.channelRepository = await gr.database.getRepository("channels", Channel);
this.directChannelRepository = await gr.database.getRepository(
"direct_channels",
DirectChannel,
);
} catch (err) {
logger.error({ err }, "Can not initialize channel db service");
}
try {
await this.defaultChannelService.init();
} catch (err) {
logger.warn("Can not initialize default channel service", err);
}
localEventBus.subscribe<ResourceEventsPayload>("workspace:user:deleted", async data => {
if (data?.user?.id && data?.company?.id)
gr.services.channels.members.ensureUserNotInWorkspaceIsNotInChannel(
data.user,
data.workspace,
undefined,
);
});
return this;
}
async thumbnail(channelId: string, context?: ExecutionContext): Promise<{ file: Readable }> {
const logoInternalPath = `/channels/${channelId}/thumbnail.jpg`;
const file = await gr.platformServices.storage.read(logoInternalPath, {}, context);
return { file };
}
@RealtimeSaved<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async save(
channel: Channel,
options: ChannelSaveOptions,
context: WorkspaceExecutionContext,
): Promise<SaveResult<ChannelObject>> {
let channelToUpdate: Channel;
let channelToSave: Channel;
const mode = channel.id ? OperationType.UPDATE : OperationType.CREATE;
const isDirectChannel = Channel.isDirectChannel(channel);
const isWorkspaceAdmin =
!isDirectChannel && (await userIsWorkspaceAdmin(context.user, context.workspace));
const isPrivateChannel = Channel.isPrivateChannel(channel);
const isDefaultChannel = Channel.isDefaultChannel(channel);
channel.workspace_id = context.workspace.workspace_id || channel.workspace_id;
channel.company_id = context.workspace.company_id || channel.company_id;
if (isDirectChannel) {
channel.visibility = ChannelVisibility.DIRECT;
channel.workspace_id = ChannelVisibility.DIRECT;
}
if (mode === OperationType.UPDATE) {
logger.debug("Updating channel");
channelToUpdate = await this.get(this.getPrimaryKey(channel), context);
if (!channelToUpdate) {
throw CrudException.notFound("Channel not found");
}
const isChannelOwner = this.isChannelOwner(channelToUpdate, context.user);
const updatableParameters: Partial<Record<keyof Channel, boolean>> = {
name: true,
description: !isDirectChannel,
icon: !isDirectChannel,
channel_group: !isDirectChannel,
is_default: (isWorkspaceAdmin || isChannelOwner) && !isDirectChannel && !isPrivateChannel,
visibility: !isDirectChannel && (isWorkspaceAdmin || isChannelOwner),
archived: isWorkspaceAdmin || isChannelOwner,
connectors: !isDirectChannel,
is_readonly: isWorkspaceAdmin || isChannelOwner,
};
// Diff existing channel and input one, cleanup all the undefined fields for all objects
const channelDiff = diff(channelToUpdate, channel);
const fields = Object.keys(channelDiff) as Array<Partial<keyof Channel>>;
if (!fields.length) {
throw CrudException.badRequest("Nothing to update");
}
const updatableFields = fields.filter(field => updatableParameters[field]);
if (!updatableFields.length) {
throw CrudException.badRequest("Current user can not update requested fields");
}
channelToSave = cloneDeep(channelToUpdate);
updatableFields.forEach(field => {
if (channel[field] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(channelToSave as any)[field] = channel[field];
}
});
localEventBus.publish<ResourceEventsPayload>("channel:updated", {
actor: context.user,
resourcesBefore: [channelToUpdate],
resourcesAfter: [channel],
channel: channel,
user: context.user,
});
}
if (mode === OperationType.CREATE) {
if (isPrivateChannel && isDefaultChannel) {
throw CrudException.badRequest("Private channel can not be default");
}
if (isDirectChannel) {
options.members = Array.from(new Set<string>(options?.members || []).add(context.user.id));
channel.members = options.members;
logger.info("Direct channel creation with members %o", options.members);
if (context.workspace.workspace_id !== ChannelVisibility.DIRECT) {
throw CrudException.badRequest("Direct Channel creation error: bad workspace");
}
const directChannel = await this.getDirectChannelInCompany(
context.workspace.company_id,
options.members,
context,
);
if (directChannel) {
logger.debug("Direct channel already exists %o", directChannel);
const existingChannel = await this.channelRepository.findOne(
{
company_id: context.workspace.company_id,
workspace_id: ChannelType.DIRECT,
id: directChannel.channel_id,
},
{},
context,
);
if (existingChannel) {
const last_activity = await this.getChannelActivity(existingChannel, context);
const saveResult = new SaveResult(
"channels",
ChannelObject.mapTo(existingChannel, { last_activity }),
OperationType.EXISTS,
);
await this.addContextUserToChannel(context, saveResult);
return saveResult;
} else {
//Fixme: remove directChannel instance
throw CrudException.badRequest("table inconsistency");
}
}
} else {
if (!channel.name) {
throw CrudException.badRequest("'name' is required");
}
}
channelToSave = channel;
channelToSave.owner = context.user.id;
}
if (channelToSave.icon && channelToSave.icon.startsWith("data:")) {
const logoInternalPath = `/channels/${channelToSave.id}/thumbnail.jpg`;
const logoPublicPath = `/internal/services/channels/v1/companies/${
channelToSave.company_id
}/workspaces/${channelToSave.workspace_id}/channels/${
channelToSave.id
}/thumbnail?t=${new Date().getTime()}`;
const image = await sharp(Buffer.from(channel.icon.split(",").pop(), "base64"))
.resize(250, 250)
.toBuffer();
const s = new Readable();
s.push(image);
s.push(null);
await gr.platformServices.storage.write(logoInternalPath, s);
channelToSave.icon = logoPublicPath;
}
const channelActivity = await this.activityRepository.findOne(
{
channel_id: channel.id,
},
{},
context,
);
logger.info("Saving channel %o", channelToSave);
await this.channelRepository.save(channelToSave, context);
if (!isDirectChannel) {
channel.members = []; //Members is specific to direct channels
}
const saveResult = new SaveResult<ChannelObject>(
"channel",
{
...ChannelObject.mapTo(channelToSave),
last_activity: channelActivity?.last_activity || 0,
last_message: channelActivity?.last_message || {},
},
mode,
);
await this.addContextUserToChannel(context, saveResult);
await this.onSaved(channelToSave, options, context, saveResult, mode);
// Shortcut to invite members to a channel
if (!isDirectChannel && options.members && options.members.length > 0) {
await gr.services.channels.members.addUsersToChannel(
options.members.map(id => {
return { id };
}),
saveResult.entity,
context,
);
}
return saveResult;
}
async get(pk: ChannelPrimaryKey, _context?: ExecutionContext): Promise<ChannelObject> {
const primaryKey = this.getPrimaryKey(pk);
let channel = await this.channelRepository.findOne(primaryKey);
if (!channel) {
channel = await this.channelRepository.findOne({ ...primaryKey, workspace_id: "direct" });
}
if (!channel) return null;
const last_activity = await this.getChannelActivity(channel);
if (channel.visibility === ChannelVisibility.DIRECT)
channel = await this.includeUsersInDirectChannel(channel);
return ChannelObject.mapTo(channel, { last_activity });
}
@RealtimeUpdated<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async update(
pk: ChannelPrimaryKey,
channel: Channel,
context: ExecutionContext,
): Promise<UpdateResult<ChannelObject>> {
// TODO: Do the update by hand then save
if (!pk.id) {
throw CrudException.badRequest("Channel id is required for update");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mergeChannel: any = { ...channel, ...pk };
await this.channelRepository.save(mergeChannel as Channel, context);
return new UpdateResult<ChannelObject>("channel", ChannelObject.mapTo(mergeChannel));
}
@RealtimeDeleted<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async delete(
pk: ChannelPrimaryKey,
context: WorkspaceExecutionContext,
): Promise<DeleteResult<ChannelObject>> {
const channelToDelete = await this.channelRepository.findOne(
this.getPrimaryKey(pk),
{},
context,
);
if (!channelToDelete) {
throw new CrudException("Channel not found", 404);
}
const directChannel = isDirectChannel(channelToDelete);
if (directChannel) {
throw new CrudException("Direct channel can not be deleted", 400);
}
const isWorkspaceAdmin =
!directChannel && (await userIsWorkspaceAdmin(context.user, context.workspace));
const isChannelOwner = this.isChannelOwner(channelToDelete, context.user);
if (!isWorkspaceAdmin && !isChannelOwner) {
throw new CrudException("Channel can not be deleted", 400);
}
await this.channelRepository.remove(channelToDelete, context);
const result = new DeleteResult("channel", ChannelObject.mapTo(pk as Channel), true);
this.onDeleted(channelToDelete, result);
localEventBus.publish<ResourceEventsPayload>("channel:deleted", {
channel: channelToDelete,
user: context.user,
});
return result;
}
@RealtimeUpdated<ChannelActivity>((channelActivity, context) => {
return [
{
room: ResourcePath.get(
getRoomName(channelActivity.getChannelPrimaryKey(), context as WorkspaceExecutionContext),
),
path: getChannelPath(
channelActivity.getChannelPrimaryKey(),
context as WorkspaceExecutionContext,
),
resource: {
company_id: channelActivity.company_id,
workspace_id: channelActivity.workspace_id,
id: channelActivity.channel_id,
last_activity: channelActivity.last_activity,
last_message: channelActivity.last_message,
},
},
];
})
async updateLastActivity(
payload: {
date: number;
channel: ChannelPrimaryKey;
message: ChannelActivityMessage | null;
},
context: WorkspaceExecutionContext,
): Promise<UpdateResult<ChannelActivity>> {
const channelPK = payload.channel;
const channelActivityMessage = payload.message;
const channel = (await this.channelRepository.findOne(
_.pick(channelPK, "company_id", "workspace_id", "id"),
{},
context,
)) as ChannelObject & { stats: ChannelObject["stats"] };
await gr.services.channels.channels.completeWithStatistics([channel]);
const entity = new ChannelActivity();
entity.channel_id = channelPK.id;
entity.company_id = channelPK.company_id;
entity.workspace_id = channelPK.workspace_id;
entity.last_activity = payload.date;
entity.stats = channel.stats;
entity.last_message = channelActivityMessage
? {
date: channelActivityMessage.date,
sender: channelActivityMessage.sender,
sender_name: channelActivityMessage.sender_name,
title: channelActivityMessage.title,
text: channelActivityMessage.text,
}
: null;
entity.channel = channel;
logger.info(`Update activity for channel ${entity.channel_id} to ${entity.last_activity}`);
localEventBus.publish<KnowledgeGraphGenericEventPayload<Channel>>(
KnowledgeGraphEvents.CHANNEL_UPSERT,
{
id: channel.id,
resource: channel,
links: [],
},
);
await this.activityRepository.save(entity, context);
return new UpdateResult<ChannelActivity>("channel_activity", entity);
}
public async getChannelActivity(channel: Channel, context?: ExecutionContext): Promise<number> {
let result = 0;
if (!channel) {
return result;
}
try {
const activity = await this.activityRepository.findOne(
{
company_id: channel.company_id,
workspace_id: channel.workspace_id,
channel_id: channel.id,
} as ChannelActivity,
{},
context,
);
result = activity?.last_activity || 0;
} catch (error) {
logger.debug(`Can not get channel last activity for channel ${channel.id}`);
}
return result;
}
public async fillChannelActivities(
channels: Channel[],
context: ExecutionContext,
): Promise<ChannelObject[]> {
const filledChannels: ChannelObject[] = [];
for (const channel of channels) {
const activity = await this.getChannelActivity(channel, context);
const chObj = ChannelObject.mapTo(channel);
chObj.last_activity = activity;
filledChannels.push(chObj);
}
return filledChannels;
}
async list(
pagination: Pagination,
options: ChannelListOptions,
context: WorkspaceExecutionContext,
): Promise<ListResult<Channel | UserChannel>> {
const isDirectWorkspace = isDirectChannel(context.workspace);
const isWorkspaceAdmin =
!isDirectWorkspace && userIsWorkspaceAdmin(context.user, context.workspace);
const findFilters: FindFilter = {
company_id: context.workspace.company_id,
workspace_id: context.workspace.workspace_id,
};
let user_id = context.user.id;
if (context.user.application_id) {
user_id = user_id || options.user_id;
}
if ((options?.mine || isDirectWorkspace) && user_id) {
if (!context.user.application_id && !context.user.server_request) {
localEventBus.publish<ResourceEventsPayload>("channel:list", {
user: context.user,
company: {
id: context.workspace.company_id,
},
});
}
return this.getChannelsForUsersInWorkspace(
context.workspace.company_id,
context.workspace.workspace_id,
context.user.id,
pagination,
context,
);
}
const channels = await this.channelRepository.find(findFilters, { pagination }, context);
channels.filterEntities(channel => channel.visibility !== ChannelVisibility.DIRECT);
if (!isWorkspaceAdmin && !context.user.server_request) {
channels.filterEntities(channel => channel.visibility === ChannelVisibility.PUBLIC);
}
return channels;
}
async getChannelsForUsersInWorkspace(
companyId: string,
workspaceId: string | "direct",
userId: string,
pagination?: Pagination,
context?: ExecutionContext,
): Promise<ListResult<UserChannel>> {
const isDirectWorkspace = isDirectChannel({ workspace_id: workspaceId });
const findFilters: FindFilter = {
company_id: companyId,
workspace_id: workspaceId,
};
const userChannels = await gr.services.channels.members.listUserChannels(
{ id: userId },
pagination,
{
user: {
id: userId,
},
workspace: {
workspace_id: workspaceId,
company_id: companyId,
},
},
);
let activityPerChannel: Map<string, ChannelActivity>;
const channels = await this.channelRepository.find(
findFilters,
{
$in: [["id", userChannels.getEntities().map(channelMember => channelMember.channel_id)]],
},
context,
);
if (!channels.isEmpty()) {
const activities = await this.activityRepository.find(
findFilters,
{
$in: [["channel_id", channels.getEntities().map(channel => channel.id)]],
},
context,
);
activityPerChannel = new Map<string, ChannelActivity>(
activities.getEntities().map(activity => [activity.channel_id, activity]),
);
} else {
activityPerChannel = new Map<string, ChannelActivity>();
}
channels.mapEntities(<UserChannel>(channel: Channel) => {
const userChannel = find(userChannels.getEntities(), { channel_id: channel.id });
const channelActivity = activityPerChannel.get(channel.id);
return {
...channel,
...{ user_member: userChannel },
last_activity: channelActivity?.last_activity || 0,
last_message: channelActivity?.last_message || {},
} as unknown as UserChannel;
});
if (isDirectWorkspace) {
channels.mapEntities(<UserDirectChannel>(channel: UserChannel) => {
return {
...channel,
...{
members: channel.members || [],
},
} as unknown as UserDirectChannel;
});
}
return new ListResult(
channels.type,
channels.getEntities() as UserChannel[],
userChannels.nextPage,
);
}
async createDirectChannel(
directChannel: DirectChannel,
context: ExecutionContext,
): Promise<DirectChannel> {
const instance = getDirectChannelInstance(directChannel);
await this.directChannelRepository.save(instance, context);
return instance;
}
getDirectChannel(
directChannel: DirectChannel,
context: ExecutionContext,
): Promise<DirectChannel> {
return this.directChannelRepository.findOne(directChannel, {}, context);
}
async getDirectChannelInCompany(
companyId: string,
users: string[],
context: ExecutionContext,
): Promise<DirectChannel> {
const directChannel = await this.directChannelRepository.findOne(
{
company_id: companyId,
users: DirectChannel.getUsersAsString(users),
},
{},
context,
);
return directChannel;
}
async getDirectChannelsInCompany(
pagination: Pagination,
company_id: string,
context: ExecutionContext,
): Promise<ListResult<Channel>> {
return await this.channelRepository.find(
{ company_id, workspace_id: "direct" },
{ pagination },
context,
);
}
async getDirectChannelsForUsersInCompany(
companyId: string,
userId: string,
context: ExecutionContext,
): Promise<DirectChannel[]> {
const list = await this.directChannelRepository.find(
{
company_id: companyId,
},
{
$like: [["users", userId]],
},
context,
);
return list.getEntities();
}
async markAsRead(
pk: ChannelPrimaryKey,
user: Pick<User, "id">,
context: ExecutionContext,
): Promise<boolean> {
const now = Date.now();
const channel = await this.get(pk, context);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
await this.completeWithStatistics([channel]);
const member = await gr.services.channels.members.getChannelMember(
user,
channel,
undefined,
context,
);
if (!member) {
throw CrudException.badRequest("User is not channel member");
}
// Updating the member will also publish a message in the message-queue channel
// This message will be handled in the notification service and will update the notification preferences for the member
// cf this.members.onUpdated
member.last_access = now;
member.last_increment = channel.stats.messages;
const updatedMember = (
await gr.services.channels.members.save(member, {
channel,
user,
})
).entity;
this.onRead(channel, updatedMember);
return true;
}
async markAsUnread(
pk: ChannelPrimaryKey,
user: User,
context: ExecutionContext,
): Promise<boolean> {
const channel = await this.get(pk, context);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
const member = await gr.services.channels.members.getChannelMember(user, channel);
if (!member) {
throw CrudException.badRequest("User is not channel member");
}
// do nothing here but send a notification so that notification service is updated...
this.onUnread(channel, member);
return true;
}
getDefaultChannels(
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<DefaultChannel[]> {
return this.defaultChannelService.getDefaultChannels(workspace);
}
async addUserToDefaultChannels(
user: User,
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<ChannelMember[]> {
const result = await this.defaultChannelService.addUserToDefaultChannels(user, workspace);
return result.filter(e => e.added).map(e => e.member);
}
getPrimaryKey(channelOrPrimaryKey: Channel | ChannelPrimaryKey): ChannelPrimaryKey {
return pick(channelOrPrimaryKey, ...(["company_id", "workspace_id", "id"] as const));
}
isChannelOwner(channel: Channel, user: User): boolean {
return channel.owner && String(channel.owner) === user.id;
}
async addContextUserToChannel(
context: WorkspaceExecutionContext,
result: SaveResult<Channel>,
): Promise<void> {
const savedChannel = result.entity;
//Add requester as member
if (context.user.id) {
try {
await gr.services.channels.members.addUserToChannels({ id: context.user.id }, [
savedChannel,
]);
} catch (err) {
logger.warn({ err }, "Can not add requester as channel member");
}
}
}
public async includeUsersInDirectChannel(
channel: Channel,
excludeUserId?: string,
): Promise<UsersIncludedChannel> {
const channelWithUsers: UsersIncludedChannel = { users: [], ...channel };
if (isDirectChannel(channel)) {
const users = [];
for (const user of channel.members) {
if (user) {
const e = await formatUser(await gr.services.users.getCached({ id: user }));
if (e) users.push(e);
}
}
channelWithUsers.users = users;
channelWithUsers.name = users
.filter(u => u.id != excludeUserId)
.map(u => u.full_name?.trim())
.filter(a => a)
.join(", ");
}
return channelWithUsers;
}
/**
* Called when channel update has been successfully called
*
* @param channel The channel before update has been processed
* @param result The channel update result
*/
async onSaved(
channel: Channel,
options: ChannelSaveOptions,
context: WorkspaceExecutionContext,
result: SaveResult<Channel>,
mode: OperationType.CREATE | OperationType.UPDATE,
): Promise<void> {
const savedChannel = result.entity;
if (mode === OperationType.CREATE) {
if (isDirectChannel(channel)) {
const directChannel = {
channel_id: savedChannel.id,
company_id: savedChannel.company_id,
users: DirectChannel.getUsersAsString(options.members),
} as DirectChannel;
await this.createDirectChannel(directChannel, context);
} else {
if (options.addCreatorAsMember && savedChannel.owner) {
try {
await gr.services.channels.members.addUserToChannels({ id: savedChannel.owner }, [
savedChannel,
]);
} catch (err) {
logger.warn({ err }, "Can not add owner as channel member");
}
}
}
this.updateLastActivity(
{
date: new Date().getTime(),
channel: channel,
message: null,
},
context,
);
localEventBus.publish<ResourceEventsPayload>("channel:created", {
channel,
user: context.user,
});
}
}
/**
* Called when channel delete has been successfully called
*
* @param channel The channel to delete
* @param result The delete result
*/
onDeleted(channel: Channel, result: DeleteResult<Channel>): void {
if (result.deleted) {
logger.debug("Channel %s has been deleted", channel.id);
}
}
/**
* Called when a channel as been marked as read.
* Will publish `channel:read` notification in the message-queue service
*
* @param channel
* @param member
*/
onRead(channel: Channel, member: ChannelMember): void {
logger.info(`Channel ${channel.id} as been marked as read for user ${member.id}`);
gr.platformServices.messageQueue.publish("channel:read", {
data: {
channel,
member,
},
});
}
/**
* Called when a channel as been marked as unread.
* Will publish `channel:unread` notification in the message-queue service
*
* @param channel
* @param member
*/
onUnread(channel: Channel, member: ChannelMember): void {
logger.info(`Channel ${channel.id} as been marked as unread for user ${member.id}`);
gr.platformServices.messageQueue.publish("channel:unread", {
data: {
channel,
member,
},
});
}
async search(
pagination: Pagination,
options: SearchChannelOptions,
context: ExecutionContext,
): Promise<ListResult<Channel>> {
const rep = gr.platformServices.search.getRepository<Channel>("channel", Channel);
return rep.search(
{},
{
pagination,
...(options.companyId ? { $in: [["company_id", [options.companyId]]] } : {}),
$text: {
$search: options.search,
},
},
context,
);
}
async getAllChannelsInWorkspace(
company_id: string,
workspace_id: string,
context?: ExecutionContext,
): Promise<Channel[]> {
let pagination = new Pagination(null, "100");
const channels: Channel[] = [];
do {
const res = await this.channelRepository.find(
{
company_id: company_id,
workspace_id: workspace_id,
},
{
pagination,
},
context,
);
pagination = new Pagination(res.nextPage.page_token, res.nextPage.limitStr);
channels.push(...res.getEntities());
} while (pagination.page_token);
return channels;
}
async completeWithStatistics(
channels: Pick<ChannelObject, "id" | "company_id" | "workspace_id" | "stats">[],
) {
await Promise.all(
channels.map(async a => {
const members = await gr.services.channels.members.getUsersCount({
..._.pick(a, "id", "company_id", "workspace_id"),
counter_type: ChannelUserCounterType.MEMBERS,
});
//Fixme: even if it works strange to use "getUsersCount" to get messages count
const messages = await gr.services.channels.members.getUsersCount({
..._.pick(a, "id", "company_id", "workspace_id"),
counter_type: ChannelUserCounterType.MESSAGES,
});
a.stats = { members, messages };
}),
);
}
}
@@ -0,0 +1,86 @@
import { merge } from "lodash";
import { Channel } from "../../entities/channel";
import { ChannelActivity } from "../../entities/channel-activity";
import { ChannelMember } from "../../entities";
import { UserObject } from "../../../user/web/types";
export type NewUserInWorkspaceNotification = {
user_id: string;
company_id: string;
workspace_id: string;
};
export type ChannelStats = {
members: number;
messages: number;
};
type ChannelType = "workspace" | "direct";
export class ChannelMemberObject extends ChannelMember {
id: string;
user_id: string;
// type: "member" | "guest" | "bot";
last_access: number; //Timestamp in seconds
last_increment: number;
favorite: boolean; //Did the user add this channel to its favorites
// notification_level: "all" | "none" | "group_mentions" | "user_mentions";
static mapTo(
channelMember: ChannelMember,
channelMemberLikeObject: Partial<ChannelMemberObject> = {},
): ChannelMemberObject {
if (!channelMember) {
return null;
}
return merge(new ChannelMemberObject(), {
favorite: false,
last_increment: 0,
last_access: 0,
...channelMember,
...channelMemberLikeObject,
});
}
}
export class ChannelObject extends Channel {
last_activity?: ChannelActivity["last_activity"];
last_message?: ChannelActivity["last_message"];
default: boolean;
type: ChannelType;
user_member: ChannelMemberObject;
users: UserObject[];
stats: ChannelStats;
constructor() {
super();
}
static mapTo(channel: Channel, channelLikeObject: Partial<ChannelObject> = {}): ChannelObject {
if (!channel) {
return null;
}
return merge(new ChannelObject(), {
...{ last_activity: 0 },
...channel,
...channelLikeObject,
...{ members: channel?.members?.length ? channel.members : [] },
default: channel.is_default,
});
}
}
export type SearchChannelOptions = {
search: string;
companyId?: string;
};
export type ChannelActivityMessage = {
date: number;
sender: string;
sender_name: string;
title: string;
text: string;
};
@@ -0,0 +1,41 @@
import { Channel, User, WebsocketMetadata } from "../../../../utils/types";
import { ChannelMember } from "../../entities";
import { getChannelPath, getChannelsPath } from "../channel/realtime";
import { ChannelExecutionContext } from "../../types";
export function getPrivateRoomName(channel: Channel, user: User): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}?type=private&user=${user.id}`;
}
export function getPublicRoomName(channel: Channel): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}?type=public`;
}
export function getRoomName(channelOrContext: Channel | ChannelExecutionContext): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channel = (channelOrContext as any).channel
? (channelOrContext as ChannelExecutionContext).channel
: (channelOrContext as Channel);
return getPublicRoomName(channel);
}
export function getMembersPath(channel: Channel): string {
return `${getChannelsPath(channel)}/members`;
}
export function getMemberPath(member: ChannelMember, context?: ChannelExecutionContext): string {
const parentContext = {
workspace: {
company_id: context?.channel?.company_id,
workspace_id: context?.channel?.workspace_id,
},
user: context?.user,
};
return `${getChannelPath(context.channel, parentContext)}/members/${member.user_id}`;
}
export function getChannelRooms(channel: Channel, user: User): WebsocketMetadata[] {
return [{ room: getPublicRoomName(channel) }, { room: getPrivateRoomName(channel, user) }];
}
@@ -0,0 +1,911 @@
import { getLogger, RealtimeDeleted, RealtimeSaved } from "../../../../core/platform/framework";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../core/platform/framework/api/crud-service";
import {
Channel as ChannelEntity,
ChannelMember,
ChannelMemberPrimaryKey,
ChannelMemberReadCursors,
getChannelMemberInstance,
getMemberOfChannelInstance,
MemberOfChannel,
ReadSection,
} from "../../entities";
import {
ChannelExecutionContext,
ChannelMemberType,
ChannelVisibility,
WorkspaceExecutionContext,
} from "../../types";
import { Channel, ResourceEventsPayload, User } from "../../../../utils/types";
import { cloneDeep, isNil, omitBy } from "lodash";
import { updatedDiff } from "deep-object-diff";
import { pick } from "../../../../utils/pick";
import { getMemberPath, getRoomName } from "./realtime";
import { ChannelListOptions } from "../../web/types";
import { ResourcePath } from "../../../../core/platform/services/realtime/types";
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import { plainToClass } from "class-transformer";
import {
ChannelCounterEntity,
ChannelCounterPrimaryKey,
ChannelUserCounterType,
TYPE as ChannelCounterEntityType,
} from "../../entities/channel-counters";
import { CounterProvider } from "../../../../core/platform/services/counter/provider";
import { countRepositoryItems } from "../../../../utils/counters";
import NodeCache from "node-cache";
import { UserPrimaryKey } from "../../../user/entities/user";
import { WorkspacePrimaryKey } from "../../../workspaces/entities/workspace";
import gr from "../../../global-resolver";
import uuidTime from "uuid-time";
import { ChannelObject } from "../channel/types";
import { CompanyExecutionContext } from "../../../applications/web/types";
const USER_CHANNEL_KEYS = [
"id",
"company_id",
"workspace_id",
"user_id",
"channel_id",
"type",
"last_access",
"last_increment",
"favorite",
"notification_level",
"expiration",
] as const;
const CHANNEL_MEMBERS_KEYS = [
"company_id",
"workspace_id",
"user_id",
"channel_id",
"type",
] as const;
const logger = getLogger("channel.member");
export class MemberServiceImpl {
version: "1";
userChannelsRepository: Repository<ChannelMember>;
channelMembersRepository: Repository<MemberOfChannel>;
channelMembersReadCursorRepository: Repository<ChannelMemberReadCursors>;
private channelCounter: CounterProvider<ChannelCounterEntity>;
private cache: NodeCache;
async init(): Promise<this> {
try {
this.userChannelsRepository = await gr.database.getRepository("user_channels", ChannelMember);
this.channelMembersRepository = await gr.database.getRepository(
"channel_members",
MemberOfChannel,
);
this.channelMembersReadCursorRepository = await gr.database.getRepository(
"channel_members_read_cursors",
ChannelMemberReadCursors,
);
} catch (err) {
logger.error({ err }, "Can not initialize channel member service");
}
const channelCountersRepository = await gr.database.getRepository<ChannelCounterEntity>(
ChannelCounterEntityType,
ChannelCounterEntity,
);
this.channelCounter = await gr.platformServices.counter.getCounter<ChannelCounterEntity>(
channelCountersRepository,
);
this.channelCounter.setReviseCallback(async (pk: ChannelCounterPrimaryKey) => {
if (pk.counter_type === ChannelUserCounterType.MESSAGES) {
return;
}
const type =
ChannelUserCounterType.MEMBERS === pk.counter_type
? ChannelMemberType.MEMBER
: ChannelMemberType.GUEST;
return countRepositoryItems(
this.channelMembersRepository,
{ channel_id: pk.id, company_id: pk.company_id, workspace_id: pk.workspace_id },
{ type },
);
}, 400);
this.cache = new NodeCache({ stdTTL: 1, checkperiod: 120 });
return this;
}
@RealtimeSaved<ChannelMember>((member, context) => {
return [
//Send member preferences update to channels collections
{
room: `/companies/${member.company_id}/workspaces/${member.workspace_id}/channels?type=${
member.workspace_id === ChannelVisibility.DIRECT ? "direct" : "private"
}&user=${member.user_id}`,
resource: {
company_id: member.company_id,
workspace_id: member.workspace_id,
id: member.channel_id,
user_member: member,
},
},
{
room: ResourcePath.get(getRoomName(context as ChannelExecutionContext)),
path: getMemberPath(member, context as ChannelExecutionContext),
},
];
})
async save(
member: ChannelMember,
context: ChannelExecutionContext,
): Promise<SaveResult<ChannelMember>> {
let memberToSave: ChannelMember;
const channel = await gr.services.channels.channels.get(context.channel, context);
if (!channel) {
throw CrudException.notFound("Channel does not exists");
}
const memberToUpdate = await this.userChannelsRepository.findOne(
this.getPrimaryKey(member),
{},
context,
);
const mode = memberToUpdate ? OperationType.UPDATE : OperationType.CREATE;
logger.debug(`MemberService.save - ${mode} member %o`, memberToUpdate);
if (mode === OperationType.UPDATE) {
const isCurrentUser = this.isCurrentUser(memberToUpdate, context.user);
if (!isCurrentUser) {
throw CrudException.badRequest(`Channel member ${member.user_id} can not be updated`);
}
const updatableParameters: Partial<Record<keyof ChannelMember, boolean>> = {
notification_level: isCurrentUser,
favorite: isCurrentUser,
last_access: isCurrentUser,
last_increment: isCurrentUser,
};
// Diff existing channel and input one, cleanup all the undefined fields for all objects
const memberDiff = omitBy(updatedDiff(memberToUpdate, member), isNil);
const fields = Object.keys(memberDiff) as Array<Partial<keyof ChannelMember>>;
if (!fields.length) {
return new SaveResult<ChannelMember>("channel_member", member, mode);
}
const updatableFields = fields.filter(field => updatableParameters[field]);
if (!updatableFields.length) {
throw CrudException.badRequest("Current user can not update requested fields");
}
memberToSave = cloneDeep(memberToUpdate);
//Ensure it is a boolean
memberToSave.favorite = !!memberToSave.favorite;
updatableFields.forEach(field => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(memberToSave as any)[field] = member[field];
});
await this.saveChannelMember(memberToSave, context);
this.onUpdated(
context.channel,
memberToSave,
new UpdateResult<ChannelMember>("channel_member", memberToSave),
);
} else {
const currentUserIsMember = !!(await this.getChannelMember(
context.user,
channel,
undefined,
context,
));
const isPrivateChannel = ChannelEntity.isPrivateChannel(channel);
const isPublicChannel = ChannelEntity.isPublicChannel(channel);
const isDirectChannel = ChannelEntity.isDirectChannel(channel);
const userIsDefinedInChannelUserList = (channel.members || []).includes(
String(member.user_id),
);
const isChannelCreator = context.user && String(channel.owner) === String(context.user.id);
// 1. Private channel: user can not join private channel by themself when it is private
// only member can add other users in channel
// 2. The channel creator check is only here on channel creation
if (
isChannelCreator ||
isPublicChannel ||
context.user.server_request ||
(isPrivateChannel && currentUserIsMember) ||
(isDirectChannel && userIsDefinedInChannelUserList)
) {
const memberToSave = { ...member, ...context.channel };
await this.saveChannelMember(memberToSave, context);
await this.usersCounterIncrease(channel, member.user_id);
this.onCreated(
channel,
member,
context.user,
new CreateResult<ChannelMember>("channel_member", memberToSave),
);
} else {
throw CrudException.badRequest(
`User ${member.user_id} is not allowed to join this channel`,
);
}
}
return new SaveResult<ChannelMember>("channel_member", member, mode);
}
async get(pk: ChannelMemberPrimaryKey, context: ExecutionContext): Promise<ChannelMember> {
// FIXME: Who can fetch a single member?
return await this.userChannelsRepository.findOne(this.getPrimaryKey(pk), {}, context);
}
@RealtimeDeleted<ChannelMember>((member, context) => [
{
room: `/companies/${member.company_id}/workspaces/${member.workspace_id}/channels?type=${
member.workspace_id === ChannelVisibility.DIRECT ? "direct" : "private"
}&user=${member.user_id}`,
resource: {
company_id: member.company_id,
workspace_id: member.workspace_id,
id: member.channel_id,
user_id: member.user_id,
user_member: null,
},
},
{
room: ResourcePath.get(getRoomName(context as ChannelExecutionContext)),
path: getMemberPath(member, context as ChannelExecutionContext),
},
])
async delete(
pk: ChannelMemberPrimaryKey,
context: ChannelExecutionContext,
): Promise<DeleteResult<ChannelMember>> {
const memberToDelete = await this.userChannelsRepository.findOne(pk, {}, context);
const channel = await gr.services.channels.channels.get(context.channel, context);
if (!channel) {
throw CrudException.notFound("Channel does not exists");
}
if (!memberToDelete) {
throw CrudException.notFound("Channel member not found");
}
if (ChannelEntity.isDirectChannel(channel)) {
if (!this.isCurrentUser(memberToDelete, context.user)) {
throw CrudException.badRequest("User can not remove other users from direct channel");
}
}
if (ChannelEntity.isPrivateChannel(channel)) {
const canLeave = await this.canLeavePrivateChannel(context.user, channel);
if (!canLeave) {
throw CrudException.badRequest("User can not leave the private channel");
}
}
await this.userChannelsRepository.remove(getChannelMemberInstance(pk), context);
await this.channelMembersRepository.remove(getMemberOfChannelInstance(pk), context);
await this.usersCounterIncrease(channel, pk.user_id, -1);
this.onDeleted(memberToDelete, context.user, channel);
return new DeleteResult<ChannelMember>("channel_member", pk as ChannelMember, true);
}
/**
* List given channel members
*/
async list(
pagination: Pagination,
options: ChannelListOptions,
context: ChannelExecutionContext,
): Promise<ListResult<ChannelMember>> {
const channel = await gr.services.channels.channels.get(
{
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
id: context.channel.id,
},
context,
);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
if (ChannelEntity.isDirectChannel(channel) || ChannelEntity.isPrivateChannel(channel)) {
const isMember = await this.getChannelMember(context.user, channel, undefined, context);
if (!isMember && !context.user.application_id && !context.user.server_request) {
throw CrudException.badRequest("User does not have enough rights to get channels");
}
}
const result = await this.channelMembersRepository.find(
{
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
},
{ pagination },
context,
);
const companyUsers = await gr.services.companies.getUsers(
{
group_id: context.channel.company_id,
},
new Pagination(),
{ userIds: result.getEntities().map(member => member.user_id) },
);
if (options.company_role) {
companyUsers.filterEntities(entity => entity.role === options.company_role);
}
const companyUserIds = companyUsers.getEntities().map(entity => entity.user_id);
result.filterEntities(cm => companyUserIds.includes(cm.user_id));
return new ListResult<ChannelMember>(
"channel_member",
result
.getEntities()
.map(member => plainToClass(ChannelMember, { id: member.user_id, ...member })),
result.nextPage,
);
}
async listAllUserChannelsIds(
user_id: string,
company_id: string,
workspace_id: string,
context: ExecutionContext,
): Promise<string[]> {
let pagination = new Pagination(null);
const channels: string[] = [];
let result: ListResult<ChannelMember>;
let hasMoreItems = true;
do {
result = await this.userChannelsRepository.find(
{
company_id,
workspace_id,
user_id,
},
{ pagination },
context,
);
if (result.isEmpty()) {
hasMoreItems = false;
} else {
result.getEntities().forEach(entity => {
channels.push(entity.channel_id);
});
if (!result.nextPage.page_token) hasMoreItems = false;
else pagination = new Pagination(result.nextPage.page_token);
}
} while (hasMoreItems);
return channels;
}
async listUserChannels(
user: User,
pagination: Pagination,
context: WorkspaceExecutionContext,
): Promise<ListResult<ChannelMember>> {
const result = await this.userChannelsRepository.find(
{
company_id: context.workspace.company_id,
workspace_id: context.workspace.workspace_id,
user_id: user.id,
},
{ pagination },
context,
);
return new ListResult<ChannelMember>(
"channel_member",
result
.getEntities()
.map(member => plainToClass(ChannelMember, { id: member.user_id, ...member })),
result.nextPage,
);
}
/**
* Can leave only if the number of members is > 1
* since counting rows in DB takes too much time
* we query a list for 2 members without offset and check the length
*/
async canLeavePrivateChannel(user: User, channel: ChannelEntity): Promise<boolean> {
const list = this.list(new Pagination(undefined, "2"), {}, { channel, user });
return (await list).getEntities().length > 1;
}
async addUsersToChannel(
users: Pick<User, "id">[] = [],
channel: ChannelEntity & { stats: ChannelObject["stats"] },
context?: ExecutionContext,
): Promise<
ListResult<{ channel: ChannelEntity; added: boolean; member?: ChannelMember; err?: Error }>
> {
if (!channel) {
throw CrudException.badRequest("Channel is required");
}
logger.debug(
"Add users %o to channel %o",
users.map(u => u.id),
channel.id,
);
await gr.services.channels.channels.completeWithStatistics([channel]);
const members: Array<{
channel: ChannelEntity;
added: boolean;
member?: ChannelMember;
err?: Error;
}> = await Promise.all(
users.map(async user => {
const channelContext: ChannelExecutionContext = {
channel,
user: context?.user || user,
};
const member: ChannelMember = getChannelMemberInstance({
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
last_increment: channel.stats.messages,
last_access: Date.now(),
} as ChannelMember);
try {
const isAlreadyMember = await this.getChannelMember(user, channel, undefined, context);
if (isAlreadyMember) {
logger.debug("User %s is already member in channel %s", member.user_id, channel.id);
return { channel, added: false };
}
const result = await this.save(member, channelContext);
return {
channel,
added: true,
member: result.entity,
};
} catch (err) {
logger.warn({ err }, "Member has not been added %o", member);
return {
channel,
err,
added: false,
};
}
}),
);
return new ListResult("channel_member", members);
}
async addUserToChannels(
user: Pick<User, "id">,
channels: ChannelEntity[],
): Promise<
ListResult<{ channel: ChannelEntity; added: boolean; member?: ChannelMember; err?: Error }>
> {
logger.debug(
"Add user %s to channels %o",
user.id,
channels.map(c => c.id),
);
const members: Array<{
channel: ChannelEntity;
added: boolean;
member?: ChannelMember;
err?: Error;
}> = await Promise.all(
channels.map(async channel => {
const context: ChannelExecutionContext = {
channel,
user,
};
const member = getChannelMemberInstance({
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
});
try {
const isAlreadyMember = await this.getChannelMember(user, channel, undefined, context);
if (isAlreadyMember) {
logger.debug("User %s is already member in channel %s", member.user_id, channel.id);
return { channel, added: false };
}
const result = await this.save(member, context);
return { channel, member: result.entity, added: true };
} catch (err) {
logger.warn({ err }, "Member has not been added %o", member);
return { channel, added: false, err };
}
}),
);
return new ListResult("channel_member", members);
}
onUpdated(
channel: Channel,
member: ChannelMember,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateResult: UpdateResult<ChannelMember>,
): void {
logger.debug("Member updated %o", member);
gr.platformServices.messageQueue.publish("channel:member:updated", {
data: {
channel,
member,
},
});
}
onCreated(
channel: ChannelEntity,
member: ChannelMember,
user: User,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createResult: SaveResult<ChannelMember>,
): void {
logger.debug("Member created %o", member);
gr.platformServices.messageQueue.publish<ResourceEventsPayload>("channel:member:created", {
data: {
channel,
user,
member,
},
});
localEventBus.publish<ResourceEventsPayload>("channel:member:created", {
channel,
user,
member,
actor: user,
resourcesAfter: [member],
});
}
onDeleted(member: ChannelMember, user: User, channel: ChannelEntity): void {
logger.debug("Member deleted %o", member);
gr.platformServices.messageQueue.publish<ResourceEventsPayload>("channel:member:deleted", {
data: {
channel,
user,
member,
},
});
localEventBus.publish<ResourceEventsPayload>("channel:member:deleted", {
actor: user,
resourcesBefore: [member],
channel,
user,
member,
});
}
isCurrentUser(member: ChannelMember | MemberOfChannel, user: User): boolean {
return String(member.user_id) === String(user.id);
}
async getChannelMember(
user: User,
channel: Partial<Pick<Channel, "company_id" | "workspace_id" | "id">>,
cacheTtlSec?: number,
context?: ExecutionContext,
): Promise<ChannelMember> {
if (!user) {
return;
}
if (cacheTtlSec) {
const pk = JSON.stringify({ user, channel });
if (this.cache.has(pk)) return this.cache.get<ChannelMember>(pk);
const entity = await this.getChannelMember(user, channel, undefined, context);
this.cache.set<ChannelMember>(pk, entity, cacheTtlSec);
return entity;
}
return this.get(
{
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
},
context,
);
}
getPrimaryKey(
memberOrPrimaryKey: ChannelMember | ChannelMemberPrimaryKey,
): ChannelMemberPrimaryKey {
return pick(
memberOrPrimaryKey,
...(["company_id", "workspace_id", "channel_id", "user_id"] as const),
);
}
private async usersCounterIncrease(channel: Channel, userId: string, increaseValue: number = 1) {
// const isMember = await this.isCompanyMember(channel.company_id, userId);
const isMember = true; // Since actually all users are now added to the channel as members, we are removing the guest counter for now.
const counter_type = isMember ? ChannelUserCounterType.MEMBERS : ChannelUserCounterType.GUESTS;
return this.channelCounter.increase(
{
id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
counter_type,
},
increaseValue,
);
}
getUsersCount(pk: ChannelCounterPrimaryKey): Promise<number> {
return this.channelCounter.get(pk);
}
async isCompanyMember(companyId: string, userId: string): Promise<boolean> {
return (await gr.services.companies.getUserRole(companyId, userId)) != "guest";
}
private async saveChannelMember(
memberToSave: ChannelMember & Channel,
context: ExecutionContext,
) {
const userChannel = getChannelMemberInstance(pick(memberToSave, ...USER_CHANNEL_KEYS));
const channelMember = getMemberOfChannelInstance(pick(memberToSave, ...CHANNEL_MEMBERS_KEYS));
await this.userChannelsRepository.save(userChannel, context);
await this.channelMembersRepository.save(channelMember, context);
}
async ensureUserNotInWorkspaceIsNotInChannel(
userPk: UserPrimaryKey,
workspacePk: WorkspacePrimaryKey,
context: ExecutionContext,
) {
const workspace = await gr.services.workspaces.get(workspacePk);
const member = await gr.services.workspaces.getUser({
workspaceId: workspace.id,
userId: userPk.id,
});
if (!member) {
const result = await this.userChannelsRepository.find(
{
company_id: workspace.company_id,
workspace_id: workspace.id,
user_id: userPk.id,
},
{},
context,
);
for (const channel of result.getEntities()) {
logger.warn(
`User ${userPk.id} is not in workspace ${workspace.id} so it will be removed from channel ${channel.id}`,
);
await this.delete(channel, { user: { id: userPk.id }, channel });
}
}
}
/**
* The list of members read sections of the channel.
*
* @param {ChannelExecutionContext} context - The context of the execution.
* @returns {Promise<ListResult<ChannelMemberReadCursors>>} - The channel members read cursors.
*/
async getChannelMembersReadSections(
context: ChannelExecutionContext,
): Promise<ListResult<ChannelMemberReadCursors>> {
try {
const readCursors = await this.channelMembersReadCursorRepository.find(
{
company_id: context.channel.company_id,
channel_id: context.channel.id,
},
{ pagination: { limitStr: "100" } },
context,
);
return new ListResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
readCursors.getEntities(),
);
} catch (error) {
logger.error(error);
throw error;
}
}
/**
* The channel member read section of the channel.
*
* @param {ChannelExecutionContext} context - The context of the execution.
* @returns {Promise<ChannelMemberReadCursors>} - The channel member read cursors.
*/
async getChannelMemberReadSections(
member: string,
context: ChannelExecutionContext,
): Promise<ChannelMemberReadCursors> {
try {
const readCursors = await this.channelMembersReadCursorRepository.findOne(
{
company_id: context.channel.company_id,
channel_id: context.channel.id,
user_id: member,
},
{},
context,
);
return readCursors;
} catch (error) {
logger.error(error);
throw error;
}
}
/**
* Updates the channel member read section.
*
* @param {{ start: string; end: string }} section - The section to save the cursor for.
* @param {CompanyExecutionContext & { channel_id: string }} context - The channel execution context.
* @returns {Promise<SaveResult<ChannelMemberReadCursors>>} - The result of the save operation.
*/
async setChannelMemberReadSections(
section: { start: string; end: string },
context: CompanyExecutionContext & { channel_id: string; workspace_id: string },
): Promise<SaveResult<ChannelMemberReadCursors>> {
const member = await this.getChannelMember(
context.user,
{
id: context.channel_id,
company_id: context.company.id,
workspace_id: context.workspace_id,
},
null,
context,
);
if (!member) {
throw CrudException.badRequest(
`User ${context.user.id} is not a member of channel ${context.channel_id}`,
);
}
const existingReadSection = await this.channelMembersReadCursorRepository.findOne(
{
company_id: context.company.id,
channel_id: context.channel_id,
user_id: context.user.id,
},
{},
context,
);
const { start, end } = section;
if (
!existingReadSection ||
!existingReadSection.read_section ||
!existingReadSection.read_section.length
) {
const newMemberReadCursor = new ChannelMemberReadCursors();
newMemberReadCursor.company_id = context.company.id;
newMemberReadCursor.channel_id = context.channel_id;
newMemberReadCursor.user_id = context.user.id;
newMemberReadCursor.read_section = [start, end] as ReadSection;
await this.channelMembersReadCursorRepository.save(newMemberReadCursor, context);
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
newMemberReadCursor,
OperationType.CREATE,
);
}
const updatedReadSection = { start, end };
const [existingStart, existingEnd] = existingReadSection.read_section;
const existingStartTime = uuidTime.v1(existingStart);
const existingEndTime = uuidTime.v1(existingEnd);
const startTime = uuidTime.v1(start);
const endTime = uuidTime.v1(end);
if (existingStartTime < startTime) {
updatedReadSection.start = existingStart;
}
if (existingEndTime > endTime) {
updatedReadSection.end = existingEnd;
}
if (existingStart === updatedReadSection.start && existingEnd === updatedReadSection.end) {
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
existingReadSection,
OperationType.EXISTS,
);
}
existingReadSection.read_section = [updatedReadSection.start, updatedReadSection.end];
await this.channelMembersReadCursorRepository.save(existingReadSection, context);
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
existingReadSection,
OperationType.UPDATE,
);
}
/**
* list users who have seen the message.
*
* @param {String} id - the message id
* @param {ChannelExecutionContext} context - the thread execution context
* @returns { Promise<string[]>} - the promise containing the user id list
*/
async getChannelMessageSeenByUsers(
id: string,
context: ChannelExecutionContext,
): Promise<string[]> {
const channelReadSections = await this.getChannelMembersReadSections(context);
return channelReadSections
.getEntities()
.filter(section => {
const sectionEnd = section.read_section[1];
const sectionEndTime = uuidTime.v1(sectionEnd);
const messageTime = uuidTime.v1(id);
return messageTime <= sectionEndTime;
})
.map(section => section.user_id);
}
}
@@ -0,0 +1,22 @@
import { Initializable } from "../../../../core/platform/framework";
import { NewChannelActivityProcessor } from "./new-channel-activity";
import { NewUserInWorkspaceJoinDefaultChannelsProcessor } from "./new-user-in-workspace-join-default-channels";
import { NewPendingEmailsInWorkspaceJoinChannelsProcessor } from "./new-pending-emails-in-workspace-join-channels";
import { NewWorkspaceProcessor } from "./new-workspace";
import gr from "../../../global-resolver";
export class ChannelsMessageQueueListener implements Initializable {
async init(): Promise<this> {
const channelActivityProcessor = await new NewChannelActivityProcessor().init();
gr.platformServices.messageQueue.processor.addHandler(channelActivityProcessor);
gr.platformServices.messageQueue.processor.addHandler(
new NewUserInWorkspaceJoinDefaultChannelsProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(
new NewPendingEmailsInWorkspaceJoinChannelsProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(new NewWorkspaceProcessor());
return this;
}
}
@@ -0,0 +1,91 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { ChannelActivityNotification } from "../../types";
import { CounterProvider } from "../../../../core/platform/services/counter/provider";
import {
ChannelCounterEntity,
ChannelUserCounterType,
TYPE as ChannelCounterEntityType,
} from "../../entities/channel-counters";
import gr from "../../../global-resolver";
const logger = getLogger("channel.message-queue.new-channel-activity");
export class NewChannelActivityProcessor
implements MessageQueueHandler<ChannelActivityNotification, void>
{
private channelCounter: CounterProvider<ChannelCounterEntity>;
async init() {
const channelCountersRepository = await gr.database.getRepository<ChannelCounterEntity>(
ChannelCounterEntityType,
ChannelCounterEntity,
);
this.channelCounter = await gr.platformServices.counter.getCounter<ChannelCounterEntity>(
channelCountersRepository,
);
return this;
}
readonly topics = {
in: "channel:activity",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "NewChannelActivityProcessor";
validate(message: ChannelActivityNotification): boolean {
return !!(message && message.channel_id && message.company_id && message.workspace_id);
}
async process(message: ChannelActivityNotification): Promise<void> {
logger.info(`${this.name} - Processing new activity in channel ${message.channel_id}`);
await this.channelCounter.increase(
{
id: message.channel_id,
company_id: message.company_id,
workspace_id: message.workspace_id,
counter_type: ChannelUserCounterType.MESSAGES,
},
1,
);
try {
await gr.services.channels.channels.updateLastActivity(
{
date: message.date,
channel: {
id: message.channel_id,
workspace_id: message.workspace_id,
company_id: message.company_id,
},
message: {
date: message.date,
sender: message.sender,
sender_name: message.sender_name,
title: message.title,
text: message.body,
},
},
{
workspace: {
workspace_id: message.workspace_id,
company_id: message.company_id,
},
user: {
id: message.sender,
},
},
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while applying channel new activity in channel ${message?.channel_id}`,
);
}
}
}
@@ -0,0 +1,58 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { NewUserInWorkspaceNotification } from "../channel/types";
import gr from "../../../global-resolver";
import { ExecutionContext } from "../../../../core/platform/framework/api/crud-service";
const NAME = "Channel::NewPendingEmailsInWorkspaceJoinChannelsProcessor";
const logger = getLogger(
"channel.message-queue.new-pending-emails-in-workspace-join-channels-processor",
);
/**
* When a new pending email is added in a workspace, a `workspace:email:added` event is published.
* In such case, the email must be added to all the channels that he is invited.
*/
export class NewPendingEmailsInWorkspaceJoinChannelsProcessor
implements MessageQueueHandler<NewUserInWorkspaceNotification, void>
{
readonly topics = {
in: "workspace:member:added",
};
readonly options = {
unique: true,
ack: true,
queue: "workspace:user:added:consumer_channel_pending_emails",
};
readonly name = NAME;
validate(message: NewUserInWorkspaceNotification): boolean {
return !!(message && message.company_id && message.workspace_id && message.user_id);
}
async process(
message: NewUserInWorkspaceNotification,
context?: ExecutionContext,
): Promise<void> {
logger.debug("Processing notification for message %o", message);
try {
await gr.services.channelPendingEmail.proccessPendingEmails(
message,
{
workspace_id: message.workspace_id,
company_id: message.company_id,
},
context,
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while processing message for pending emails %o`,
message,
);
}
}
}
@@ -0,0 +1,59 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { NewUserInWorkspaceNotification } from "../channel/types";
import gr from "../../../global-resolver";
const NAME = "Channel::NewUserInWorkspaceJoinDefaultChannelsProcessor";
const logger = getLogger(
"channel.message-queue.new-user-in-workspace-join-default-channels-processor",
);
/**
* When a new user is added in a workspace, a `workspace:user:added` event is published.
* In such case, the user must be added to all the default channels of the workspace.
*/
export class NewUserInWorkspaceJoinDefaultChannelsProcessor
implements MessageQueueHandler<NewUserInWorkspaceNotification, void>
{
readonly topics = {
in: "workspace:member:added",
};
readonly options = {
unique: true,
ack: true,
queue: "workspace:user:added:consumer_default_channels",
};
readonly name = NAME;
validate(message: NewUserInWorkspaceNotification): boolean {
return !!(message && message.company_id && message.workspace_id && message.user_id);
}
async process(message: NewUserInWorkspaceNotification): Promise<void> {
logger.debug("Processing notification for message %o", message);
try {
const channelMembers = await gr.services.channels.channels.addUserToDefaultChannels(
{ id: message.user_id },
{
company_id: message.company_id,
workspace_id: message.workspace_id,
},
);
logger.debug(
"User %s has been added as member to default channels %o",
message.user_id,
(channelMembers || []).map(c => c.channel_id),
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while processing message for default channels %o`,
message,
);
}
}
}
@@ -0,0 +1,57 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { ChannelActivityNotification, ChannelVisibility } from "../../types";
import { getInstance } from "../../../channels/entities/channel";
import gr from "../../../global-resolver";
const logger = getLogger("channel.message-queue.new-channel-activity");
export class NewWorkspaceProcessor
implements MessageQueueHandler<ChannelActivityNotification, void>
{
readonly topics = {
in: "workspace:added",
queue: "workspace:added:consumer1",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "NewWorkspaceProcessor";
validate(message: ChannelActivityNotification): boolean {
return !!(message && message.company_id && message.workspace_id);
}
async process(message: ChannelActivityNotification): Promise<void> {
logger.info(`${this.name} - Processing new workspace created ${message.workspace_id}`);
try {
await gr.services.channels.channels.save(
getInstance({
icon: "💬",
name: "General",
description: "",
visibility: ChannelVisibility.PUBLIC,
is_default: true,
}),
{},
{
workspace: {
workspace_id: message.workspace_id,
company_id: message.company_id,
},
user: {
id: message.sender,
},
},
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while generating default channels for workspace ${message?.workspace_id}`,
);
}
}
}
@@ -0,0 +1,136 @@
import _ from "lodash";
import { RealtimeDeleted, RealtimeSaved } from "../../../core/platform/framework";
import {
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../core/platform/framework/api/crud-service";
import { ChannelTab, ChannelTabPrimaryKey } from "../entities";
import { ChannelExecutionContext } from "../types";
import { Channel } from "../../../utils/types";
import { ResourcePath } from "../../../core/platform/services/realtime/types";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import gr from "../../global-resolver";
export class TabServiceImpl {
version: "1";
repository: Repository<ChannelTab>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository("channel_tabs", ChannelTab);
return this;
}
@RealtimeSaved<ChannelTab>((tab, context) => [
{
room: ResourcePath.get(getTabsRealtimeRoom((context as ChannelExecutionContext).channel)),
path: getTabsRealtimeResourcePath(tab, (context as ChannelExecutionContext).channel),
},
])
async save<SaveOptions>(
tab: ChannelTab,
options: SaveOptions,
context: ChannelExecutionContext,
): Promise<SaveResult<ChannelTab>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
id: tab.id,
};
let tabEntity = await this.repository.findOne(pk, {}, context);
if (!tabEntity || !tab.id) {
tabEntity = new ChannelTab();
tabEntity = _.merge(tabEntity, pk);
tabEntity.owner = context.user.id;
}
tabEntity = _.merge(
tabEntity,
_.pick(tab, ["name", "configuration", "application_id", "order"]),
);
await this.repository.save(tabEntity, context);
return new SaveResult("channel_tabs", tabEntity, OperationType.CREATE);
}
async get(tabPk: ChannelTabPrimaryKey, context: ChannelExecutionContext): Promise<ChannelTab> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
id: tabPk.id,
};
return await this.repository.findOne(pk, {}, context);
}
@RealtimeDeleted<ChannelTab>((tab, context) => [
{
room: ResourcePath.get(getTabsRealtimeRoom((context as ChannelExecutionContext).channel)),
path: getTabsRealtimeResourcePath(tab, (context as ChannelExecutionContext).channel),
},
])
async delete(
pk: ChannelTabPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<ChannelTab>> {
const tabEntity = await this.repository.findOne(pk, {}, context);
if (tabEntity) {
await this.repository.remove(tabEntity, context);
}
return new DeleteResult("channel_tabs", tabEntity, true);
}
async list<ListOptions>(
pagination: Pagination,
options: ListOptions,
context: ChannelExecutionContext,
): Promise<ListResult<ChannelTab>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
};
return await this.repository.find(pk, { pagination }, context);
}
onUpdated(
channel: Channel,
tab: ChannelTab,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateResult: UpdateResult<ChannelTab>,
): void {
console.log("Tab updated", tab);
}
onCreated(
channel: Channel,
tab: ChannelTab,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createResult: SaveResult<ChannelTab>,
): void {
console.log("Tab created", tab);
}
onDeleted(channel: Channel, tab: ChannelTab): void {
console.log("Tab deleted", tab);
}
}
export function getTabsRealtimeRoom(channel: ChannelExecutionContext["channel"]): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}/tabs`;
}
export function getTabsRealtimeResourcePath(
tab: ChannelTab,
channel: ChannelExecutionContext["channel"],
): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}/tabs/${tab.id}`;
}
@@ -0,0 +1,54 @@
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
import { Channel, uuid, Workspace } from "../../utils/types";
export interface WorkspaceExecutionContext extends ExecutionContext {
workspace: Workspace;
}
export interface ChannelExecutionContext extends ExecutionContext {
channel: Channel;
}
export interface ChannelSystemExecutionContext {
workspace: Workspace;
channel?: Channel;
}
export enum ChannelVisibility {
PRIVATE = "private",
PUBLIC = "public",
DIRECT = "direct",
}
export enum ChannelType {
DIRECT = "direct",
}
export enum ChannelMemberType {
MEMBER = "member",
GUEST = "guest",
BOT = "bot",
}
export enum ChannelMemberNotificationLevel {
// be notified on all messages
ALL = "all",
// Only be notified on @user, @all, @here, @everyone mentions
MENTIONS = "mentions",
// Only be notified on @user mention
ME = "me",
// do not be notified at all even when someone mention user, not on direct channels
NONE = "none",
}
export type ChannelActivityNotification = {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
date: number;
sender: string;
title: string;
text: string;
sender_name: string;
body: string;
};
@@ -0,0 +1,5 @@
import { ChannelType } from "./types";
export function isDirectChannel(channel: { workspace_id: string }): boolean {
return channel?.workspace_id === ChannelType.DIRECT;
}
@@ -0,0 +1,623 @@
import { plainToClass } from "class-transformer";
import { FastifyReply, FastifyRequest } from "fastify";
import {
CrudException,
ExecutionContext,
ListResult,
Pagination,
} from "../../../../core/platform/framework/api/crud-service";
import { CrudController } from "../../../../core/platform/services/webserver/types";
import {
Channel,
ChannelMember,
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
ChannelPrimaryKey,
getChannelPendingEmailsInstance,
UserChannel,
UsersIncludedChannel,
} from "../../entities";
import { getWebsocketInformation, getWorkspaceRooms } from "../../services/channel/realtime";
import {
BaseChannelsParameters,
ChannelListQueryParameters,
ChannelParameters,
ChannelPendingEmailsDeleteQueryParameters,
ChannelSaveOptions,
ChannelSearchQueryParameters,
CreateChannelBody,
ReadChannelBody,
UpdateChannelBody,
} from "../types";
import { ChannelExecutionContext, ChannelVisibility, WorkspaceExecutionContext } from "../../types";
import { handleError } from "../../../../utils/handleError";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import { getLogger } from "../../../../core/platform/framework/logger";
import _ from "lodash";
import { ChannelMemberObject, ChannelObject } from "../../services/channel/types";
import gr from "../../../global-resolver";
import { checkUserBelongsToCompany } from "../../../../utils/company";
const logger = getLogger("channel.controller");
export class ChannelCrudController
implements
CrudController<
ResourceGetResponse<ChannelObject>,
ResourceCreateResponse<ChannelObject>,
ResourceListResponse<ChannelObject>,
ResourceDeleteResponse
>
{
getPrimaryKey(request: FastifyRequest<{ Params: ChannelParameters }>): ChannelPrimaryKey {
return {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Querystring: ChannelListQueryParameters; Params: ChannelParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelObject>> {
const context = getExecutionContext(request);
let channel: Channel | UsersIncludedChannel = await gr.services.channels.channels.get(
this.getPrimaryKey(request),
context,
);
if (!channel) {
throw CrudException.notFound(`Channel ${request.params.id} not found`);
}
if (Channel.isDirectChannel(channel) || Channel.isPrivateChannel(channel)) {
const isMember = await gr.services.channels.members.getChannelMember(
request.currentUser,
channel,
undefined,
context,
);
if (!isMember && !context.user.application_id && !context.user.server_request) {
throw CrudException.badRequest("User does not have enough rights to get channel");
}
}
if (request.query.include_users)
channel = await gr.services.channels.channels.includeUsersInDirectChannel(channel);
const member = await gr.services.channels.members.get(
_.assign(new ChannelMember(), {
channel_id: channel.id,
workspace_id: channel.workspace_id,
company_id: channel.company_id,
user_id: context.user.id,
}),
context,
);
const channelObject = ChannelObject.mapTo(channel, {
user_member: ChannelMemberObject.mapTo(member),
});
await gr.services.channels.channels.completeWithStatistics([channelObject]);
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(channel)],
context.user.id,
)[0],
resource: channelObject,
};
}
async search(
request: FastifyRequest<{
Querystring: ChannelSearchQueryParameters;
Params: { company_id: string };
}>,
_reply: FastifyReply,
): Promise<ResourceListResponse<Channel>> {
if (request.query?.q?.length === 0) {
return this.recent(request);
}
const context = getSimpleExecutionContext(request);
const userId = request.currentUser.id;
await checkUserBelongsToCompany(request.currentUser.id, request.params.company_id);
const limit = request.query.limit || 100;
async function* getNextChannels(): AsyncIterableIterator<Channel> {
let lastPageToken = null;
let channels: Channel[] = [];
let hasMore = true;
do {
channels = await gr.services.channels.channels
.search(
new Pagination(lastPageToken, limit.toString()),
{
search: request.query.q,
companyId: request.params.company_id,
},
context,
)
.then((a: ListResult<Channel>) => {
lastPageToken = a.nextPage.page_token;
if (!lastPageToken) {
hasMore = false;
}
return a.getEntities();
});
if (channels.length) {
for (const channel of channels) {
yield channel;
}
} else {
hasMore = false;
}
} while (hasMore);
}
const channels = [] as (Channel & { user_member: ChannelMemberObject })[];
for await (const ch of getNextChannels()) {
const channelMember = await gr.services.channels.members.getChannelMember(
{ id: request.currentUser.id },
ch,
50,
context,
);
//If not public channel and not member then ignore
if (!channelMember && ch.visibility !== "public") continue;
//If public channel but not in the workspace then ignore
if (
!channelMember &&
!(await gr.services.workspaces.getUser({ workspaceId: ch.workspace_id, userId }))
)
continue;
const chWithUser = await gr.services.channels.channels.includeUsersInDirectChannel(
ch,
userId,
);
channels.push({ ...chWithUser, user_member: channelMember });
if (channels.length == limit) {
break;
}
}
await gr.services.channels.channels.completeWithStatistics(channels as ChannelObject[]);
return { resources: channels };
}
async getForPHP(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<void> {
const context = getExecutionContext(request);
const channel = await gr.services.channels.channels.get(this.getPrimaryKey(request), context);
reply.send(channel);
}
async thumbnail(
request: FastifyRequest<{ Params: ChannelParameters }>,
response: FastifyReply,
): Promise<void> {
const data = await gr.services.channels.channels.thumbnail(request.params.id);
const filename = "thumbnail.jpg";
response.header("Content-disposition", `inline; filename="${filename}"`);
response.type("image/jpeg");
response.send(data.file);
}
async save(
request: FastifyRequest<{
Body: CreateChannelBody;
Params: ChannelParameters;
Querystring: { include_users: boolean };
}>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelObject>> {
const entity = plainToClass(Channel, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
});
logger.debug("reqId: %s - save - Channel input %o", request.id, entity);
try {
const options = {
members: request.body.options ? request.body.options.members || [] : [],
addCreatorAsMember: true,
} as ChannelSaveOptions;
const context = getExecutionContext(request);
const channelResult = await gr.services.channels.channels.save(entity, options, context);
logger.debug("reqId: %s - save - Channel %s created", request.id, channelResult.entity.id);
const member = await gr.services.channels.members.get(
_.assign(new ChannelMember(), {
channel_id: channelResult.entity.id,
workspace_id: channelResult.entity.workspace_id,
company_id: channelResult.entity.company_id,
user_id: context.user.id,
}),
context,
);
let entityWithUsers: Channel = channelResult.entity;
if (request.query.include_users)
entityWithUsers = await gr.services.channels.channels.includeUsersInDirectChannel(
entityWithUsers,
context.user.id,
);
const resultEntity = {
...entityWithUsers,
...{ user_member: member },
} as unknown as UserChannel;
if (entity) {
reply.code(201);
}
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(channelResult.entity)],
context.user.id,
)[0],
resource: ChannelObject.mapTo(resultEntity),
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelBody; Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelObject>> {
const entity = plainToClass(Channel, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
id: request.params.id,
},
});
try {
const context = getExecutionContext(request);
const result = await gr.services.channels.channels.save(entity, {}, context);
if (result.entity) {
reply.code(201);
}
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(result.entity)],
context.user.id,
)[0],
resource: ChannelObject.mapTo(result.entity),
};
} catch (err) {
handleError(reply, err);
}
}
async list(
request: FastifyRequest<{
Querystring: ChannelListQueryParameters;
Params: BaseChannelsParameters;
}>,
): Promise<ResourceListResponse<ChannelObject>> {
const context = getExecutionContext(request);
//Fixme: this slow down the channel get operation. Once we stabilize the workspace:member:added event we can remove this
if (context.workspace.workspace_id !== ChannelVisibility.DIRECT) {
await gr.services.channelPendingEmail.proccessPendingEmails(
{
user_id: context.user.id,
...context.workspace,
},
context.workspace,
context,
);
}
const list = await gr.services.channels.channels.list(
new Pagination(request.query.page_token, request.query.limit),
{ ...request.query },
context,
);
let entities = [];
if (request.query.include_users) {
entities = [];
for (const e of list.getEntities()) {
entities.push(
await gr.services.channels.channels.includeUsersInDirectChannel(e, context.user.id),
);
}
} else {
entities = list.getEntities();
}
const resources = entities.map(a => ChannelObject.mapTo(a));
await gr.services.channels.channels.completeWithStatistics(resources);
return {
...{
resources: resources,
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getWorkspaceRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
async delete(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const deleteResult = await gr.services.channels.channels.delete(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (deleteResult.deleted) {
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
async updateRead(
request: FastifyRequest<{ Body: ReadChannelBody; Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<boolean> {
const read = request.body.value;
const context = getExecutionContext(request);
try {
const result = read
? await gr.services.channels.channels.markAsRead(
this.getPrimaryKey(request),
request.currentUser,
context,
)
: await gr.services.channels.channels.markAsUnread(
this.getPrimaryKey(request),
request.currentUser,
context,
);
return result;
} catch (err) {
handleError(reply, err);
}
}
async findPendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{
Querystring: ChannelListQueryParameters;
Params: ChannelPendingEmailsPrimaryKey;
}>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceListResponse<ChannelPendingEmails>> {
const list = await gr.services.channelPendingEmail.list(
new Pagination(request.query.page_token, request.query.limit),
{ ...request.query },
getChannelPendingEmailsExecutionContext(request),
);
return {
resources: list.getEntities(),
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getWorkspaceRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
async savePendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{
Body: { resource: ChannelPendingEmails };
Params: ChannelPendingEmailsPrimaryKey;
}>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelPendingEmails>> {
const context = getSimpleExecutionContext(request);
const pendingEmail = await gr.services.channelPendingEmail.create(
getChannelPendingEmailsInstance(request.body.resource),
context,
);
logger.debug("reqId: %s - save - PendingEmails input %o", request.id, pendingEmail.entity);
return { resource: pendingEmail.entity };
}
async deletePendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{ Params: ChannelPendingEmailsDeleteQueryParameters }>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
const pendingEmail = await gr.services.channelPendingEmail.delete(
getChannelPendingEmailsInstance({
channel_id: request.params.channel_id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
email: request.params.email,
}),
);
return { status: pendingEmail.deleted ? "success" : "error" };
}
async recent(
request: FastifyRequest<{
Querystring: { limit?: string };
Params: Pick<BaseChannelsParameters, "company_id">;
}>,
): Promise<ResourceListResponse<UsersIncludedChannel>> {
const companyId = request.params.company_id;
const userId = request.currentUser.id;
const context = getSimpleExecutionContext(request);
const limit = parseInt(request.query.limit) || 100;
const workspaces = (
await gr.services.workspaces.getAllForUser({ userId }, { id: companyId })
).map(a => a.workspaceId);
let channels: UserChannel[] = await gr.services.channels.channels
.getChannelsForUsersInWorkspace(companyId, "direct", userId, undefined, context)
.then(list => list.getEntities());
for (const workspaceId of workspaces) {
const workspaceChannels = await gr.services.channels.channels
.getChannelsForUsersInWorkspace(companyId, workspaceId, userId, undefined, context)
.then(list => list.getEntities());
channels = [...channels, ...workspaceChannels];
}
channels = channels.sort(
(a, b) =>
(b.last_activity || 0) / 100 +
(b.user_member.last_access || 0) -
((a.user_member.last_access || 0) + (a.last_activity || 0) / 100),
);
channels = channels.slice(0, 100);
if (channels.length < limit) {
let otherChannels: UserChannel[] = [];
for (const workspaceId of workspaces) {
//Include channels we could join in this result
otherChannels = [
...otherChannels,
...(
await gr.services.channels.channels.getAllChannelsInWorkspace(
companyId,
workspaceId,
context,
)
).map(ch => {
return {
...ch,
user_member: null,
} as UserChannel;
}),
];
}
channels = _.unionBy(channels, otherChannels, "id");
}
const userIncludedChannels: UsersIncludedChannel[] = await Promise.all(
channels.map(channel => {
return gr.services.channels.channels.includeUsersInDirectChannel(channel, userId);
}),
);
const resources = userIncludedChannels.slice(0, limit).map(r => ChannelObject.mapTo(r, {}));
await gr.services.channels.channels.completeWithStatistics(resources);
await new Promise(r => setTimeout(r, 2000));
return {
resources,
};
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: BaseChannelsParameters }>,
): WorkspaceExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
workspace: {
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function getChannelPendingEmailsExecutionContext(
request: FastifyRequest<{
Params: ChannelPendingEmailsPrimaryKey | ChannelPendingEmailsDeleteQueryParameters;
}>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.channel_id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function getSimpleExecutionContext(request: FastifyRequest): ExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
};
}
@@ -0,0 +1,3 @@
export * from "./channel";
export * from "./member";
export * from "./tab";
@@ -0,0 +1,319 @@
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { CrudException, Pagination } from "../../../../core/platform/framework/api/crud-service";
import {
ChannelMember,
ChannelMemberPrimaryKey,
ChannelMemberReadCursors,
ChannelMemberWithUser,
} from "../../entities";
import {
ChannelMemberParameters,
ChannelParameters,
CreateChannelMemberBody,
PaginationQueryParameters,
UpdateChannelMemberBody,
} from "../types";
import { FastifyReply, FastifyRequest } from "fastify";
import { ChannelExecutionContext } from "../../types";
import { plainToClass } from "class-transformer";
import { handleError } from "../../../../utils/handleError";
import { getChannelRooms } from "../../services/member/realtime";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
User,
} from "../../../../utils/types";
import gr from "../../../global-resolver";
import { formatUser } from "../../../../utils/users";
export class ChannelMemberCrudController
implements
CrudController<
ResourceGetResponse<ChannelMember>,
ResourceCreateResponse<ChannelMember>,
ResourceListResponse<ChannelMember>,
ResourceDeleteResponse
>
{
getPrimaryKey(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
): ChannelMemberPrimaryKey {
return {
user_id: request.params.member_id,
channel_id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelMember>> {
if (!isCurrentUser(request.params.member_id, request.currentUser)) {
throw CrudException.badRequest("User does not have enough rights to get member");
}
const context = getExecutionContext(request);
const resource = await gr.services.channels.members.get(this.getPrimaryKey(request), context);
if (!resource) {
throw CrudException.notFound(`Channel member ${request.params.member_id} not found`);
}
return {
resource,
};
}
async save(
request: FastifyRequest<{ Body: CreateChannelMemberBody; Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelMember>> {
const entity = plainToClass(ChannelMember, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
channel_id: request.params.id,
},
});
try {
const result = await gr.services.channels.members.save(entity, getExecutionContext(request));
if (result.entity) {
reply.code(201);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelMemberBody; Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelMember>> {
const entity = plainToClass(ChannelMember, {
...request.body.resource,
...this.getPrimaryKey(request),
});
if (!isCurrentUser(entity.user_id, request.currentUser)) {
throw CrudException.badRequest("Current user can not update this member");
}
try {
const result = await gr.services.channels.members.save(entity, getExecutionContext(request));
if (result.entity) {
reply.code(200);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async delete(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const deleteResult = await gr.services.channels.members.delete(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (deleteResult.deleted) {
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
/**
* List channel members
*
* @param request
*/
async list(
request: FastifyRequest<{
Querystring: PaginationQueryParameters & { company_role?: string; search?: string };
Params: ChannelParameters;
}>,
): Promise<ResourceListResponse<ChannelMemberWithUser>> {
let list: ChannelMember[] = [];
let nextPageToken: string = null;
const resources = [];
const context = getExecutionContext(request);
if (request.query.search) {
const users = await gr.services.users.search(
new Pagination(request.query.page_token, request.query.limit),
{
search: request.query.search,
companyId: request.params.company_id,
},
context,
);
nextPageToken = users.nextPage?.page_token;
for (const user of users.getEntities()) {
const channelMember = await gr.services.channels.members.getChannelMember(
user,
{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
id: request.params.id,
},
undefined,
context,
);
if (channelMember) {
list.push(channelMember);
}
}
} else {
const channelMembers = await gr.services.channels.members.list(
new Pagination(request.query.page_token, request.query.limit),
{ company_role: request.query.company_role },
context,
);
nextPageToken = channelMembers.nextPage?.page_token;
list = channelMembers.getEntities();
}
for (const member of list) {
if (member) {
const user = await formatUser(await gr.services.users.getCached({ id: member.user_id }), {
includeCompanies: true,
});
resources.push({ ...member, user });
}
}
return {
...{
resources,
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getChannelRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
next_page_token: nextPageToken,
};
}
/**
*
* @param request Private exists
* @param reply
*/
async exists(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<Response> {
const context = getExecutionContext(request);
const resource = await gr.services.channels.members.get(this.getPrimaryKey(request), context);
if (!resource) {
return reply.status(200).send({ has_access: false });
}
return reply.status(200).send({ has_access: true });
}
/**
* Lists the channel read sections for all members.
*
* @param {FastifyRequest<{ Params: ChannelParameters }>} request - the request object
* @param {FastifyReply} reply - the reply object
* @returns {Promise<ResourceListResponse<ChannelMemberReadCursors>>} - the response
*/
async getAllChannelMembersReadSections(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceListResponse<ChannelMemberReadCursors>> {
try {
const context = getExecutionContext(request);
const channelMembersReadSections =
await gr.services.channels.members.getChannelMembersReadSections(context);
return {
resources: channelMembersReadSections.getEntities(),
};
} catch (err) {
handleError(reply, err);
}
}
/**
* Get the channel member read section for a user.
*
* @param {FastifyRequest<{ Params: ChannelMemberParameters }>} request - the request object
* @param {FastifyReply} reply - the reply object
* @returns {Promise<ResourceGetResponse<ChannelMemberReadCursors>>} - the read section for the member
*/
async getChannelMemberReadSections(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelMemberReadCursors>> {
try {
const { member_id } = request.params;
const context = getExecutionContext(request);
const channelMembersReadSections =
await gr.services.channels.members.getChannelMemberReadSections(member_id, context);
return {
resource: channelMembersReadSections,
};
} catch (err) {
handleError(reply, err);
}
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: ChannelParameters }>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function isCurrentUser(memberId: string, user: User): boolean {
return memberId && memberId === user.id;
}
@@ -0,0 +1,204 @@
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { CrudException, Pagination } from "../../../../core/platform/framework/api/crud-service";
import { ChannelTab, ChannelTabPrimaryKey } from "../../entities";
import {
ChannelParameters,
ChannelTabParameters,
CreateChannelTabBody,
PaginationQueryParameters,
UpdateChannelTabBody,
} from "../types";
import { FastifyReply, FastifyRequest } from "fastify";
import { ChannelExecutionContext } from "../../types";
import { plainToClass } from "class-transformer";
import { handleError } from "../../../../utils/handleError";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceEventsPayload,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import { getTabsRealtimeRoom } from "../../services/tab";
import gr from "../../../global-resolver";
export class ChannelTabCrudController
implements
CrudController<
ResourceGetResponse<ChannelTab>,
ResourceCreateResponse<ChannelTab>,
ResourceListResponse<ChannelTab>,
ResourceDeleteResponse
>
{
getPrimaryKey(request: FastifyRequest<{ Params: ChannelTabParameters }>): ChannelTabPrimaryKey {
return {
id: request.params.tab_id,
channel_id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Params: ChannelTabParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelTab>> {
const resource = await gr.services.tab.get(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (!resource) {
throw CrudException.notFound(`Channel member ${request.params.tab_id} not found`);
}
return {
resource,
};
}
async save(
request: FastifyRequest<{ Body: CreateChannelTabBody; Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelTab>> {
const entity = plainToClass(ChannelTab, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
channel_id: request.params.id,
},
});
try {
const context = getExecutionContext(request);
const result = await gr.services.tab.save(entity, {}, context);
if (result.entity) {
localEventBus.publish<ResourceEventsPayload>("channel:tab:created", {
tab: result.entity,
actor: getExecutionContext(request).user,
resourcesAfter: [result.entity],
channelParameters: request.params,
user: context.user,
});
reply.code(201);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelTabBody; Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelTab>> {
const entity = plainToClass(ChannelTab, {
...request.body.resource,
...this.getPrimaryKey(request),
});
try {
const result = await gr.services.tab.save(entity, {}, getExecutionContext(request));
if (result.entity) {
reply.code(200);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async delete(
request: FastifyRequest<{ Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const context = getExecutionContext(request);
const deleteResult = await gr.services.tab.delete(this.getPrimaryKey(request));
if (deleteResult.deleted) {
localEventBus.publish<ResourceEventsPayload>("channel:tab:deleted", {
tab: deleteResult.entity,
actor: getExecutionContext(request).user,
resourcesAfter: [deleteResult.entity],
channelParameters: request.params,
user: context.user,
});
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
/**
* List channel tabs
*
* @param request
*/
async list(
request: FastifyRequest<{
Querystring: PaginationQueryParameters;
Params: ChannelParameters;
}>,
): Promise<ResourceListResponse<ChannelTab>> {
const context = getExecutionContext(request);
const list = await gr.services.tab.list(
new Pagination(request.query.page_token, request.query.limit),
{},
context,
);
return {
...{
resources: list.getEntities(),
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
[{ room: getTabsRealtimeRoom(context.channel) }],
context.user.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: ChannelParameters }>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
@@ -0,0 +1,12 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
}>,
): void => {
fastify.log.debug("Configuring /internal/services/channels/v1 routes");
fastify.register(routes, options);
};
@@ -0,0 +1,12 @@
import { validate as validateUuid } from "uuid";
import { ChannelVisibility } from "../types";
export function checkCompanyAndWorkspaceForUser(
companyId: string,
workspaceId: string,
): Promise<boolean> {
return Promise.resolve(
validateUuid(companyId) &&
(validateUuid(workspaceId) || workspaceId === ChannelVisibility.DIRECT),
);
}
@@ -0,0 +1,270 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import {
BaseChannelsParameters,
ChannelParameters,
PaginationQueryParameters,
RecentChannelsParameters,
} from "./types";
import {
createChannelMemberSchema,
createChannelSchema,
getChannelMemberSchema,
getChannelSchema,
updateChannelMemberSchema,
updateChannelSchema,
} from "./schemas";
import {
ChannelCrudController,
ChannelMemberCrudController,
ChannelTabCrudController,
} from "./controllers";
import { checkCompanyAndWorkspaceForUser } from "./middleware";
import { FastifyRequest } from "fastify/types/request";
import { checkUserBelongsToCompany } from "../../../utils/company";
const channelsUrl = "/companies/:company_id/workspaces/:workspace_id/channels";
const membersUrl = `${channelsUrl}/:id/members`;
const tabsUrl = `${channelsUrl}/:id/tabs`;
const pendingEmailsUrl = `${channelsUrl}/:channel_id/pending_emails`;
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const channelsController = new ChannelCrudController();
const membersController = new ChannelMemberCrudController();
const tabsController = new ChannelTabCrudController();
const accessControlCompanyOnly = async (
request: FastifyRequest<{ Params: RecentChannelsParameters }>,
) => {
await checkUserBelongsToCompany(request.currentUser.id, request.params.company_id);
};
const accessControl = async (request: FastifyRequest<{ Params: BaseChannelsParameters }>) => {
const authorized = await checkCompanyAndWorkspaceForUser(
request.params.company_id,
request.params.workspace_id,
);
if (!authorized) {
throw fastify.httpErrors.badRequest("Invalid company/workspace");
}
};
const validateQuery = async (
request: FastifyRequest<{
Params: BaseChannelsParameters;
Querystring: PaginationQueryParameters;
}>,
) => {
if (isNaN(+request.query.limit)) {
request.query.limit = "100";
}
};
// channels
fastify.route({
method: "GET",
url: channelsUrl,
preHandler: [accessControl, validateQuery],
preValidation: [fastify.authenticate],
handler: channelsController.list.bind(channelsController),
});
fastify.route({
method: "GET",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: getChannelSchema,
handler: channelsController.get.bind(channelsController),
});
fastify.route({
method: "GET",
url: `${channelsUrl}/:id/thumbnail`,
handler: channelsController.thumbnail.bind(channelsController),
});
fastify.route({
method: "POST",
url: channelsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: createChannelSchema,
handler: channelsController.save.bind(channelsController),
});
fastify.route({
method: "POST",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: updateChannelSchema,
handler: channelsController.update.bind(channelsController),
});
fastify.route<{ Params: ChannelParameters }>({
method: "DELETE",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.delete.bind(channelsController),
});
fastify.route<{ Params: ChannelParameters }>({
method: "POST",
url: `${channelsUrl}/:id/read`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.updateRead.bind(channelsController),
});
fastify.route({
method: "GET",
url: "/companies/:company_id/search",
preValidation: [fastify.authenticate],
handler: channelsController.search.bind(channelsController),
});
fastify.route({
method: "GET",
url: "/companies/:company_id/channels/recent",
preHandler: accessControlCompanyOnly,
preValidation: [fastify.authenticate],
handler: channelsController.recent.bind(channelsController),
});
// members
fastify.route({
method: "GET",
url: membersUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.list.bind(membersController),
});
fastify.route({
method: "POST",
url: membersUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: createChannelMemberSchema,
handler: membersController.save.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: getChannelMemberSchema,
handler: membersController.get.bind(membersController),
});
fastify.route({
method: "POST",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: updateChannelMemberSchema,
handler: membersController.update.bind(membersController),
});
fastify.route({
method: "DELETE",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.delete.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/read_sections`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.getAllChannelMembersReadSections.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/:member_id/read_sections`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.getChannelMemberReadSections.bind(membersController),
});
// pending_emails
fastify.route({
method: "GET",
url: pendingEmailsUrl,
preHandler: [accessControl, validateQuery],
preValidation: [fastify.authenticate],
//schema: getChannelPendingEmailsSchema,
handler: channelsController.findPendingEmails.bind(channelsController),
});
fastify.route({
method: "POST",
url: pendingEmailsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
//schema: createChannelPendingEmailsSchema,
handler: channelsController.savePendingEmails.bind(channelsController),
});
fastify.route({
method: "DELETE",
url: `${pendingEmailsUrl}/:email`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.deletePendingEmails.bind(channelsController),
});
// tabs
fastify.route({
method: "GET",
url: tabsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.list.bind(tabsController),
});
fastify.route({
method: "POST",
url: tabsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.save.bind(tabsController),
});
fastify.route({
method: "GET",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.get.bind(tabsController),
});
fastify.route({
method: "POST",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.update.bind(tabsController),
});
fastify.route({
method: "DELETE",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.delete.bind(tabsController),
});
next();
};
export default routes;
@@ -0,0 +1,301 @@
import { webSocketSchema } from "../../../utils/types";
const channelMemberSchema = {
type: "object",
properties: {
id: { type: "string" },
user_id: { type: "string" },
channel_id: { type: "string" },
company_id: { type: "string" },
workspace_id: { type: "string" },
type: { type: "string", enum: ["member", "guest", "bot"] },
favorite: { type: "boolean" },
last_access: { type: "number" },
last_increment: { type: "number" },
notification_level: {
type: "string",
enum: ["all", "none", "group_mentions", "user_mentions"],
},
},
};
const channelSchema = {
type: "object",
properties: {
id: { type: "string" },
company_id: { type: "string" },
workspace_id: { type: "string" },
type: { type: "string", enum: ["workspace", "direct"] },
name: { type: "string" },
description: { type: "string" },
archivation_date: { type: "number" },
archived: { type: "boolean" },
channel_group: { type: "string" },
connectors: { type: ["array", "null"] },
icon: { type: "string" },
default: { type: "boolean" },
members: { type: ["array", "null"] },
users: { type: ["array", "null"] },
owner: { type: "string" },
visibility: { type: "string", enum: ["private", "public", "direct"] },
last_activity: { type: "number" },
user_member: {},
direct_channel_members: { type: "array" },
stats: {},
is_readonly: { type: "boolean" },
},
};
const channelTabSchema = {
type: "object",
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
channel_id: { type: "string" },
id: { type: "string" },
owner: { type: "string" },
order: { type: "string" },
configuration: {
type: "object",
properties: {
board_id: { type: "string" },
directory_id: { type: "string" },
},
},
application_id: { type: "string" },
name: { type: "string" },
},
};
export const createChannelSchema = {
body: {
type: "object",
properties: {
options: {
type: "object",
properties: {
members: { type: "array" },
},
},
resource: {
type: "object",
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
required: ["resource"],
},
},
};
export const updateChannelSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
required: ["resource"],
},
},
};
export const getChannelSchema = {
request: {
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
},
required: ["company_id", "workspace_id"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
},
},
};
export const getChannelMemberSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const createChannelMemberSchema = {
body: {
type: "object",
properties: {
resource: channelMemberSchema,
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const updateChannelMemberSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {
favorite: { type: "boolean" },
notification_level: { type: "string" },
},
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const getChannelTabSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
export const createChannelTabSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
export const updateChannelTabSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
const channelPendingEmailsSchema = {
type: "object",
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
channel_id: { type: "string" },
email: { type: "string" },
},
};
export const getChannelPendingEmailsSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelPendingEmailsSchema,
},
required: ["resource"],
},
},
};
export const createChannelPendingEmailsSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelPendingEmailsSchema,
},
required: ["resource"],
},
},
};
@@ -0,0 +1,117 @@
import { Channel, ChannelMember, ChannelPendingEmails, ChannelTab } from "../entities";
export declare type DirectChannel = "direct";
export interface RecentChannelsParameters {
company_id: string;
}
export interface BaseChannelsParameters {
company_id: string;
workspace_id: string | DirectChannel;
}
export interface ChannelParameters extends BaseChannelsParameters {
/** the channel id */
id: string;
}
export interface ChannelMemberParameters extends ChannelParameters {
member_id: string;
}
export interface PaginationQueryParameters {
page_token?: string;
limit?: string;
websockets?: boolean;
}
export interface ChannelListQueryParameters extends PaginationQueryParameters {
include_users?: string;
search_query?: string;
mine?: boolean;
}
export interface ChannelSearchQueryParameters extends PaginationQueryParameters {
q: string;
}
export class CreateChannelBody {
options?: ChannelCreateOptions;
resource: ChannelCreateResource;
}
export class ChannelCreateOptions {
/**
* Members to add to channel in case of direct channel
*/
members?: string[];
}
export type ChannelSaveOptions = ChannelCreateOptions & {
/**
* Add the channel creator as member.
*/
addCreatorAsMember?: boolean;
};
export class ChannelListOptions {
channels?: string[];
mine?: boolean;
company_role?: string;
user_id?: string;
}
export class UpdateChannelBody {
resource: ChannelUpdateResource;
}
export class ReadChannelBody {
value: boolean;
}
export type ChannelCreateResource = Pick<Channel, "name">;
export type ChannelUpdateResource = Pick<Channel, "name">;
// channel members
export class CreateChannelMemberBody {
resource: Pick<ChannelMember, "user_id">;
}
export class UpdateChannelMemberBody {
resource: Pick<ChannelMember, "favorite" | "notification_level">;
}
export type ChannelMemberSaveOptions = null;
// channel tabs
export interface ChannelTabParameters extends ChannelParameters {
tab_id: string;
}
export class CreateChannelTabBody {
resource: ChannelTab;
}
export class UpdateChannelTabBody {
resource: ChannelTab;
}
export type ChannelTabSaveOptions = {
resource: ChannelTab;
};
export interface ChannelPendingEmailsParameters extends BaseChannelsParameters {
channel_id: string;
}
export interface ChannelPendingEmailsDeleteQueryParameters extends ChannelPendingEmailsParameters {
email: ChannelPendingEmails["email"];
}
export interface ChannelPendingEmailsListQueryParameters extends BaseChannelsParameters {
channel_id: string;
}
@@ -0,0 +1,21 @@
import { ConsoleRemoteClient } from "./clients/remote";
import { ConsoleServiceClient } from "./client-interface";
import { ConsoleType } from "./types";
import { ConsoleInternalClient } from "./clients/internal";
import { ConsoleServiceImpl } from "./service";
class StaticConsoleClientFactory {
create(consoleInstance: ConsoleServiceImpl): ConsoleServiceClient {
const type: ConsoleType = consoleInstance.consoleType;
switch (type) {
case "remote":
return new ConsoleRemoteClient(consoleInstance);
case "internal":
return new ConsoleInternalClient(consoleInstance);
default:
throw new Error(`${type} is not supported`);
}
}
}
export const ConsoleClientFactory = new StaticConsoleClientFactory();
@@ -0,0 +1,66 @@
import {
ConsoleCompany,
ConsoleHookCompany,
ConsoleHookUser,
CreateConsoleCompany,
CreateConsoleUser,
CreatedConsoleCompany,
CreatedConsoleUser,
CreateInternalUser,
UpdateConsoleUserRole,
UpdatedConsoleUserRole,
} from "./types";
import User from "../user/entities/user";
import Company, { CompanySearchKey } from "../user/entities/company";
export interface ConsoleServiceClient {
/**
* Create a company
*
* @param company
*/
createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany>;
/**
* Add user to company
*
* @param company
* @param user
* @param inviter
*/
addUserToCompany(company: ConsoleCompany, user: CreateConsoleUser): Promise<CreatedConsoleUser>;
/**
* Add user to tdrive in general (for non-console version)
*
* @param user
*/
addUserToTdrive(user: CreateInternalUser): Promise<User>;
/**
* Update user role
*
* @param company
* @param user
*/
updateUserRole(
company: ConsoleCompany,
user: UpdateConsoleUserRole,
): Promise<UpdatedConsoleUserRole>;
updateLocalCompanyFromConsole(companyDTO: ConsoleHookCompany): Promise<Company>;
updateLocalUserFromConsole(user: ConsoleHookUser): Promise<User>;
removeCompanyUser(consoleUserId: string, company: Company): Promise<void>;
removeUser(consoleUserId: string): Promise<void>;
removeCompany(companySearchKey: CompanySearchKey): Promise<void>;
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany>;
getUserByAccessToken(idToken: string): Promise<ConsoleHookUser>;
resendVerificationEmail(email: string): Promise<void>;
}
@@ -0,0 +1,107 @@
import { AxiosInstance } from "axios";
import { merge } from "lodash";
import { ConsoleServiceClient } from "../client-interface";
import {
ConsoleCompany,
ConsoleHookCompany,
ConsoleHookUser,
CreateConsoleCompany,
CreateConsoleUser,
CreatedConsoleCompany,
CreatedConsoleUser,
UpdateConsoleUserRole,
UpdatedConsoleUserRole,
} from "../types";
import { v1 as uuidv1 } from "uuid";
import User, { getInstance as getUserInstance } from "../../user/entities/user";
import Company, { CompanySearchKey } from "../../user/entities/company";
import { logger } from "../../../core/platform/framework/logger";
import gr from "../../global-resolver";
import { ConsoleServiceImpl } from "../service";
export class ConsoleInternalClient implements ConsoleServiceClient {
version: "1";
client: AxiosInstance;
constructor(private consoleInstance: ConsoleServiceImpl) {}
async addUserToCompany(
company: ConsoleCompany,
user: CreateConsoleUser,
): Promise<CreatedConsoleUser> {
logger.info("Internal: addUserToCompany");
await gr.services.companies.setUserRole(company.id, user.id, user.role);
return merge(user, { _id: user.id });
}
async updateUserRole(
_company: ConsoleCompany,
_user: UpdateConsoleUserRole,
): Promise<UpdatedConsoleUserRole> {
logger.info("Internal: updateUserRole");
throw Error("ConsoleInternalClient.updateUserRole is not implemented");
}
async createCompany(_company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
logger.info("Internal: ");
throw Error("ConsoleInternalClient.createCompany is not implemented");
}
async addUserToTdrive(user: CreateConsoleUser): Promise<User> {
logger.info("Internal: addUserToTdrive");
const userToCreate = getUserInstance({
id: uuidv1(),
email_canonical: user.email,
first_name: user.firstName,
last_name: user.lastName,
});
const createdUser = await gr.services.users.save(userToCreate).then(result => result.entity);
if (user.password) {
await gr.services.users.setPassword({ id: createdUser.id }, user.password);
}
return createdUser;
}
updateLocalCompanyFromConsole(_companyDTO: ConsoleHookCompany): Promise<Company> {
logger.info("Internal: updateLocalCompanyFromConsole");
throw new Error("Method should not be implemented.");
}
updateLocalUserFromConsole(_user: ConsoleHookUser): Promise<User> {
logger.info("Internal: updateLocalUserFromConsole");
throw new Error("Method should not be implemented.");
}
removeCompany(_companySearchKey: CompanySearchKey): Promise<void> {
logger.info("Internal: removeCompany");
throw new Error("Method should not be implemented.");
}
removeCompanyUser(_consoleUserId: string, _company: Company): Promise<void> {
logger.info("Internal: removeCompanyUser");
throw new Error("Method should not be implemented.");
}
removeUser(_consoleUserId: string): Promise<void> {
logger.info("Internal: removeUser");
throw new Error("Method should not be implemented.");
}
fetchCompanyInfo(_consoleCompanyCode: string): Promise<ConsoleHookCompany> {
logger.info("Internal: fetchCompanyInfo");
throw new Error("Method should not be implemented.");
}
getUserByAccessToken(_idToken: string): Promise<ConsoleHookUser> {
logger.info("Internal: getUserByAccessToken");
throw new Error("Method should not be implemented.");
}
resendVerificationEmail(_accessToken: string): Promise<void> {
logger.info("Internal: resendVerificationEmail");
throw new Error("Method should not be implemented.");
}
}
@@ -0,0 +1,97 @@
import JwksClient from "jwks-rsa";
import nJwt from "njwt";
function verifyAudience(expected: string, aud: string | Array<string>) {
if (!expected) {
throw new Error("expected audience is required");
}
if (Array.isArray(aud) && !aud.includes(expected)) {
throw new Error(
`audience claim ${expected} does not match one of the expected audiences: ${aud.join(", ")}`,
);
}
if (!Array.isArray(aud) && aud !== expected) {
throw new Error(`audience claim ${aud} does not match expected audience: ${expected}`);
}
}
function verifyIssuer(expected: string, issuer: string) {
if (issuer !== expected) {
throw new Error(`issuer ${issuer} does not match expected issuer: ${expected}`);
}
}
export class OidcJwtVerifier {
public claimsToAssert: any;
private issuer: string;
private jwksUri: string;
private jwksClient: any;
private verifier: any;
constructor(options: { [key: string]: any } = {}) {
// https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options
if (options.requestAgentOptions) {
// jwks-rsa no longer accepts 'requestAgentOptions' and instead requires a http(s).Agent be passed directly
const msg = `\`requestAgentOptions\` has been deprecated, use \`requestAgent\` instead.
For more info see https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options`;
throw new Error(msg);
}
this.claimsToAssert = options.assertClaims || {};
this.issuer = options.issuer;
this.jwksUri = options.jwksUri;
this.jwksClient = JwksClient({
jwksUri: this.jwksUri,
cache: true,
cacheMaxAge: options.cacheMaxAge || 60 * 60 * 1000,
cacheMaxEntries: 3,
jwksRequestsPerMinute: options.jwksRequestsPerMinute || 10,
rateLimit: true,
requestAgent: options.requestAgent,
});
this.verifier = nJwt
.createVerifier()
.setSigningAlgorithm("RS256")
.withKeyResolver((kid: string, cb: any) => {
if (kid) {
this.jwksClient.getSigningKey(kid, (err: any, key: any) => {
cb(err, key && (key.publicKey || key.rsaPublicKey));
});
} else {
cb("No KID specified", null);
}
return null;
});
}
async verifyAsPromise(tokenString: string): Promise<any> {
return new Promise((resolve, reject) => {
// Convert to a promise
this.verifier.verify(tokenString, (err: any, jwt: any) => {
if (err) {
return reject(err);
}
const jwtBodyProxy = new Proxy(jwt.body, {});
Object.defineProperty(jwt, "claims", {
enumerable: true,
writable: false,
value: jwtBodyProxy,
});
delete jwt.body;
resolve(jwt);
});
});
}
async verifyIdToken(idTokenString: string, expectedClientId: string) {
const jwt = await this.verifyAsPromise(idTokenString);
verifyAudience(expectedClientId, jwt.claims.aud);
verifyIssuer(this.issuer, jwt.claims.iss);
return jwt;
}
}
@@ -0,0 +1,237 @@
import { AxiosInstance } from "axios";
import { ConsoleServiceClient } from "../client-interface";
import {
ConsoleCompany,
ConsoleHookCompany,
ConsoleHookUser,
ConsoleOptions,
CreateConsoleCompany,
CreateConsoleUser,
CreatedConsoleCompany,
CreatedConsoleUser,
UpdateConsoleUserRole,
UpdatedConsoleUserRole,
} from "../types";
import { OidcJwtVerifier } from "./remote-jwks-verifier";
import { CrudException } from "../../../core/platform/framework/api/crud-service";
import { logger } from "../../../core/platform/framework/logger";
import gr from "../../global-resolver";
import Company, { CompanySearchKey } from "../../user/entities/company";
import User, { getInstance } from "../../user/entities/user";
import { getInstance as getCompanyInstance } from "../../user/entities/company";
import { ConsoleServiceImpl } from "../service";
import coalesce from "../../../utils/coalesce";
export class ConsoleRemoteClient implements ConsoleServiceClient {
version: "1";
client: AxiosInstance;
private infos: ConsoleOptions;
private verifier: OidcJwtVerifier;
constructor(consoleInstance: ConsoleServiceImpl) {
this.infos = consoleInstance.consoleOptions;
this.verifier = new OidcJwtVerifier({
clientId: this.infos.client_id,
issuer: this.infos.issuer?.replace(/\/+$/, ""),
jwksUri: this.infos.jwks_uri,
// For local deployment create a https agent that ignore self signed certificate
// eslint-disable-next-line @typescript-eslint/no-var-requires
requestAgent: new (require("https").Agent)({
rejectUnauthorized: this.infos.issuer.includes("example.com") ? false : true,
}),
});
}
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany> {
throw new Error(`Method not implemented, ${consoleCompanyCode}.`);
}
resendVerificationEmail(email: string): Promise<void> {
throw new Error(`Method not implemented, ${email}.`);
}
private auth() {
return {};
}
async addUserToCompany(
company: ConsoleCompany,
user: CreateConsoleUser,
): Promise<CreatedConsoleUser> {
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
return null;
}
async updateUserRole(
company: ConsoleCompany,
user: UpdateConsoleUserRole,
): Promise<UpdatedConsoleUserRole> {
logger.info("Remote: updateUserRole");
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
return null;
}
async createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
logger.info("Remote: createCompany");
logger.info(`Method not implemented, ${company}.`);
return null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
addUserToTdrive(user: CreateConsoleUser): Promise<User> {
logger.info("Remote: addUserToTdrive");
//should do noting for real console
return Promise.resolve(undefined);
}
async updateLocalCompanyFromConsole(partialCompanyDTO: ConsoleHookCompany): Promise<Company> {
logger.info(`Method not implemented, ${partialCompanyDTO}.`);
return null;
}
async updateLocalUserFromConsole(userDTO: ConsoleHookUser): Promise<User> {
logger.info("Remote: updateLocalUserFromConsole");
if (!userDTO) {
throw CrudException.badRequest("User not found on Console");
}
const roles = userDTO.roles.filter(
role => role.applications === undefined || role.applications.find(a => a.code === "tdrive"),
);
//REMOVE LATER
logger.info(`Roles are: ${roles}.`);
let user = await gr.services.users.getByConsoleId(userDTO.email);
if (!user) {
if (!userDTO.email) {
throw CrudException.badRequest("Email is required");
}
let username = userDTO.email
.split("@")[0]
.toLocaleLowerCase()
.replace(/[^a-zA-Z0-9]/g, "")
.replace(/ +/g, "_");
if (await gr.services.users.isEmailAlreadyInUse(userDTO.email)) {
throw CrudException.badRequest("Console user not created because email already exists");
}
username = await gr.services.users.getAvailableUsername(username);
if (!username) {
throw CrudException.badRequest("Console user not created because username already exists");
}
user = getInstance({});
user.username_canonical = (username || "").toLocaleLowerCase();
user.email_canonical = userDTO.email;
user.deleted = false;
}
user.email_canonical = coalesce(userDTO.email, user.email_canonical);
user.phone = "";
user.first_name = coalesce(userDTO.name, user.first_name);
user.last_name = coalesce(userDTO.surname, user.last_name);
user.identity_provider = "console";
user.identity_provider_id = userDTO.email;
user.mail_verified = coalesce(userDTO.isVerified, user.mail_verified);
if (userDTO.preference) {
user.preferences = user.preferences || {};
user.preferences.allow_tracking = coalesce(
userDTO.preference.allowTrackingPersonalInfo,
user.preferences?.allow_tracking,
);
user.preferences.language = coalesce(userDTO.preference.locale, user.preferences?.language);
user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone);
}
user.picture = userDTO.avatar.value;
await gr.services.users.save(user);
//For now TDrive works with only one company as we don't get it from the SSO
let company = await gr.services.companies.getCompany({
id: "00000000-0000-4000-0000-000000000000",
});
if (!company) {
company = await gr.services.companies.createCompany(
getCompanyInstance({
id: "00000000-0000-4000-0000-000000000000",
name: "Tdrive",
plan: { name: "Local", limits: undefined, features: undefined },
}),
);
}
await gr.services.companies.setUserRole(company.id, user.id, "admin");
await gr.services.users.save(user, { user: { id: user.id, server_request: true } });
return user;
}
async removeCompanyUser(consoleUserId: string, company: Company): Promise<void> {
logger.info("Remote: removeCompanyUser");
const user = await gr.services.users.getByConsoleId(consoleUserId);
if (!user) {
throw CrudException.notFound(`User ${consoleUserId} doesn't exists`);
}
await gr.services.companies.removeUserFromCompany({ id: company.id }, { id: user.id });
}
async removeUser(consoleUserId: string): Promise<void> {
logger.info("Remote: removeUser");
const user = await gr.services.users.getByConsoleId(consoleUserId);
if (!user) {
throw new Error("User does not exists on Tdrive.");
}
await gr.services.users.anonymizeAndDelete(
{ id: user.id },
{
user: { id: user.id, server_request: true },
},
);
}
async removeCompany(companySearchKey: CompanySearchKey): Promise<void> {
logger.info("Remote: removeCompany");
await gr.services.companies.removeCompany(companySearchKey);
}
async getUserByAccessToken(idToken: string): Promise<ConsoleHookUser> {
const user = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims as {
sub: string;
email: string;
family_name: string;
given_name: string;
name: string;
locale?: string;
picture?: string;
};
return {
_id: user.email || user.sub,
roles: [] as any,
email: user.email,
name: user.given_name,
surname: user.family_name,
isVerified: true,
preference: {
locale: user.locale,
timeZone: 0,
allowTrackingPersonalInfo: true,
},
avatar: {
type: "url",
value: user.picture,
},
};
}
}
@@ -0,0 +1,23 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/console/v1")
export default class ConsoleService extends TdriveService<undefined> {
version = "1";
name = "console";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -0,0 +1,58 @@
import { DatabaseServiceAPI } from "../../core/platform/services/database/api";
import { ConsoleOptions, ConsoleType } from "./types";
import { ConsoleServiceClient } from "./client-interface";
import { ConsoleClientFactory } from "./client-factory";
import User from "../user/entities/user";
import gr from "../global-resolver";
import { Configuration, TdriveServiceProvider } from "../../core/platform/framework";
import assert from "assert";
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
export class ConsoleServiceImpl implements TdriveServiceProvider {
version: "1";
consoleType: ConsoleType;
consoleOptions: ConsoleOptions;
services: {
database: DatabaseServiceAPI;
};
private configuration: Configuration;
constructor(options?: ConsoleOptions) {
this.consoleOptions = options;
}
async init(): Promise<this> {
this.configuration = new Configuration("general.accounts");
assert(this.configuration, "console configuration is missing");
const type = this.configuration.get("type") as ConsoleType;
assert(type, "console configuration type is not defined");
const s = this.configuration.get(type) as ConsoleOptions;
this.consoleOptions = {
type: type,
authority: s.authority,
client_id: s.client_id,
client_secret: s.client_secret,
audience: s.audience,
issuer: s.issuer,
jwks_uri: s.jwks_uri,
redirect_uris: s.redirect_uris,
disable_account_creation: s.disable_account_creation,
};
this.consoleOptions.type = type;
this.consoleType = type;
return this;
}
getClient(): ConsoleServiceClient {
return ConsoleClientFactory.create(this);
}
async processPendingUser(user: User, context?: ExecutionContext): Promise<void> {
await gr.services.workspaces.processPendingUser(user, null, context);
}
}
@@ -0,0 +1,239 @@
import { Observable } from "rxjs";
import Company from "../user/entities/company";
import CompanyUser from "../user/entities/company_user";
import { CompanyUserRole } from "../user/web/types";
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
import { AccessToken } from "../../utils/types";
export interface CreateConsoleCompany {
code: string;
displayName: string;
avatar: {
type: "url";
value: string;
};
country?: string;
address?: string;
logo?: string;
status: string;
domain?: string[];
applications?: string[];
planId?: string;
limits?: {
members?: number; //Old console version
guests?: number; //Old console version
tdrive: {
members: number;
guests: number;
storage: number;
};
};
}
export type CreatedConsoleCompany = Partial<CreateConsoleCompany>;
export interface CreateConsoleUser {
id?: string;
email: string;
firstName: string;
lastName: string;
name: string;
avatar: {
type: "url";
value: string;
};
password: string;
role: CompanyUserRole;
skipInvite: boolean;
inviterEmail: string;
}
export interface CreateInternalUser {
email: string;
password: string;
}
export type CreatedConsoleUser = Partial<CreateConsoleUser> & { _id: string };
export type UpdateConsoleUserRole = Pick<CreateConsoleUser, "role"> & { id: string };
export type UpdatedConsoleUserRole = UpdateConsoleUserRole;
export type ConsoleUser = {
id: string;
companyCode: string;
};
export type ConsoleCompany = {
id?: string;
code: string;
};
export type CompanyCreatedStreamObject = {
source: Company;
destination: ConsoleCompany;
// Creation error
error?: Error;
};
export type UserCreatedStreamObject = {
source: {
user: CompanyUser;
company: Company;
};
destination: ConsoleUser;
// Creation error
error?: Error;
};
type ReportStatus = "success" | "failure";
export type CompanyReport = {
sourceId: string;
destinationCode: string;
status: ReportStatus;
company: CompanyCreatedStreamObject;
error?: Error;
};
export type UserReport = {
sourceId: string;
destinationId: string;
destinationCompanyCode: string;
status: ReportStatus;
user: UserCreatedStreamObject;
error?: Error;
};
export type MergeProgress = Observable<ProcessReport>;
export type ProcessReport = {
type:
| "user:updated"
| "user:updating"
| "user:created"
| "user:error"
| "company:created"
| "processing:owner"
| "log"
| "company:withoutadmin";
//error?: Error;
message?: string;
data?: UserReport | CompanyReport | Error | Company[];
};
export type ConsoleType = "remote" | "internal";
export type ConsoleOptions = {
type: ConsoleType;
authority: string;
client_id: string;
client_secret: string;
issuer: string;
jwks_uri: string;
audience: string;
redirect_uris: string[];
disable_account_creation: boolean;
};
export type ConsoleHookCompany = {
stats: string;
limits: {
members?: number; //Old console version
guests?: number; //Old console version
tdrive: {
members: number;
guests: number;
storage: number;
};
};
value: string;
details: {
code: string;
logo: string;
avatar: {
value: string;
type: string;
};
name: string;
country: string;
address: string;
};
};
export type ConsoleHookUser = {
_id: string;
roles: [
{
targetCode: string;
roleCode: CompanyUserRole;
status: "active" | "deactivated";
applications: {
code: "tdrive";
}[];
},
];
email: string;
name: string;
surname: string;
isVerified: boolean;
preference: {
locale: string;
timeZone: number;
allowTrackingPersonalInfo: boolean;
};
avatar: {
type: string;
value: string;
};
};
export type ConsoleHookBodyContent = {
company: ConsoleHookCompany;
user: ConsoleHookUser;
};
export type ConsoleHookCompanyDeletedContent = {
companyCode: string;
};
export type ConsoleHookPreferenceContent = {
preference: {
targetCode: string;
};
};
export type ConsoleHookBody = {
type: string;
content:
| ConsoleHookBodyContent
| ConsoleHookUser
| ConsoleHookCompany
| ConsoleHookCompanyDeletedContent;
signature: string;
secret_key?: string;
};
export class ConsoleHookQueryString {
secret_key: string;
}
export class ConsoleHookResponse {
success?: boolean;
error?: string;
}
export interface ConsoleExecutionContext extends ExecutionContext {
options: ConsoleOptions;
}
export interface AuthRequest {
email?: string;
password?: string;
oidc_id_token?: string;
}
export interface AuthResponse {
access_token?: AccessToken;
error?: string;
}
@@ -0,0 +1,298 @@
import { FastifyReply, FastifyRequest } from "fastify";
import {
AuthRequest,
AuthResponse,
ConsoleHookBody,
ConsoleHookBodyContent,
ConsoleHookCompany,
ConsoleHookCompanyDeletedContent,
ConsoleHookPreferenceContent,
ConsoleHookResponse,
ConsoleHookUser,
ConsoleType,
} from "../types";
import Company from "../../user/entities/company";
import { CrudException } from "../../../core/platform/framework/api/crud-service";
import PasswordEncoder from "../../../utils/password-encoder";
import { AccessToken } from "../../../utils/types";
import assert from "assert";
import { logger } from "../../../core/platform/framework";
import { getInstance } from "../../user/entities/user";
import { getInstance as getCompanyInstance } from "../../../services/user/entities/company";
import Workspace from "../../../services/workspaces/entities/workspace";
import gr from "../../global-resolver";
import { Configuration } from "../../../core/platform/framework";
export class ConsoleController {
private passwordEncoder: PasswordEncoder;
constructor() {
this.passwordEncoder = new PasswordEncoder();
}
async auth(request: FastifyRequest<{ Body: AuthRequest }>): Promise<AuthResponse> {
if (request.body.oidc_id_token) {
return { access_token: await this.authByToken(request.body.oidc_id_token) };
} else if (request.body.email && request.body.password) {
return { access_token: await this.authByPassword(request.body.email, request.body.password) };
} else {
throw CrudException.badRequest("remote_access_token or email+password are required");
}
}
async signup(
request: FastifyRequest<{
Body: { email: string; password: string; first_name: string; last_name: string };
}>,
): Promise<AuthResponse> {
const configuration = new Configuration("console");
const type = configuration.get("type") as ConsoleType;
try {
//Allow only if no console is set up in this case everyone will be in the same company
//Console is set up
if (type !== "internal") {
throw new Error("Unable to signup in console mode");
}
//Allow only if signup isn't disabled
if (configuration.get("disable_account_creation")) {
throw new Error("Account creation is disabled");
}
const email = request.body.email.trim().toLocaleLowerCase();
if (await gr.services.users.getByEmail(email)) {
throw new Error("This email is already used");
}
try {
const newUser = getInstance({
first_name: request.body.first_name,
last_name: request.body.last_name,
email_canonical: email,
username_canonical: (email.replace("@", ".") || "").toLocaleLowerCase(),
});
const user = await gr.services.users.create(newUser);
await gr.services.users.setPassword({ id: user.entity.id }, request.body.password);
//Create a global company for all users in local mode
const companies = await gr.services.companies.getCompanies();
let company = companies.getEntities()?.[0];
if (!company) {
const newCompany = getCompanyInstance({
name: "Tdrive",
plan: { name: "Local", limits: undefined, features: undefined },
});
company = await gr.services.companies.createCompany(newCompany);
}
await gr.services.companies.setUserRole(company.id, user.entity.id, "admin");
//In case someone invited us to a workspace
await gr.services.workspaces.processPendingUser(user.entity);
//If user is in no workspace, then we create one for they
const workspaces = await gr.services.workspaces.getAllForUser(
{ userId: user.entity.id },
{ id: company.id },
);
if (workspaces.length === 0) {
gr.services.workspaces.create(
{
company_id: company.id,
name: `${
newUser.first_name || newUser.last_name || newUser.username_canonical
}'s space`,
} as Workspace,
{ user: { id: user.entity.id } },
);
}
return {
access_token: await this.authByPassword(request.body.email, request.body.password),
};
} catch (err) {
throw new Error("An unknown error occured");
}
} catch (err) {
return { error: err.toString() };
}
}
async tokenRenewal(request: FastifyRequest): Promise<AuthResponse> {
return {
access_token: gr.platformServices.auth.generateJWT(
request.currentUser.id,
request.currentUser.email,
{
track: request.currentUser?.allow_tracking || false,
provider_id: request.currentUser.identity_provider_id,
},
),
};
}
async resendVerificationEmail(
request: FastifyRequest,
): Promise<{ success: boolean; email: string }> {
const user = await gr.services.users.get({ id: request.currentUser.id });
await gr.services.console.getClient().resendVerificationEmail(user.email_canonical);
return {
success: true,
email: user.email_canonical,
};
}
private async getCompanyDataFromConsole(
company: ConsoleHookCompany | ConsoleHookCompany["details"] | { code: string },
): Promise<ConsoleHookCompany> {
return gr.services.console
.getClient()
.fetchCompanyInfo(
(company as ConsoleHookCompany["details"])?.code ||
(company as ConsoleHookCompany)?.details?.code,
);
}
private async updateCompany(company: ConsoleHookCompany): Promise<Company> {
const companyDTO = await this.getCompanyDataFromConsole(company);
return gr.services.console.getClient().updateLocalCompanyFromConsole(companyDTO);
}
async hook(
request: FastifyRequest<{ Body: ConsoleHookBody }>,
reply: FastifyReply,
): Promise<ConsoleHookResponse> {
try {
logger.info(`Received event ${request.body.type}`);
switch (request.body.type) {
case "user_created":
case "company_user_added":
case "company_user_activated":
case "company_user_updated":
await this.userAdded(request.body.content as ConsoleHookBodyContent);
break;
case "company_user_deactivated":
case "company_user_deleted":
await this.userDisabled(request.body.content as ConsoleHookBodyContent);
break;
case "user_updated":
await this.userUpdated((request.body.content as ConsoleHookBodyContent).user._id);
break;
case "user_preference_updated":
await this.userUpdated(
(request.body.content as unknown as ConsoleHookPreferenceContent).preference.targetCode,
);
break;
case "user_deleted":
await this.userRemoved(request.body.content as ConsoleHookUser);
break;
case "plan_updated":
await this.planUpdated(request.body.content as ConsoleHookBodyContent);
break;
case "company_deleted":
await this.companyRemoved(request.body.content as ConsoleHookCompanyDeletedContent);
break;
case "company_created":
await this.companyUpdated(request.body.content as ConsoleHookBodyContent);
if ((request.body.content as ConsoleHookBodyContent)?.user?._id) {
await this.userUpdated((request.body.content as ConsoleHookBodyContent).user._id);
}
break;
case "company_updated":
await this.companyUpdated(request.body.content as ConsoleHookBodyContent);
break;
default:
logger.info("Event not recognized");
throw CrudException.notImplemented("Unimplemented");
}
} catch (e) {
reply.status(400);
return {
error: e.message,
};
}
return {
success: true,
};
}
private async userAdded(content: ConsoleHookBodyContent): Promise<void> {
const userDTO = content.user;
const user = await gr.services.console.getClient().updateLocalUserFromConsole(userDTO);
await this.updateCompany(content.company);
await gr.services.console.processPendingUser(user);
}
private async userDisabled(content: ConsoleHookBodyContent): Promise<void> {
const company = await this.updateCompany(content.company);
await gr.services.console.getClient().removeCompanyUser(content.user._id, company);
}
private async userRemoved(content: ConsoleHookUser): Promise<void> {
await gr.services.console.getClient().removeUser(content._id);
}
private async userUpdated(code: string) {
//Not implemented yet
throw CrudException.notImplemented(`Method not implemented, ${code}.`);
}
private async companyRemoved(content: ConsoleHookCompanyDeletedContent) {
assert(content.companyCode, "content.companyCode is missing");
await gr.services.console.getClient().removeCompany({
identity_provider: "console",
identity_provider_id: content.companyCode,
});
}
private async companyUpdated(content: ConsoleHookBodyContent) {
await this.updateCompany(content.company);
}
private async planUpdated(content: ConsoleHookBodyContent) {
await this.updateCompany(content.company);
}
private async authByPassword(email: string, password: string): Promise<AccessToken> {
const user = await gr.services.users.getByEmail(email);
if (!user) {
throw CrudException.forbidden("User doesn't exists");
}
// allow to login in development mode with any password. This can be used to test without the console provider because the password is not stored locally...
if (process.env.NODE_ENV !== "development") {
const [storedPassword, salt] = await gr.services.users.getHashedPassword({
id: user.id,
});
if (!(await this.passwordEncoder.isPasswordValid(storedPassword, password, salt))) {
throw CrudException.forbidden("Password doesn't match");
}
} else if (process.env.NODE_ENV === "development") {
logger.warn("ERROR_NOTONPROD: YOU ARE RUNNING IN DEVELOPMENT MODE, AUTH IS DISABLED!!!");
}
return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, {
track: user?.preferences?.allow_tracking || false,
provider_id: user.identity_provider_id,
});
}
private async authByToken(idToken: string): Promise<AccessToken> {
const client = gr.services.console.getClient();
const userDTO = await client.getUserByAccessToken(idToken);
const user = await client.updateLocalUserFromConsole(userDTO);
if (!user) {
throw CrudException.notFound(`User details not found for access token ${idToken}`);
}
return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, {
track: user?.preferences?.allow_tracking || false,
provider_id: user.identity_provider_id,
});
}
}
@@ -0,0 +1,12 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
}>,
): void => {
fastify.log.debug("Configuring /console routes");
fastify.register(routes, options);
};
@@ -0,0 +1,57 @@
import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify";
import { authenticationSchema, consoleHookSchema, tokenRenewalSchema } from "./schemas";
// import { WorkspaceBaseRequest, WorkspaceUsersBaseRequest, WorkspaceUsersRequest } from "./types";
import { ConsoleController } from "./controller";
import { ConsoleHookBody, ConsoleHookQueryString } from "../types";
const hookUrl = "/hook";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const controller = new ConsoleController();
const accessControl = async (
request: FastifyRequest<{ Body: ConsoleHookBody; Querystring: ConsoleHookQueryString }>,
) => {
throw fastify.httpErrors.notImplemented(`Hook service doesn't exist anymore, ${request}.`);
};
fastify.route({
method: "POST",
url: `${hookUrl}`,
preHandler: [accessControl],
schema: consoleHookSchema,
handler: controller.hook.bind(controller),
});
fastify.route({
method: "POST",
url: "/login",
schema: authenticationSchema,
handler: controller.auth.bind(controller),
});
fastify.route({
method: "POST",
url: "/signup",
handler: controller.signup.bind(controller),
});
fastify.route({
method: "POST",
url: "/token",
preValidation: [fastify.authenticate],
schema: tokenRenewalSchema,
handler: controller.tokenRenewal.bind(controller),
});
fastify.route({
method: "POST",
url: "/resend-verification-email",
preValidation: [fastify.authenticate],
handler: controller.resendVerificationEmail.bind(controller),
});
next();
};
export default routes;
@@ -0,0 +1,77 @@
export const consoleHookSchema = {
querystring: {
type: "object",
properties: {
secret_key: { type: "string" },
},
},
body: {
type: "object",
properties: {
type: { type: "string" },
content: { type: "object" },
signature: { type: "string" },
secret_key: { type: "string" },
},
required: ["type", "content"],
},
response: {
"2xx": {
type: "object",
properties: {
success: { type: "boolean" },
error: { type: "string" },
},
},
},
};
export const authenticationSchema = {
body: {
type: "object",
properties: {
email: { type: "string" },
password: { type: "string" },
oidc_id_token: { type: "string" },
},
},
response: {
"2xx": {
type: "object",
properties: {
access_token: {
type: "object",
properties: {
time: { type: "number" },
expiration: { type: "number" },
refresh_expiration: { type: "number" },
value: { type: "string" },
refresh: { type: "string" },
type: { type: "string" },
},
},
},
},
},
};
export const tokenRenewalSchema = {
response: {
"2xx": {
type: "object",
properties: {
access_token: {
type: "object",
properties: {
time: { type: "number" },
expiration: { type: "number" },
refresh_expiration: { type: "number" },
value: { type: "string" },
refresh: { type: "string" },
type: { type: "string" },
},
},
},
},
},
};
@@ -0,0 +1,171 @@
export const stopWords = [
"about",
"above",
"after",
"again",
"all",
"also",
"am",
"an",
"and",
"another",
"any",
"are",
"as",
"at",
"be",
"because",
"been",
"before",
"being",
"below",
"between",
"both",
"but",
"by",
"came",
"can",
"cannot",
"come",
"could",
"did",
"do",
"does",
"doing",
"during",
"each",
"few",
"for",
"from",
"further",
"get",
"got",
"has",
"had",
"he",
"have",
"her",
"here",
"him",
"himself",
"his",
"how",
"if",
"in",
"into",
"is",
"it",
"its",
"itself",
"like",
"make",
"many",
"me",
"might",
"more",
"most",
"much",
"must",
"my",
"myself",
"never",
"now",
"of",
"on",
"only",
"or",
"other",
"our",
"ours",
"ourselves",
"out",
"over",
"own",
"said",
"same",
"see",
"should",
"since",
"so",
"some",
"still",
"such",
"take",
"than",
"that",
"the",
"their",
"theirs",
"them",
"themselves",
"then",
"there",
"these",
"they",
"this",
"those",
"through",
"to",
"too",
"under",
"until",
"up",
"very",
"was",
"way",
"we",
"well",
"were",
"what",
"where",
"when",
"which",
"while",
"who",
"whom",
"with",
"would",
"why",
"you",
"your",
"yours",
"yourself",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"$",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
"_",
];
@@ -0,0 +1,33 @@
import { DriveFile, TYPE } from "./drive-file";
export default {
index: TYPE,
source: (entity: DriveFile) => ({
content_keywords: entity.content_keywords,
tags: (entity.tags || []).join(" "),
creator: entity.creator,
added: entity.added,
name: entity.name,
company_id: entity.company_id,
}),
mongoMapping: {
text: {
content_keywords: "text",
tags: "text",
creator: "text",
added: "text",
name: "text",
company_id: "text",
},
},
esMapping: {
properties: {
name: { type: "text", index_prefixes: { min_chars: 1 } },
content_keywords: { type: "text", index_prefixes: { min_chars: 1 } },
tags: { type: "keyword" },
creator: { type: "keyword" },
added: { type: "keyword" },
company_id: { type: "keyword" },
},
},
};
@@ -0,0 +1,90 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { DriveFileAccessLevel, publicAccessLevel } from "../types";
import { FileVersion } from "./file-version";
import search from "./drive-file.search";
export const TYPE = "drive_files";
@Entity(TYPE, {
globalIndexes: [["company_id", "parent_id"]],
primaryKey: [["company_id"], "id"],
type: TYPE,
search,
})
export class DriveFile {
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Type(() => String)
@Column("parent_id", "string")
parent_id: string;
@Type(() => Boolean)
@Column("is_in_trash", "boolean")
is_in_trash: boolean;
@Type(() => Boolean)
@Column("is_directory", "boolean")
is_directory: boolean;
@Type(() => String)
@Column("name", "string")
name: string;
@Type(() => String)
@Column("extension", "string")
extension: string;
@Type(() => String)
@Column("description", "string")
description: string;
@Column("tags", "encoded_json")
tags: string[];
@Type(() => String)
@Column("added", "string")
added: string;
@Type(() => String)
@Column("last_modified", "string")
last_modified: string;
@Column("access_info", "encoded_json")
access_info: AccessInformation;
@Type(() => String)
@Column("content_keywords", "string")
content_keywords: string;
@Type(() => String)
@Column("creator", "uuid")
creator: string;
@Type(() => Number)
@Column("size", "number")
size: number;
@Column("last_version_cache", "encoded_json")
last_version_cache: Partial<FileVersion>;
}
export type AccessInformation = {
public?: {
token: string;
level: publicAccessLevel;
};
entities: AuthEntity[];
};
type AuthEntity = {
type: "user" | "channel" | "company" | "folder";
id: string | "parent";
level: publicAccessLevel | DriveFileAccessLevel;
};
@@ -0,0 +1,30 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "drive_tdrive_tab";
@Entity(TYPE, {
primaryKey: [["company_id"], "tab_id"],
type: TYPE,
})
export class DriveTdriveTab {
@Type(() => String)
@Column("company_id", "string")
company_id: string;
@Type(() => String)
@Column("tab_id", "string")
tab_id: string;
@Type(() => String)
@Column("channel__id", "string")
channel_id: string;
@Type(() => String)
@Column("item_id", "string")
item_id: string;
@Type(() => String)
@Column("level", "string")
level: "read" | "write";
}
@@ -0,0 +1,83 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "drive_file_versions";
@Entity(TYPE, {
primaryKey: [["drive_item_id"], "id"],
type: TYPE,
})
export class FileVersion {
@Type(() => String)
@Column("drive_item_id", "uuid")
drive_item_id: string;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Type(() => String)
@Column("provider", "string")
provider: "internal" | "drive" | string;
@Column("file_metadata", "encoded_json")
file_metadata: DriveFileMetadata;
@Type(() => Number)
@Column("date_added", "number")
date_added: number;
@Type(() => String)
@Column("creator_id", "uuid")
creator_id: string;
@Type(() => String)
@Column("application_id", "string")
application_id: string;
@Type(() => String)
@Column("realname", "string")
realname: string;
@Type(() => String)
@Column("key", "string")
key: string;
@Type(() => String)
@Column("mode", "string")
mode: string | "OpenSSL-2";
@Type(() => Number)
@Column("file_size", "number")
file_size: number;
@Type(() => String)
@Column("filename", "string")
filename: string;
@Column("data", "encoded_json")
data: unknown;
}
export type DriveFileMetadata = {
source?: "internal" | "drive" | string;
external_id: string;
name?: string;
mime?: string;
size?: number;
thumbnails?: DriveFileThumbnail[];
};
type DriveFileThumbnail = {
index: number;
id: string;
type: string;
size: number;
width: number;
height: number;
url: string;
full_url?: string;
};
@@ -0,0 +1,23 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/documents/v1")
export default class DocumentsService extends TdriveService<undefined> {
version = "1";
name = "documents";
public doInit: () => Promise<this> = async () => {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _options, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
};
api = (): undefined => {
return undefined;
};
}
@@ -0,0 +1,78 @@
import globalResolver from "../../../../services/global-resolver";
import { logger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { DocumentsMessageQueueCallback, DocumentsMessageQueueRequest } from "../../types";
import { extractKeywords, officeFileToString, pdfFileToString, isFileType } from "../../utils";
import { officeExtensions, textExtensions, pdfExtensions } from "../../../../utils/mime";
import { readableToString } from "../../../../utils/files";
export class DocumentsProcessor
implements MessageQueueHandler<DocumentsMessageQueueRequest, DocumentsMessageQueueCallback>
{
readonly name = "DocumentsProcessor";
readonly topics = {
in: "services:documents:process",
out: "services:documents:process:callback",
};
readonly options = {
unique: true,
ack: true,
};
validate(message: DocumentsMessageQueueRequest): boolean {
return !!(
message &&
message.context &&
message.item &&
message.item.id &&
message.version &&
message.version.file_metadata &&
message.version.file_metadata.external_id &&
!message.item.is_directory
);
}
async process(message: DocumentsMessageQueueRequest): Promise<DocumentsMessageQueueCallback> {
logger.info(`${this.name} - process document content keywords for ${message.item.id}`);
return await this.generate(message);
}
async generate(message: DocumentsMessageQueueRequest): Promise<DocumentsMessageQueueCallback> {
let content_keywords = "";
let content_strings = "";
try {
const storedFile = await globalResolver.services.files.download(
message.version.file_metadata.external_id,
message.context,
);
const extension = storedFile.name.split(".").pop();
if (isFileType(storedFile.mime, storedFile.name, textExtensions)) {
logger.info("Processing text file");
content_strings = await readableToString(storedFile.file);
}
if (isFileType(storedFile.mime, storedFile.name, pdfExtensions)) {
logger.info("Processing PDF file");
content_strings = await pdfFileToString(storedFile.file);
}
if (isFileType(storedFile.mime, storedFile.name, officeExtensions)) {
logger.info("Processing office file");
content_strings = await officeFileToString(storedFile.file, extension);
}
content_keywords = extractKeywords(content_strings);
} catch (error) {
console.debug(error);
logger.error("Failed to generate content keywords", error);
}
return { content_keywords, item: message.item };
}
}
@@ -0,0 +1,18 @@
import globalResolver from "../../../global-resolver";
import { Initializable } from "../../../../core/platform/framework";
import { DocumentsProcessor } from "./extract-keywords";
import { DriveFile, TYPE } from "../../entities/drive-file";
import { DocumentsFinishedProcess } from "./save-keywords";
export class DocumentsEngine implements Initializable {
async init(): Promise<this> {
const repository = await globalResolver.database.getRepository<DriveFile>(TYPE, DriveFile);
globalResolver.platformServices.messageQueue.processor.addHandler(new DocumentsProcessor());
globalResolver.platformServices.messageQueue.processor.addHandler(
new DocumentsFinishedProcess(repository),
);
return this;
}
}
@@ -0,0 +1,65 @@
import { logger } from "../../../../core/platform/framework";
import { ExecutionContext } from "../../../../core/platform/framework/api/crud-service";
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { DriveFile } from "../../entities/drive-file";
import { DocumentsMessageQueueCallback } from "../../types";
export class DocumentsFinishedProcess
implements MessageQueueHandler<DocumentsMessageQueueCallback, void>
{
constructor(private repository: Repository<DriveFile>) {}
readonly name = "DocumentsFinishedProcess";
readonly topics = {
in: "services:documents:process:callback",
};
readonly options = {
unique: true,
ack: true,
};
init? = (): Promise<this> => {
throw Error("Method not implemented.");
};
validate = (message: DocumentsMessageQueueCallback): boolean => {
return !!(
message &&
message.content_keywords &&
message.content_keywords.length &&
message.item &&
message.item.id
);
};
process = async (
message: DocumentsMessageQueueCallback,
context?: ExecutionContext,
): Promise<void> => {
logger.info(`${this.name} - updating drive item content keywords`);
try {
const entity = await this.repository.findOne(
{
id: message.item.id,
company_id: message.item.company_id,
},
{},
context,
);
if (!entity) {
throw Error("Drive item not found");
}
entity.content_keywords = message.content_keywords;
return await this.repository.save(entity, context);
} catch (error) {
logger.error(`${this.name} - Failed to set content keywords`, error);
return;
}
};
}
@@ -0,0 +1,840 @@
import SearchRepository from "../../../core/platform/services/search/repository";
import { getLogger, logger, TdriveLogger } from "../../../core/platform/framework";
import { CrudException, ListResult } from "../../../core/platform/framework/api/crud-service";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { PublicFile } from "../../../services/files/entities/file";
import globalResolver from "../../../services/global-resolver";
import { hasCompanyAdminLevel } from "../../../utils/company";
import gr from "../../global-resolver";
import { DriveFile, TYPE } from "../entities/drive-file";
import { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
import {
DriveTdriveTab as DriveTdriveTabEntity,
TYPE as DriveTdriveTabRepoType,
} from "../entities/drive-tdrive-tab";
import {
DriveExecutionContext,
DocumentsMessageQueueRequest,
DriveItemDetails,
RootType,
SearchDocumentsOptions,
TrashType,
CompanyExecutionContext,
DriveTdriveTab,
} from "../types";
import {
addDriveItemToArchive,
calculateItemSize,
canMoveItem,
checkAccess,
getAccessLevel,
getDefaultDriveItem,
getDefaultDriveItemVersion,
getFileMetadata,
getItemName,
getPath,
hasAccessLevel,
makeStandaloneAccessLevel,
updateItemSize,
} from "../utils";
import { websocketEventBus } from "../../../core/platform/services/realtime/bus";
import archiver from "archiver";
import internal from "stream";
import {
RealtimeEntityActionType,
ResourcePath,
} from "../../../core/platform/services/realtime/types";
export class DocumentsService {
version: "1";
repository: Repository<DriveFile>;
searchRepository: SearchRepository<DriveFile>;
fileVersionRepository: Repository<FileVersion>;
driveTdriveTabRepository: Repository<DriveTdriveTabEntity>;
ROOT: RootType = "root";
TRASH: TrashType = "trash";
logger: TdriveLogger = getLogger("Documents Service");
async init(): Promise<this> {
try {
this.repository = await globalResolver.database.getRepository<DriveFile>(TYPE, DriveFile);
this.searchRepository = globalResolver.platformServices.search.getRepository<DriveFile>(
TYPE,
DriveFile,
);
this.fileVersionRepository = await globalResolver.database.getRepository<FileVersion>(
FileVersionType,
FileVersion,
);
this.driveTdriveTabRepository =
await globalResolver.database.getRepository<DriveTdriveTabEntity>(
DriveTdriveTabRepoType,
DriveTdriveTabEntity,
);
} catch (error) {
logger.error("Error while initializing Documents Service", error);
}
return this;
}
/**
* Fetches a drive element
*
* @param {string} id - the id of the DriveFile to fetch or "trash" or an empty string for root folder.
* @param {DriveExecutionContext} context
* @returns {Promise<DriveItemDetails>}
*/
get = async (id: string, context: DriveExecutionContext): Promise<DriveItemDetails> => {
if (!context) {
this.logger.error("invalid context");
return null;
}
id = id || this.ROOT;
//Get requested entity
const entity =
id === this.ROOT || id === this.TRASH
? null
: await this.repository.findOne(
{
company_id: context.company.id,
id,
},
{},
context,
);
if (!entity && !(id === this.ROOT || id === this.TRASH)) {
this.logger.error("Drive item not found");
throw new CrudException("Item not found", 404);
}
//Check access to entity
try {
const hasAccess = await checkAccess(id, entity, "read", this.repository, context);
if (!hasAccess) {
this.logger.error("user does not have access drive item ", id);
throw Error("user does not have access to this item");
}
} catch (error) {
this.logger.error("Failed to grant access to the drive item", error);
throw new CrudException("User does not have access to this item or its children", 401);
}
const isDirectory = entity ? entity.is_directory : true;
//Get entity version in case of a file
const versions = isDirectory
? []
: (
await this.fileVersionRepository.find(
{
drive_item_id: entity.id,
},
{},
context,
)
).getEntities();
//Get children if it is a directory
let children = isDirectory
? (
await this.repository.find(
{
company_id: context.company.id,
parent_id: id,
},
{},
context,
)
).getEntities()
: [];
//Check each children for access
const accessMap: { [key: string]: boolean } = {};
await Promise.all(
children.map(async child => {
accessMap[child.id] = await checkAccess(child.id, child, "read", this.repository, context);
}),
);
children = children.filter(child => accessMap[child.id]);
//Return complete object
return {
path: await getPath(id, this.repository, false, context),
item:
entity ||
({
id,
parent_id: null,
name: id === this.ROOT ? "root" : id === this.TRASH ? "trash" : "unknown",
size: await calculateItemSize(
id === this.ROOT ? this.ROOT : "trash",
this.repository,
context,
),
} as DriveFile),
versions: versions,
children: children,
access: await getAccessLevel(id, entity, this.repository, context),
};
};
/**
* Creates a DriveFile item.
*
* @param {PublicFile} file - the multipart file
* @param {Partial<DriveFile>} content - the DriveFile item to create
* @param {Partial<FileVersion>} version - the DriveFile version.
* @param {DriveExecutionContext} context - the company execution context.
* @returns {Promise<DriveFile>} - the created DriveFile
*/
create = async (
file: PublicFile | null,
content: Partial<DriveFile>,
version: Partial<FileVersion>,
context: DriveExecutionContext,
): Promise<DriveFile> => {
try {
const driveItem = getDefaultDriveItem(content, context);
const driveItemVersion = getDefaultDriveItemVersion(version, context);
const hasAccess = await checkAccess(
driveItem.parent_id,
null,
"write",
this.repository,
context,
);
if (!hasAccess) {
this.logger.error("User does not have access to parent drive item", driveItem.parent_id);
throw Error("User does not have access to this item parent");
}
if (file || driveItem.is_directory === false) {
let fileToProcess;
if (file) {
fileToProcess = file;
} else if (driveItemVersion.file_metadata.external_id) {
fileToProcess = await globalResolver.services.files.getFile(
{
id: driveItemVersion.file_metadata.external_id,
company_id: driveItem.company_id,
},
context,
{ waitForThumbnail: true },
);
}
if (fileToProcess) {
driveItem.size = fileToProcess.upload_data.size;
driveItem.is_directory = false;
driveItem.extension = fileToProcess.metadata.name.split(".").pop();
driveItemVersion.filename = driveItemVersion.filename || fileToProcess.metadata.name;
driveItemVersion.file_size = fileToProcess.upload_data.size;
driveItemVersion.file_metadata.external_id = fileToProcess.id;
driveItemVersion.file_metadata.mime = fileToProcess.metadata.mime;
driveItemVersion.file_metadata.size = fileToProcess.upload_data.size;
driveItemVersion.file_metadata.name = fileToProcess.metadata.name;
driveItemVersion.file_metadata.thumbnails = fileToProcess.thumbnails;
if (context.user.application_id) {
driveItemVersion.application_id = context.user.application_id;
}
}
}
driveItem.name = await getItemName(
driveItem.parent_id,
driveItem.id,
driveItem.name,
driveItem.is_directory,
this.repository,
context,
);
await this.repository.save(driveItem);
driveItemVersion.drive_item_id = driveItem.id;
await this.fileVersionRepository.save(driveItemVersion);
driveItem.last_version_cache = driveItemVersion;
await this.repository.save(driveItem);
await updateItemSize(driveItem.parent_id, this.repository, context);
this.notifyWebsocket(driveItem.parent_id, context);
globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
"services:documents:process",
{
data: {
item: driveItem,
version: driveItemVersion,
context,
},
},
);
return driveItem;
} catch (error) {
this.logger.error("Failed to create drive item", error);
throw new CrudException("Failed to create item", 500);
}
};
/**
* Updates a DriveFile item
*
* @param {string} id - the id of the DriveFile to update.
* @param {Partial<DriveFile>} content - the updated content
* @param {DriveExecutionContext} context - the company execution context
* @returns {Promise<DriveFile>} - the updated DriveFile
*/
update = async (
id: string,
content: Partial<DriveFile>,
context: CompanyExecutionContext,
): Promise<DriveFile> => {
if (!context) {
this.logger.error("invalid execution context");
return null;
}
try {
let oldParent = null;
const level = await getAccessLevel(id, null, this.repository, context);
const hasAccess = hasAccessLevel("write", level);
if (!hasAccess) {
this.logger.error("user does not have access drive item ", id);
throw Error("user does not have access to this item");
}
const item = await this.repository.findOne({
company_id: context.company.id,
id,
});
if (!item) {
this.logger.error("Drive item not found");
throw Error("Item not found");
}
if (content.id && content.id !== id) {
this.logger.error("content mismatch");
throw Error("content mismatch");
}
const updatable = ["access_info", "name", "tags", "parent_id", "description"];
for (const key of updatable) {
if ((content as any)[key]) {
if (
key === "parent_id" &&
!(await canMoveItem(item.id, content.parent_id, this.repository, context))
) {
throw Error("Move operation not permitted");
} else {
oldParent = item.parent_id;
}
if (key === "name") {
item.name = await getItemName(
content.parent_id || item.parent_id,
item.id,
content.name,
item.is_directory,
this.repository,
context,
);
} else {
(item as any)[key] = (content as any)[key];
}
}
}
//We cannot do a change that would make the item unreachable
if (
level === "manage" &&
!(await checkAccess(item.id, item, "manage", this.repository, context))
) {
throw new Error("Cannot change access level to make the item unreachable");
}
await this.repository.save(item);
await updateItemSize(item.parent_id, this.repository, context);
if (oldParent) {
await updateItemSize(oldParent, this.repository, context);
this.notifyWebsocket(oldParent, context);
}
this.notifyWebsocket(item.parent_id, context);
if (item.parent_id === this.TRASH) {
//When moving to trash we recompute the access level to make them flat
item.access_info = await makeStandaloneAccessLevel(
item.company_id,
item.id,
item,
this.repository,
);
}
return item;
} catch (error) {
this.logger.error("Failed to update drive item", error);
throw new CrudException("Failed to update item", 500);
}
};
/**
* deletes or moves to Trash a Drive Document and its children
*
* @param {string} id - the item id
* @param {DriveExecutionContext} context - the execution context
* @returns {Promise<void>}
*/
delete = async (
id: string | RootType | TrashType,
item?: DriveFile,
context?: DriveExecutionContext,
): Promise<void> => {
if (!id) {
//We can't remove the root folder
return;
}
//In the case of the trash we definitively delete the items
if (id === "trash") {
//Only administrators can execute this action
const role = await gr.services.companies.getUserRole(context.company.id, context.user?.id);
if (hasCompanyAdminLevel(role) === false) {
throw new CrudException("Only administrators can empty the trash", 403);
}
try {
const itemsInTrash = await this.repository.find(
{
company_id: context.company.id,
parent_id: "trash",
},
{},
context,
);
await Promise.all(
itemsInTrash.getEntities().map(async item => {
await this.delete(item.id, item, context);
}),
);
} catch (error) {
this.logger.error("Failed to empty trash", error);
throw new CrudException("Failed to empty trash", 500);
}
return;
} else {
item =
item ||
(await this.repository.findOne({
company_id: context.company.id,
id,
}));
if (!item) {
this.logger.error("item to delete not found");
throw new CrudException("Drive item not found", 404);
}
try {
if (!(await checkAccess(item.id, item, "manage", this.repository, context))) {
this.logger.error("user does not have access drive item ", id);
throw Error("user does not have access to this item");
}
} catch (error) {
this.logger.error("Failed to grant access to the drive item", error);
throw new CrudException("User does not have access to this item or its children", 401);
}
const previousParentId = item.parent_id;
if (
item.parent_id === this.TRASH ||
(await getPath(item.parent_id, this.repository, true, context))[0].id === this.TRASH
) {
//This item is already in trash, we can delete it definitively
if (item.is_directory) {
//We delete the children
const children = await this.repository.find(
{
company_id: context.company.id,
parent_id: item.id,
},
{},
context,
);
await Promise.all(
children.getEntities().map(async child => {
await this.delete(child.id, child, context);
}),
);
} else {
//Delete the version and stored file
const itemVersions = await this.fileVersionRepository.find(
{
drive_item_id: item.id,
},
{},
context,
);
await Promise.all(
itemVersions.getEntities().map(async version => {
await this.fileVersionRepository.remove(version);
await gr.services.files.delete(version.file_metadata.external_id, context);
}),
);
}
await this.repository.remove(item);
} else {
//This item is not in trash, we move it to trash
item.parent_id = this.TRASH;
await this.update(item.id, item, context);
}
await updateItemSize(previousParentId, this.repository, context);
this.notifyWebsocket(previousParentId, context);
}
this.notifyWebsocket("trash", context);
};
/**
* Create a Drive item version
*
* @param {string} id - the Drive item id to create a version for.
* @param {Partial<FileVersion>} version - the version item.
* @param {DriveExecutionContext} context - the company execution context
* @returns {Promise<FileVersion>} - the created FileVersion
*/
createVersion = async (
id: string,
version: Partial<FileVersion>,
context: DriveExecutionContext,
): Promise<FileVersion> => {
if (!context) {
this.logger.error("invalid execution context");
return null;
}
try {
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
if (!hasAccess) {
this.logger.error("user does not have access drive item ", id);
throw Error("user does not have access to this item");
}
const item = await this.repository.findOne(
{
id,
company_id: context.company.id,
},
{},
context,
);
if (!item) {
throw Error("Drive item not found");
}
if (item.is_directory) {
throw Error("cannot create version for a directory");
}
const driveItemVersion = getDefaultDriveItemVersion(version, context);
const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context);
driveItemVersion.file_size = metadata.size;
driveItemVersion.file_metadata.size = metadata.size;
driveItemVersion.file_metadata.name = metadata.name;
driveItemVersion.file_metadata.mime = metadata.mime;
driveItemVersion.file_metadata.thumbnails = metadata.thumbnails;
driveItemVersion.drive_item_id = item.id;
if (context.user.application_id) {
driveItemVersion.application_id = context.user.application_id;
}
await this.fileVersionRepository.save(driveItemVersion);
item.last_version_cache = driveItemVersion;
item.size = driveItemVersion.file_size;
await this.repository.save(item);
this.notifyWebsocket(item.parent_id, context);
await updateItemSize(item.parent_id, this.repository, context);
globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
"services:documents:process",
{
data: {
item,
version: driveItemVersion,
context,
},
},
);
return driveItemVersion;
} catch (error) {
this.logger.error("Failed to create Drive item version", error);
throw new CrudException("Failed to create Drive item version", 500);
}
};
downloadGetToken = async (
ids: string[],
versionId: string | null,
context: DriveExecutionContext,
): Promise<string> => {
for (const id of ids) {
const item = await this.get(id, context);
if (!item) {
throw new CrudException("Drive item not found", 404);
}
}
return globalResolver.platformServices.auth.sign({
ids,
version_id: versionId,
company_id: context.company.id,
user_id: context.user?.id,
});
};
applyDownloadTokenToContext = async (
ids: string[],
versionId: string | null,
token: string,
context: DriveExecutionContext,
): Promise<void> => {
try {
const v = globalResolver.platformServices.auth.verifyTokenObject<{
ids: string[];
version_id: string;
company_id: string;
user_id: string;
}>(token);
if (
ids.some(a => !(v?.ids || [])?.includes(a)) ||
(v?.version_id && versionId && v?.version_id !== versionId)
) {
return;
}
context.company = { id: v.company_id };
context.user = { id: v.user_id };
} catch (e) {
if (token) throw new CrudException("Invalid token", 401);
}
};
download = async (
id: string,
versionId: string | null,
context: DriveExecutionContext,
): Promise<{
archive?: archiver.Archiver;
file?: {
file: internal.Readable;
name: string;
mime: string;
size: number;
};
}> => {
const item = await this.get(id, context);
if (item.item.is_directory) {
return { archive: await this.createZip([id], context) };
}
let version = item.item.last_version_cache;
if (versionId) version = item.versions.find(version => version.id === versionId);
if (!version) {
throw new CrudException("Version not found", 404);
}
const fileId = version.file_metadata.external_id;
const file = await globalResolver.services.files.download(fileId, context);
return { file };
};
/**
* Creates a zip archive containing the drive items.
*
* @param {string[]} ids - the drive item list
* @param {DriveExecutionContext} context - the execution context
* @returns {Promise<archiver.Archiver>} the created archive.
*/
createZip = async (
ids: string[] = [],
context: DriveExecutionContext,
): Promise<archiver.Archiver> => {
if (!context) {
this.logger.error("invalid execution context");
return null;
}
const archive = archiver("zip", {
zlib: { level: 9 },
});
for (const id of ids) {
if (!(await checkAccess(id, null, "read", this.repository, context))) {
this.logger.warn(`not enough permissions to download ${id}, skipping`);
return;
}
try {
await addDriveItemToArchive(id, null, archive, this.repository, context);
} catch (error) {
console.error(error);
this.logger.warn("failed to add item to archive", error);
}
}
archive.finalize();
return archive;
};
notifyWebsocket = async (id: string, context: DriveExecutionContext) => {
websocketEventBus.publish(RealtimeEntityActionType.Event, {
type: "documents:updated",
room: ResourcePath.get(`/companies/${context.company.id}/documents/item/${id}`),
entity: {
companyId: context.company.id,
id: id,
},
resourcePath: null,
result: null,
});
};
/**
* Search for Drive items.
*
* @param {SearchDocumentsOptions} options - the search optins.
* @param {DriveExecutionContext} context - the execution context.
* @returns {Promise<ListResult<DriveFile>>} - the search result.
*/
search = async (
options: SearchDocumentsOptions,
context?: DriveExecutionContext,
): Promise<ListResult<DriveFile>> => {
const result = await this.searchRepository.search(
{},
{
pagination: {
limitStr: "100",
},
...(options.company_id ? { $in: [["company_id", [options.company_id]]] } : {}),
...(options.creator ? { $in: [["creator", [options.creator]]] } : {}),
...(options.added ? { $in: [["added", [options.added]]] } : {}),
$text: {
$search: options.search,
},
},
context,
);
// Use Promise.all to check access on each item in parallel
const filteredResult = await Promise.all(
result.getEntities().filter(async item => {
try {
// Check access for each item
const hasAccess = await checkAccess(item.id, null, "read", this.repository, context);
// Return true if the user has access
return hasAccess;
} catch (error) {
this.logger.warn("failed to check item access", error);
return false;
}
}),
);
return new ListResult(result.type, filteredResult, result.nextPage);
};
getTab = async (tabId: string, context: CompanyExecutionContext): Promise<DriveTdriveTab> => {
const tab = await this.driveTdriveTabRepository.findOne(
{ company_id: context.company.id, tab_id: tabId },
{},
context,
);
return tab;
};
setTab = async (
tabId: string,
channelId: string,
itemId: string,
level: "read" | "write",
context: CompanyExecutionContext,
): Promise<DriveTdriveTab> => {
const hasAccess = await checkAccess(itemId, null, "manage", this.repository, context);
if (!hasAccess) {
throw new CrudException("Not enough permissions", 403);
}
const previousTabConfiguration = await this.getTab(tabId, context);
const item = await this.repository.findOne(
{
company_id: context.company.id,
id: itemId,
},
{},
context,
);
await this.driveTdriveTabRepository.save(
Object.assign(new DriveTdriveTabEntity(), {
company_id: context.company.id,
tab_id: tabId,
channel_id: channelId,
item_id: itemId,
level,
}),
context,
);
await this.update(
item.id,
{
...item,
access_info: {
...item.access_info,
entities: [
...(item.access_info?.entities || []).filter(
e =>
!previousTabConfiguration ||
!(e.type === "channel" && e.id !== previousTabConfiguration.channel_id),
),
{
type: "channel",
id: channelId,
level: level === "write" ? "write" : "read",
},
],
},
},
context,
);
return await this.getTab(tabId, context);
};
}
@@ -0,0 +1,75 @@
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
import { DriveFile } from "./entities/drive-file";
import { FileVersion } from "./entities/file-version";
export interface CompanyExecutionContext extends ExecutionContext {
company: { id: string };
}
export type DriveExecutionContext = CompanyExecutionContext & {
public_token?: string;
};
export type RequestParams = {
company_id: string;
};
export type ItemRequestParams = RequestParams & {
id: string;
};
export type DriveItemDetails = {
path: DriveFile[];
item?: DriveFile;
versions?: FileVersion[];
children: DriveFile[];
access: DriveFileAccessLevel | "none";
};
export type DriveFileAccessLevel = "read" | "write" | "manage";
export type publicAccessLevel = "write" | "read" | "none";
export type RootType = "root";
export type TrashType = "trash";
export type DownloadZipBodyRequest = {
items: string[];
};
export type SearchDocumentsOptions = {
search?: string;
company_id?: string;
creator?: string;
added?: string;
};
export type SearchDocumentsBody = {
search?: string;
company_id?: string;
creator?: string;
added?: string;
};
export type DocumentsMessageQueueRequest = {
item: DriveFile;
version: FileVersion;
context: CompanyExecutionContext;
};
export type DocumentsMessageQueueCallback = {
item: DriveFile;
content_keywords: string;
};
export type exportKeywordPayload = {
file_id: string;
company_id: string;
};
export type DriveTdriveTab = {
company_id: string;
tab_id: string;
channel_id: string;
item_id: string;
level: "read" | "write";
};
@@ -0,0 +1,802 @@
import mimes from "../../utils/mime";
import { merge } from "lodash";
import { DriveFile } from "./entities/drive-file";
import {
CompanyExecutionContext,
DriveExecutionContext,
DriveFileAccessLevel,
RootType,
TrashType,
} from "./types";
import crypto from "crypto";
import { FileVersion, DriveFileMetadata } from "./entities/file-version";
import globalResolver from "../global-resolver";
import Repository from "../../core/platform/services/database/services/orm/repository/repository";
import archiver from "archiver";
import { Readable } from "stream";
import { stopWords } from "./const";
import unoconv from "unoconv-promise";
import {
writeToTemporaryFile,
cleanFiles,
getTmpFile,
readFromTemporaryFile,
readableToBuffer,
} from "../../utils/files";
import PdfParse from "pdf-parse";
import _ from "lodash";
import { logger } from "../../core/platform/framework";
/**
* Returns the default DriveFile object using existing data
*
* @param {Partial<DriveFile>} item - the partial drive file item.
* @param {CompanyExecutionContext} context - the company execution context
* @returns {DriveFile} - the Default DriveFile
*/
export const getDefaultDriveItem = (
item: Partial<DriveFile>,
context: CompanyExecutionContext,
): DriveFile => {
const defaultDriveItem = merge<DriveFile, Partial<DriveFile>>(new DriveFile(), {
company_id: context.company.id,
added: item.added || new Date().getTime().toString(),
creator: item.creator || context.user?.id,
is_directory: item.is_directory || false,
is_in_trash: item.is_in_trash || false,
last_modified: new Date().getTime().toString(),
parent_id: item.parent_id || "root",
content_keywords: item.content_keywords || "",
description: item.description || "",
access_info: item.access_info || {
entities: [
{
id: "parent",
type: "folder",
level: "manage",
},
{
id: item.company_id,
type: "company",
level: "none",
},
{
id: context.user?.id,
type: "user",
level: "manage",
},
],
public: {
level: "none",
token: generateAccessToken(),
},
},
extension: item.extension || "",
last_version_cache: item.last_version_cache,
name: item.name || "",
size: item.size || 0,
tags: item.tags || [],
});
if (item.id) {
defaultDriveItem.id = item.id;
}
return defaultDriveItem;
};
/**
* Returns the default FileVersion item.
*
* @param {Partial<FileVersion>} version - the partial version item
* @param {CompanyExecutionContext} context - the execution context
* @returns
*/
export const getDefaultDriveItemVersion = (
version: Partial<FileVersion>,
context: CompanyExecutionContext,
): FileVersion => {
const defaultVersion = merge(new FileVersion(), {
application_id: version.application_id || "",
creator_id: version.creator_id || context.user?.id,
data: version.data || {},
date_added: version.date_added || new Date().getTime(),
drive_item_id: version.drive_item_id || "",
file_metadata: version.file_metadata || {},
file_size: version.file_size || 0,
filename: version.filename || "",
key: version.key || "",
mode: version.mode || "OpenSSL-2",
provider: version.provider,
realname: version.realname,
});
if (version.id) {
defaultVersion.id = version.id;
}
return defaultVersion;
};
/**
* Generates a random sha1 access token
*
* @returns {String} - the random access token ( sha1 hex digest ).
*/
export const generateAccessToken = (): string => {
const randomBytes = crypto.randomBytes(64);
return crypto.createHash("sha1").update(randomBytes).digest("hex");
};
/**
* Checks if the level meets the required level.
*
* @param {publicAccessLevel | DriveFileAccessLevel} requiredLevel
* @param {publicAccessLevel} level
* @returns {boolean}
*/
export const hasAccessLevel = (
requiredLevel: DriveFileAccessLevel | "none",
level: DriveFileAccessLevel | "none",
): boolean => {
if (requiredLevel === level) return true;
if (requiredLevel === "write") {
return level === "manage";
}
if (requiredLevel === "read") {
return level === "manage" || level === "write";
}
return requiredLevel === "none";
};
/**
* checks the current user is a guest
*
* @param {CompanyExecutionContext} context
* @returns {Promise<boolean>}
*/
export const isCompanyGuest = async (context: CompanyExecutionContext): Promise<boolean> => {
const userRole = await globalResolver.services.companies.getUserRole(
context.company.id,
context.user?.id,
);
return userRole === "guest" || !userRole;
};
/**
* checks the current user is a admin
*
* @param {CompanyExecutionContext} context
* @returns {Promise<boolean>}
*/
export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise<boolean> => {
const userRole = await globalResolver.services.companies.getUserRole(
context.company.id,
context.user?.id,
);
return userRole === "admin";
};
/**
* Calculates the size of the Drive Item
*
* @param {DriveFile} item - The file or directory
* @param {Repository<DriveFile>} repository - the database repository
* @param {CompanyExecutionContext} context - the execution context
* @returns {Promise<number>} - the size of the Drive Item
*/
export const calculateItemSize = async (
item: DriveFile | TrashType | RootType,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
): Promise<number> => {
if (item === "trash") {
const trashedItems = await repository.find(
{ company_id: context.company.id, parent_id: "trash" },
{},
context,
);
return trashedItems.getEntities().reduce((acc, curr) => acc + curr.size, 0);
}
if (item === "root" || !item) {
const rootFolderItems = await repository.find(
{ company_id: context.company.id, parent_id: "root" },
{},
context,
);
return rootFolderItems.getEntities().reduce((acc, curr) => acc + curr.size, 0);
}
if (item.is_directory) {
const children = await repository.find(
{
company_id: context.company.id,
parent_id: item.id,
},
{},
context,
);
return children.getEntities().reduce((acc, curr) => acc + curr.size, 0);
}
return item.size;
};
/**
* Recalculates and updates the Drive item size
*
* @param {string} id - the item id
* @param {Repository<DriveFile>} repository
* @param {CompanyExecutionContext} context - the execution context
* @returns {Promise<void>}
*/
export const updateItemSize = async (
id: string,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
): Promise<void> => {
if (!id || id === "root" || id === "trash") return;
const item = await repository.findOne({ id, company_id: context.company.id });
if (!item) {
throw Error("Drive item doesn't exist");
}
item.size = await calculateItemSize(item, repository, context);
await repository.save(item);
if (item.parent_id === "root" || item.parent_id === "trash") {
return;
}
await updateItemSize(item.parent_id, repository, context);
};
/**
* gets the path for the driveitem
*
* @param {string} id
* @param {Repository<DriveFile>} repository
* @param {boolean} ignoreAccess
* @param {CompanyExecutionContext} context
* @returns
*/
export const getPath = async (
id: string,
repository: Repository<DriveFile>,
ignoreAccess?: boolean,
context?: DriveExecutionContext,
): Promise<DriveFile[]> => {
id = id || "root";
if (id === "root" || id === "trash")
return !context.public_token || ignoreAccess
? [
{
id,
name: id === "root" ? "Home" : "Trash",
} as DriveFile,
]
: [];
const item = await repository.findOne({
id,
company_id: context.company.id,
});
if (!item || (!(await checkAccess(id, item, "read", repository, context)) && !ignoreAccess)) {
return [];
}
return [...(await getPath(item.parent_id, repository, ignoreAccess, context)), item];
};
/**
* checks if access can be granted for the drive item
*
* @param {string} id
* @param {DriveFile | null} item
* @param {DriveFileAccessLevel} level
* @param {Repository<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const checkAccess = async (
id: string,
item: DriveFile | null,
level: DriveFileAccessLevel,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<boolean> => {
if (context.user?.server_request) {
return true;
}
const grantedLevel = await getAccessLevel(id, item, repository, context);
const hasAccess = hasAccessLevel(level, grantedLevel);
logger.info(
`Got level ${grantedLevel} for drive item ${id} and required ${level} - returning ${hasAccess}`,
);
return hasAccess;
};
/**
* get maximum level for the drive item
*
* @param {string} id
* @param {DriveFile | null} item
* @param {Repository<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const getAccessLevel = async (
id: string,
item: DriveFile | null,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<DriveFileAccessLevel | "none"> => {
if (!id || id === "root")
return !context?.user?.id ? "none" : (await isCompanyGuest(context)) ? "read" : "manage";
if (id === "trash")
return (await isCompanyGuest(context)) || !context?.user?.id
? "none"
: (await isCompanyAdmin(context))
? "manage"
: "write";
const publicToken = context.public_token;
try {
item =
item ||
(await repository.findOne({
id,
company_id: context.company.id,
}));
if (!item) {
throw Error("Drive item doesn't exist");
}
/*
* Specific user or channel rule is applied first. Then less restrictive level will be chosen
* between the parent folder and company accesses.
*/
//Public access
if (publicToken) {
if (!item.access_info.public.token) return "none";
const { token: itemToken, level: itemLevel } = item.access_info.public;
if (itemToken === publicToken) return itemLevel;
}
const accessEntities = item.access_info.entities || [];
const otherLevels = [];
//From there a user must be logged in
if (context?.user?.id) {
//Users
const matchingUser = accessEntities.find(a => a.type === "user" && a.id === context.user?.id);
if (matchingUser) return matchingUser.level;
//Channels
if (context.tdrive_tab_token) {
try {
const [channelId] = context.tdrive_tab_token.split("+"); //First item will be the channel id
const matchingChannel = accessEntities.find(
a => a.type === "channel" && a.id === channelId,
);
if (matchingChannel) return matchingChannel.level;
} catch (e) {
console.log(e);
}
}
//Companies
const matchingCompany = accessEntities.find(
a => a.type === "company" && a.id === context.company.id,
);
if (matchingCompany) otherLevels.push(matchingCompany.level);
}
//Parent folder
const maxParentFolderLevel =
accessEntities.find(a => a.type === "folder" && a.id === "parent")?.level || "none";
if (maxParentFolderLevel === "none") {
otherLevels.push(maxParentFolderLevel);
} else {
const parentFolderLevel = await getAccessLevel(item.parent_id, null, repository, context);
otherLevels.push(parentFolderLevel);
}
//Return least restrictive level of otherLevels
return otherLevels.reduce(
(previousValue, b) =>
hasAccessLevel(b as DriveFileAccessLevel, previousValue as DriveFileAccessLevel)
? previousValue
: b,
"none",
) as DriveFileAccessLevel | "none";
} catch (error) {
throw Error(error);
}
};
/**
* Isolate access level information from parent folder logic
* Used when putting folder in the trash
* @param id
* @param item
* @param repository
*/
export const makeStandaloneAccessLevel = async (
companyId: string,
id: string,
item: DriveFile | null,
repository: Repository<DriveFile>,
options: { removePublicAccess?: boolean } = { removePublicAccess: true },
): Promise<DriveFile["access_info"]> => {
item =
item ||
(await repository.findOne({
id,
company_id: companyId,
}));
if (!item) {
throw Error("Drive item doesn't exist");
}
const accessInfo = _.cloneDeep(item.access_info);
if (options?.removePublicAccess && accessInfo?.public?.level) accessInfo.public.level = "none";
const parentFolderAccess = accessInfo.entities.find(
a => a.type === "folder" && a.id === "parent",
);
if (!parentFolderAccess || parentFolderAccess.level === "none") {
return accessInfo;
} else if (item.parent_id !== "root" && item.parent_id !== "trash") {
// Get limitations from parent folder
const accessEntitiesFromParent = await makeStandaloneAccessLevel(
companyId,
item.parent_id,
null,
repository,
options,
);
let mostRestrictiveFolderLevel = parentFolderAccess.level as DriveFileAccessLevel | "none";
const keptEntities = accessEntitiesFromParent.entities.filter(a => {
if (["user", "channel"].includes(a.type)) {
return !accessInfo.entities.find(b => b.type === a.type && b.id === a.id);
} else {
if (a.type === "folder" && a.id === "parent") {
mostRestrictiveFolderLevel = hasAccessLevel(a.level, mostRestrictiveFolderLevel)
? a.level
: mostRestrictiveFolderLevel;
}
return false;
}
});
accessInfo.entities = accessInfo.entities.map(a => {
if (a.type === "folder" && a.id === "parent") {
a.level = mostRestrictiveFolderLevel;
}
return a;
}) as DriveFile["access_info"]["entities"];
accessInfo.entities = [...accessInfo.entities, ...keptEntities];
}
return accessInfo;
};
/**
* Adds drive items to an archive recursively
*
* @param {string} id - the drive item id
* @param {DriveFile | null } entity - the drive item entity
* @param {archiver.Archiver} archive - the archive
* @param {Repository<DriveFile>} repository - the repository
* @param {CompanyExecutionContext} context - the execution context
* @param {string} prefix - folder prefix
* @returns {Promise<void>}
*/
export const addDriveItemToArchive = async (
id: string,
entity: DriveFile | null,
archive: archiver.Archiver,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
prefix?: string,
): Promise<void> => {
const item = entity || (await repository.findOne({ id, company_id: context.company.id }));
if (!item) {
throw Error("item not found");
}
if (!item.is_directory) {
const file_id = item.last_version_cache.file_metadata.external_id;
const file = await globalResolver.services.files.download(file_id, context);
if (!file) {
throw Error("file not found");
}
archive.append(file.file, { name: file.name, prefix: prefix ?? "" });
return;
} else {
const items = await repository.find({
parent_id: item.id,
company_id: context.company.id,
});
for (const child of items.getEntities()) {
await addDriveItemToArchive(
child.id,
child,
archive,
repository,
context,
`${prefix || ""}${item.name}/`,
);
}
return;
}
};
/**
* Extracts the most popular 250 keywords from a text.
*
* @param {string} data - file data string.
* @returns {string}
*/
export const extractKeywords = (data: string): string => {
const words = data.toLowerCase().split(/[^a-zA-Z']+/);
const filteredWords = words.filter(word => !stopWords.includes(word) && word.length > 3);
const wordFrequency = filteredWords.reduce((acc: Record<string, number>, word: string) => {
acc[word] = (acc[word] || 0) + 1;
return acc;
}, {});
const sortedFrequency = Object.entries(wordFrequency)
.sort((a, b) => b[1] - a[1])
.reduce((acc: Record<string, number>, [key, val]) => {
acc[key] = val;
return acc;
}, {});
return Object.keys(sortedFrequency).slice(0, 250).join(" ");
};
/**
* Converts an office file stream into a human readable string.
*
* @param {Readable} file - the input file stream.
* @param {string} extension - the file extension.
* @returns {Promise<string>}
*/
export const officeFileToString = async (file: Readable, extension: string): Promise<string> => {
const officeFilePath = await writeToTemporaryFile(file, extension);
const outputPath = getTmpFile(".pdf");
try {
await unoconv.run({
file: officeFilePath,
output: outputPath,
});
cleanFiles([officeFilePath]);
return await pdfFileToString(outputPath);
} catch (error) {
cleanFiles([officeFilePath]);
throw Error(error);
}
};
/**
* Converts a PDF file stream into a human readable string.
*
* @param {Readable | string} file - the input file stream or path.
* @returns {Promise<string>}
*/
export const pdfFileToString = async (file: Readable | string): Promise<string> => {
let inputBuffer: Buffer;
try {
if (typeof file === "string") {
inputBuffer = await readFromTemporaryFile(file);
cleanFiles([file]);
} else {
inputBuffer = await readableToBuffer(file);
}
const result = await PdfParse(inputBuffer);
return result.text;
} catch (error) {
if (typeof file === "string") {
cleanFiles([file]);
}
throw Error(error);
}
};
/**
* returns the file metadata.
*
* @param {string} fileId - the file id
* @param {CompanyExecutionContext} context - the execution context
* @returns {DriveFileMetadata}
*/
export const getFileMetadata = async (
fileId: string,
context: CompanyExecutionContext,
): Promise<DriveFileMetadata> => {
const file = await globalResolver.services.files.getFile(
{
id: fileId,
company_id: context.company.id,
},
context,
{ ...(context.user.server_request ? {} : { waitForThumbnail: true }) },
);
if (!file) {
throw Error("File doesn't exist");
}
return {
source: "internal",
external_id: fileId,
mime: file.metadata.mime,
name: file.metadata.name,
size: file.upload_data.size,
thumbnails: file.thumbnails,
} as DriveFileMetadata;
};
/**
* Finds a suitable name for an item based on items inside the same folder.
*
* @param {string} parent_id - the parent id.
* @param {string} name - the item name.
* @param {Repository<DriveFile>} repository - the drive repository.
* @param {CompanyExecutionContext} context - the execution context.
* @returns {Promise<string>} - the drive item name.
*/
export const getItemName = async (
parent_id: string,
id: string,
name: string,
is_directory: boolean,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
): Promise<string> => {
try {
let newName = name.substring(0, 255);
let exists = true;
const children = await repository.find(
{
parent_id,
company_id: context.company.id,
},
{},
context,
);
while (exists) {
exists = !!children
.getEntities()
.find(
child => child.name === newName && child.is_directory === is_directory && child.id !== id,
);
if (exists) {
const ext = newName.split(".").pop();
newName =
ext && ext !== newName ? `${newName.slice(0, -ext.length - 1)}-2.${ext}` : `${newName}-2`;
}
}
return newName;
} catch (error) {
throw Error("Failed to get item name");
}
};
/**
* Checks if an item can be moved to its destination
* An item cannot be moved to itself or any of its derived chilren.
*
* @param {string} source - the to be moved item id.
* @param {string} target - the to be moved to item id.
* @param {string} repository - the Drive item repository.
* @param {CompanyExecutionContex} context - the execution context.
* @returns {Promise<boolean>} - whether the move is possible or not.
*/
export const canMoveItem = async (
source: string,
target: string,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
): Promise<boolean> => {
if (source === target) return false;
if (target === "root" || target === "trash") return true;
const item = await repository.findOne({
id: source,
company_id: context.company.id,
});
if (!item.is_directory) {
return true;
}
const targetItem = await repository.findOne({
id: target,
company_id: context.company.id,
});
if (!targetItem || !targetItem.is_directory) {
throw Error("target item doesn't exist or not a directory");
}
if (!(await checkAccess(target, targetItem, "write", repository, context))) {
return false;
}
if (!item) {
throw Error("Item not found");
}
const children = (
await repository.find({
parent_id: source,
company_id: context.company.id,
})
).getEntities();
if (children.some(child => child.id === target)) {
return false;
}
for (const child of children) {
if (child.is_directory && !(await canMoveItem(child.id, target, repository, context))) {
return false;
}
}
return true;
};
export function isFileType(fileMime: string, fileName: string, requiredExtensions: string[]): any {
const extension = fileName.split(".").pop();
const secondaryExtensions = Object.keys(mimes).filter(k => mimes[k] === fileMime);
const fileExtensions = [extension, ...secondaryExtensions];
return fileExtensions.some(e => requiredExtensions.includes(e));
}
@@ -0,0 +1,401 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { logger } from "../../../../core/platform/framework";
import { CrudException, ListResult } from "../../../../core/platform/framework/api/crud-service";
import { File } from "../../../../services/files/entities/file";
import { UploadOptions } from "../../../../services/files/types";
import globalResolver from "../../../../services/global-resolver";
import { PaginationQueryParameters, ResourceWebsocket } from "../../../../utils/types";
import { DriveFile } from "../../entities/drive-file";
import { FileVersion } from "../../entities/file-version";
import {
CompanyExecutionContext,
DriveExecutionContext,
DriveItemDetails,
DriveTdriveTab,
ItemRequestParams,
RequestParams,
SearchDocumentsBody,
SearchDocumentsOptions,
} from "../../types";
export class DocumentsController {
/**
* Creates a DriveFile item
*
* @param {FastifyRequest} request
* @returns
*/
create = async (
request: FastifyRequest<{
Params: RequestParams;
Querystring: Record<string, string>;
Body: {
item: Partial<DriveFile>;
version: Partial<FileVersion>;
};
}>,
): Promise<DriveFile> => {
try {
const context = getDriveExecutionContext(request);
let createdFile: File = null;
if (request.isMultipart()) {
const file = await request.file();
const q = request.query;
const options: UploadOptions = {
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
filename: q.resumableFilename || q.filename || file?.filename || undefined,
type: q.resumableType || q.type || file?.mimetype || undefined,
waitForThumbnail: !!q.thumbnail_sync,
};
createdFile = await globalResolver.services.files.save(null, file, options, context);
}
const { item, version } = request.body;
return await globalResolver.services.documents.documents.create(
createdFile,
item,
version,
context,
);
} catch (error) {
logger.error("Failed to create Drive item", error);
throw new CrudException("Failed to create Drive item", 500);
}
};
/**
* Deletes a DriveFile item or empty the trash or delete root folder contents
*
* @param {FastifyRequest} request
* @returns {Promise<void>}
*/
delete = async (
request: FastifyRequest<{ Params: ItemRequestParams; Querystring: { public_token?: string } }>,
reply: FastifyReply,
): Promise<void> => {
try {
const context = getDriveExecutionContext(request);
await globalResolver.services.documents.documents.delete(request.params.id, null, context);
reply.status(200).send();
} catch (error) {
logger.error("Failed to delete drive item", error);
throw new CrudException("Failed to delete drive item", 500);
}
};
/**
* Lists the drive root folder.
*
* @param {FastifyRequest} request
* @returns {Promise<DriveItemDetails>}
*/
listRootFolder = async (
request: FastifyRequest<{
Params: RequestParams;
Querystring: PaginationQueryParameters & { public_token?: string };
}>,
): Promise<DriveItemDetails> => {
const context = getDriveExecutionContext(request);
return await globalResolver.services.documents.documents.get(null, context);
};
/**
* Fetches a DriveFile item.
*
* @param {FastifyRequest} request
* @returns {Promise<DriveItemDetails>}
*/
get = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Querystring: PaginationQueryParameters & { public_token?: string };
}>,
): Promise<DriveItemDetails & { websockets: ResourceWebsocket[] }> => {
const context = getDriveExecutionContext(request);
const { id } = request.params;
return {
...(await globalResolver.services.documents.documents.get(id, context)),
websockets: request.currentUser?.id
? globalResolver.platformServices.realtime.sign(
[{ room: `/companies/${context.company.id}/documents/item/${id}` }],
request.currentUser?.id,
)
: [],
};
};
/**
* Update drive item
*
* @param {FastifyRequest} request
* @returns {Promise<DriveFile>}
*/
update = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Body: Partial<DriveFile>;
Querystring: { public_token?: string };
}>,
): Promise<DriveFile> => {
const context = getDriveExecutionContext(request);
const { id } = request.params;
const update = request.body;
if (!id) throw new CrudException("Missing id", 400);
return await globalResolver.services.documents.documents.update(id, update, context);
};
/**
* Create a drive file version.
*
* @param {FastifyRequest} request
* @returns {Promise<FileVersion>}
*/
createVersion = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Body: Partial<FileVersion>;
Querystring: { public_token?: string };
}>,
): Promise<FileVersion> => {
const context = getDriveExecutionContext(request);
const { id } = request.params;
const version = request.body;
if (!id) throw new CrudException("Missing id", 400);
return await globalResolver.services.documents.documents.createVersion(id, version, context);
};
downloadGetToken = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Querystring: { version_id?: string; items?: string; public_token?: string };
}>,
): Promise<{ token: string }> => {
const ids = (request.query.items || "").split(",");
const context = getDriveExecutionContext(request);
return {
token: await globalResolver.services.documents.documents.downloadGetToken(
ids,
request.query.version_id,
context,
),
};
};
/**
* Shortcut to download a file (you can also use the file-service directly).
* If the item is a folder, a zip will be automatically generated.
*
* @param {FastifyRequest} request
* @param {FastifyReply} reply
*/
download = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Querystring: { version_id?: string; token?: string; public_token?: string };
}>,
response: FastifyReply,
): Promise<void> => {
const context = getDriveExecutionContext(request);
const id = request.params.id || "";
const versionId = request.query.version_id || null;
const token = request.query.token;
await globalResolver.services.documents.documents.applyDownloadTokenToContext(
[id],
versionId,
token,
context,
);
try {
const archiveOrFile = await globalResolver.services.documents.documents.download(
id,
versionId,
context,
);
if (archiveOrFile.archive) {
const archive = archiveOrFile.archive;
archive.on("finish", () => {
response.status(200);
});
archive.on("error", () => {
response.internalServerError();
});
archive.pipe(response.raw);
} else if (archiveOrFile.file) {
const data = archiveOrFile.file;
const filename = data.name.replace(/[^a-zA-Z0-9 -_.]/g, "");
response.header("Content-disposition", `attachment; filename="${filename}"`);
if (data.size) response.header("Content-Length", data.size);
response.type(data.mime);
response.send(data.file);
}
} catch (error) {
logger.error("failed to download file", error);
throw new CrudException("Failed to download file", 500);
}
};
/**
* Downloads a zip archive containing the drive items.
*
* @param {FastifyRequest} request
* @param {FastifyReply} reply
*/
downloadZip = async (
request: FastifyRequest<{
Params: RequestParams;
Querystring: { token?: string; items: string; public_token?: string };
}>,
reply: FastifyReply,
): Promise<void> => {
const context = getDriveExecutionContext(request);
let ids = (request.query.items || "").split(",");
const token = request.query.token;
await globalResolver.services.documents.documents.applyDownloadTokenToContext(
ids,
null,
token,
context,
);
if (ids[0] === "root") {
const items = await globalResolver.services.documents.documents.get(ids[0], context);
ids = items.children.map(item => item.id);
}
try {
const archive = await globalResolver.services.documents.documents.createZip(ids, context);
archive.on("finish", () => {
reply.status(200);
});
archive.on("error", () => {
reply.internalServerError();
});
archive.pipe(reply.raw);
} catch (error) {
logger.error("failed to send zip file", error);
throw new CrudException("Failed to create zip file", 500);
}
};
/**
* Search for documents.
*
* @param {FastifyRequest} request
* @returns {Promise<ListResult<DriveFile>>}
*/
search = async (
request: FastifyRequest<{
Params: RequestParams;
Body: SearchDocumentsBody;
Querystring: { public_token?: string };
}>,
): Promise<ListResult<DriveFile>> => {
try {
const context = getDriveExecutionContext(request);
const { search = "", added = "", company_id = "", creator = "" } = request.body;
const options: SearchDocumentsOptions = {
company_id: company_id || context.company.id,
...(search ? { search } : {}),
...(added ? { added } : {}),
...(creator ? { creator } : {}),
};
if (!Object.keys(options).length) {
throw Error("Search options are empty");
}
return await globalResolver.services.documents.documents.search(options, context);
} catch (error) {
logger.error("error while searching for document", error);
throw new CrudException("Failed to search for documents", 500);
}
};
getTab = async (
request: FastifyRequest<{
Params: { tab_id: string; company_id: string };
}>,
): Promise<DriveTdriveTab> => {
const context = getCompanyExecutionContext(request);
const { tab_id } = request.params;
return await globalResolver.services.documents.documents.getTab(tab_id, context);
};
setTab = async (
request: FastifyRequest<{
Params: { tab_id: string; company_id: string };
Body: DriveTdriveTab;
}>,
): Promise<DriveTdriveTab> => {
const context = getCompanyExecutionContext(request);
const { tab_id } = request.params;
if (!request.body.channel_id || !request.body.item_id)
throw new Error("Missing parameters (channel_id, item_id)");
return await globalResolver.services.documents.documents.setTab(
tab_id,
request.body.channel_id,
request.body.item_id,
request.body.level,
context,
);
};
}
/**
* Gets the company execution context
*
* @param { FastifyRequest<{ Params: { company_id: string } }>} req
* @returns {CompanyExecutionContext}
*/
const getDriveExecutionContext = (
req: FastifyRequest<{ Params: { company_id: string }; Querystring: { public_token?: string } }>,
): DriveExecutionContext => ({
public_token: req.query.public_token,
user: req.currentUser,
company: { id: req.params.company_id },
url: req.url,
method: req.routerMethod,
reqId: req.id,
transport: "http",
});
function getCompanyExecutionContext(
request: FastifyRequest<{
Params: { company_id: string };
}>,
): CompanyExecutionContext {
return {
user: request.currentUser,
company: { id: request.params.company_id },
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
};
}
@@ -0,0 +1 @@
export * from "./documents";
@@ -0,0 +1,12 @@
import fastifyCaching from "@fastify/caching";
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{ prefix: string }>,
): void => {
fastify.log.debug("configuring /internal/services/documents/v1 routes");
fastify.register(fastifyCaching, { expiresIn: 31536000, privacy: fastifyCaching.privacy.PUBLIC });
fastify.register(routes, options);
};
@@ -0,0 +1,100 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import { DocumentsController } from "./controllers";
import { createDocumentSchema, createVersionSchema } from "./schemas";
const baseUrl = "/companies/:company_id";
const serviceUrl = `${baseUrl}/item`;
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) => {
const documentsController = new DocumentsController();
fastify.route({
method: "GET",
url: `${serviceUrl}`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.listRootFolder.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/:id`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.get.bind(documentsController),
});
fastify.route({
method: "POST",
url: serviceUrl,
preValidation: [fastify.authenticateOptional],
schema: createDocumentSchema,
handler: documentsController.create.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${serviceUrl}/:id`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.update.bind(documentsController),
});
fastify.route({
method: "DELETE",
url: `${serviceUrl}/:id`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.delete.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${serviceUrl}/:id/version`,
preValidation: [fastify.authenticateOptional],
schema: createVersionSchema,
handler: documentsController.createVersion.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/download/token`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.downloadGetToken.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/:id/download`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.download.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/download/zip`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.downloadZip.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${baseUrl}/search`,
preValidation: [fastify.authenticate],
handler: documentsController.search.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${baseUrl}/tabs/:tab_id`,
preValidation: [fastify.authenticate],
handler: documentsController.getTab.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${baseUrl}/tabs/:tab_id`,
preValidation: [fastify.authenticate],
handler: documentsController.setTab.bind(documentsController),
});
return next();
};
export default routes;
@@ -0,0 +1,108 @@
const fileVersionSchema = {
type: "object",
properties: {
id: { type: "string" },
provider: { type: "string" },
drive_item_id: { type: "string" },
file_metadata: {
type: "object",
properties: {
source: { type: "string" },
external_id: { type: "string" },
name: { type: "string" },
mime: { type: "string" },
size: { type: "string" },
thumbnails: {
type: "object",
properties: {
index: { type: "number" },
id: { type: "string" },
type: { type: "string" },
size: { type: "number" },
width: { type: "number" },
height: { type: "number" },
url: { type: "string" },
full_url: { type: "string" },
},
},
},
},
date_added: { type: "string" },
creator_id: { type: "string" },
application_id: { type: "string" },
realname: { type: "string" },
key: { type: "string" },
mode: { type: "string" },
file_size: { type: "number" },
filename: { type: "string" },
data: {},
},
};
const documentSchema = {
type: "object",
properties: {
id: { type: "string" },
company_id: { type: "string" },
parent_id: { type: "string" },
is_in_trash: { type: "boolean" },
is_directory: { type: "boolean" },
name: { type: "string" },
extension: { type: "string" },
description: { type: "string" },
tags: { type: "array" },
added: { type: "string" },
last_modified: { type: "string" },
access_info: {
type: "object",
properties: {
public: {
type: "object",
properties: {
token: { type: "string" },
level: { type: "string" },
},
},
entities: { type: "array" },
},
},
content_keywords: { type: "string" },
hidden_data: {},
root_group_folder: { type: "string" },
creator: { type: "string" },
size: { type: "number" },
detached_file: { type: "boolean" },
has_preview: { type: "boolean" },
shared: { type: "boolean" },
url: { type: "string" },
preview_link: { type: "string" },
object_link_cache: { type: "string" },
external_storage: { type: "boolean" },
last_user: { type: "string" },
attachements: { type: "array" },
last_version_cache: fileVersionSchema,
},
};
export const createDocumentSchema = {
body: {
type: "object",
properties: {
item: { type: "object" },
version: { type: "object" },
},
required: ["item", "version"],
},
response: {
"2xx": documentSchema,
},
};
export const createVersionSchema = {
body: {
type: "object",
},
response: {
"2xx": fileVersionSchema,
},
};
@@ -0,0 +1,90 @@
import { Type } from "class-transformer";
import _ from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
@Entity("files", {
primaryKey: [["company_id"], "id"],
type: "files",
})
export class File {
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Type(() => String)
@Column("user_id", "encoded_string")
user_id: string;
@Column("application_id", "encoded_string")
application_id: null | string;
@Column("encryption_key", "encoded_string")
encryption_key: string;
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
@Column("metadata", "encoded_json")
metadata: null | {
name?: string;
mime?: string;
thumbnails_status?: "done" | "error" | "waiting";
};
@Column("thumbnails", "encoded_json")
thumbnails: Thumbnail[];
@Column("upload_data", "encoded_json")
upload_data: null | {
size: number;
chunks: number;
};
getPublicObject(): PublicFile {
return _.pick(
this,
"company_id",
"id",
"user_id",
"application_id",
"updated_at",
"created_at",
"metadata",
"thumbnails",
"upload_data",
);
}
}
export type PublicFile = Pick<
File,
| "company_id"
| "id"
| "user_id"
| "application_id"
| "updated_at"
| "created_at"
| "metadata"
| "thumbnails"
| "upload_data"
>;
export type Thumbnail = {
index: number;
id: string;
type: string;
size: number;
width: number;
height: number;
url: string;
full_url?: string;
};
@@ -0,0 +1,23 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/files/v1")
export default class FilesService extends TdriveService<undefined> {
version = "1";
name = "files";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -0,0 +1,231 @@
import { randomBytes } from "crypto";
import { Readable } from "stream";
import { Multipart } from "fastify-multipart";
import { UploadOptions } from "../types";
import { File } from "../entities/file";
import Repository from "../../../../src/core/platform/services/database/services/orm/repository/repository";
import { CompanyExecutionContext } from "../web/types";
import { logger } from "../../../core/platform/framework";
import { getDownloadRoute, getThumbnailRoute } from "../web/routes";
import {
CrudException,
DeleteResult,
ExecutionContext,
} from "../../../core/platform/framework/api/crud-service";
import gr from "../../global-resolver";
export class FileServiceImpl {
version: "1";
repository: Repository<File>;
private algorithm = "aes-256-cbc";
private max_preview_file_size = 50000000;
async init(): Promise<this> {
try {
await Promise.all([(this.repository = await gr.database.getRepository<File>("files", File))]);
} catch (err) {
logger.error("Error while initializing files service", err);
}
return this;
}
async save(
id: string,
file: Multipart,
options: UploadOptions,
context: CompanyExecutionContext,
): Promise<File> {
const userId = context.user?.id;
const applicationId: string | null = context.user?.application_id || null;
let entity: File = null;
if (id) {
entity = await this.repository.findOne(
{
company_id: context.company.id,
id: id,
},
{},
context,
);
if (!entity) {
throw new Error(`This file ${id} does not exist`);
}
}
if (!entity) {
entity = new File();
entity.company_id = `${context.company.id}`;
entity.metadata = null;
entity.thumbnails = [];
const iv = randomBytes(8).toString("hex");
const secret_key = randomBytes(16).toString("hex");
entity.encryption_key = `${secret_key}.${iv}`;
entity.user_id = userId;
entity.application_id = applicationId;
entity.upload_data = null;
this.repository.save(entity, context);
}
if (file) {
// Detect a new file upload
// Only applications can overwrite a file.
// Users alone can only write an empty file.
if (applicationId || !entity.upload_data?.size || context.user.server_request) {
if (
//If there was any change to the file
entity.upload_data?.size !== options.totalSize ||
entity.metadata?.name !== options.filename
) {
entity.metadata = {
name: options.filename,
mime: options.type,
thumbnails_status: "done",
};
entity.upload_data = {
size: options.totalSize,
chunks: options.totalChunks || 1,
};
this.repository.save(entity, context);
}
}
let totalUploadedSize = 0;
file.file.on("data", function (chunk) {
totalUploadedSize += chunk.length;
});
await gr.platformServices.storage.write(getFilePath(entity), file.file, {
chunkNumber: options.chunkNumber,
encryptionAlgo: this.algorithm,
encryptionKey: entity.encryption_key,
});
if (entity.upload_data.chunks === 1 && totalUploadedSize) {
entity.upload_data.size = totalUploadedSize;
await this.repository.save(entity, context);
}
}
return entity;
}
async exists(id: string, companyId: string, context?: CompanyExecutionContext): Promise<boolean> {
const entity = await this.getFile({ id, company_id: companyId }, context);
return !!entity;
}
async download(
id: string,
context: CompanyExecutionContext,
): Promise<{ file: Readable; name: string; mime: string; size: number }> {
const entity = await this.get(id, context);
if (!entity) {
throw "File not found";
}
const readable = await gr.platformServices.storage.read(getFilePath(entity), {
totalChunks: entity.upload_data.chunks,
encryptionAlgo: this.algorithm,
encryptionKey: entity.encryption_key,
});
return {
file: readable,
name: entity.metadata.name,
mime: entity.metadata.mime,
size: entity.upload_data.size,
};
}
async thumbnail(
id: string,
index: string,
context: CompanyExecutionContext,
): Promise<{ file: Readable; type: string; size: number }> {
const entity = await this.get(id, context);
if (!entity) {
throw "File not found";
}
const thumbnail = entity.thumbnails[parseInt(index)];
if (!thumbnail) {
throw `Thumbnail ${parseInt(index)} not found`;
}
const thumbnailPath = `${getFilePath(entity)}/thumbnails/${thumbnail.id}`;
const readable = await gr.platformServices.storage.read(thumbnailPath, {
encryptionAlgo: this.algorithm,
encryptionKey: entity.encryption_key,
});
return {
file: readable,
type: thumbnail.type,
size: thumbnail.size,
};
}
get(id: string, context: CompanyExecutionContext): Promise<File> {
if (!id || !context.company.id) {
return null;
}
return this.getFile({ id, company_id: context.company.id }, context);
}
async getFile(
pk: Pick<File, "company_id" | "id">,
context?: ExecutionContext,
options?: {
waitForThumbnail?: boolean;
},
): Promise<File> {
let entity = await this.repository.findOne(pk, {}, context);
if (options?.waitForThumbnail) {
for (let i = 1; i < 100; i++) {
if (entity.metadata.thumbnails_status === "done") {
break;
}
await new Promise(r => setTimeout(r, i * 200));
entity = await this.repository.findOne(pk, {}, context);
}
}
return entity;
}
getThumbnailRoute(file: File, index: string) {
return getThumbnailRoute(file, index);
}
getDownloadRoute(file: File) {
return getDownloadRoute(file);
}
async delete(id: string, context: CompanyExecutionContext): Promise<DeleteResult<File>> {
const fileToDelete = await this.get(id, context);
if (!fileToDelete) {
throw new CrudException("File not found", 404);
}
await this.repository.remove(fileToDelete, context);
const path = getFilePath(fileToDelete);
await gr.platformServices.storage.remove(path, {
totalChunks: fileToDelete.upload_data.chunks,
});
return new DeleteResult("files", fileToDelete, true);
}
}
function getFilePath(entity: File): string {
return `/tdrive/files/${entity.company_id}/${entity.user_id || "anonymous"}/${entity.id}`;
}
@@ -0,0 +1,8 @@
export type UploadOptions = {
filename: string;
type: string;
totalSize: number;
totalChunks: number;
chunkNumber: number;
waitForThumbnail: boolean;
};
@@ -0,0 +1,24 @@
import { File, PublicFile } from "./entities/file";
export const formatPublicFile = (file: Partial<File | PublicFile>): PublicFile => {
if ((file as Partial<File>).getPublicObject) file = (file as Partial<File>).getPublicObject();
return {
...file,
thumbnails: [
...file.thumbnails.map(thumbnail => ({
...thumbnail,
full_url: thumbnail.url.match(/https?:\/\//)
? "/internal/services/files/v1/" + thumbnail.url.replace(/^\//, "")
: thumbnail.url,
})),
],
} as PublicFile;
};
export const fileIsMedia = (file: Partial<File>): boolean => {
return (
file.metadata?.mime?.startsWith("video/") ||
file.metadata?.mime?.startsWith("audio/") ||
file.metadata?.mime?.startsWith("image/")
);
};
@@ -0,0 +1,113 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { Multipart } from "fastify-multipart";
import { ResourceDeleteResponse } from "../../../../utils/types";
import { CompanyExecutionContext } from "../types";
import { UploadOptions } from "../../types";
import { PublicFile } from "../../entities/file";
import gr from "../../../global-resolver";
export class FileController {
async save(
request: FastifyRequest<{
Params: { company_id: string; id: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Querystring: any;
}>,
): Promise<{ resource: PublicFile }> {
const context = getCompanyExecutionContext(request);
let file: null | Multipart = null;
if (request.isMultipart()) {
file = await request.file();
}
const q = request.query;
const options: UploadOptions = {
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
filename: q.resumableFilename || q.filename || file?.filename || undefined,
type: q.resumableType || q.type || file?.mimetype || undefined,
waitForThumbnail: q.thumbnail_sync,
};
const id = request.params.id;
const result = await gr.services.files.save(id, file, options, context);
return {
resource: result.getPublicObject(),
};
}
async download(
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
response: FastifyReply,
): Promise<void> {
const context = getCompanyExecutionContext(request);
const params = request.params;
const data = await gr.services.files.download(params.id, context);
const filename = data.name.replace(/[^a-zA-Z0-9 -_.]/g, "");
response.header("Content-disposition", `attachment; filename="${filename}"`);
if (data.size) response.header("Content-Length", data.size);
response.type(data.mime);
response.send(data.file);
}
async thumbnail(
request: FastifyRequest<{ Params: { company_id: string; id: string; index: string } }>,
response: FastifyReply,
): Promise<void> {
const context = getCompanyExecutionContext(request);
const params = request.params;
try {
const data = await gr.services.files.thumbnail(params.id, params.index, context);
response.header("Content-disposition", "inline");
response.expires(new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 365));
if (data.size) response.header("Content-Length", data.size);
response.type(data.type);
response.send(data.file);
} catch (err) {
console.log(err);
response.statusCode = 500;
response.send("");
}
}
async get(
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
): Promise<{ resource: PublicFile }> {
const context = getCompanyExecutionContext(request);
const params = request.params;
const resource = await gr.services.files.get(params.id, context);
return { resource: resource.getPublicObject() };
}
async delete(
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
): Promise<ResourceDeleteResponse> {
const params = request.params;
const context = getCompanyExecutionContext(request);
const deleteResult = await gr.services.files.delete(params.id, context);
return { status: deleteResult.deleted ? "success" : "error" };
}
}
function getCompanyExecutionContext(
request: FastifyRequest<{
Params: { company_id: string };
}>,
): CompanyExecutionContext {
return {
user: request.currentUser,
company: { id: request.params.company_id },
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
};
}
@@ -0,0 +1 @@
export * from "./files";
@@ -0,0 +1,12 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import fastifyCaching from "@fastify/caching";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{ prefix: string }>,
): void => {
fastify.log.debug("Configuring /internal/services/files/v1 routes");
fastify.register(fastifyCaching, { expiresIn: 31536000, privacy: fastifyCaching.privacy.PUBLIC });
fastify.register(routes, options);
};
@@ -0,0 +1,61 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import { FileController } from "./controllers";
import { File } from "../entities/file";
const filesUrl = "/companies/:company_id/files";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const fileController = new FileController();
fastify.route({
method: "POST",
url: filesUrl,
preValidation: [fastify.authenticate],
handler: fileController.save.bind(fileController),
});
fastify.route({
method: "POST",
url: `${filesUrl}/:id`,
preValidation: [fastify.authenticate],
handler: fileController.save.bind(fileController),
});
fastify.route({
method: "GET",
url: `${filesUrl}/:id/download`,
handler: fileController.download.bind(fileController),
});
fastify.route({
method: "GET",
url: `${filesUrl}/:id`,
preValidation: [fastify.authenticate],
handler: fileController.get.bind(fileController),
});
fastify.route({
method: "GET",
url: `${filesUrl}/:id/thumbnails/:index`,
handler: fileController.thumbnail.bind(fileController),
});
fastify.route({
method: "DELETE",
url: `${filesUrl}/:id`,
preValidation: [fastify.authenticate],
handler: fileController.delete.bind(fileController),
});
next();
};
export const getDownloadRoute = (file: File) => {
return filesUrl.replace(":company_id", file.company_id) + `/${file.id}/download`;
};
export const getThumbnailRoute = (file: File, index: string) => {
return `/internal/services/files/v1/companies/${file.company_id}/files/${file.id}/thumbnails/${index}`;
};
export default routes;
@@ -0,0 +1,13 @@
export const filesSchema = {
type: "object",
properties: {
preferences: {
type: "object",
},
id: { type: "string" },
name: { type: "string" },
size: { type: "number" },
width: { type: "number" },
height: { type: "number" },
},
};
@@ -0,0 +1,5 @@
import { ExecutionContext } from "../../../core/platform/framework/api/crud-service";
export interface CompanyExecutionContext extends ExecutionContext {
company: { id: string };
}
@@ -0,0 +1,3 @@
import { Initializable, TdriveServiceProvider } from "../../core/platform/framework/api";
export interface GeneralServiceAPI extends TdriveServiceProvider, Initializable {}
@@ -0,0 +1,30 @@
import WebServerAPI from "../../core/platform/services/webserver/provider";
import { Consumes, Prefix, TdriveService } from "../../core/platform/framework";
import { GeneralServiceAPI } from "./api";
import web from "./web/index";
import { ServerConfiguration } from "./types";
@Prefix("/internal/services/general/v1")
@Consumes(["webserver"])
export default class GeneralService extends TdriveService<GeneralServiceAPI> {
version = "1";
name = "general";
service: GeneralServiceAPI;
api(): GeneralServiceAPI {
return this.service;
}
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
const configuration = this.configuration.get<ServerConfiguration["configuration"]>();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix, configuration: configuration });
next();
});
return this;
}
}
@@ -0,0 +1,6 @@
import { Languages } from "./types";
export const languages: Languages = {
default: "en",
availables: ["en", "fr", "es", "vn", "ru"],
};
@@ -0,0 +1,41 @@
export type Languages = {
default: string;
availables: string[];
};
export type ServerConfiguration = {
status: "ready";
version: {
current: string;
minimal: {
web: string;
mobile: string;
};
};
configuration: {
help_url: string | null;
pricing_plan_url: string | null;
app_download_url: string | null;
app_grid: { logo: string; name: string; url: string }[];
mobile: {
mobile_redirect: string;
mobile_appstore: string;
mobile_googleplay: string;
};
accounts: {
type: "remote" | "internal";
remote: null | {
authority: string;
client_id: string;
account_management_url: string;
company_management_url: string;
company_subscription_url: string;
collaborators_management_url: string;
};
internal: null | {
disable_account_creation: boolean;
disable_email_verification: boolean;
};
};
};
};
@@ -0,0 +1,14 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import { ServerConfiguration } from "../types";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
configuration: ServerConfiguration["configuration"];
}>,
): void => {
fastify.log.debug("Configuring /internal/services/general/v1 routes");
fastify.register(routes, options);
};

Some files were not shown because too many files have changed in this diff Show More