📁 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
+10
View File
@@ -0,0 +1,10 @@
# /node_modules/* in the project root is ignored by default
# build artefacts
dist/*
coverage/*
# data definition files
**/*.d.ts
# custom definition files
/src/types/
# tests
test/*
+36
View File
@@ -0,0 +1,36 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier", "unused-imports"],
"extends": ["plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"ignoreRestSiblings": false,
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"semi": ["off"],
"@typescript-eslint/semi": ["error"],
"quotes": ["error", "double", { "avoidEscape": true }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": 1,
"@typescript-eslint/no-inferrable-types": [
"warn",
{
"ignoreParameters": true
}
],
"unused-imports/no-unused-imports": "error",
"@typescript-eslint/no-extra-semi": ["error"],
"prettier/prettier": 2
}
}
+41
View File
@@ -0,0 +1,41 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.env.development
config/local.json
config/production.json
config/development.json
jest.config.js
/config/test-local.json
+1
View File
@@ -0,0 +1 @@
12
+11
View File
@@ -0,0 +1,11 @@
dist
vendor
node_modules
src/client/public
src/client/images
public
build
.idea
.storybook
.git
utils/scaffolds
+10
View File
@@ -0,0 +1,10 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid"
}
+530
View File
@@ -0,0 +1,530 @@
# Tdrive backend
## Developer guide
### Getting started
1. Clone and install dependencies (assumes that you have Node.js 12 and npm installed. If not, we suggest to use [nvm](https://github.com/nvm-sh/nvm/)):
```sh
git clone git@github.com:Tdrive/Tdrive.git
cd Tdrive/Tdrive/backend/node
npm install
```
2. Run in developer mode (will restart on each change)
```sh
npm run dev
```
3. Backend is now running and available on [http://localhost:3000](http://localhost:3000)
### Docker
Run all tests
```sh
docker-compose -f ./docker-compose.test.yml up
```
Run specific tests
```sh
docker-compose -f ./docker-compose.test.yml run node npm run test:unit
```
will run unit tests only (`test:unit`). For possible tests to run, check the `package.json` scripts.
### Command Line Interface (CLI)
The Tdrive backend CLI provides a set of commands to manage/use/develop Tdrive from the `tdrive-cli` binary.
Before to use the CLI, you must `compile` Tdrive with `npm run build`. Once done, you can get help on on any command with the `--help` flag like `./bin/tdrive-cli console --help`.
#### The 'console merge' command
This command allows to connect to the database configured in the `./config/default.json` file and to "merge" the Tdrive users and companies into the "Tdrive Console".
```sh
./bin/tdrive-cli console merge --url http://console.tdrive.app --client tdrive-app --secret supersecret
```
The simplified console workflow is like (some parts are done in parallel):
1. Get all the companies
2. Iterate over companies and create them in the console side
3. For each company, get all the users
4. Iterate over all the users and create them in the console (if the user is in several companies, create once, add it to all the companies)
5. For each company, get all the admins and choose the oldest one which will be 'marked' as the owner on the console side
At the end of the 'merge', a report will be displayed.
### Component Framework
The backend is developed using a software component approach in order to compose and adapt the platform based on needs and constraints.
The current section describes this approach, and how to extend it by creating new components.
The platform has the following properties:
- A platform is composed of multiple components
- A component has an unique name in the platform
- A component can provide a `service`
- A component can consume `services` from other components
- A component has a lifecycle composed of several states: `ready`, `initialized`, `started`, `stopped`
- A component lifecycle changes when a lifecycle event is triggered by the platform: `init`, `start`, `stop`
- By creating links between components (service producers and consumers), components lifecycles **are also linked together**: A component going from `ready` to `initialized` will wait for all its dependencies to be in `initialized` state. This is automatically handled by the platform.
The platform currently have some limitations:
- Components can not have cyclic dependencies: if `component X` requires a component which requires `component X` directly or in one of its dependencies, the platform will not start
- Components can only have local dependencies.
#### Creating a new component
To create a new component, a new folder must be created under the `src/services` one and an `index.ts` file must export the a class. This class will be instantiated by the platform and will be linked to the required services automatically.
In order to illustrate how to create a component, let's create a fake Notification service.
1. Create the folder `src/services/notification`
2. Create an `index.ts` file which exports a `NotificationService` class
```js
// File src/services/notification/index.ts
import { TdriveService } from "../../core/platform/framework";
import NotificationServiceAPI from "./api.ts";
export default class NotificationService extends TdriveService<NotificationServiceAPI> {
version = "1";
name = "notification";
service: NotificationServiceAPI;
api(): NotificationServiceAPI {
return this.service;
}
}
```
3. Our `NotificationService` class extends the generic `TdriveService` class and we defined the `NotificationServiceAPI` as its generic type parameter. It means that in the platform, the other components will be able to retrieve the component from its name and then consume the API defined in the `NotificationServiceAPI` interface and exposed by the `api` method.
We need to create this `NotificationServiceAPI` interface which must extend the `TdriveServiceProvider` from the platform like:
```js
// File src/services/notification/api.ts
import { TdriveServiceProvider } from "../../core/platform/framework/api";
export default interface NotificationServiceAPI extends TdriveServiceProvider {
/**
* Send a message to a list of recipients
*/
send(message: string, recipients: string[]): Promise<string>;
}
```
4. Now that the interfaces are defined, we need to create the `NotificationServiceAPI` implementation (this is a dummy implementation which does nothing but illustrates the process):
```js
// File src/services/notification/services/api.ts
import NotificationServiceAPI from "../api";
export class NotificationServiceImpl implements NotificationServiceAPI {
version = "1";
async send(message: string, recipients: string[]): Promise<string> {
return Promise.resolve(`${message} sent`);
}
}
```
5. `NotificationServiceImpl` now needs to be instanciated from the `NotificationService` class since this is where we choose to keep its reference and expose it. There are several places which can be used to instanciate it, in the constructor itself, or in one of the `TdriveService` lifecycle hooks. The `TdriveService` abstract class has several lifecycle hooks which can be extended by the service implementation for customization pusposes:
- `public async doInit(): Promise<this>;` Customize the `init` step of the component. This is generally the place where services are instanciated. From this step, you can retrieve services consumed by the current component which have been already initialized by the platform.
- `public async doStart(): Promise<this>;` Customize the `start` step of the component. You have access to all other services which are already started.
```js
// File src/services/notification/index.ts
import { TdriveService } from "../../core/platform/framework";
import NotificationServiceAPI from "./api.ts";
import NotificationServiceImpl from "./services/api.ts";
export default class NotificationService extends TdriveService<NotificationServiceAPI> {
version = "1";
name = "notification";
service: NotificationServiceAPI;
api(): NotificationServiceAPI {
return this.service;
}
public async doInit(): Promise<this> {
this.service = new NotificationServiceImpl();
return this;
}
}
```
6. Now that the service is fully created, we can consume it from any other service in the platform. To do this, we rely on Typescript decorators to define the links between components. For example, let's say that the a `MessageService` needs to call the `NotificationServiceAPI`, we can create the link with the help of the `@Consumes` decorator and get a reference to the `NotificationServiceAPI` by calling the `getProvider` on the component context like:
```js
import { TdriveService, Consumes } from "../../core/platform/framework";
import MessageServiceAPI from "./providapier";
import NotificationServiceAPI from "../notification/api";
@Consumes(["notification"])
export default class MessageService extends TdriveService<MessageServiceAPI> {
public async doInit(): Promise<this> {
const notificationService = this.context.getProvider<NotificationServiceAPI>("notification");
// You can not call anything defined in the NotificationServiceAPI interface from here or from inner services by passing down the reference to notificationService.
}
}
```
#### Configuration
The platform and services configuration is defined in the `config/default.json` file. It uses [node-config](https://github.com/lorenwest/node-config) under the hood and to configuration file inheritence is supported in the platform.
The list of services to start is defined in the `services` array like:
```json
{
"services": ["auth", "user", "channels", "webserver", "websocket", "database", "realtime"]
}
```
Then each service can have its own configuration block which is accessible from its service name i.e. `websocket` service configuration is defined in the `websocket` element like:
```json
{
"services": ["auth", "user", "channels", "webserver", "websocket", "orm"],
"websocket": {
"path": "/socket",
"adapters": {
"types": [],
"redis": {
"host": "redis",
"port": 6379
}
}
}
}
```
On the component class side, the configuration object is directly accessible from the `configuration` property like:
```js
export default class WebSocket extends TdriveService<WebSocketAPI> {
async doInit(): Promise<this> {
// get the "path" value, defaults to "/socket" if not defined
const path = this.configuration.get < string > ("path", "/socket");
// The "get" method is generic and can accept custom types like
const adapters = this.configuration.get < AdaptersConfiguration > "adapters";
}
}
interface AdaptersConfiguration {
types: Array<string>;
redis: SocketIORedis.SocketIORedisOptions;
}
```
### Platform
The Tdrive Platform is built using the component framework described just before and so, is composed of several technical services on which business services can rely on to provide a micro-services based platform.
The current chapter describes the technical services of the plaform, how to use them, how to build business services on top of them...
Current technical services are located in `src/core/platform/services`:
- `auth`: To manage authentication
- `database`: To manage database connections
- `realtime`: To provide realtime notification on platform resources
- `webserver`: To expose services as REST ones
- `websocket`: To communicate between client and server using websockets
#### Database Technical Service
Database technical service provides an abstraction layer over several databases to get a connection through the help of drivers and to use them in any other services.
Supported databases are currently [MongoDB](https://www.mongodb.com/) and [Cassandra](https://cassandra.apache.org/). Switching from one to other one is achieved from the database configuration document by switching the `database.type` flag:
```json
{
"database": {
"type": "cassandra",
"mongodb": {
"uri": "mongodb://localhost:27017",
"database": "tdrive"
},
"cassandra": {
"contactPoints": ["localhost:9042"],
"localDataCenter": "datacenter1",
"keyspace": "tdrive"
}
}
}
```
In the example above, the `type` is set to `cassandra`, so the `database.cassandra` document will be used to connect to cassandra.
##### Cassandra
In order to use Cassandra, we will have to:
1. Create a keyspace. From the configuration above, the keyspace is `tdrive`
2. Create all the required tables
To achieve these steps, you have to use [cqlsh](https://cassandra.apache.org/doc/latest/tools/cqlsh.html) from a terminal then:
1. Create the keyspace:
```sh
CREATE KEYSPACE tdrive WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': '2'} AND durable_writes = true;
```
2. Create the required tables
```sh
USE tdrive;
CREATE TABLE channels(company_id uuid, workspace_id uuid, id uuid, archivation_date date, archived boolean, channel_group text, description text, icon text, is_default boolean, name text, owner uuid, visibility text, PRIMARY KEY ((company_id, workspace_id), id));
```
##### MongoDB
There are no special steps to achieve to use MongoDB.
#### Realtime Technical Service
The framework provides simple way to create CRUD Services which will notify clients using Websockets by following some conventions:
1. The service must implement the `CRUDService` generic interface
2. In order to push notification to clients, Typescript decorators must be added on methods (Create, Update, Delete methods)
For example, let's say that we want to implement a CRUD Service for `messages`:
```js
import {
CRUDService,
CreateResult,
DeleteResult,
UpdateResult,
EntityId,
} from "@/core/platform/framework/api/crud-service";
class Message {
text: string;
createdAt: Date;
author: string;
}
class MessageService implements CRUDService<Message> {
async create(item: Message): Promise<CreateResult<Message>> {
// save the message then return a CreateResult instance
}
async get(id: EntityId): Promise<Message> {
// get the message from its ID and return it
}
async update(id: EntityId, item: Message): Promise<UpdateResult<Message>> {
// update the message with the given id, patch it with `item` values
// then return an instance of UpdateResult
}
async delete(id: EntityId): Promise<DeleteResult<Message>> {
// delete the message from its id then return a DeleteResult instance
}
async list(): Promise<Message[]> {
// get a list of messages
}
}
```
By implementing the CRUD service following the API, we can now add realtime message to clients connected to Websocket on a selected collection of CRUD operations. For example, if we want to just notify when a `message` is created, we just have to add the right decorator on the `create` method like:
```js
import { RealtimeCreated } from "@/core/platform/framework/decorators";
// top and bottom code removed for clarity
class MessageService implements CRUDService<Message> {
@RealtimeCreated<Message>({
room: "/messages",
path: message => `/messages/${message.id}`
})
async create(item: Message): Promise<CreateResult<Message>> {
// save the message then return a CreateResult instance
const created: Message = new Message(/* */);
return new CreateResult<Message>("message", created)
}
// ...
}
```
The `RealtimeCreated` decorator will intercept the `create` call and will publish an event in an internal event bus with the creation result, the input data and the `"/messages"` path. On the other side of the event bus, an event listener will be in charge of delivering the event to the right Websocket clients as described next.
The Realtime\* decorators to add on methods to intercept calls and publish data are all following the same API.
A decorator takes two parameters as input:
- First one is the room name to publish the notification to (`/messages` in the example above)
- Second one is the full path of the resource linked to the action (`message => /messages/${message.id}` in the example above)
Both parameters can take a string or an arrow function as parameter. If arrow function is used, the input parameter will be the result element. By doing this, the paths can be generated dynamically at runtime.
#### Websocket Technical Service
Services annotated as described above automatically publish events to WebSockets. Under the hood, it uses Socket.IO rooms to send events to the right clients.
##### Authentication
- Client have to provide a valid JWT token to be able to connect and be authenticated by providing it as string in the `authenticate` event like `{ token: "the jwt token value" }`
- If the JWT token is valid, the client will receive a `authenticated` event
- If the JWT token is not valid or empty, the client will receive a `unauthorized` event
**Example**
```js
const io = require("socket.io-client");
// Get a JWT token from the Tdrive API first
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOjEsImlhdCI6MTYwMzE5ODkzMn0.NvQoV9KeWuTNzRvzqbJ5uZCQ8Nmi2rCYQzcKk-WsJJ8";
const socket = io.connect("http://localhost:3000", { path: "/socket" });
socket.on("connect", () => {
socket
.emit("authenticate", { token })
.on("authenticated", () => {
console.log("User Authenticated");
})
.on("unauthorized", err => {
console.log("User is not authorized", err);
});
});
socket.on("disconnected", () => console.log("Disconnected"));
```
##### Joining rooms
CRUD operations on resources are pushing events in Socket.io rooms. In order to receive events, clients must subscribe to rooms by sending an `realtime:join` on an authenticated socket with the name of the room to join and with a valid JWT token like `{ name: "room name", token: "the jwt token for this room" }`: Users can not subscribe to arbitratry rooms, they have to be authorized to.
As a result, the client will receive events:
- `realtime:join:success` when join is succesful with data containing the name of the linked room like `{ name: "room" }`.
- `realtime:join:error` when join failed with data containing the name of the linked room and the error details like `{ name: "room", error: "some error message" }`.
**Example**
```js
const io = require("socket.io-client");
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOjEsImlhdCI6MTYwMzE5ODkzMn0.NvQoV9KeWuTNzRvzqbJ5uZCQ8Nmi2rCYQzcKk-WsJJ8";
const socket = io.connect("http://localhost:3000", { path: "/socket" });
socket.on("connect", () => {
socket
.emit("authenticate", { token })
.on("authenticated", () => {
// join the /channels room
socket.emit("realtime:join", { name: "/channels", token: "tdrive" });
socket.on("realtime:join:error", message => {
// will fire when join does not provide a valid token
console.log("Error on join", message);
});
// will be fired on each successful join.
// As event based, this event is not linked only to the join above
// but to all joins. So you have to dig into the message to know which one
// is successful.
socket.on("realtime:join:success", message => {
console.log("Successfully joined room", message.name);
});
})
.on("unauthorized", err => {
console.log("Unauthorized", err);
});
});
socket.on("disconnected", () => console.log("Disconnected"));
```
##### Leaving rooms
The client can leave the room by emitting a `realtime:leave` event with the name of the room given as `{ name: "the room to leave" }`.
As a result, the client will receive events:
- `realtime:leave:success` when leave is succesful with data containing the name of the linked room like `{ name: "room" }`.
- `realtime:leave:error` when leave failed with data containing the name of the linked room and the error details like `{ name: "room", error: "some error message" }`.
Note: Asking to leave a room which has not been joined will not fire any error.
**Example**
```js
socket.on("connect", () => {
socket.emit("authenticate", { token }).on("authenticated", () => {
// leave the "/channels" room
socket.emit("realtime:leave", { name: "/channels" });
socket.on("realtime:leave:error", message => {
// will fire when join does not provide a valid token
console.log("Error on leave", message);
});
// will be fired on each successful leave.
// As event based, this event is not linked only to the leave above
// but to all leaves. So you have to dig into the message to know which one
// is successful.
socket.on("realtime:leave:success", message => {
console.log("Successfully left room", message.name);
});
});
});
```
##### Subscribe to resource events
Once the given room has been joined, the client will receive `realtime:resource` events with the resource linked to the event as data:
```json
{
"action": "created",
"room": "/channels",
"type": "channel",
"path": "/channels/5f905327e3e1626399aaad79",
"resource": {
"name": "My channel",
"id": "5f905327e3e1626399aaad79"
}
```
**Example:**
```js
socket.on("connect", () => {
socket
.emit("authenticate", { token })
.on("authenticated", () => {
// join the "/channels" room
socket.emit("realtime:join", { name: "/channels", token: "tdrive" });
// will only occur when an action occured on a resource
// and if and only if the client joined the room
// in which the resource is linked
socket.on("realtime:resource", event => {
console.log("Resource has been ${event.action}", event.resource);
});
})
.on("unauthorized", err => {
console.log("Unauthorized", err);
});
});
```
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"],
plugins: [
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
"babel-plugin-parameter-decorator",
],
};
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
require("../dist/cli/index");
+1
View File
@@ -0,0 +1 @@
{}
@@ -0,0 +1,109 @@
{
"general": {
"accounts": {
"type": "ACCOUNTS_TYPE",
"remote": {
"authority": "SSO_AUTHORITY",
"client_id": "SSO_CLIENT_ID",
"issuer": "SSO_ISSUER",
"jwks_uri": "SSO_JWKS_URI"
}
}
},
"sentry": {
"dsn": "SENTRY_DSN"
},
"logger": {
"level": "LOG_LEVEL"
},
"webserver": {
"logger": {
"level": "LOG_LEVEL"
},
"static": {
"root": "STATIC_ROOT"
}
},
"phpnode": {
"secret": "PHP_NODE_API_SECRET"
},
"websocket": {
"auth": {
"jwt": {
"secret": "AUTH_JWT_SECRET"
}
}
},
"tracker": {
"segment": {
"key": "SEGMENT_SECRET_KEY"
}
},
"auth": {
"jwt": {
"secret": "AUTH_JWT_SECRET"
}
},
"notifications": {
"push": {
"type": "PUSH_DRIVER",
"fcm": {
"endpoint": "PUSH_FCM_ENDPOINT",
"key": "PUSH_FCM_KEY"
}
}
},
"database": {
"secret": "DB_SECRET",
"type": "DB_DRIVER",
"mongodb": {
"uri": "DB_MONGO_URI",
"database": "DB_MONGO_DATABASE"
},
"cassandra": {
"contactPoints": "DB_CASSANDRA_URI",
"localDataCenter": "DB_CASSANDRA_LOCALDATACENTER",
"keyspace": "DB_CASSANDRA_KEYSPACE"
}
},
"search": {
"type": "SEARCH_DRIVER",
"elasticsearch": {
"endpoint": "SEARCH_ES_ENDPOINT",
"flushInterval": "SEARCH_ES_FLUSHINTERVAL"
}
},
"storage": {
"secret": "STORAGE_SECRET",
"iv": "STORAGE_SECRET_BASE_IV",
"type": "STORAGE_DRIVER",
"local": {
"path": "STORAGE_LOCAL_PATH"
},
"S3": {
"bucket": "STORAGE_S3_BUCKET",
"region": "STORAGE_S3_REGION",
"endPoint": "STORAGE_S3_ENDPOINT",
"port": "STORAGE_S3_PORT",
"useSSL": "STORAGE_S3_USE_SSL",
"accessKey": "STORAGE_S3_ACCESS_KEY",
"secretKey": "STORAGE_S3_SECRET_KEY"
}
},
"message-queue": {
"type": "PUBSUB_TYPE",
"amqp": {
"urls": "PUBSUB_URLS"
}
},
"pusbub": {
"//": "//deprecated",
"type": "PUBSUB_TYPE",
"amqp": {
"urls": "PUBSUB_URLS"
}
},
"plugins": {
"server": "PLUGINS_SERVER"
}
}
+186
View File
@@ -0,0 +1,186 @@
{
"general": {
"help_url": false,
"pricing_plan_url": "",
"app_download_url": "https://tdrive.app/download",
"mobile": {
"mobile_redirect": "mobile.tdrive.app",
"mobile_appstore": "https://apps.apple.com/fr/app/tdrive/id1588764852?l=en",
"mobile_googleplay": "https://play.google.com/store/apps/details?id=com.tdrive.tdrive&gl=FR"
},
"app_grid": [
{
"name": "Tmail",
"logo": "/public/img/grid/tmail.png",
"url": "https://tmail.linagora.com/"
},
{
"name": "Tdrive",
"logo": "/public/img/grid/tdrive.png",
"url": "https://tdrive.app/"
}
],
"accounts": {
"_type": "remote",
"type": "internal",
"internal": {
"disable_account_creation": false
},
"remote": {
"authority": "http://auth.example.com/",
"client_id": "tdriveweb",
"client_secret": "",
"issuer": "",
"audience": "",
"redirect_uris": [""],
"account_management_url": "http://web.tdrive-console.local/profile?company-code={company_id}",
"collaborators_management_url": "http://web.tdrive-console.local/compaies/{company_id}/users?company-code={company_id}",
"company_management_url": "http://web.tdrive-console.local/companies?company-code={company_id}"
}
}
},
"console": {
"type": "internal"
},
"sentry": {
"dsn": ""
},
"logger": {
"level": "debug"
},
"tracker": {
"type": "segment",
"segment": {
"key": ""
}
},
"webserver": {
"port": 4000,
"logger": {
"level": "info"
},
"cors": {
"origin": "*"
},
"static": {
"root": "./public"
}
},
"websocket": {
"path": "/socket/",
"adapters": {
"types": [],
"redis": {
"host": "redis",
"port": 6379
}
},
"auth": {
"jwt": {
"secret": "supersecret"
}
}
},
"auth": {
"jwt": {
"secret": "supersecret",
"expiration": 3600,
"refresh_expiration": 2592000
}
},
"database": {
"secret": "ab63bb3e90c0271c9a1c06651a7c0967eab8851a7a897766",
"type": "cassandra",
"mongodb": {
"uri": "mongodb://mongo:27017",
"database": "tdrive"
},
"cassandra": {
"contactPoints": ["scylladb:9042"],
"localDataCenter": "datacenter1",
"keyspace": "tdrive",
"wait": false,
"retries": 10,
"delay": 200
}
},
"message-queue": {
"// possible 'type' values are": "'amqp' or 'local'",
"type": "amqp",
"amqp": {
"urls": ["amqp://guest:guest@rabbitmq:5672"]
}
},
"search": {
"type": "elasticsearch",
"elasticsearch": {
"endpoint": "http://elasticsearch:9200"
}
},
"push": {
"type": false,
"fcm": {
"endpoint": "https://fcm.googleapis.com/fcm/send",
"key": ""
}
},
"storage": {
"secret": "0ea28a329df23220fa814e005bfb671c",
"iv": "1234abcd00000000",
"type": "local",
"S3": {
"endPoint": "play.min.io",
"port": 9000,
"useSSL": false,
"accessKey": "ABCD",
"secretKey": "x1yz"
},
"local": {
"path": "/storage/"
}
},
"plugins": {
"server": "plugins:3100"
},
"knowledge-graph": {
"endpoint": "http://host-gateway:8888",
"callback_token": "secret",
"use": false,
"forwarded_companies": []
},
"email-pusher": {
"endpoint": "https://api.smtp2go.com/v3/email/send",
"api_key": "secret",
"sender": "noreply@tdrive.app"
},
"services": [
"auth",
"push",
"storage",
"webserver",
"websocket",
"database",
"cron",
"search",
"message-queue",
"realtime",
"phpnode",
"tracker",
"general",
"user",
"channels",
"notifications",
"files",
"workspaces",
"console",
"counter",
"statistics",
"cron",
"online",
"knowledge-graph",
"knowledge-graph-web",
"email-pusher",
"documents",
"tags"
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"database": {
"secret": "ab63bb3e90c0271c9a1c06651a7c0967eab8851a7a897766",
"mongodb": {
"uri": "mongodb://mongo:27017"
},
"cassandra": {
"contactPoints": ["scylladb:9042"],
"wait": true,
"retries": 50,
"delay": 500,
"queryOptions": {
"consistency": 1
}
}
},
"message-queue": {
"type": "amqp",
"amqp": {
"urls": ["amqp://guest:guest@rabbitmq:5672"]
}
},
"phpnode": {
"php_endpoint": false
}
}
+12195
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
{
"name": "@tdrive/tdrive-backend",
"version": "1.0.0",
"description": "Tdrive backend",
"scripts": {
"build": "npm run build:clean && npm run build:ts && npm run build:copy-assets",
"build:ts": "tsc",
"build:clean": "rimraf ./dist",
"build:copy-assets": "npm run build:copy-config && npm run build:copy-templates",
"build:copy-config": "cp -R config dist",
"build:copy-templates": "cpy src/**/*.eta dist/",
"dev": "tsc-watch --project . --noClear --outDir ./dist --onSuccess \"nodemon ./dist/server.js\" | pino-pretty",
"dev:cli": "tsc-watch --project . --noClear --outDir ./dist --onSuccess \"nodemon ./dist/cli/index.js workspace user\" | pino-pretty",
"dev:cli-dry": "tsc-watch --project . --noClear --outDir ./dist --onSuccess \"nodemon ./dist/cli/index.js console merge --dry\" | pino-pretty",
"dev:debug": "tsc-watch --project . --noClear --outDir ./dist --onSuccess \"nodemon --inspect=0.0.0.0 ./dist/server.js\" | pino-pretty",
"lint": "eslint . --ext .ts --quiet --fix",
"lint:no-fix": "eslint . --ext .ts",
"lint:prettier": "prettier --check --config .prettierrc 'src/**/*.ts'",
"serve": "node --unhandled-rejections=warn --max-http-header-size=30000 dist/server.js",
"serve:watch": "nodemon dist/server.js | pino-pretty",
"serve:debug": "nodemon --inspect dist/server.js | pino-pretty",
"start": "npm run serve",
"test": "jest --forceExit --coverage --detectOpenHandles --runInBand --verbose false | pino-pretty",
"test:local": "jest --forceExit --detectOpenHandles --runInBand mock.spec.ts",
"test:watch": "npm run test -- --watchAll --verbose false | pino-pretty",
"test:e2e": "node ./test/e2e/run-all.js",
"test:unit": "jest test/unit --forceExit --coverage --detectOpenHandles --maxWorkers=1 --testTimeout=30000 --verbose false > coverage/coverage-report.txt",
"test:unit:watch": "npm run test:unit -- --watchAll --verbose false | pino-pretty",
"test:merge:json": "npx istanbul report --dir coverage/merged --include 'coverage/**/coverage-final.json' json-summary",
"test:merge:text": "npx istanbul report --dir coverage/merged --include 'coverage/**/coverage-final.json' text > coverage/merged/coverage-report.txt",
"kill": "kill $(lsof -t -i:3000) | exit 0"
},
"jest": {
"collectCoverage": true,
"coverageReporters": [
"json",
"html",
"clover",
"json-summary",
"text"
],
"verbose": true,
"testEnvironment": "node"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Tdrive/tdrive-backend.git"
},
"author": "Tdrive",
"license": "ISC",
"bugs": {
"url": "https://github.com/Tdrive/tdrive-backend/issues"
},
"homepage": "https://github.com/Tdrive/tdrive-backend#readme",
"bin": {
"tdrive-cli": "bin/tdrive-cli"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@types/amqp-connection-manager": "^2.0.10",
"@types/analytics-node": "^3.1.5",
"@types/archiver": "^5.3.1",
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/chai": "^4.2.12",
"@types/cli-table": "^0.3.0",
"@types/config": "0.0.36",
"@types/eslint": "^7.2.3",
"@types/fastify-multipart": "^0.7.0",
"@types/fastify-static": "^2.2.1",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/html-to-text": "^8.1.1",
"@types/jest": "^26.0.14",
"@types/lodash": "^4.14.165",
"@types/minio": "^7.0.7",
"@types/mongodb": "^4.0.7",
"@types/node": "^14.11.2",
"@types/node-cron": "^3.0.0",
"@types/node-fetch": "^2.5.12",
"@types/node-uuid": "^0.0.28",
"@types/pdf-image": "^2.0.1",
"@types/pino": "^6.3.2",
"@types/probe-image-size": "^7.0.1",
"@types/pump": "^1.1.1",
"@types/random-useragent": "^0.3.1",
"@types/socket.io-client": "^1.4.34",
"@types/supertest": "2.0.4",
"@types/uuid": "^8.3.0",
"@types/ws": "^7.2.7",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^4.2.0",
"babel-jest": "^26.5.2",
"babel-plugin-parameter-decorator": "^1.0.16",
"chai": "^4.2.0",
"cpy-cli": "^4.2.0",
"eslint": "^7.10.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-unused-imports": "^2.0.0",
"form-auto-content": "^2.2.0",
"jest": "^26.6.3",
"nodemon": "2.0.4",
"pino-pretty": "^10.0.0",
"prettier": "^2.1.2",
"rimraf": "^3.0.2",
"supertest": "4.0.2",
"ts-jest": "^26.4.0",
"ts-node": "^9.0.0",
"ts-node-dev": "^1.1.8",
"tsc-watch": "^4.2.9",
"typescript": "^4.0.3"
},
"dependencies": {
"@elastic/elasticsearch": "7",
"@fastify/caching": "^7.0.0",
"@fastify/formbody": "^6.0.0",
"@fastify/static": "^5.0.1",
"@ffprobe-installer/ffprobe": "^1.4.1",
"@sentry/node": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@socket.io/redis-adapter": "^7.1.0",
"@types/pdf-parse": "^1.1.1",
"@types/redis": "^4.0.11",
"@types/sharp": "^0.29.5",
"@types/socket.io-parser": "^3.0.0",
"amqp-connection-manager": "^3.7.0",
"amqplib": "^0.8.0",
"analytics-node": "^5.0.0",
"archiver": "^5.3.1",
"axios": "^0.21.3",
"bcrypt": "^5.0.1",
"cassandra-driver": "^4.6.0",
"class-transformer": "^0.3.1",
"cli-table": "^0.3.6",
"config": "^3.3.2",
"deep-object-diff": "^1.1.0",
"emoji-name-map": "^1.2.9",
"eta": "^1.12.3",
"fast-proxy": "^2.1.0",
"fastify": "^3.29.4",
"fastify-cors": "^4.1.0",
"fastify-formbody": "^5.0.0",
"fastify-jwt": "^2.2.0",
"fastify-multipart": "5.3.1",
"fastify-plugin": "^2.3.4",
"fastify-sensible": "=3.0.1",
"fastify-socket.io": "^3.0.0",
"fastify-static": "^4.7.0",
"fastify-swagger": "^4.12.6",
"fastify-websocket": "^2.0.11",
"find-my-way": "^5.2.0",
"fluent-ffmpeg": "^2.1.2",
"fold-to-ascii": "^5.0.0",
"free-email-domains": "1.0.26",
"generate-password": "^1.6.0",
"get-website-favicon": "^0.0.7",
"html-metadata-parser": "^2.0.4",
"html-to-text": "^8.2.1",
"idtoken-verifier": "^2.2.3",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^3.0.1",
"keyv": "^4.5.0",
"lodash": "^4.17.21",
"match-all": "^1.2.6",
"minio": "^7.0.18",
"moment": "^2.29.4",
"mongodb": "^4.1.0",
"multistream": "^4.1.0",
"njwt": "^2.0.0",
"node-cache": "^5.1.2",
"node-cron": "^3.0.0",
"node-fetch": "^2.6.7",
"node-uuid": "^1.4.8",
"openid-client": "^5.4.0",
"ora": "^5.4.0",
"pdf-parse": "^1.1.1",
"pdf2pic": "^2.1.4",
"pino": "^6.8.0",
"pino-std-serializers": "^6.1.0",
"probe-image-size": "^7.2.3",
"pump": "^3.0.0",
"random-useragent": "^0.5.0",
"redis": "3",
"reflect-metadata": "^0.1.13",
"rxjs": "^6.6.3",
"sharp": "^0.30.5",
"socket.io": "4",
"socket.io-client": "^3.0.0",
"unoconv-promise": "^1.0.8",
"uuid": "^8.3.2",
"uuid-time": "^1.0.0",
"yargs": "^16.2.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
Tdrive frontend is not here, please do one of the following:<br/>
- use the nginx container as a separate process<br/>
- attach the built frontend to this container and use the STATIC_ROOT env variable<br/>
- use the production container
@@ -0,0 +1 @@
declare module "emoji-name-map";
+21
View File
@@ -0,0 +1,21 @@
import { FastifyReply, FastifyRequest } from "fastify";
import SocketIO from "socket.io";
import { User } from "../utils/types";
export interface authenticateDecorator {
(request: FastifyRequest): FastifyRequest;
(reply: FastifyReply): FastifyReply;
}
declare module "fastify" {
interface FastifyInstance {
phpnodeAuthenticate(): void;
authenticate(): void;
authenticateOptional(): void;
io: SocketIO.Server;
}
interface FastifyRequest {
currentUser: User;
}
}
@@ -0,0 +1 @@
declare module "free-email-domains";
@@ -0,0 +1 @@
declare module "get-website-favicon";
@@ -0,0 +1 @@
declare module "multistream";
@@ -0,0 +1 @@
declare module "unoconv-promise";
@@ -0,0 +1 @@
declare module "uuid-time";
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Manage Tdrive Applications",
command: "applications <command>",
builder: yargs =>
yargs.commandDir("applications_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Export Tdrive data",
command: "export <command>",
builder: yargs =>
yargs.commandDir("export_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,197 @@
import yargs from "yargs";
import tdrive from "../../../tdrive";
import { mkdirSync, writeFileSync } from "fs";
import { Pagination } from "../../../core/platform/framework/api/crud-service";
import WorkspaceUser from "../../../services/workspaces/entities/workspace_user";
import { ChannelVisibility } from "../../../services/channels/types";
import { Channel, ChannelMember } from "../../../services/channels/entities";
import { formatCompany } from "../../../services/user/utils";
import gr from "../../../services/global-resolver";
import { formatUser } from "../../../utils/users";
/**
* Merge command parameters. Check the builder definition below for more details.
*/
type CLIArgs = {
id: string;
};
const services = [
"auth",
"storage",
"counter",
"message-queue",
"user",
"files",
"messages",
"workspaces",
"platform-services",
"console",
"applications",
"search",
"database",
"webserver",
"channels",
"statistics",
];
const command: yargs.CommandModule<unknown, CLIArgs> = {
command: "company",
describe:
"command to export everything inside a company (publicly data only available to a new member)",
builder: {
id: {
default: "",
type: "string",
description: "Company ID",
},
output: {
default: "",
type: "string",
description: "Folder containing the exported data",
},
},
handler: async argv => {
const platform = await tdrive.run(services);
await gr.doInit(platform);
const company = await gr.services.companies.getCompany({ id: argv.id });
if (!company) {
return "No such company";
}
console.log(`Start export for company ${company.id}`);
const output = (argv.output as string) || `export-${company.id}`;
mkdirSync(output, { recursive: true });
//Company
console.log("- Create company json file");
writeFileSync(`${output}/company.json`, JSON.stringify(formatCompany(company)));
//Workspaces
console.log("- Create workspaces json file");
const workspaces = await gr.services.workspaces.getAllForCompany(company.id);
writeFileSync(`${output}/workspaces.json`, JSON.stringify(workspaces));
//Users
console.log("- Create users json file");
const users = [];
for (const workspace of workspaces) {
const workspace_users = [];
let workspaceUsers: WorkspaceUser[] = [];
let pagination = new Pagination();
do {
const res = await gr.services.workspaces.getUsers(
{ workspaceId: workspace.id },
pagination,
);
workspaceUsers = [...workspaceUsers, ...res.getEntities()];
pagination = res.nextPage as Pagination;
} while (pagination.page_token);
for (const workspaceUser of workspaceUsers) {
const user = await gr.services.users.get({ id: workspaceUser.userId });
if (user) {
users.push(await formatUser(user));
workspace_users.push({ ...workspaceUser, user });
}
}
mkdirSync(`${output}/workspaces/${workspace.id}`, { recursive: true });
writeFileSync(
`${output}/workspaces/${workspace.id}/users.json`,
JSON.stringify(workspace_users),
);
}
writeFileSync(`${output}/users.json`, JSON.stringify(users));
//Channels
console.log("- Create channels json file");
const directChannels: Channel[] = [];
let allPublicChannels: Channel[] = [];
let pagination = new Pagination();
do {
const page = await gr.services.channels.channels.getDirectChannelsInCompany(
pagination,
company.id,
undefined,
);
for (const channel of page.getEntities()) {
const channelDetail = await gr.services.channels.channels.get(
{
company_id: channel.company_id,
workspace_id: "direct",
id: channel.id,
},
undefined,
);
directChannels.push(channelDetail);
}
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
for (const workspace of workspaces) {
let pagination = new Pagination();
let publicChannels: Channel[] = [];
pagination = new Pagination();
do {
const page = await gr.services.channels.channels.list(
pagination,
{},
{
user: { id: "", server_request: true },
workspace: { workspace_id: workspace.id, company_id: company.id },
},
);
const chans = page.getEntities().filter(c => c.visibility == ChannelVisibility.PUBLIC);
allPublicChannels = [...allPublicChannels, ...chans];
publicChannels = [...publicChannels, ...chans];
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
mkdirSync(`${output}/workspaces/${workspace.id}`, { recursive: true });
writeFileSync(
`${output}/workspaces/${workspace.id}/channels.json`,
JSON.stringify(publicChannels),
);
}
writeFileSync(`${output}/direct_channels.json`, JSON.stringify(directChannels));
//Channels users
console.log("- Create channels users json file");
for (const channel of [...allPublicChannels /*, ...directChannels*/]) {
let members: ChannelMember[] = [];
let pagination = new Pagination();
do {
const page = await gr.services.channels.members.list(
pagination,
{},
{
user: { id: "", server_request: true },
channel: {
company_id: channel.company_id,
workspace_id: channel.workspace_id,
id: channel.id,
},
},
);
members = [...members, ...page.getEntities()] as ChannelMember[];
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
mkdirSync(`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}`, {
recursive: true,
});
writeFileSync(
`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}/members.json`,
JSON.stringify(members),
);
}
await platform.stop();
},
};
export default command;
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Generate Tdrive data",
command: "generate <command>",
builder: yargs =>
yargs.commandDir("generate_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,147 @@
import passwordGenerator from "generate-password";
import { from, pipe } from "rxjs";
import { bufferCount, first, map, mergeMap, switchMap, tap } from "rxjs/operators";
import { v1 as uuid } from "uuid";
import yargs from "yargs";
import Company, {
getInstance as getCompanyInstance,
} from "../../../services/user/entities/company";
import CompanyUser from "../../../services/user/entities/company_user";
import tdrive from "../../../tdrive";
import User, { getInstance as getUserInstance } from "../../../services/user/entities/user";
import gr from "../../../services/global-resolver";
type CLIArgs = {
company: number;
user: number;
concurrent: number;
};
const services = [
"storage",
"counter",
"applications",
"statistics",
"auth",
"realtime",
"push",
"platform-services",
"user",
"search",
"channels",
"notifications",
"database",
"webserver",
"message-queue",
];
// eslint-disable-next-line @typescript-eslint/ban-types
const command: yargs.CommandModule<{}, CLIArgs> = {
command: "users",
describe: "Generate users and companies",
builder: {
company: {
alias: "c",
default: 3,
type: "number",
description: "Number of companies to generate",
},
user: {
alias: "u",
default: 5,
type: "number",
description: "Number of users to generate in the company",
},
concurrent: {
default: 1,
type: "number",
description: "Number of concurrent creation tasks",
},
},
handler: async argv => {
const concurrentTasks = argv.concurrent;
const nbUsersPerCompany = argv.user;
const nbCompanies = argv.company;
const platform = await tdrive.run(services);
await gr.doInit(platform);
const companies = getCompanies(nbCompanies);
const createUser = async (userInCompany: {
user: User;
company: Company;
}): Promise<CompanyUser> => {
console.log("Creating user", userInCompany);
const created = await gr.services.users.create(getUserInstance(userInCompany.user));
return (await gr.services.companies.setUserRole(userInCompany.company.id, created.entity.id))
?.entity;
};
const createCompany = (company: Company) => {
console.log("Creating company", company);
return gr.services.companies.createCompany(company);
};
const obsv$ = from(companies).pipe(
// Create companies sequentially
mergeMap(company => createCompany(company), concurrentTasks),
// until we create enough companies
bufferCount(companies.length),
tap(companies => console.log("Created companies", companies.length)),
// for each created company
switchMap(companies => from(companies)),
// generate a set of user for each company
map(company => getUsersForCompany(company, nbUsersPerCompany)),
// Create users sequentially
pipe(
switchMap(userCompanies => from(userCompanies)),
mergeMap(userCompany => createUser(userCompany), concurrentTasks),
// until we reach the number of users in the company
bufferCount(nbUsersPerCompany),
),
// until we reach the number of companies
bufferCount(companies.length),
first(),
);
return obsv$
.toPromise()
.then(() => platform.stop())
.finally(() => console.log("✅ Company users are now created"));
},
};
const getCompanies = (size: number = 10): Company[] => {
return [...Array(size).keys()].map(i =>
getCompanyInstance({
id: uuid(),
name: `tdrive${i}.app`,
displayName: `My Tdrive Company #${i}`,
}),
);
};
const getUsersForCompany = (company: Company, size: number = 10) => {
return getUsers(company, size).map(user => ({
user,
company,
}));
};
const getUsers = (company: Company, size: number = 100): User[] => {
return [...Array(size).keys()].map(i => getUser(company, i));
};
const getUser = (company: Company, id: number): User => {
return getUserInstance({
id: uuid(),
first_name: "John",
last_name: `Doe${id}`,
email_canonical: `user${id}@${company.name}`,
password: passwordGenerator.generate({
length: 10,
numbers: true,
}),
});
};
export default command;
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Migrate your php message to node",
command: "migration <command>",
builder: yargs =>
yargs.commandDir("migration_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Work with search middleware",
command: "search <command>",
builder: yargs =>
yargs.commandDir("search_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,143 @@
import yargs from "yargs";
import tdrive from "../../../tdrive";
import ora from "ora";
import { TdrivePlatform } from "../../../core/platform/platform";
import { DatabaseServiceAPI } from "../../../core/platform/services/database/api";
import { Pagination } from "../../../core/platform/framework/api/crud-service";
import User, { TYPE as UserTYPE } from "../../../services/user/entities/user";
import { Channel } from "../../../services/channels/entities";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { SearchServiceAPI } from "../../../core/platform/services/search/api";
import CompanyUser, { TYPE as CompanyUserTYPE } from "../../../services/user/entities/company_user";
import gr from "../../../services/global-resolver";
type Options = {
repository?: string;
repairEntities?: boolean;
};
class SearchIndexAll {
database: DatabaseServiceAPI;
search: SearchServiceAPI;
constructor(readonly platform: TdrivePlatform) {
this.database = this.platform.getProvider<DatabaseServiceAPI>("database");
this.search = this.platform.getProvider<SearchServiceAPI>("search");
}
public async run(options: Options = {}): Promise<void> {
const repositories: Map<string, Repository<any>> = new Map();
repositories.set("users", await this.database.getRepository(UserTYPE, User));
repositories.set("channels", await this.database.getRepository("channels", Channel));
const repository = repositories.get(options.repository);
if (!repository) {
throw (
"No such repository ready for indexation, available are: " +
Array.from(repositories.keys()).join(", ")
);
}
// Complete user with companies in cache
if (options.repository === "users" && options.repairEntities) {
console.log("Complete user with companies in cache");
const companiesUsersRepository = await this.database.getRepository(
CompanyUserTYPE,
CompanyUser,
);
const userRepository = await this.database.getRepository(UserTYPE, User);
let page: Pagination = { limitStr: "100" };
// For each rows
do {
const list = await userRepository.find({}, { pagination: page }, undefined);
for (const user of list.getEntities()) {
const companies = await companiesUsersRepository.find(
{ user_id: user.id },
{},
undefined,
);
user.cache ||= { companies: [] };
user.cache.companies = companies.getEntities().map(company => company.group_id);
await repositories.get("users").save(user, undefined);
}
page = list.nextPage as Pagination;
await new Promise(r => setTimeout(r, 200));
} while (page.page_token);
}
console.log("Start indexing...");
let count = 0;
// Get all items
let page: Pagination = { limitStr: "100" };
do {
console.log("Indexed " + count + " items...");
const list = await repository.find({}, { pagination: page }, undefined);
page = list.nextPage as Pagination;
await this.search.upsert(list.getEntities());
count += list.getEntities().length;
await new Promise(r => setTimeout(r, 200));
} while (page.page_token);
console.log("Emptying flush (10s)...");
await new Promise(r => setTimeout(r, 10000));
console.log("Done!");
}
}
const services = [
"search",
"database",
"webserver",
"auth",
"counter",
"cron",
"message-queue",
"push",
"realtime",
"storage",
"tracker",
"websocket",
];
const command: yargs.CommandModule<unknown, unknown> = {
command: "index",
describe: "command to reindex search middleware from db entities",
builder: {
repository: {
default: "",
type: "string",
description: "Choose a repository to reindex",
},
repairEntities: {
default: false,
type: "boolean",
description: "Choose to repair entities too when possible",
},
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handler: async argv => {
const spinner = ora({ text: "Reindex repository - " }).start();
const platform = await tdrive.run(services);
await gr.doInit(platform);
const migrator = new SearchIndexAll(platform);
const repository = (argv.repository || "") as string;
if (!repository) {
throw "No repository was set.";
}
await migrator.run({
repository,
});
return spinner.stop();
},
};
export default command;
+14
View File
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Manage Tdrive Users",
command: "users <command>",
builder: yargs =>
yargs.commandDir("users_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,85 @@
import yargs from "yargs";
import ora from "ora";
import tdrive from "../../../tdrive";
import Table from "cli-table";
import { exit } from "process";
import gr from "../../../services/global-resolver";
/**
* Merge command parameters. Check the builder definition below for more details.
*/
type CLIArgs = {
id: string;
};
const services = [
"storage",
"counter",
"message-queue",
"platform-services",
"user",
"search",
"database",
"webserver",
"statistics",
"applications",
"auth",
"realtime",
"websocket",
];
const command: yargs.CommandModule<unknown, CLIArgs> = {
command: "remove",
describe: "command that allow you to remove one user",
builder: {
id: {
default: "",
type: "string",
description: "User ID",
},
},
handler: async argv => {
const tableBefore = new Table({
head: ["User ID", "Username", "Deleted"],
colWidths: [40, 40, 10],
});
const tableAfter = new Table({
head: ["User ID", "Username", "Deleted"],
colWidths: [40, 40, 10],
});
const spinner = ora({ text: "Retrieving user" }).start();
const platform = await tdrive.run(services);
await gr.doInit(platform);
const user = await gr.services.users.get({ id: argv.id });
if (!user) {
console.error("Error: You need to provide User ID");
return spinner.stop();
}
if (user) {
// Table before
tableBefore.push([user.id, user.username_canonical, user.deleted]);
await gr.services.users.anonymizeAndDelete(
{ id: user.id },
{
user: { id: user.id, server_request: true },
},
);
const finalUser = await gr.services.users.get({ id: argv.id });
// Table after
tableAfter.push([finalUser.id, finalUser.username_canonical, finalUser.deleted]);
spinner.stop();
}
exit();
},
};
export default command;
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Manage Tdrive Workspaces",
command: "workspace <command>",
builder: yargs =>
yargs.commandDir("workspace_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,47 @@
import yargs from "yargs";
import ora from "ora";
import Table from "cli-table";
import tdrive from "../../../tdrive";
import gr from "../../../services/global-resolver";
/**
* Merge command parameters. Check the builder definition below for more details.
*/
type ListParams = {
size: string;
};
const services = [
"platform-services",
"user",
"search",
"channels",
"notifications",
"database",
"webserver",
"message-queue",
];
const command: yargs.CommandModule<ListParams, ListParams> = {
command: "list",
describe: "List Tdrive workspaces",
builder: {
size: {
default: "50",
type: "string",
description: "Number of workspaces to fetch",
},
},
handler: async argv => {
const table = new Table({ head: ["ID", "Name"], colWidths: [40, 50] });
const spinner = ora({ text: "List Tdrive workspaces" }).start();
const platform = await tdrive.run(services);
await gr.doInit(platform);
const workspaces = await gr.services.workspaces.list({ limitStr: argv.size });
spinner.stop();
workspaces.getEntities().forEach(ws => table.push([ws.id, ws.name]));
console.log(table.toString());
},
};
export default command;
@@ -0,0 +1,49 @@
import yargs from "yargs";
import ora from "ora";
import Table from "cli-table";
import tdrive from "../../../tdrive";
import gr from "../../../services/global-resolver";
/**
* Merge command parameters. Check the builder definition below for more details.
*/
type ListParams = {
id: string;
};
const services = [
"platform-services",
"user",
"search",
"channels",
"notifications",
"database",
"webserver",
"message-queue",
];
const command: yargs.CommandModule<ListParams, ListParams> = {
command: "user",
describe: "List workspace ers",
builder: {
id: {
default: "f339d54a-e833-11ea-92c3-0242ac120004",
type: "string",
description: "Workspace ID",
},
},
handler: async argv => {
const table = new Table({ head: ["user ID", "Date Added"], colWidths: [40, 40] });
const spinner = ora({ text: "Retrieving workspace users" }).start();
const platform = await tdrive.run(services);
await gr.doInit(platform);
const users = await gr.services.workspaces.getUsers({ workspaceId: argv.id });
spinner.stop();
users
.getEntities()
.forEach(u => table.push([u.id, new Date(u.dateAdded).toLocaleDateString()]));
console.log(table.toString());
},
};
export default command;
+28
View File
@@ -0,0 +1,28 @@
import yargs from "yargs";
import { logger } from "../core/platform/framework/logger";
process.env.NODE_ENV = "cli";
yargs
.strict()
.usage("Usage: $0 <command> [options]")
.middleware([
argv => {
logger.level = argv.verbose ? "debug" : "fatal";
},
])
.commandDir("cmds", {
visit: commandModule => commandModule.default,
})
.option("verbose", {
alias: "v",
default: false,
type: "boolean",
description: "Run with verbose logging",
})
.demandCommand(1, "Please supply a valid command")
.alias("help", "h")
.help("help")
.version()
.epilogue("for more information, go to https://tdrive.app")
.example("$0 <command> --help", "show help of the issue command").argv;
@@ -0,0 +1,2 @@
node_modules
yarn.lock
@@ -0,0 +1,4 @@
Edit the file to your needs
yarn install
node index.js
@@ -0,0 +1,214 @@
/* eslint-disable */
var cassandra = require("cassandra-driver");
var fromAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var fromContactPoints = [""];
var fromKeyspace = "tdrive";
var toAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var toContactPoints = [""];
var toKeyspace = "tdrive";
var forceUpdateAll = false;
var ignoreTables = ["notification"];
var countersTables = ["statistics", "stats_counter", "channel_counters", "scheduled_queue_counter"];
var specialConversions = {
"tdrive.group_user.date_added": value => {
return new Date(value).getTime();
},
};
// -- start process
var fromClient = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: fromContactPoints,
authProvider: fromAuthProvider,
keyspace: fromKeyspace,
});
var toClient = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: toContactPoints,
authProvider: toAuthProvider,
keyspace: toKeyspace,
queryOptions: {
consistency: cassandra.types.consistencies.quorum,
},
});
// -- Get all tables and copy schema
async function client(origin, query, parameters, options) {
return await new Promise((resolve, reject) => {
origin.execute(query, [], {}, function (err, result) {
if (err) {
reject({ err, result });
} else {
resolve(result);
}
});
});
}
(async () => {
const result = await client(
fromClient,
"SELECT table_name from system_schema.tables WHERE keyspace_name = '" + fromKeyspace + "'",
[],
{},
);
for (row of result.rows) {
try {
const fromTable = fromKeyspace + "." + row.table_name;
const toTable = toKeyspace + "." + row.table_name;
if (ignoreTables.includes(row.table_name)) {
console.log(fromTable.padEnd(50) + " | " + "ignored".padEnd(20) + " | ⏺");
continue;
}
let fromCount = 0;
const destColumns = (
await client(
toClient,
"SELECT column_name from system_schema.columns where keyspace_name = '" +
toKeyspace +
"' and table_name = '" +
row.table_name +
"'",
[],
{},
)
).rows.map(r => r.column_name);
try {
const fromResult = await client(
fromClient,
"SELECT count(*) from " + fromTable + "",
[],
{},
);
fromCount = parseInt(fromResult.rows[0].count);
} catch (err) {
fromCount = NaN;
}
if (destColumns.length === 0) {
if (fromCount > 0)
console.log(
fromTable.padEnd(50) + " | " + ("not_in_dest" + "/" + fromCount).padEnd(20) + " | ⏺",
);
continue;
}
try {
const toResult = await client(toClient, "SELECT count(*) from " + toTable + "", [], {});
const toCount = parseInt(toResult.rows[0].count);
console.log(
fromTable.padEnd(50) +
" | " +
(toCount + "/" + fromCount).padEnd(20) +
" | " +
(toCount >= fromCount ? "✅" : "❌"),
);
if (row.table_name.indexOf("counter") >= 0 || countersTables.includes(row.table_name)) {
console.log(
fromTable.padEnd(50) + " | " + ("counter_table" + "/" + fromCount).padEnd(20) + " | 🧮",
);
if (fromCount > toCount || !fromCount || forceUpdateAll) {
await new Promise(r => {
fromClient.eachRow(
"SELECT JSON * from " + fromTable,
[],
{ prepare: true, fetchSize: 1000 },
async function (n, row) {
//
//TODO handle counters (it is special !)
},
async function (err, result) {
if (result && result.nextPage) {
await new Promise(r => setTimeout(r, 1000));
result.nextPage();
} else {
r();
}
},
);
});
}
continue;
}
if (fromCount > toCount || !fromCount || forceUpdateAll) {
await new Promise(r => {
fromClient.eachRow(
"SELECT JSON * from " + fromTable,
[],
{ prepare: true, fetchSize: 1000 },
async function (n, row) {
try {
const json = JSON.parse(row["[json]"]);
//The from table can have additional depreciated fields, we need to remove them
const filteredJson = {};
for (const col of destColumns) {
if (
typeof json[col] == "string" &&
(json[col] || "").match(
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]+/,
)
) {
json[col] = json[col].split(".")[0];
}
if (specialConversions[fromTable + "." + col]) {
json[col] = specialConversions[fromTable + "." + col](json[col]);
}
if (json[col] !== undefined) filteredJson[col] = json[col];
}
await client(
toClient,
"INSERT INTO " +
toTable +
" JSON '" +
JSON.stringify(filteredJson).replace(/'/g, "'$&") +
"'",
[],
{},
);
} catch (err) {
console.log(err);
}
},
async function (err, result) {
if (result && result.nextPage) {
await new Promise(r => setTimeout(r, 1000));
result.nextPage();
} else {
r();
}
},
);
});
}
} catch (err) {
console.log(
fromTable.padEnd(50) + " | " + ("error" + "/" + fromCount).padEnd(20) + " | ❌",
);
}
} catch (err) {
console.log(err);
continue;
}
//TODO copy content
}
})();
@@ -0,0 +1,9 @@
{
"name": "copy-cluster",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cassandra-driver": "^4.6.3"
}
}
@@ -0,0 +1,2 @@
node_modules
yarn.lock
@@ -0,0 +1,4 @@
Edit the file to your needs
yarn install
node index.js
@@ -0,0 +1,98 @@
/* eslint-disable */
var cassandra = require("cassandra-driver");
var fromAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var fromContactPoints = [""];
var toAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var toContactPoints = [""];
// -- start process
var fromClient = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: fromContactPoints,
authProvider: fromAuthProvider,
keyspace: "tdrive",
});
var toClient = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: toContactPoints,
authProvider: toAuthProvider,
keyspace: "tdrive",
queryOptions: {
consistency: cassandra.types.consistencies.quorum,
},
});
// -- Get all tables and copy schema
async function client(origin, query, parameters, options) {
return await new Promise((resolve, reject) => {
origin.execute(query, [], {}, function (err, result) {
if (err) {
reject({ err, result });
} else {
resolve(result);
}
});
});
}
// Get all threads and then copy all messages in each threads
let copiedMessages = 0;
let copiedThreads = 0;
(async () => {
await new Promise(r => {
fromClient.eachRow(
"SELECT id from tdrive.threads",
[],
{ prepare: true, fetchSize: 50 },
async function (n, row) {
const threadId = row["id"];
console.log("Threads / Messages :", copiedThreads, "/", copiedMessages);
copiedThreads++;
await new Promise(r2 => {
fromClient.eachRow(
"SELECT JSON * from tdrive.messages where thread_id = ? order by id desc",
[threadId],
{ prepare: true, fetchSize: 1000 },
async function (n, row) {
const message = row["[json]"];
copiedMessages++;
await toClient.execute(
"INSERT INTO tdrive.messages JSON '" + message.replace(/'/g, "'$&") + "'",
[],
{ prepare: true },
);
},
async function (err, result) {
if (result && result.nextPage) {
await new Promise(r => setTimeout(r, 100));
result.nextPage();
} else {
r2();
}
},
);
});
},
async function (err, result) {
if (result && result.nextPage) {
await new Promise(r => setTimeout(r, 2000));
result.nextPage();
} else {
r();
}
},
);
});
})();
@@ -0,0 +1,9 @@
{
"name": "copy-messages",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cassandra-driver": "^4.6.3"
}
}
@@ -0,0 +1,4 @@
Edit the file to your needs
yarn install
node index.js
@@ -0,0 +1,88 @@
/* eslint-disable */
var cassandra = require("cassandra-driver");
var authProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var contactPoints = [""];
var from = "table_a";
var to = "table_b";
var client = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: contactPoints,
authProvider: authProvider,
keyspace: "tdrive",
});
//Copy object to other table
(async () => {
const query = "SELECT * FROM " + from;
const parameters = [];
let count = 0;
let max = 0;
let min = new Date().getTime();
let rows = [];
const options = { prepare: true, fetchSize: 1000 };
await new Promise(r => {
client.eachRow(
query,
parameters,
options,
function (n, row) {
min = Math.min(parseInt(row.created_at.toString()), min);
max = Math.max(parseInt(row.created_at.toString()), max);
count++;
if (count % 100 == 0) {
console.log(count);
}
rows.push(row);
},
function (err, result) {
if (result && result.nextPage) {
result.nextPage();
} else {
r();
}
},
);
});
console.log("Downloaded ", count, " rows of ", from, ". Starting copy...");
let copyCount = 0;
for (let i = 0; i < count; i++) {
copyCount++;
if (copyCount % 100 == 0) {
console.log(copyCount, "of", count);
}
const row = rows[i];
await new Promise(r => {
let query =
"UPDATE " +
to +
" SET answers=?, created_at=?, created_by=?, last_activity=?, participants=?, updated_at=? WHERE id=?";
client.execute(
query,
[
row.answers,
row.created_at,
row.created_by,
row.last_activity,
row.participants,
row.updated_at,
row.id,
],
() => {
r();
},
);
});
}
console.log("Ended with ", count, "threads");
})();
@@ -0,0 +1,6 @@
{
"name": "copy-table",
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
@@ -0,0 +1,4 @@
Edit the file to your needs
yarn install
node index.js
@@ -0,0 +1,96 @@
/* eslint-disable */
var cassandra = require("cassandra-driver");
var authProvider = new cassandra.auth.PlainTextAuthProvider("", "");
var contactPoints = [""];
var client = new cassandra.Client({
localDataCenter: "datacenter1",
contactPoints: contactPoints,
authProvider: authProvider,
keyspace: "tdrive",
});
var threadsTable = "threads";
var messagesTable = "messages";
//Copy object to other table
(async () => {
const query = "SELECT * FROM " + threadsTable + " where answers>1000 allow filtering";
const parameters = [];
const options = { prepare: true, fetchSize: 1000 };
let count = 0;
let threads = {};
await new Promise(r => {
client.eachRow(
query,
parameters,
options,
function (n, row) {
threads[row.id] ||= { answers: 0, id: row.id };
count++;
},
function (err, result) {
if (result && result.nextPage) {
result.nextPage();
} else {
r();
}
},
);
});
console.log(
"Downloaded ",
Object.keys(threads).length,
" threads of ",
messagesTable,
". Starting copy...",
);
for (var key of Object.keys(threads)) {
await new Promise(r => {
const query = "SELECT id FROM " + messagesTable + " where thread_id=" + key;
const parameters = [];
const options = { prepare: true, fetchSize: 1000 };
client.eachRow(
query,
parameters,
options,
function (n, row) {
threads[key].answers++;
},
function (err, result) {
if (result && result.nextPage) {
result.nextPage();
} else {
r();
}
},
);
r();
});
}
var copyCount = 0;
for (var key of Object.keys(threads)) {
copyCount++;
if (copyCount % 100 == 0) {
console.log(copyCount, "of", Object.keys(threads));
}
const row = threads[key];
await new Promise(r => {
let query = "UPDATE " + threadsTable + " SET answers=? WHERE id=?";
console.log(query, [row.answers, row.id + ""]);
//client.execute(query, [row.answers, row.id], () => {
// r();
//});
});
}
console.log("Ended with ", Object.keys(threads), "threads");
})();
@@ -0,0 +1,9 @@
{
"name": "repair-threads",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cassandra-driver": "^4.6.3"
}
}
@@ -0,0 +1,3 @@
import config from "config";
export default config;
@@ -0,0 +1,50 @@
import legacy from "./legacy";
import v1 from "./v1";
import v2 from "./v2";
import { createHash } from "crypto";
export type CryptoResult = {
/**
* Encrypted | Decrypted data if done, original data if not done due to error or not encrypted data to decrypt
*/
data: any;
/**
* Is there an error while processing the input data?
*/
err?: Error;
/**
* Did we encrypted | decrypted the data? true if yes.
*/
done?: boolean;
};
export function decrypt(data: string, encryptionKey: string): CryptoResult {
let result = v2.decrypt(data, encryptionKey);
if (result.done) {
return result;
}
result = v1.decrypt(data, encryptionKey);
if (result.done) {
return result;
}
result = legacy.decrypt(data, encryptionKey);
if (result.done) {
return result;
}
return result;
}
export function md5(value: string): string {
return createHash("md5").update(value).digest("hex");
}
export function encrypt(
value: any,
encryptionKey: any,
options: { disableSalts?: boolean } = {},
): CryptoResult {
return v2.encrypt(value, encryptionKey, options);
}
@@ -0,0 +1,45 @@
import { createDecipheriv } from "crypto";
import { CryptoResult } from ".";
const PREFIX = "encrypted_";
export default {
decrypt,
encrypt,
};
function decrypt(data: string, encryptionKey: string): CryptoResult {
if (!data || !data.startsWith(PREFIX)) {
return {
data,
done: false,
};
}
const toDecode = data.substr(PREFIX.length);
const encryptedArray = toDecode.split("_");
// If array length is 1, the value has not been salted and iv is the default one
const encrypted = Buffer.from(encryptedArray[0], "base64");
const salt = encryptedArray[1]
? Buffer.from(encryptedArray[1], "utf-8")
: Buffer.from("", "utf-8");
const iv =
encryptedArray.length >= 3
? Buffer.from(encryptedArray[2], "base64")
: Buffer.from("twake_constantiv", "utf-8");
const password = Buffer.concat([Buffer.from(encryptionKey, "hex"), salt], 32);
const decipher = createDecipheriv("AES-256-CBC", password, iv);
return {
data: Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf-8"),
done: true,
};
}
function encrypt(data: any): CryptoResult {
return {
data,
done: false,
};
}
+67
View File
@@ -0,0 +1,67 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { CryptoResult } from ".";
export default {
encrypt,
decrypt,
};
function encrypt(
data: any,
encryptionKey: any,
options: { disableSalts?: boolean } = {},
): CryptoResult {
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
try {
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
const cipher = createCipheriv("aes-256-cbc", key, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]).toString(
"hex",
);
return {
data: `${iv.toString("hex")}:${encrypted}`,
done: true,
};
} catch (err) {
return {
err,
data,
done: false,
};
}
}
function decrypt(data: string, encryptionKey: any): CryptoResult {
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
const encryptedArray = data.split(":");
if (!encryptedArray.length || encryptedArray.length !== 2) {
return {
data,
done: false,
};
}
let iv: Buffer | string = Buffer.from(encryptedArray[0], "hex");
if (encryptedArray[0] === "0000000000000000") {
iv = "0000000000000000";
}
try {
const encrypted = Buffer.from(encryptedArray[1], "hex");
const decipher = createDecipheriv("aes-256-cbc", key, iv);
const decrypt = JSON.parse(
Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(),
);
return {
data: decrypt,
done: true,
};
} catch (err) {
return {
data,
done: false,
};
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { CryptoResult } from ".";
export default {
encrypt,
decrypt,
};
function encrypt(
data: any,
encryptionKey: any,
options: { disableSalts?: boolean } = {},
): CryptoResult {
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
try {
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted =
cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64");
return {
data: `${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${encrypted}`,
done: true,
};
} catch (err) {
return {
err,
data,
done: false,
};
}
}
function decrypt(data: string, encryptionKey: any): CryptoResult {
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
const encryptedArray = data.split(":");
if (!encryptedArray.length || encryptedArray.length !== 3) {
return {
data,
done: false,
};
}
let iv: Buffer | string = Buffer.from(encryptedArray[0], "base64");
if (encryptedArray[0] === "0000000000000000") {
iv = "0000000000000000";
}
try {
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(Buffer.from(encryptedArray[1], "base64"));
const encrypted = encryptedArray[2];
let str = decipher.update(encrypted, "base64", "utf8");
str += decipher.final("utf8");
const decrypt = JSON.parse(str);
return {
data: decrypt,
done: true,
};
} catch (err) {
return {
data,
done: false,
};
}
}
@@ -0,0 +1,7 @@
import { TdriveServiceOptions } from "./service-options";
import { TdriveServiceConfiguration } from "./service-configuration";
export class TdriveAppConfiguration extends TdriveServiceOptions<TdriveServiceConfiguration> {
services: Array<string>;
servicesPath: string;
}
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Class = { new (...args: any[]): any };
@@ -0,0 +1,67 @@
import { BehaviorSubject } from "rxjs";
import { TdriveService } from "./service";
import { TdriveServiceProvider } from "./service-provider";
import { ServiceDefinition } from "./service-definition";
import { TdriveServiceState } from "./service-state";
import { logger } from "../logger";
export class TdriveComponent {
instance: TdriveService<TdriveServiceProvider>;
components: Array<TdriveComponent> = new Array<TdriveComponent>();
constructor(public name: string, private serviceDefinition: ServiceDefinition) {}
getServiceDefinition(): ServiceDefinition {
return this.serviceDefinition;
}
setServiceInstance(instance: TdriveService<TdriveServiceProvider>): void {
this.instance = instance;
}
getServiceInstance(): TdriveService<TdriveServiceProvider> {
return this.instance;
}
addDependency(component: TdriveComponent): void {
this.components.push(component);
}
getStateTree(): string {
return `${this.name}(${this.instance.state.value}) => {${this.components
.map(component => component.getStateTree())
.join(",")}}`;
}
async switchToState(
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
recursionDepth?: number,
): Promise<void> {
if (recursionDepth > 10) {
logger.error("Maximum recursion depth exceeded (will exit process)");
process.exit(1);
}
const states: BehaviorSubject<TdriveServiceState>[] = this.components.map(
component => component.instance.state,
);
if (states.length) {
for (const component of this.components) {
await component.switchToState(state, (recursionDepth || 0) + 1);
}
logger.info(`Children of ${this.name} are all in ${state} state`);
logger.info(this.getStateTree());
} else {
logger.info(`${this.name} does not have children`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function _switchServiceToState(service: TdriveService<any>) {
state === TdriveServiceState.Initialized && (await service.init());
state === TdriveServiceState.Started && (await service.start());
state === TdriveServiceState.Stopped && (await service.stop());
}
await _switchServiceToState(this.instance);
}
}
@@ -0,0 +1,3 @@
export const PREFIX_METADATA = "service:prefix";
export const CONSUMES_METADATA = "service:consumes";
export const SERVICENAME_METADATA = "service:name";
@@ -0,0 +1,73 @@
import { TdriveAppConfiguration } from "./application-configuration";
import { TdriveComponent } from "./component";
import { TdriveContext } from "./context";
import { TdriveServiceProvider } from "./service-provider";
import { TdriveService } from "./service";
import { TdriveServiceState } from "./service-state";
import * as ComponentUtils from "../utils/component-utils";
/**
* A container contains components. It provides methods to manage and to retrieve them.
*/
export abstract class TdriveContainer
extends TdriveService<TdriveServiceProvider>
implements TdriveContext
{
private components: Map<string, TdriveComponent>;
name = "TdriveContainer";
constructor(protected options?: TdriveAppConfiguration) {
super(options);
}
abstract loadComponents(): Promise<Map<string, TdriveComponent>>;
abstract loadComponent(name: string): Promise<TdriveComponent>;
getProvider<T extends TdriveServiceProvider>(name: string): T {
const service = this.components.get(name)?.getServiceInstance();
if (!service) {
throw new Error(`Service "${name}" not found`);
}
return service.api() as T;
}
async doInit(): Promise<this> {
this.components = await this.loadComponents();
await ComponentUtils.buildDependenciesTree(this.components, async (name: string) => {
const component = await this.loadComponent(name);
if (component) this.components.set(name, component);
return component;
});
await this.launchInit();
return this;
}
protected async launchInit(): Promise<this> {
await this.switchToState(TdriveServiceState.Initialized);
return this;
}
async doStart(): Promise<this> {
await this.switchToState(TdriveServiceState.Started);
return this;
}
async doStop(): Promise<this> {
await this.switchToState(TdriveServiceState.Stopped);
return this;
}
protected async switchToState(
state: TdriveServiceState.Started | TdriveServiceState.Initialized | TdriveServiceState.Stopped,
): Promise<void> {
await ComponentUtils.switchComponentsToState(this.components, state);
}
}
@@ -0,0 +1,5 @@
import { TdriveServiceProvider } from "./service-provider";
export interface TdriveContext {
getProvider<T extends TdriveServiceProvider>(name: string): T;
}
@@ -0,0 +1,233 @@
import { User } from "../../../../utils/types";
export class ContextualizedTarget {
context?: ExecutionContext;
readonly operation: OperationType;
}
export class EntityTarget<Entity> extends ContextualizedTarget {
/**
*
* @param type type of entity
* @param entity the entity itself
*/
constructor(readonly type: string, readonly entity: Entity) {
super();
}
}
export class UpdateResult<Entity> extends EntityTarget<Entity> {
readonly operation = OperationType.UPDATE;
/**
* Result sent back by the underlying database
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
raw?: any;
/**
* Number of rows affected by the update.
*/
affected?: number;
}
export class CreateResult<Entity> extends EntityTarget<Entity> {
readonly operation = OperationType.CREATE;
/**
* Result sent back by the underlying database
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
raw?: any;
}
export class SaveResult<Entity> extends EntityTarget<Entity> {
/**
* Result sent back by the underlying database
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
raw?: any;
/**
*
* @param type Type of entity
* @param entity The entity itself
* @param operation Save can be for a "create", an "update" or "exists" when resource already exists
*/
constructor(
readonly type: string,
readonly entity: Entity,
readonly operation: OperationType.UPDATE | OperationType.CREATE | OperationType.EXISTS,
) {
super(type, entity);
}
}
export class DeleteResult<Entity> extends EntityTarget<Entity> {
readonly operation = OperationType.DELETE;
/**
*
* @param type type of entity
* @param entity the entity itself
* @param deleted the entity has been deleted or not
*/
constructor(readonly type: string, readonly entity: Entity, readonly deleted: boolean) {
super(type, entity);
}
}
export class ListResult<Entity> extends ContextualizedTarget implements Paginable {
// next page token
page_token: string;
constructor(readonly type: string, protected entities: Entity[], readonly nextPage?: Paginable) {
super();
this.page_token = nextPage?.page_token;
}
mapEntities(mapper: <T extends Entity>(entity: Entity) => T): void {
this.entities = this.entities.map(entity => mapper(entity));
}
filterEntities(filter: (entity: Entity) => boolean): void {
this.entities = this.entities.filter(entity => filter(entity));
}
getEntities(): Entity[] {
return this.entities || [];
}
isEmpty(): boolean {
return this.getEntities().length === 0;
}
}
export enum OperationType {
CREATE = "create",
UPDATE = "update",
SAVE = "save",
DELETE = "delete",
EXISTS = "exists",
}
export declare type EntityOperationResult<Entity> =
| CreateResult<Entity>
| UpdateResult<Entity>
| SaveResult<Entity>
| DeleteResult<Entity>;
export interface ExecutionContext {
user: User;
reqId?: string;
url?: string;
method?: string;
transport?: "http" | "ws";
}
export class CrudException extends Error {
constructor(readonly details: string, readonly status: number) {
super();
this.message = details;
}
static badRequest(details: string): CrudException {
return new CrudException(details, 400);
}
static notFound(details: string): CrudException {
return new CrudException(details, 404);
}
static forbidden(details: string): CrudException {
return new CrudException(details, 403);
}
static notImplemented(details: string): CrudException {
return new CrudException(details, 501);
}
static badGateway(details: string): CrudException {
return new CrudException(details, 502);
}
}
export interface Paginable {
page_token?: string;
limitStr?: string;
reversed?: boolean;
}
export class Pagination implements Paginable {
reversed?: boolean;
constructor(readonly page_token?: string, readonly limitStr = "100", reversed = false) {
this.reversed = reversed;
}
public static fromPaginable(p: Paginable) {
return new Pagination(p.page_token, p.limitStr, p.reversed);
}
}
export interface CRUDService<Entity, PrimaryKey, Context extends ExecutionContext> {
/**
* Creates a resource
*
* @param item
* @param context
*/
create?(item: Entity, context?: Context): Promise<CreateResult<Entity>>;
/**
* Get a resource
*
* @param pk
* @param context
*/
get(pk: PrimaryKey, context?: Context): Promise<Entity>;
/**
* Update a resource
*
* @param pk
* @param item
* @param context
*/
update?(
pk: PrimaryKey,
item: Entity,
context?: Context /* TODO: Options */,
): Promise<UpdateResult<Entity>>;
/**
* Save a resource.
* If the resource exists, it is updated, if it does not exists, it is created.
*
* @param itemOrItems
* @param options
* @param context
*/
save?<SaveOptions>(
item: Entity,
options?: SaveOptions,
context?: Context,
): Promise<SaveResult<Entity>>;
/**
* Delete a resource
*
* @param pk
* @param context
*/
delete(pk: PrimaryKey, context?: Context): Promise<DeleteResult<Entity>>;
/**
* List a resource
*
* @param context
*/
list<ListOptions>(
pagination: Paginable,
options?: ListOptions,
context?: Context /* TODO: Options */,
): Promise<ListResult<Entity>>;
}
@@ -0,0 +1,14 @@
export * from "./application-configuration";
export * from "./class";
export * from "./component";
export * from "./constants";
export * from "./context";
export * from "./container";
export * from "./lifecycle";
export * from "./service-configuration";
export * from "./service-definition";
export * from "./service-interface";
export * from "./service-options";
export * from "./service-provider";
export * from "./service-state";
export * from "./service";
@@ -0,0 +1,6 @@
import { TdriveContext } from "./context";
export interface Initializable {
// TODO: Flag to tell if a service which fails to initialize must break all
init?(context?: TdriveContext): Promise<this>;
}
@@ -0,0 +1,3 @@
export interface TdriveServiceConfiguration {
get<T>(name?: string, defaultValue?: T): T;
}
@@ -0,0 +1,6 @@
import { Class } from "./class";
export interface ServiceDefinition {
name: string;
clazz: Class;
}
@@ -0,0 +1,8 @@
import { TdriveServiceProvider } from "./service-provider";
export interface TdriveServiceInterface<T extends TdriveServiceProvider> {
doInit(): Promise<this>;
doStart(): Promise<this>;
doStop(): Promise<this>;
api(): T;
}
@@ -0,0 +1,5 @@
export class TdriveServiceOptions<TdriveServiceConfiguration> {
name?: string;
// TODO: configuration is abstract and comes from all others
configuration?: TdriveServiceConfiguration;
}
@@ -0,0 +1,3 @@
export interface TdriveServiceProvider {
version: string;
}
@@ -0,0 +1,10 @@
export enum TdriveServiceState {
Ready = "READY",
Initializing = "INITIALIZING",
Initialized = "INITIALIZED",
Starting = "STARTING",
Started = "STARTED",
Stopping = "STOPPING",
Stopped = "STOPPED",
Errored = "ERRORED",
}
@@ -0,0 +1,133 @@
import { BehaviorSubject } from "rxjs";
import { TdriveServiceInterface } from "./service-interface";
import { TdriveServiceProvider } from "./service-provider";
import { TdriveServiceState } from "./service-state";
import { TdriveServiceConfiguration } from "./service-configuration";
import { TdriveContext } from "./context";
import { TdriveServiceOptions } from "./service-options";
import { CONSUMES_METADATA, PREFIX_METADATA } from "./constants";
import { getLogger, logger } from "../logger";
import { TdriveLogger } from "..";
const pendingServices: any = {};
export abstract class TdriveService<T extends TdriveServiceProvider>
implements TdriveServiceInterface<TdriveServiceProvider>
{
state: BehaviorSubject<TdriveServiceState>;
readonly name: string;
protected readonly configuration: TdriveServiceConfiguration;
context: TdriveContext;
logger: TdriveLogger;
constructor(protected options?: TdriveServiceOptions<TdriveServiceConfiguration>) {
this.state = new BehaviorSubject<TdriveServiceState>(TdriveServiceState.Ready);
// REMOVE ME, we should import config from framework folder instead
this.configuration = options?.configuration;
this.logger = getLogger(`core.platform.services.${this.name}Service`);
}
abstract api(): T;
public get prefix(): string {
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
}
getConsumes(): Array<string> {
return Reflect.getMetadata(CONSUMES_METADATA, this) || [];
}
async init(): Promise<this> {
if (this.state.value !== TdriveServiceState.Ready) {
logger.info("Service %s is already initialized", this.name);
return this;
}
try {
logger.info("Initializing service %s", this.name);
pendingServices[this.name] = true;
this.state.next(TdriveServiceState.Initializing);
await this.doInit();
this.state.next(TdriveServiceState.Initialized);
logger.info("Service %s is initialized", this.name);
delete pendingServices[this.name];
logger.info("Pending services: %s", JSON.stringify(Object.keys(pendingServices)));
return this;
} catch (err) {
logger.error("Error while initializing service %s", this.name);
logger.error(err);
this.state.error(new Error(`Error while initializing service ${this.name}`));
throw err;
}
}
async doInit(): Promise<this> {
return this;
}
async doStart(): Promise<this> {
return this;
}
async start(): Promise<this> {
if (
this.state.value === TdriveServiceState.Starting ||
this.state.value === TdriveServiceState.Started
) {
logger.info("Service %s is already started", this.name);
return this;
}
try {
logger.info("Starting service %s", this.name);
this.state.next(TdriveServiceState.Starting);
await this.doStart();
this.state.next(TdriveServiceState.Started);
logger.info("Service %s is started", this.name);
return this;
} catch (err) {
logger.error("Error while starting service %s", this.name, err);
logger.error(err);
this.state.error(new Error(`Error while starting service ${this.name}`));
throw err;
}
}
async stop(): Promise<this> {
if (
this.state.value === TdriveServiceState.Stopping ||
this.state.value === TdriveServiceState.Stopped
) {
logger.info("Service %s is already stopped", this.name);
return this;
}
if (this.state.value !== TdriveServiceState.Started) {
logger.info("Service %s can not be stopped until started", this.name);
return this;
}
try {
logger.info("Stopping service %s", this.name);
this.state.next(TdriveServiceState.Stopping);
await this.doStop();
this.state.next(TdriveServiceState.Stopped);
logger.info("Service %s is stopped", this.name);
return this;
} catch (err) {
logger.error("Error while stopping service %s", this.name, err);
logger.error(err);
this.state.error(new Error(`Error while stopping service ${this.name}`));
throw err;
}
}
async doStop(): Promise<this> {
return this;
}
}
@@ -0,0 +1,21 @@
import { PREFIX_METADATA } from "./constants";
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import gr from "../../../../services/global-resolver";
export abstract class AbstractWebService {
abstract routes(fastify: FastifyInstance): void;
public get prefix(): string {
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
}
public process(): void {
const wrapper: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
this.routes(fastify);
next();
};
gr.fastify.register(wrapper, { prefix: this.prefix });
}
}
@@ -0,0 +1,32 @@
import { IConfig } from "config";
import configuration from "../../config";
import { TdriveServiceConfiguration } from "./api";
export class Configuration implements TdriveServiceConfiguration {
configuration: IConfig;
serviceConfiguration: IConfig;
constructor(path: string) {
try {
this.serviceConfiguration = configuration.get(path);
} catch {
// NOP
}
}
get<T>(name?: string, defaultValue?: T): T {
let value: T;
try {
value = this.serviceConfiguration as unknown as T;
if (name) {
value =
this.serviceConfiguration &&
(this.serviceConfiguration.get(name) || configuration.get(name));
}
} catch {
value = defaultValue || null;
} finally {
return value;
}
}
}
@@ -0,0 +1,8 @@
import { CONSUMES_METADATA } from "../api";
export function Consumes(services: string[] = []): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function): void {
Reflect.defineMetadata(CONSUMES_METADATA, services, target.prototype);
};
}
@@ -0,0 +1,4 @@
export * from "./consumes";
export * from "./prefix";
export * from "./service-name";
export * from "./realtime";
@@ -0,0 +1,8 @@
import { PREFIX_METADATA } from "../api";
export function Prefix(prefix: string = "/"): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function): void {
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
};
}
@@ -0,0 +1,41 @@
import { CreateResult } from "../../api/crud-service";
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
import { websocketEventBus } from "../../../services/realtime/bus";
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from ".";
/**
*
* @param path the path to push the notification to
* @param resourcePath the path of the resource itself
*/
export function RealtimeCreated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
const result: CreateResult<T> = await originalMethod.apply(this, args);
// context should always be the last arg
const context = args && args[args.length - 1];
if (!(result instanceof CreateResult)) {
return result;
}
getRealtimeRecipients(recipients, result.entity, context).forEach(
({ room, path, resource }) => {
websocketEventBus.publish<T>(RealtimeEntityActionType.Created, {
type: result.type,
room: getRoom(room, result, context),
resourcePath: path || "/",
entity: resource,
result,
} as RealtimeEntityEvent<T>);
},
);
return result;
};
};
}
@@ -0,0 +1,41 @@
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
import { DeleteResult } from "../../api/crud-service";
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
import { websocketEventBus } from "../../../services/realtime/bus";
/**
*
* @param path the path to push the notification to
* @param resourcePath the path of the resource itself
*/
export function RealtimeDeleted<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
const result: DeleteResult<T> = await originalMethod.apply(this, args);
const context = args && args[args.length - 1];
if (!(result instanceof DeleteResult)) {
return result;
}
if (result.deleted)
getRealtimeRecipients(recipients, result.entity, context).forEach(
({ room, path, resource }) => {
websocketEventBus.publish<T>(RealtimeEntityActionType.Deleted, {
type: result.type,
room: getRoom(room, result, context),
resourcePath: path,
entity: resource,
result,
} as RealtimeEntityEvent<T>);
},
);
return result;
};
};
}
@@ -0,0 +1,67 @@
import { ResourcePath } from "../../../../../core/platform/services/realtime/types";
import { EntityTarget, ExecutionContext } from "../../api/crud-service";
export * from "./created";
export * from "./deleted";
export * from "./updated";
export * from "./saved";
export type RealtimeRecipients<T> =
| RealtimeRecipient<T>
| RealtimeRecipient<T>[]
| ((type: T, context?: ExecutionContext) => RealtimeRecipient<T>[] | RealtimeRecipient<T>);
export type RealtimeRecipient<T> = {
room: RealtimePath<T>;
path?: string;
resource?: any | T;
};
export function getRealtimeRecipients<T>(
recipients: RealtimeRecipients<T>,
type: T,
context?: ExecutionContext,
): RealtimeRecipient<T>[] {
if (typeof recipients === "function") recipients = recipients(type, context);
if (!recipients) return [];
if (!(recipients as RealtimeRecipient<T>[]).length)
recipients = [recipients as RealtimeRecipient<T>];
return (recipients as RealtimeRecipient<T>[]).map(recipient => {
return {
resource: recipient.resource || type,
path: recipient.path || "/",
room: recipient.room,
};
});
}
export type RealtimePath<T> = string | ResourcePath | ResourcePathResolver<T>;
export interface ResourcePathResolver<T> {
(type: T, context?: ExecutionContext): ResourcePath;
}
export interface PathResolver<T> {
(type: T, context?: ExecutionContext): string;
}
export type RealtimeEntity<T> = null | ((type: T, context?: ExecutionContext) => T);
export function getRoom<T>(
path: RealtimePath<T> = ResourcePath.default(),
entity: EntityTarget<T>,
context: ExecutionContext,
): ResourcePath {
if (typeof path === "string") {
return ResourcePath.get(path);
}
return typeof path === "function" ? path(entity.entity, context) : path;
}
export function getPath<T>(
path: string | PathResolver<T> = "/",
entity: EntityTarget<T>,
context: ExecutionContext,
): string {
return typeof path === "function" ? path(entity.entity, context) : path;
}
@@ -0,0 +1,41 @@
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
import { SaveResult } from "../../api/crud-service";
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
import { websocketEventBus } from "../../../services/realtime/bus";
/**
*
* @param path the path to push the notification to
* @param resourcePath the path of the resource itself
*/
export function RealtimeSaved<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
const result: SaveResult<T> = await originalMethod.apply(this, args);
// context should always be the last arg
const context = args && args[args.length - 1];
if (!(result instanceof SaveResult)) {
return result;
}
getRealtimeRecipients(recipients, result.entity, context).forEach(
({ room, path, resource }) => {
websocketEventBus.publish<T>(RealtimeEntityActionType.Saved, {
type: result.type,
room: getRoom(room, result, context),
resourcePath: path,
entity: resource,
result,
} as RealtimeEntityEvent<T>);
},
);
return result;
};
};
}
@@ -0,0 +1,40 @@
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
import { UpdateResult } from "../../api/crud-service";
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
import { websocketEventBus } from "../../../services/realtime/bus";
/**
*
* @param path the path to push the notification to
* @param resourcePath the path of the resource itself
*/
export function RealtimeUpdated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
const result: UpdateResult<T> = await originalMethod.apply(this, args);
const context = args && args[args.length - 1];
if (!(result instanceof UpdateResult)) {
return result;
}
getRealtimeRecipients(recipients, result.entity, context).forEach(
({ room, path, resource }) => {
websocketEventBus.publish<T>(RealtimeEntityActionType.Updated, {
type: result.type,
room: getRoom(room, result, context),
resourcePath: path,
entity: resource,
result,
} as RealtimeEntityEvent<T>);
},
);
return result;
};
};
}
@@ -0,0 +1,10 @@
import { SERVICENAME_METADATA } from "../api";
export function ServiceName(name: string): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function): void {
if (name) {
Reflect.defineMetadata(SERVICENAME_METADATA, name, target.prototype);
}
};
}
@@ -0,0 +1,35 @@
import { getLogger } from "../logger";
const logger = getLogger("core.platform.framework.decorators.Skip");
type SkipCondition = () => Promise<boolean> | boolean;
/**
* Skip a method call based on some condition
*/
export function Skip(condition: SkipCondition): MethodDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Object, _propertyKey: string, descriptor: PropertyDescriptor): void {
logger.trace("Skipping method call");
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
const skipIt = await (condition && condition());
if (skipIt) {
return target;
}
return await originalMethod.apply(this, args);
};
};
}
/**
* Skip when process.env.NODE_ENV is set to "cli"
*/
export function SkipCLI(): MethodDecorator {
return Skip(() => process.env.NODE_ENV === "cli");
}
@@ -0,0 +1,8 @@
import { PREFIX_METADATA } from "../api";
export function WebService(prefix: string = "/"): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function): void {
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
};
}
@@ -0,0 +1,47 @@
import { Subject } from "rxjs";
import { logger as rootLogger } from "./logger";
import { ExecutionContext } from "./api/crud-service";
const logger = rootLogger.child({
component: "tdrive.core.platform.framework.event-bus",
});
/**
* A local event bus in the platform. Used by platform components and services to communicate using publish subscribe.
*/
class EventBus {
private subjects: Map<string, Subject<any>>;
constructor() {
this.subjects = new Map<string, Subject<any>>();
}
subscribe<T>(name: string, listener: (_data: T) => void): this {
if (!this.subjects.has(name)) {
this.subjects.set(name, new Subject<T>());
}
this.subjects.get(name).subscribe({
next: (value: T) => {
try {
listener(value);
} catch (err) {
logger.warn({ err }, "Error while calling listener");
}
},
});
return this;
}
publish<T>(name: string, data: T, _context?: ExecutionContext): boolean {
if (!this.subjects.has(name)) {
return false;
}
this.subjects.get(name)?.next(data);
return true;
}
}
export const localEventBus = new EventBus();
@@ -0,0 +1,32 @@
import {
TdriveContext,
TdriveService,
TdriveServiceConfiguration,
TdriveServiceOptions,
TdriveServiceProvider,
} from "./api";
import { Configuration } from "./configuration";
class StaticTdriveServiceFactory {
public async create<
T extends TdriveService<TdriveServiceProvider> = TdriveService<TdriveServiceProvider>,
>(
module: { new (options?: TdriveServiceOptions<TdriveServiceConfiguration>): T },
context: TdriveContext,
configuration?: string,
): Promise<T> {
let config;
if (configuration) {
config = new Configuration(configuration);
}
const instance = new module({ configuration: config });
instance.context = context;
return instance;
}
}
export const TdriveServiceFactory = new StaticTdriveServiceFactory();
@@ -0,0 +1,8 @@
import "reflect-metadata";
export * from "./api";
export * from "./decorators";
export * from "./configuration";
export * from "./factory";
export * from "./logger";
export * from "./utils/component-utils";
@@ -0,0 +1,15 @@
import pino from "pino";
import { Configuration } from "./configuration";
const config = new Configuration("logger");
export type TdriveLogger = pino.Logger;
export const logger = pino({
name: "TdriveApp",
level: config.get("level", "info") || "info",
prettyPrint: false,
});
export const getLogger = (name?: string): TdriveLogger =>
logger.child({ name: `tdrive${name ? "." + name : ""}` });
@@ -0,0 +1,108 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
import {
logger,
TdriveComponent,
TdriveContext,
TdriveServiceFactory,
TdriveServiceState,
} from "../index";
import { Loader } from "./loader";
export async function buildDependenciesTree(
components: Map<string, TdriveComponent>,
loadComponent: (name: string) => any,
): Promise<void> {
for (const [name, component] of components) {
const dependencies: string[] = component.getServiceInstance().getConsumes() || [];
for (const dependencyName of dependencies) {
if (name === dependencyName) {
throw new Error(`There is a circular dependency for component ${dependencyName}`);
}
let dependencyComponent = components.get(dependencyName);
if (!dependencyComponent) {
//Except in the tests, we allow this to happen with a warning
if (process.env.NODE_ENV === "test") {
throw new Error(
`The component dependency ${dependencyName} has not been found for component ${name}`,
);
} else {
console.warn(
`(warning) The component dependency ${dependencyName} has not been found for component ${name} it will be imported asynchronously`,
);
try {
dependencyComponent = await loadComponent(name);
if (dependencyComponent) components.set(name, dependencyComponent);
} catch (err) {
dependencyComponent = null;
}
if (!dependencyComponent) {
throw new Error(
`The component dependency ${dependencyName} has not been found for component ${name} even with async load`,
);
}
}
}
component.addDependency(dependencyComponent);
}
}
}
/**
* Load specified components from given list of paths
*
* @param paths Paths to search components in
* @param names Components to load
* @param context
*/
export async function loadComponents(
paths: string[],
names: string[] = [],
context: TdriveContext,
): Promise<Map<string, TdriveComponent>> {
const result = new Map<string, TdriveComponent>();
const loader = new Loader(paths);
const components: TdriveComponent[] = await Promise.all(
names.map(async name => {
const clazz = await loader.load(name);
const component = new TdriveComponent(name, { clazz, name });
result.set(name, component);
return component;
}),
);
await Promise.all(
components.map(async component => {
const service = await TdriveServiceFactory.create(
component.getServiceDefinition().clazz,
context,
component.getServiceDefinition().name,
);
component.setServiceInstance(service);
}),
);
return result;
}
export async function switchComponentsToState(
components: Map<string, TdriveComponent>,
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
): Promise<void> {
const states = [];
for (const [name, component] of components) {
logger.info(`Asking for ${state} on ${name} dependencies`);
states.push(component.getServiceInstance().state);
await component.switchToState(state);
}
logger.info(`All components are now in ${state} state`);
}
@@ -0,0 +1,45 @@
import { logger } from "../logger";
import fs from "fs";
export class Loader {
constructor(readonly paths: string[]) {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async load(componentName: string): Promise<any> {
const modulesPaths = this.paths.map(path => `${path}/${componentName}`);
let classes = await Promise.all(
modulesPaths.map(async modulePath => {
if (fs.existsSync(modulePath)) {
try {
return await import(modulePath);
} catch (err) {
logger.debug(
{ err },
`${modulePath} can not be loaded (file was found but we were unable to import the module)`,
);
}
}
}),
);
classes = classes.filter(Boolean);
if (!classes || !classes.length) {
modulesPaths.map(modulePath => {
if (fs.existsSync(modulePath)) {
logger.debug(`${modulePath} content was: [${fs.readdirSync(modulePath).join(", ")}]`);
}
});
throw new Error(
`Can not find or load ${componentName} in any given path [${modulesPaths.join(
" - ",
)}] see previous logs for more details.`,
);
}
logger.debug(`Loaded ${componentName}`);
return classes[0].default;
}
}
@@ -0,0 +1,47 @@
import { TdriveContainer, TdriveServiceProvider, TdriveComponent } from "./framework";
import * as ComponentUtils from "./framework/utils/component-utils";
import path from "path";
export class TdrivePlatform extends TdriveContainer {
constructor(protected options: TdrivePlatformConfiguration) {
super();
}
api(): TdriveServiceProvider {
return null;
}
async loadComponents(): Promise<Map<string, TdriveComponent>> {
return await ComponentUtils.loadComponents(
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
this.options.services,
{
getProvider: this.getProvider.bind(this),
},
);
}
async loadComponent(name: string): Promise<TdriveComponent> {
return (
await ComponentUtils.loadComponents(
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
[name],
{
getProvider: this.getProvider.bind(this),
},
)
).get(name);
}
}
export class TdrivePlatformConfiguration {
/**
* The services to load in the container
*/
services: string[];
/**
* The path to load services from
*/
servicesPath: string;
}
@@ -0,0 +1,29 @@
import { TdriveService, Consumes, Prefix, ServiceName } from "../../framework";
import web from "./web/index";
import AuthServiceAPI, { JwtConfiguration } from "./provider";
import { AuthService as AuthServiceImpl } from "./service";
import WebServerAPI from "../webserver/provider";
@Prefix("/api/auth")
@Consumes(["webserver"])
@ServiceName("auth")
export default class AuthService extends TdriveService<AuthServiceAPI> {
name = "auth";
service: AuthServiceAPI;
api(): AuthServiceAPI {
return this.service;
}
public async doInit(): Promise<this> {
this.service = new AuthServiceImpl(this.configuration.get<JwtConfiguration>("jwt"));
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
}
@@ -0,0 +1,39 @@
import { TdriveServiceProvider } from "../../framework/api";
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
export default interface AuthServiceAPI extends TdriveServiceProvider {
/**
* Get the authentication types
*/
getTypes(): Array<string>;
/**
* Sign payload
*/
sign(payload: any): string;
/**
* Verify token
*
* @param token
*/
verifyToken(token: string): JWTObject;
verifyTokenObject<T>(token: string): T;
generateJWT(
userId: uuid,
email: string,
options: {
track: boolean;
provider_id: string;
application_id?: string;
} & any,
): AccessToken;
}
export interface JwtConfiguration {
secret: string;
expiration: number;
refresh_expiration: number;
}
@@ -0,0 +1,71 @@
import { JwtConfiguration } from "./provider";
import jwt from "jsonwebtoken";
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
import assert from "assert";
import { JwtType } from "../types";
import AuthServiceAPI from "./provider";
export class AuthService implements AuthServiceAPI {
version: "1";
constructor(private configuration: JwtConfiguration) {}
getTypes(): string[] {
throw new Error("Method not implemented.");
}
// eslint-disable-next-line @typescript-eslint/ban-types
sign(payload: string | object | Buffer): string {
return jwt.sign(payload, this.configuration.secret);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
verifyToken(token: string): JWTObject {
return jwt.verify(token, this.configuration.secret) as JWTObject;
}
verifyTokenObject<T>(token: string): T {
return jwt.verify(token, this.configuration.secret) as T;
}
generateJWT(
userId: uuid,
email: string,
options: { track: boolean; provider_id: string; application_id?: string },
): AccessToken {
const now = Math.round(new Date().getTime() / 1000); // Current time in UTC
assert(this.configuration.expiration, "jwt.expiration is missing");
assert(this.configuration.refresh_expiration, "jwt.refresh_expiration is missing");
const jwtExpiration = now + this.configuration.expiration;
const jwtRefreshExpiration = now + this.configuration.refresh_expiration;
return {
time: now,
expiration: jwtExpiration,
refresh_expiration: jwtRefreshExpiration,
value: this.sign({
exp: jwtExpiration,
type: "access",
iat: now - 60 * 10,
nbf: now - 60 * 10,
sub: userId,
email: email,
track: !!options.track,
provider_id: options.provider_id || "",
application_id: options.application_id,
} as JwtType),
refresh: this.sign({
exp: jwtRefreshExpiration,
type: "refresh",
iat: now - 60 * 10,
nbf: now - 60 * 10,
sub: userId,
email: email,
track: !!options.track,
provider_id: options.provider_id || "",
application_id: options.application_id,
} as JwtType),
type: "Bearer",
};
}
}
@@ -0,0 +1,10 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
opts: FastifyRegisterOptions<{ prefix: string }>,
): void => {
fastify.log.debug("Configuring /auth");
fastify.register(routes, opts);
};
@@ -0,0 +1,47 @@
import { FastifyPluginCallback, FastifyRequest } from "fastify";
import fastifyJwt from "fastify-jwt";
import fp from "fastify-plugin";
import config from "../../../../config";
import { JwtType } from "../../types";
const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => {
fastify.register(fastifyJwt, {
secret: config.get("auth.jwt.secret"),
});
const authenticate = async (request: FastifyRequest) => {
const jwt: JwtType = await request.jwtVerify();
if (jwt.type === "refresh") {
// TODO in the future we must invalidate the refresh token (because it should be single use)
}
request.currentUser = {
...{ email: jwt.email },
...{ id: jwt.sub },
...{ identity_provider_id: jwt.provider_id },
...{ application_id: jwt.application_id || null },
...{ server_request: jwt.server_request || false },
...{ allow_tracking: jwt.track || false },
};
request.log.debug(`Authenticated as user ${request.currentUser.id}`);
};
fastify.decorate("authenticate", async (request: FastifyRequest) => {
try {
await authenticate(request);
} catch (err) {
throw fastify.httpErrors.unauthorized("Bad credentials");
}
});
fastify.decorate("authenticateOptional", async (request: FastifyRequest) => {
try {
await authenticate(request);
} catch (err) {}
});
next();
};
export default fp(jwtPlugin, {
name: "authenticate",
});
@@ -0,0 +1,18 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import { v4 as uuidv4 } from "uuid";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _opts, next) => {
fastify.get("/login", async (_request, reply) => {
const userId = uuidv4();
reply.send({
token: fastify.jwt.sign({ sub: userId }),
user: {
id: userId,
},
});
});
next();
};
export default routes;
@@ -0,0 +1,21 @@
import { TdriveService } from "../../framework";
import { CounterProvider } from "./provider";
import { CounterAPI } from "./types";
import Repository from "../database/services/orm/repository/repository";
export default class CounterService extends TdriveService<CounterAPI> implements CounterAPI {
name = "counter";
version = "1";
api(): CounterAPI {
return this;
}
async doInit(): Promise<this> {
return Promise.resolve(this);
}
getCounter<T>(repository: Repository<T>): CounterProvider<T> {
return new CounterProvider(repository);
}
}
@@ -0,0 +1,91 @@
import Repository from "../database/services/orm/repository/repository";
import { logger } from "../../framework";
import { ExecutionContext } from "../../framework/api/crud-service";
type LastRevised = {
calls: number;
period: number;
};
export class CounterProvider<T> {
private name = "CounterProvider";
protected readonly repository: Repository<T>;
private reviseHandler: (pk: Partial<T>) => Promise<number>;
private reviseMaxCalls = 0;
private reviseMaxPeriod = 0;
private lastRevisedMap = new Map<string, LastRevised>();
constructor(repository: Repository<T>) {
this.repository = repository;
logger.debug(`${this.name} Created counter provider for ${this.repository.table}`);
}
async increase(pk: Partial<T>, value: number, context?: ExecutionContext): Promise<void> {
return this.repository.save(this.repository.createEntityFromObject({ value, ...pk }), context);
}
async get(pk: Partial<T>, context?: ExecutionContext): Promise<number> {
try {
const counter = await this.repository.findOne(pk, {}, context);
const val = counter ? (counter as any).value : 0;
return this.revise(pk, val);
} catch (e) {
throw e;
}
}
setReviseCallback(
handler: (pk: Partial<T>) => Promise<number>,
maxCalls: number = 10,
maxPeriod: number = 24 * 60 * 60 * 1000,
): void {
logger.debug(`${this.name} Set setReviseCallback for ${this.repository.table}`);
this.reviseHandler = handler;
this.reviseMaxCalls = maxCalls;
this.reviseMaxPeriod = maxPeriod;
}
private async revise(pk: Partial<T>, currentValue: number): Promise<number> {
const now = new Date().getTime();
const lastRevised: LastRevised = this.lastRevisedMap.get(JSON.stringify(pk)) || {
calls: -1,
period: now,
};
logger.debug(
`${this.name} revision status for ${JSON.stringify(pk)} is ${JSON.stringify(lastRevised)}`,
);
if (
lastRevised.calls >= this.reviseMaxCalls ||
now > lastRevised.period + this.reviseMaxPeriod ||
Math.random() < 1 / Math.max(1, currentValue) //The slowest the number is, the more we update it
) {
if (!this.reviseHandler) {
logger.debug(
`${this.name} No setReviseCallback handler found for ${this.repository.table}`,
);
return currentValue;
}
logger.debug(`${this.name} Execute setReviseCallback handler for ${this.repository.table}`);
const actual = await this.reviseHandler(pk);
if (actual !== undefined && actual != currentValue) {
await this.increase(pk, actual - currentValue);
currentValue = actual;
}
lastRevised.calls = 0;
lastRevised.period = now;
} else {
lastRevised.calls++;
}
this.lastRevisedMap.set(JSON.stringify(pk), lastRevised);
return currentValue;
}
}

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