♻️Remove Cassandra Support

This commit is contained in:
Anton SHEPILOV
2024-05-27 17:53:24 +02:00
committed by Anton Shepilov
parent 8214a5f6d4
commit 8f75c04fdd
25 changed files with 4 additions and 1807 deletions
-6
View File
@@ -68,12 +68,6 @@ jobs:
docker-compose -f docker-compose.dev.tests.opensearch.yml logs
docker-compose -f docker-compose.dev.tests.opensearch.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=opensearch -e DB_DRIVER=postgres -e PUBSUB_TYPE=local node npm run test:all
docker-compose -f docker-compose.dev.tests.opensearch.yml down
- name: e2e-cassandra-test
run: |
cd tdrive
docker-compose -f docker-compose.tests.yml up -d scylladb elasticsearch rabbitmq
sleep 60
docker-compose -f docker-compose.tests.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=elasticsearch -e DB_DRIVER=cassandra node npm run test:all
- name: coverage
uses: adRise/jest-cov-reporter@e13d7638888cad7788298f4022b203b31a10c7c8
with:
@@ -43,11 +43,6 @@
"uri": "DB_MONGO_URI",
"database": "DB_MONGO_DATABASE"
},
"cassandra": {
"contactPoints": "DB_CASSANDRA_URI",
"localDataCenter": "DB_CASSANDRA_LOCALDATACENTER",
"keyspace": "DB_CASSANDRA_KEYSPACE"
},
"postgres": {
"database": "DB_POSTGRES_DBNAME",
"host": "DB_POSTGRES_HOST",
+1 -9
View File
@@ -63,20 +63,12 @@
},
"database": {
"secret": "",
"type": "cassandra",
"type": "mongodb",
"encryption": "aes-256-cbc",
"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'",
-9
View File
@@ -8,15 +8,6 @@
"secret": "",
"mongodb": {
"uri": "mongodb://mongo:27017"
},
"cassandra": {
"contactPoints": ["scylladb:9042"],
"wait": true,
"retries": 50,
"delay": 500,
"queryOptions": {
"consistency": 1
}
}
},
"message-queue": {
-1
View File
@@ -129,7 +129,6 @@
"archiver": "^5.3.1",
"axios": "^1.6.8",
"bcrypt": "^5.0.1",
"cassandra-driver": "^4.6.0",
"class-transformer": "^0.3.1",
"cli-table": "^0.3.6",
"config": "^3.3.2",
@@ -29,14 +29,6 @@ export default function printConfigSummary(useIcons: boolean = !!process.env.HAV
"user",
user,
],
cassandra: ({ contactPoints, localDataCenter, keyspace }) => [
"host",
contactPoints,
"datacenter",
localDataCenter,
"keyspace",
keyspace,
],
S3: ({ endPoint, port, accessKey, bucket }) => [
"host",
[endPoint, port],
@@ -62,7 +54,6 @@ export default function printConfigSummary(useIcons: boolean = !!process.env.HAV
elasticsearch: "ES",
opensearch: "OS",
mongodb: "mongo",
cassandra: "Cass.",
};
if (useIcons) {
const makeLyingLengthIcon = str => ({ length: 1, toString: _ => str } as string);
@@ -1,2 +0,0 @@
node_modules
yarn.lock
@@ -1,4 +0,0 @@
Edit the file to your needs
yarn install
node index.js
@@ -1,214 +0,0 @@
/* 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
}
})();
@@ -1,9 +0,0 @@
{
"name": "copy-cluster",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cassandra-driver": "^4.6.3"
}
}
@@ -1,4 +0,0 @@
Edit the file to your needs
yarn install
node index.js
@@ -1,88 +0,0 @@
/* 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");
})();
@@ -1,6 +0,0 @@
{
"name": "copy-table",
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
@@ -1,17 +1,11 @@
import { DatabaseType } from ".";
import { ConnectionOptions, Connector } from "./orm/connectors";
import {
CassandraConnectionOptions,
CassandraConnector,
} from "./orm/connectors/cassandra/cassandra";
import { MongoConnectionOptions, MongoConnector } from "./orm/connectors/mongodb/mongodb";
import { PostgresConnectionOptions, PostgresConnector } from "./orm/connectors/postgres/postgres";
export class ConnectorFactory {
public create(type: DatabaseType, options: ConnectionOptions, secret: string): Connector {
switch (type) {
case "cassandra":
return new CassandraConnector(type, options as CassandraConnectionOptions, secret);
case "mongodb":
return new MongoConnector(type, options as MongoConnectionOptions, secret);
case "postgres":
@@ -3,7 +3,6 @@ import { ConnectorFactory } from "./connector-factory";
import { Connector } from "./orm/connectors";
import Manager from "./orm/manager";
import Repository from "./orm/repository/repository";
import { CassandraConnectionOptions } from "./orm/connectors/cassandra/cassandra";
import { MongoConnectionOptions } from "./orm/connectors/mongodb/mongodb";
import { EntityTarget } from "./orm/types";
import { RepositoryManager } from "./orm/repository/manager";
@@ -48,9 +47,6 @@ export default class DatabaseService implements DatabaseServiceAPI {
}
}
export declare type ConnectionOptions =
| MongoConnectionOptions
| CassandraConnectionOptions
| PostgresConnectionOptions;
export declare type ConnectionOptions = MongoConnectionOptions | PostgresConnectionOptions;
export declare type DatabaseType = "mongodb" | "cassandra" | "postgres";
export declare type DatabaseType = "mongodb" | "postgres";
@@ -1,565 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
import cassandra, { types } from "cassandra-driver";
import { md5 } from "../../../../../../../crypto";
import { defer, Subject, throwError, timer } from "rxjs";
import { concat, delayWhen, retryWhen, take, tap } from "rxjs/operators";
import { UpsertOptions } from "..";
import { logger } from "../../../../../../framework";
import { getEntityDefinition, unwrapPrimarykey } from "../../utils";
import { EntityDefinition, ColumnDefinition, ObjectType } from "../../types";
import { AbstractConnector } from "../abstract-connector";
import {
transformValueToDbString,
cassandraType,
transformValueFromDbString,
} from "./typeTransforms";
import { FindOptions } from "../../repository/repository";
import { ListResult, Pagination } from "../../../../../../framework/api/crud-service";
import { Paginable } from "../../../../../../framework/api/crud-service";
import { buildSelectQuery } from "./query-builder";
export { CassandraPagination } from "./pagination";
export interface CassandraConnectionOptions {
contactPoints: string[];
localDataCenter: string;
username: string;
password: string;
keyspace: string;
/**
* Consistency level
*/
queryOptions: { consistency: number };
/**
* Wait for keyspace and tables to be created at init
*/
wait: boolean;
/**
* When wait = true, retry to get the resources N times where N = retries
*/
retries?: number;
/**
* Delay in ms between the retries. The delay is growing each time a retry fails like delay = retryCount * delay
*/
delay?: number;
}
export class CassandraConnector extends AbstractConnector<CassandraConnectionOptions> {
private client: cassandra.Client;
private keyspaceExists = false;
getClient(): cassandra.Client {
return this.client;
}
async init(): Promise<this> {
if (!this.client) {
await this.connect();
}
try {
await this.createKeyspace();
} catch (err) {
logger.warn("services.database.orm.cassandra - Keyspace can not be created", err);
}
if (this.options.wait) {
await this.waitForKeyspace(this.options.delay, this.options.retries);
}
return this;
}
createKeyspace(): Promise<cassandra.types.ResultSet> {
const query = `CREATE KEYSPACE IF NOT EXISTS ${this.options.keyspace} WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': '2'} AND durable_writes = true;`;
logger.info(query);
return this.client.execute(query);
}
async isKeyspaceCreated(): Promise<boolean> {
let result;
if (this.keyspaceExists) {
return true;
}
try {
result = await this.client.execute(
`SELECT * FROM system_schema.keyspaces where keyspace_name = '${this.options.keyspace}'`,
);
if (!result) {
throw new Error("No result for keyspace query");
}
} catch (err) {
throw new Error("Keyspace query error");
}
if (result) {
await new Promise(resolve => setTimeout(resolve, 2000));
this.keyspaceExists = true;
logger.info(`Keyspace '${this.options.keyspace}' found.`);
}
return result ? Promise.resolve(true) : Promise.reject(new Error("Keyspace not found"));
}
waitForKeyspace(delay: number = 500, retries: number = 10): Promise<boolean> {
const subject = new Subject<boolean>();
const obs$ = defer(() => this.isKeyspaceCreated());
obs$
.pipe(
retryWhen(errors =>
errors.pipe(
delayWhen((_, i) => timer(i * delay)),
//delay(1000), if we want fixed delay
tap(() => logger.debug("services.database.orm.cassandra - Retrying...")),
take(retries),
concat(throwError("Maximum number of retries reached")),
),
),
)
.subscribe(
() => logger.debug("services.database.orm.cassandra - Keyspace has been found"),
err => {
logger.error(
{ err },
"services.database.orm.cassandra - Error while getting keyspace information",
);
subject.error(new Error("Can not find keyspace information"));
},
() => subject.complete(),
);
return subject.toPromise();
}
async drop(): Promise<this> {
logger.info("Drop database is not implemented for security reasons.");
return this;
}
async connect(): Promise<this> {
if (this.client) {
return this;
}
// Environment variable format is comma separated string
const contactPoints =
typeof this.options.contactPoints === "string"
? (this.options.contactPoints as string).split(",")
: this.options.contactPoints;
const cassandraOptions: cassandra.DseClientOptions = {
contactPoints: contactPoints,
localDataCenter: this.options.localDataCenter,
queryOptions: {},
};
if (this.options.username && this.options.password) {
cassandraOptions.authProvider = new cassandra.auth.PlainTextAuthProvider(
this.options.username,
this.options.password,
);
}
//Set default consistency level to quorum
cassandraOptions.queryOptions.consistency =
this.options?.queryOptions?.consistency || types.consistencies.quorum;
this.client = new cassandra.Client(cassandraOptions);
await this.client.connect();
return this;
}
async disconnect(): Promise<this> {
if (this.client) await this.client.shutdown();
this.client = null;
return this;
}
async getTableDefinition(name: string): Promise<string[]> {
let result: string[];
try {
const query = `SELECT column_name from system_schema.columns WHERE keyspace_name = '${this.options.keyspace}' AND table_name='${name}';`;
const dbResult = await this.client.execute(query);
result = dbResult.rows.map(row => row.values()[0]);
} catch (err) {
throw "Table query error";
}
return result ? Promise.resolve(result || []) : Promise.resolve([]);
}
async createTable(
entity: EntityDefinition,
columns: { [name: string]: ColumnDefinition },
): Promise<boolean> {
await this.waitForKeyspace(this.options.delay, this.options.retries);
let result = true;
// --- Generate column and key definition --- //
const primaryKey = entity.options.primaryKey || [];
if (primaryKey.length === 0) {
logger.error(
`services.database.orm.cassandra - Primary key was not defined for table ${entity.name}`,
);
return false;
}
const partitionKeyPart = primaryKey.shift();
const partitionKey: string[] =
typeof partitionKeyPart === "string" ? [partitionKeyPart] : partitionKeyPart;
const clusteringKeys: string[] = primaryKey as string[];
if ([...partitionKey, ...clusteringKeys].some(key => columns[key] === undefined)) {
logger.error(
`services.database.orm.cassandra - One primary key item doesn't exists in entity columns for table ${entity.name}`,
);
return false;
}
const clusteringOrderBy = clusteringKeys.map(key => {
const direction: "ASC" | "DESC" = columns[key].options.order || "ASC";
return `${key} ${direction}`;
});
const clusteringOrderByString =
clusteringOrderBy.length > 0
? `WITH CLUSTERING ORDER BY (${clusteringOrderBy.join(", ")})`
: "";
const allKeys = [`(${partitionKey.join(", ")})`, ...clusteringKeys];
const primaryKeyString = `(${allKeys.join(", ")})`;
const columnsString = Object.keys(columns)
.map(colName => {
const definition = columns[colName];
return `${colName} ${cassandraType[definition.type]}`;
})
.join(",\n");
// --- Generate final create table query --- //
let query = `
CREATE TABLE IF NOT EXISTS ${this.options.keyspace}.${entity.name}
(
${columnsString},
PRIMARY KEY ${primaryKeyString}
) ${clusteringOrderByString};`;
// --- Alter table if not up to date --- //
const existingColumns = await this.getTableDefinition(entity.name);
if (existingColumns.length > 0) {
logger.debug(
`services.database.orm.cassandra - Existing columns for table ${entity.name}, generating altertable queries`,
);
const alterQueryColumns = Object.keys(columns)
.filter(colName => existingColumns.indexOf(colName) < 0)
.map(colName => `${colName} ${cassandraType[columns[colName].type]}`);
query = `ALTER TABLE ${this.options.keyspace}.${entity.name} ADD (${alterQueryColumns.join(
", ",
)})`;
if (alterQueryColumns.length === 0) {
return true;
}
}
// --- Write table --- //
try {
logger.debug(`service.database.orm.createTable - Creating table ${entity.name} : ${query}`);
await this.client.execute(query);
} catch (err) {
logger.warn(
{ err },
`service.database.orm.createTable - creation error for table ${entity.name} : ${err.message}`,
);
result = false;
}
// --- Create indexes --- //
if (entity.options.globalIndexes) {
for (const globalIndex of entity.options.globalIndexes) {
const indexName = globalIndex.join("_");
const indexDbName = `index_${md5(indexName)}`;
const query = `CREATE INDEX IF NOT EXISTS ${indexDbName} ON ${this.options.keyspace}."${
entity.name
}" (${
globalIndex.length === 1 ? globalIndex[0] : `(${globalIndex[0]}), ${globalIndex[1]}`
})`;
try {
logger.debug(
`service.database.orm.createTable - Creating index ${indexName} (${indexDbName}) : ${query}`,
);
await this.client.execute(query);
} catch (err) {
logger.warn(
{ err },
`service.database.orm.createTable - creation error for index ${indexName} (${indexDbName}) : ${err.message}`,
);
result = false;
}
}
}
return result;
}
async upsert(entities: any[], _options: UpsertOptions = {}): Promise<boolean[]> {
return new Promise(resolve => {
const promises: Promise<boolean>[] = [];
entities.forEach(entity => {
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
const primaryKey = unwrapPrimarykey(entityDefinition);
//Set updated content
const set = Object.keys(columnsDefinition)
.filter(key => primaryKey.indexOf(key) === -1)
.filter(key => entity[columnsDefinition[key].nodename] !== undefined)
.map(key => [
`${key}`,
`${transformValueToDbString(
entity[columnsDefinition[key].nodename],
columnsDefinition[key].type,
{
columns: columnsDefinition[key].options,
secret: this.secret,
column: { key },
},
)}`,
]);
//Set primary key
const where = primaryKey.map(key => [
`${key}`,
`${transformValueToDbString(
entity[columnsDefinition[key].nodename],
columnsDefinition[key].type,
{
columns: columnsDefinition[key].options,
secret: this.secret,
disableSalts: true,
column: { key },
},
)}`,
]);
// Add time-to-live options
let ttlOptions = "";
if (entityDefinition.options.ttl && entityDefinition.options.ttl > 0) {
ttlOptions = `USING TTL ${entityDefinition.options.ttl}`;
}
// Insert and update are equivalent for most of Cassandra
// Update is prefered because the only solution for counters
let query = `UPDATE ${this.options.keyspace}.${
entityDefinition.name
} ${ttlOptions} SET ${set.map(e => `${e[0]} = ${e[1]}`).join(", ")} WHERE ${where
.map(e => `${e[0]} = ${e[1]}`)
.join(" AND ")}`;
// If no "set" part, we cannot do an update, so insert
if (set.length === 0) {
query = `INSERT INTO ${this.options.keyspace}.${
entityDefinition.name
} ${ttlOptions} (${where.map(e => e[0]).join(", ")}) VALUES (${where
.map(e => e[1])
.join(", ")})`;
}
logger.debug(`service.database.orm.upsert - Query: "${query}"`);
promises.push(
new Promise(async resolve => {
try {
await this.getClient().execute(query);
resolve(true);
} catch (err) {
logger.error(
{ err },
`services.database.orm.cassandra - Error with CQL query: ${query}`,
);
resolve(false);
}
}),
);
});
Promise.all(promises).then(resolve);
});
}
async remove(entities: any[]): Promise<boolean[]> {
return new Promise(resolve => {
const promises: Promise<boolean>[] = [];
entities.forEach(entity => {
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
const primaryKey = unwrapPrimarykey(entityDefinition);
//Set primary key
const where = primaryKey.map(
key =>
`${key} = ${transformValueToDbString(
entity[columnsDefinition[key].nodename],
columnsDefinition[key].type,
{
columns: columnsDefinition[key].options,
secret: this.secret,
disableSalts: true,
column: { key },
},
)}`,
);
const query = `DELETE FROM ${this.options.keyspace}.${
entityDefinition.name
} WHERE ${where.join(" AND ")}`;
logger.debug(`services.database.orm.cassandra.remove - Query: ${query}`);
promises.push(
new Promise(async resolve => {
try {
await this.getClient().execute(query);
resolve(true);
} catch (err) {
logger.error(
{ err },
`services.database.orm.cassandra.remove - Error with CQL query: ${query}`,
);
resolve(false);
}
}),
);
});
Promise.all(promises).then(resolve);
});
}
async find<Table>(
entityType: Table,
filters: any,
options: FindOptions = {},
): Promise<ListResult<Table>> {
const instance = new (entityType as any)();
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
const query = buildSelectQuery<Table>(
entityType as unknown as ObjectType<Table>,
filters,
options,
{
keyspace: this.options.keyspace,
secret: this.secret,
},
);
logger.debug(`services.database.orm.cassandra.find - Query: ${query}`);
const results = await this.getClient().execute(query, [], {
fetchSize: parseInt(options.pagination.limitStr) || 100,
pageState: options.pagination.page_token || undefined,
prepare: false,
});
const entities: Table[] = [];
results.rows.forEach(row => {
const entity = new (entityType as any)();
Object.keys(row).forEach(key => {
if (columnsDefinition[key]) {
entity[columnsDefinition[key].nodename] = transformValueFromDbString(
row[key],
columnsDefinition[key].type,
{ column: { key: key, ...columnsDefinition[key].options }, secret: this.secret },
);
}
});
entities.push(entity);
});
const nextPage: Paginable = new Pagination(
results.pageState,
options.pagination.limitStr || "100",
);
logger.debug(
`services.database.orm.cassandra.find - Query Result (items=${entities.length}): ${query}`,
);
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
}
}
export function waitForTable(
client: cassandra.Client,
keyspace: string,
table: string,
retries: number = 10,
delay: number = 500,
): Promise<boolean> {
const subject = new Subject<boolean>();
const obs$ = defer(() => checkForTable(client, keyspace, table));
obs$
.pipe(
retryWhen(errors =>
errors.pipe(
delayWhen((_, i) => timer(i * delay)),
//delay(1000),
tap(() =>
logger.debug("services.database.orm.cassandra - Retrying to get table metadata..."),
),
take(retries),
concat(throwError("Maximum number of retries reached")),
),
),
)
.subscribe(
() => logger.debug(`services.database.orm.cassandra - Table ${table} has been found`),
err => {
logger.debug({ err }, `services.database.orm.cassandra - Table ${table} error`);
subject.error(new Error("Can not find table"));
},
() => subject.complete(),
);
return subject.toPromise();
}
async function checkForTable(
client: cassandra.Client,
keyspace: string,
table: string,
): Promise<void> {
try {
const result: cassandra.types.ResultSet = await client.execute(
`SELECT * FROM system_schema.tables WHERE keyspace_name='${keyspace}' AND table_name='${table}'`,
);
const tableMetadata = result.rows[0];
logger.debug(
"services.database.orm.cassandra.checkForTable - Table metadata %o",
tableMetadata,
);
if (!tableMetadata) {
throw new Error("Can not find table metadata");
}
} catch (err) {
logger.error(
{ err },
"services.database.orm.cassandra.checkForTable - Error while getting table metadata",
);
throw new Error("Error while getting table metadata");
}
}
@@ -1,19 +0,0 @@
import { Paginable } from "../../../../../../framework/api/crud-service";
import { Pagination } from "../../../../../../framework/api/crud-service";
export class CassandraPagination extends Pagination {
limit = 100;
private constructor(readonly page_token: string, readonly limitStr = "100") {
super(page_token, limitStr);
this.limit = Number.parseInt(limitStr, 10);
}
static from(pagination: Paginable): CassandraPagination {
return new CassandraPagination(pagination.page_token, pagination.limitStr);
}
static next(current: Pagination, pageState: string): Paginable {
return new Pagination(pageState, current.limitStr);
}
}
@@ -1,136 +0,0 @@
import { FindOptions } from "../../repository/repository";
import { ObjectType } from "../../types";
import { getEntityDefinition, secureOperators, filteringRequired } from "../../utils";
import { transformValueToDbString } from "./typeTransforms";
export function buildSelectQuery<Entity>(
entityType: ObjectType<Entity>,
filters: Record<string, unknown>,
findOptions: FindOptions,
options: {
secret?: string;
keyspace: string;
} = {
secret: "",
keyspace: "tdrive",
},
): string {
const instance = new (entityType as any)();
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
let allowFiltering = false;
const where = Object.keys(filters)
.map(key => {
let result: string;
const filter = filters[key];
if (filteringRequired(key)) {
allowFiltering = true;
}
if (!filter) {
return;
}
if (Array.isArray(filter)) {
if (!filter.length) {
return;
}
const inClause: string[] = filter.map(
value =>
`${transformValueToDbString(value, columnsDefinition[key].type, {
columns: columnsDefinition[key].options,
secret: options.secret || "",
disableSalts: true,
})}`,
);
result = `${key} IN (${inClause.join(",")})`;
} else {
result = `${key} = ${transformValueToDbString(filter, columnsDefinition[key].type, {
columns: columnsDefinition[key].options,
secret: options.secret || "",
disableSalts: true,
})}`;
}
return result;
})
.filter(Boolean);
secureOperators(transformValueToDbString, findOptions, entityType, options);
const whereClause = `${[
...where,
...(buildComparison(findOptions) || []),
...(buildIn(findOptions) || []),
...(buildLike(findOptions) || []),
].join(" AND ")}`.trimEnd();
let orderByClause = "";
if (findOptions.pagination?.reversed) {
orderByClause = `${entityDefinition.options.primaryKey
.slice(1)
.map(
(key: string) =>
`${key} ${(columnsDefinition[key].options.order || "ASC") === "ASC" ? "DESC" : "ASC"}`,
)
.join(", ")}`;
}
const query = `SELECT * FROM ${options.keyspace}.${entityDefinition.name} ${
whereClause.trim().length ? "WHERE " + whereClause : ""
} ${orderByClause.trim().length ? "ORDER BY " + orderByClause : ""}`
.trimEnd()
.concat(allowFiltering ? " ALLOW FILTERING;" : ";");
return query;
}
export function buildComparison(options: FindOptions = {}): string[] {
let lessClause;
let lessEqualClause;
let greaterClause;
let greaterEqualClause;
if (options.$lt) {
lessClause = options.$lt.map(element => `${element[0]} < ${element[1]}`);
}
if (options.$lte) {
lessEqualClause = options.$lte.map(element => `${element[0]} <= ${element[1]}`);
}
if (options.$gt) {
greaterClause = options.$gt.map(element => `${element[0]} > ${element[1]}`);
}
if (options.$gte) {
greaterEqualClause = options.$gte.map(element => `${element[0]} >= ${element[1]}`);
}
return [
...(lessClause || []),
...(lessEqualClause || []),
...(greaterClause || []),
...(greaterEqualClause || []),
];
}
export function buildIn(options: FindOptions = {}): string[] {
let inClauses: string[];
if (options.$in) {
inClauses = options.$in.map(element => `${element[0]} IN (${element[1].join(",")})`);
}
return inClauses || [];
}
export function buildLike(options: FindOptions = {}): string[] {
let likeClauses: string[];
if (options.$like) {
likeClauses = options.$like.map(element => `${element[0]} LIKE '%${element[1]}%`);
}
return likeClauses || [];
}
@@ -1,174 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { isBoolean, isInteger, isNull, isUndefined } from "lodash";
import { ColumnOptions, ColumnType } from "../../types";
import { decrypt, encrypt } from "../../../../../../../crypto";
import { logger } from "../../../../../../../../core/platform/framework";
export const cassandraType = {
encoded_string: "TEXT",
encoded_json: "TEXT",
string: "TEXT",
json: "TEXT",
number: "BIGINT",
timeuuid: "TIMEUUID",
uuid: "UUID",
counter: "COUNTER",
blob: "BLOB",
boolean: "BOOLEAN",
// backward compatibility
tdrive_boolean: "TINYINT",
tdrive_int: "INT", //Depreciated
tdrive_datetime: "TIMESTAMP", //Depreciated
};
type TransformOptions = {
secret?: any;
disableSalts?: boolean;
columns?: ColumnOptions;
column?: any;
};
export const transformValueToDbString = (
v: any,
type: ColumnType,
options: TransformOptions = {},
): string => {
if (type === "tdrive_datetime") {
if (isNaN(v) || isNull(v)) {
return "null";
}
return `${v}`;
}
if (type === "number" || type === "tdrive_int") {
if (isNull(v)) {
return "null";
}
if (isNaN(v)) {
return "null";
}
return `${parseInt(v)}`;
}
if (type === "uuid" || type === "timeuuid") {
if (isNull(v)) {
return "null";
}
v = (v || "").toString().replace(/[^a-zA-Z0-9-]/g, "");
return `${v}`;
}
if (type === "boolean") {
//Security to avoid string with "false" in it
if (!isInteger(v) && !isBoolean(v) && !isNull(v) && !isUndefined(v)) {
throw new Error(`'${v}' is not a ${type}`);
}
return `${!!v}`;
}
if (type === "tdrive_boolean") {
if (!isBoolean(v)) {
throw new Error(`'${v}' is not a ${type}`);
}
return v ? "1" : "0";
}
if (type === "encoded_string" || type === "encoded_json") {
if (type === "encoded_json") {
try {
v = JSON.stringify(v);
} catch (err) {
v = null;
}
}
const encrypted = encrypt(v, options.secret, { disableSalts: options.disableSalts });
return `'${(encrypted.data || "").toString().replace(/'/gm, "''")}'`;
}
if (type === "blob") {
return "''"; //Not implemented yet
}
if (type === "string" || type === "json") {
if (type === "json" && v !== null) {
try {
v = JSON.stringify(v);
} catch (err) {
v = null;
}
}
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
}
if (type === "counter") {
if (isNaN(v)) throw new Error("Counter value should be a number");
return `${options.column.key} + ${v}`;
}
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
};
export const transformValueFromDbString = (
v: any,
type: string,
options: TransformOptions = {},
): any => {
logger.trace(`Transform value %o of type ${type}`, v);
if (type === "tdrive_datetime") {
return new Date(`${v}`).getTime();
}
if (v !== null && (type === "encoded_string" || type === "encoded_json")) {
let decryptedValue: any;
if (typeof v === "string" && v.trim() === "") {
return v;
}
try {
decryptedValue = decrypt(v, options.secret).data;
} catch (err) {
logger.debug(`Can not decrypt data (${err.message}) %o of type ${type}`, v);
decryptedValue = v;
}
if (type === "encoded_json") {
try {
decryptedValue = JSON.parse(decryptedValue);
} catch (err) {
logger.debug(
{ err },
`Can not parse JSON from decrypted data %o of type ${type}`,
decryptedValue,
);
decryptedValue = null;
}
}
return decryptedValue;
}
if (type === "tdrive_boolean" || type === "boolean") {
return Boolean(v).valueOf();
}
if (type === "json") {
try {
return JSON.parse(v);
} catch (err) {
return null;
}
}
if (type === "uuid" || type === "timeuuid") {
return v ? String(v).valueOf() : null;
}
if (type === "number") {
return new Number(v).valueOf();
}
if (type === "counter") {
return new Number(v).valueOf();
}
return v;
};
@@ -1,6 +1,5 @@
import { Initializable } from "../../../../../framework";
import { DatabaseType } from "../..";
import { CassandraConnectionOptions } from "./cassandra/cassandra";
import { MongoConnectionOptions } from "./mongodb/mongodb";
import { ColumnDefinition, EntityDefinition } from "../types";
import { FindOptions } from "../repository/repository";
@@ -8,7 +7,6 @@ import { ListResult } from "../../../../../framework/api/crud-service";
import { PostgresConnectionOptions } from "./postgres/postgres";
export * from "./mongodb/mongodb";
export * from "./cassandra/cassandra";
export type UpsertOptions = {
action?: "INSERT" | "UPDATE";
@@ -68,7 +66,4 @@ export interface Connector extends Initializable {
): Promise<ListResult<EntityType>>;
}
export declare type ConnectionOptions =
| MongoConnectionOptions
| CassandraConnectionOptions
| PostgresConnectionOptions;
export declare type ConnectionOptions = MongoConnectionOptions | PostgresConnectionOptions;
@@ -1,166 +0,0 @@
import "reflect-metadata";
import { describe, expect, it } from "@jest/globals";
import {
buildSelectQuery,
buildComparison,
buildIn,
} from "../../../../../../../../../src/core/platform/services/database/services/orm/connectors/cassandra/query-builder";
import { DriveFile } from "../../../../../../../../../src/services/documents/entities/drive-file";
describe("The QueryBuilder module", () => {
describe("The buildSelectQuery function", () => {
it("should build a valid query from primary key parameters", () => {
const filters = {
company_id: "comp1",
parent_id: "parent1",
};
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1';",
);
});
it("should build a valid query from primary key parameters and comparison", () => {
const filters = {
company_id: "comp1",
parent_id: "parent1",
};
const result = buildSelectQuery<DriveFile>(
DriveFile,
filters,
{
$lt: [["size", 1000]],
},
{ keyspace: "tdrive" },
);
expect(result).toEqual(
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1' AND size < 1000;",
);
});
it("should build IN query from array parameters", () => {
const filters = {
company_id: "comp1",
parent_id: "parent1",
creator: ["u1", "u2", "u3"],
};
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1' AND creator IN (u1,u2,u3);",
);
});
it("should not build IN query from array parameters when array is empty", () => {
const filters = {
company_id: "comp1",
parent_id: "parent1",
user_id: [],
};
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1';",
);
});
});
describe("The buildComparison function", () => {
it("should create a valid < string", () => {
expect(
buildComparison({
$lt: [["foo", 1]],
}),
).toContain("foo < 1");
const result = buildComparison({
$lt: [
["foo", 1],
["bar", 2],
],
});
expect(result).toContain("foo < 1");
expect(result).toContain("bar < 2");
});
it("should create a valid <= string", () => {
expect(
buildComparison({
$lte: [["foo", 1]],
}),
).toContain("foo <= 1");
const result = buildComparison({
$lte: [
["foo", 1],
["bar", 2],
],
});
expect(result).toContain("foo <= 1");
expect(result).toContain("bar <= 2");
});
it("should create a valid > string", () => {
expect(
buildComparison({
$gt: [["foo", 1]],
}),
).toContain("foo > 1");
const result = buildComparison({
$gt: [
["foo", 1],
["bar", 2],
],
});
expect(result).toContain("foo > 1");
expect(result).toContain("bar > 2");
});
it("should create a valid >= string", () => {
expect(
buildComparison({
$gte: [["foo", 1]],
}),
).toContain("foo >= 1");
const result = buildComparison({
$gte: [
["foo", 1],
["bar", 2],
],
});
expect(result).toContain("foo >= 1");
expect(result).toContain("bar >= 2");
});
it("should combine conditions", () => {
const result = buildComparison({
$gt: [["foo", 1]],
$gte: [["bar", 2]],
$lt: [["baz", 3]],
$lte: [["qix", 4]],
});
expect(result).toContain("foo > 1");
expect(result).toContain("bar >= 2");
expect(result).toContain("baz < 3");
expect(result).toContain("qix <= 4");
});
});
describe("The buildIn function", () => {
it("should create a id IN (ids) string", () => {
expect(
buildIn({
$in: [["id", ["1", "2", "3"]]],
}),
).toContain("id IN (1,2,3)");
});
});
});
@@ -1,36 +0,0 @@
{
"websocket": {
"path": "/socket/",
"adapters": {
"types": []
},
"auth": {
"jwt": {
"secret": "supersecret"
}
}
},
"auth": {
"jwt": {
"secret": "supersecret"
}
},
"database": {
"secret": "ab63bb3e90c0271c9a1c06651a7c0967eab8851a7a897766",
"type": "cassandra",
"cassandra": {
"contactPoints": ["scylladb:9042"],
"localDataCenter": "datacenter1",
"keyspace": "tdrive",
"wait": false,
"retries": 10,
"delay": 200
}
},
"pubsub": {
"type": "amqp",
"amqp": {
"urls": ["amqp://guest:guest@rabbitmq:5672"]
}
}
}
@@ -1,37 +0,0 @@
{
"websocket": {
"path": "/socket/",
"adapters": {
"types": []
},
"auth": {
"jwt": {
"secret": "supersecret (use the same as in php)"
}
}
},
"auth": {
"jwt": {
"secret": "supersecret (use the same as in php)"
}
},
"database": {
"secret": "GET YOUR SECRET FROM PHP Parameters.php: db.secret",
"type": "cassandra",
"cassandra": {
"contactPoints": [
"scylladb:9042"
],
"localDataCenter": "datacenter1",
"keyspace": "tdrive",
"wait": false,
"retries": 10,
"delay": 200
}
},
"pubsub": {
"urls": [
"amqp://admin:admin@rabbitmq:5672"
]
}
}
@@ -1,220 +0,0 @@
<?php
namespace Configuration;
class Parameters extends \Common\Configuration
{
public $configuration = [];
public function __construct()
{
$this->configuration = [
"env" => [
"type" => "prod",
"timer" => false,
"admin_api_token" => "",
"licence_key" => "",
"standalone" => true,
//"frontend_server_name" => "http://localhost:8000/", // define only if needed
"server_name" => "http://localhost:8000/",
"internal_server_name" => "http://nginx/"
],
"jwt" => [
"secret" => "supersecret",
"expiration" => 60*60, //1 hour
"refresh_expiration" => 60*60*24*31 //1 month
],
"node" => [
"api" => "http://node:3000/private/",
"secret" => "api_supersecret"
],
"db" => [
"driver" => "pdo_cassandra",
"host" => "scylladb",
"port" => 9042,
"dbname" => "tdrive",
"user" => "root",
"password" => "root",
"encryption_key" => "ab63bb3e90c0271c9a1c06651a7c0967eab8851a7a897766",
"replication" => "{'class': 'SimpleStrategy', 'replication_factor': '1'}"
],
"es" => [
"host" => false
],
"queues" => [
"rabbitmq" => [
"use" => true,
"host" => "rabbitmq",
"port" => 5672,
"username" => "guest",
"password" => "guest",
"vhost" => false
]
],
"storage" => [
"drive_previews_tmp_folder" => "/tmp/",
"drive_tmp_folder" => "/tmp/",
"drive_salt" => "SecretPassword",
"providers" => [
[
"label" => "someOpenStack",
"type" => "openstack",
"use" => false,
"project_id" => "",
"auth_url" => "https//auth.cloud.ovh.net/v3",
"buckets_prefix" => "",
"disable_encryption" => false,
"buckets" => [
"fr" => [
"public" => "",
"private" => "",
"region" => "SBG5"
]
],
"user" => [
"id" => "",
"password" => "",
"domain_name" => "default"
]
],
[
"label" => "someS3",
"type" => "S3",
"base_url" => "http://127.0.0.1:9000",
"use" => false,
"version" => "latest",
"disable_encryption" => false,
"buckets_prefix" => "dev.",
"buckets" => [
"fr" => "eu-west-3"
],
"credentials" => [
"key" => "",
"secret" => ""
]
],
[
"label" => "someLocal",
"type" => "local",
"use" => true,
"disable_encryption" => false,
"location" => "../drive/",
"preview_location" => "../web/medias/",
"preview_public_path" => "/medias/"
],
],
],
"mail" => [
"sender" => [
"host" => "",
"port" => "",
"username" => "",
"password" => "",
"auth_mode" => "plain"
],
"template_dir" => "/src/Tdrive/Core/Resources/views/",
"tdrive_domain_url" => "https://tdriveapp.com/",
"from" => "noreply@tdriveapp.com",
"from_name" => "Tdrive",
"tdrive_address" => "Tdrive, 54000 Nancy, France",
"dkim" => [
"private_key" => "",
"domain_name" => '',
"selector" => ''
],
"mailjet"=> [
"contact_list_subscribe"=> false,
"contact_list_newsletter"=> false
]
],
"push_notifications" => [
"apns_certificate" => __DIR__ . "/certs/apns_prod.pem",
"firebase_api_key" => "KEY",
],
//Defaults values for all clients but editable in database
"defaults" => [
"applications" => [
"tdrive_drive" => [ "default" => true ], //False to not install
"tdrive_calendar" => [ "default" => true ],
"tdrive_tasks" => [ "default" => true ],
"connectors" => [
"jitsi" => [ "default" => true ],
]
],
"connectors" => [
],
"branding" => [
"name" => "Tdrive",
"enable_newsletter" => false,
/*
"header" => [
"logo" => 'https://openpaas.linagora.com/images/white-logo.svg',
"apps" => [
[
"name"=> 'Accueil',
"url"=> 'https://openpaas.linagora.com/',
"icon"=> 'https://openpaas.linagora.com/images/application-menu/home-icon.svg',
],
[
"name"=> 'Inbox',
"url"=> 'https://openpaas.linagora.com/#/unifiedinbox/inbox',
"icon"=> 'https://openpaas.linagora.com/unifiedinbox/images/inbox-icon.svg',
],
[
"name"=> 'Calendrier',
"url"=> 'https://openpaas.linagora.com/#/calendar',
"icon"=> 'https://openpaas.linagora.com/calendar/images/calendar-icon.svg',
],
[
"name"=> 'Contacts',
"url"=> 'https://openpaas.linagora.com/#/contact/addressbooks/',
"icon"=> 'https://openpaas.linagora.com/contact/images/contacts-icon.svg',
],
],
],
"style" => [
"color" => '#2196F3',
"default_border_radius" => '2',
],
"link" => "https://open-paas.org/",
"logo" => "https://open-paas.org/wp-content/uploads/2019/10/openpaas.png"
*/
],
"auth" => [
"internal" => [
"use" => true,
"disable_account_creation" => false,
"disable_email_verification" => true
],
"openid" => [
"use" => false,
"provider_uri" => 'https://auth0.com',
"client_id" => '',
"client_secret" => '',
//"disable_logout_redirect" => false
"provider_config" => [
//token_endpoint
//token_endpoint_auth_methods_supported
//userinfo_endpoint
//end_session_endpoint
//authorization_endpoint
]
],
"cas" => [
"use" => false,
"base_url" => '',
"email_key" => '',
"lastname_key" => '',
"firstname_key" => ''
],
"console" => [
"use" => false
]
]
],
];
}
}
-70
View File
@@ -1,70 +0,0 @@
version: "3.4"
services:
scylladb:
image: scylladb/scylla:4.1.0
command: --smp 1 --memory 400M
ports:
- 9042:9042
volumes:
- ./docker-data/scylladb:/var/lib/scylla
rabbitmq:
image: rabbitmq:3
node:
image: tdrive/tdrive-node
ports:
- 9229:9229
environment:
- DEV=dev
- DB_DRIVER=cassandra
- DB_CASSANDRA_URI=scylladb:9042
- DB_CASSANDRA_KEYSPACE=tdrive
- PUBSUB_URLS=amqp://guest:guest@rabbitmq:5672
build:
context: .
dockerfile: docker/tdrive-node/Dockerfile
target: development
volumes:
- ./backend/node:/usr/src/app
- ./docker-data/documents/:/storage/
depends_on:
- scylladb
- rabbitmq
- elasticsearch
links:
- scylladb
extra_hosts:
- "host.docker.internal:host-gateway"
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.13.4
environment:
- cluster.name=docker-cluster
- discovery.type=single-node
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- ./docker-data/es:/usr/share/elasticsearch/data
nginx:
image: tdrive/tdrive-frontend
environment:
- DEV=dev
build:
context: .
dockerfile: docker/tdrive-frontend/Dockerfile
ports:
- 8000:80
depends_on:
- node
volumes:
- ./docker-data/logs/nginx/:/var/log/nginx
- ./docker-data/letsencrypt/:/etc/letsencrypt/
- ./frontend/:/tdrive-react/
- ./docker-data/drive-preview/:/tdrive-core/web/medias/
- ./docker-data/uploads/:/tdrive-core/web/upload/