🖥️ Onlyoffice connector moved into the TwakeDrive repository (#366)

This commit is contained in:
Montassar Ghanmy
2024-02-27 10:35:01 +01:00
committed by GitHub
parent ff74104a1b
commit dcb6fa1c36
40 changed files with 1557 additions and 3 deletions
@@ -0,0 +1,54 @@
name: publish-onlyoffice-connector
# Deploy onlyoffice-connector only if push on "main"
on:
push:
branches: [main]
paths:
- "tdrive/docker/**"
- "tdrive/onlyoffice-connector/**"
- ".github/workflows/**"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies and build
run: |
npm ci
npm run build --if-present
npm run lint
working-directory: ./server
env:
CI: true
publish:
runs-on: ubuntu-20.04
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@v5
with:
name: tdrive/onlyoffice-connector
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
workdir: .
registry: docker-registry.linagora.com
context: .
target: production
buildoptions: "-t docker-registry.linagora.com/tdrive/onlyoffice-connector"
tags: "latest"
@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
@@ -0,0 +1,2 @@
node_modules
dist
@@ -0,0 +1,33 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"prettier",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"env": {
"es6": true,
"node": true
},
"rules": {
"no-var": "error",
"semi": "error",
"indent": ["error", 2, { "SwitchCase": 1 }],
"no-multi-spaces": "error",
"space-in-parens": "error",
"no-multiple-empty-lines": "error",
"prefer-const": "error",
"@typescript-eslint/explicit-member-accessibility": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/no-parameter-properties": 0,
"@typescript-eslint/interface-name-prefix": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-var-requires": "off"
}
}
@@ -0,0 +1,106 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
# .env
# .env.test
.env.development.local
.env*
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Package-lock json file
package-lock.json
yarn.lock
@@ -0,0 +1,8 @@
{
"printWidth": 150,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"arrowParens": "avoid"
}
@@ -0,0 +1,36 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2017",
"externalHelpers": false,
"keepClassNames": true,
"loose": false,
"minify": {
"compress": false,
"mangle": false
},
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"@config": ["config"],
"@controllers/*": ["controllers/*"],
"@interfaces/*": ["interfaces/*"],
"@middlewares/*": ["middlewares/*"],
"@routes/*": ["routes/*"],
"@services/*": ["services/*"],
"@utils/*": ["utils/*"]
}
},
"module": {
"type": "commonjs"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,56 @@
{
"name": "twake-plugins-onlyoffice",
"version": "1.0.0",
"description": "twake onlyoffice and R7 connector",
"scripts": {
"dev": "concurrently \"npm run watch-compile\" \"npm run watch-dev\"",
"watch-compile": "swc src -w --out-dir dist --source-maps --copy-files",
"watch-dev": "nodemon --watch \"dist/**/*\" -e js ./dist/server.js",
"start": "cross-env NODE_ENV=production node dist/server.js",
"build": "swc src -d dist --source-maps --copy-files",
"build:tsc": "tsc && tsc-alias",
"lint": "eslint --ignore-path .gitignore --ext .ts src/",
"lint:fix": "npm run lint -- --fix"
},
"keywords": [
"onlyoffice",
"R7",
"twake"
],
"author": "rezk2ll",
"license": "ISC",
"dependencies": {
"@types/jsonwebtoken": "^9.0.1",
"axios": "^1.1.3",
"concurrently": "^7.5.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"eta": "^1.12.3",
"express": "^4.18.2",
"form-data": "^4.0.0",
"jsonwebtoken": "^9.0.0",
"tslog": "^3.3.4"
},
"devDependencies": {
"@swc/cli": "^0.1.57",
"@swc/core": "^1.2.220",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/node": "^18.11.9",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"config": "^3.3.8",
"cross-env": "^7.0.3",
"dotenv": "^16.0.3",
"eslint": "^8.26.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"node-config": "0.0.2",
"nodemon": "^2.0.19",
"prettier": "^2.7.1",
"ts-node": "^10.9.1",
"tsc-alias": "^1.7.0",
"tsconfig-paths": "^4.1.0",
"typescript": "^4.8.4"
}
}
@@ -0,0 +1,74 @@
import express from 'express';
import { Routes } from '@interfaces/routes.interface';
import { NODE_ENV } from '@config';
import cors from 'cors';
import { renderFile } from 'eta';
import path from 'path';
import errorMiddleware from './middlewares/error.middleware';
import { SERVER_PORT, SERVER_PREFIX } from '@config';
import loggerService from './services/logger.service';
import cookieParser from 'cookie-parser';
class App {
public app: express.Application;
public env: string;
public port: string | number;
constructor(routes: Routes[]) {
this.app = express();
this.env = NODE_ENV;
this.initViews();
this.initMiddlewares();
this.initRoutes(routes);
this.initErrorHandling();
}
public listen = () => {
this.app.listen(SERVER_PORT, () => {
loggerService.info(`🚀 App listening on port ${SERVER_PORT}`);
});
};
public getServer = () => this.app;
private initRoutes = (routes: Routes[]) => {
this.app.use((req, res, next) => {
console.log('Requested: ', req.method, req.originalUrl);
next();
});
routes.forEach(route => {
this.app.use(SERVER_PREFIX, route.router);
});
this.app.use(
SERVER_PREFIX.replace(/\/$/, '') + '/assets',
(req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'X-Requested-With');
next();
},
express.static(path.join(__dirname, '../assets')),
);
};
private initMiddlewares = () => {
this.app.use(cors());
this.app.use(cookieParser());
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
};
private initViews = () => {
this.app.engine('eta', renderFile);
this.app.set('view engine', 'eta');
this.app.set('views', path.join(__dirname, 'views'));
};
private initErrorHandling = () => {
this.app.use(errorMiddleware);
};
}
export default App;
@@ -0,0 +1,14 @@
import { config } from 'dotenv';
config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` });
export const {
NODE_ENV,
SERVER_PORT,
SECRET_KEY,
CREDENTIALS_ENDPOINT,
ONLY_OFFICE_SERVER,
CREDENTIALS_ID,
CREDENTIALS_SECRET,
SERVER_PREFIX,
SERVER_ORIGIN,
} = process.env;
@@ -0,0 +1,126 @@
import editorService from '@/services/editor.service';
import { NextFunction, Request, Response } from 'express';
import { CREDENTIALS_SECRET, SERVER_ORIGIN, SERVER_PREFIX } from '@config';
import jwt from 'jsonwebtoken';
import driveService from '@/services/drive.service';
import { DriveFileType } from '@/interfaces/drive.interface';
import fileService from '@/services/file.service';
import { OfficeToken } from '@/interfaces/routes.interface';
import loggerService from '@/services/logger.service';
interface RequestQuery {
mode: string;
company_id: string;
preview: string;
token: string;
file_id: string;
drive_file_id?: string;
}
interface RequestEditorQuery {
office_token: string;
company_id: string;
file_id: string;
}
class IndexController {
public index = async (req: Request<{}, {}, {}, RequestQuery>, res: Response, next: NextFunction): Promise<void> => {
try {
const { file_id, drive_file_id, company_id, preview, token } = req.query;
const { user } = req;
let driveFile: DriveFileType;
if (drive_file_id) {
//Append information about the drive file (versions, location, etc)
driveFile = await driveService.get({
drive_file_id,
company_id,
user_token: token,
});
if (!driveFile) {
throw new Error('Drive file not found');
}
}
//Get the file itself
const file = await fileService.get({
file_id: driveFile?.item?.last_version_cache?.file_metadata?.external_id || file_id,
company_id,
});
if (!file) {
throw new Error('File not found');
}
//Check whether the user has access to the file and put information to the office_token
const hasAccess =
(!driveFile && (file.user_id === user.id || preview)) ||
['manage', 'write'].includes(driveFile?.access) ||
(driveFile?.access === 'read' && preview);
if (!hasAccess) {
throw new Error('You do not have access to this file');
}
const officeToken = jwt.sign(
{
user_id: user.id, //To verify that link is opened by the same user
company_id,
drive_file_id,
file_id: file.id,
file_name: file.filename || file?.metadata?.name || '',
preview: !!preview,
} as OfficeToken,
CREDENTIALS_SECRET,
{
expiresIn: 60 * 60 * 24 * 30,
},
);
res.redirect(
`${SERVER_ORIGIN ?? ''}/${SERVER_PREFIX.replace(
/(\/+$|^\/+)/gm,
'',
)}/editor?office_token=${officeToken}&token=${token}&file_id=${file_id}&company_id=${company_id}&preview=${preview}`,
);
} catch (error) {
next(error);
}
};
public editor = async (req: Request<{}, {}, {}, RequestEditorQuery>, res: Response, next: NextFunction): Promise<void> => {
try {
const { office_token } = req.query;
const { user } = req;
const officeTokenPayload = jwt.verify(office_token, CREDENTIALS_SECRET) as OfficeToken;
const { preview, user_id, company_id, file_name, file_id, drive_file_id } = officeTokenPayload;
if (user_id !== user.id) {
throw new Error('You do not have access to this link');
}
const initResponse = await editorService.init(company_id, file_name, file_id, user, preview, drive_file_id || file_id);
const inPageToken = jwt.sign(
{
...officeTokenPayload,
in_page_token: true,
} as OfficeToken,
CREDENTIALS_SECRET,
);
res.render('index', {
...initResponse,
server: SERVER_ORIGIN.replace(/\/+$/, '') + '/' + SERVER_PREFIX.replace(/(\/+$|^\/+)/, '') || `${req.protocol}://${req.get('host')}/`,
token: inPageToken,
});
} catch (error) {
loggerService.error(error);
next(error);
}
};
}
export default IndexController;
@@ -0,0 +1,128 @@
import { CREDENTIALS_SECRET } from '@/config';
import { OfficeToken } from '@/interfaces/routes.interface';
import apiService from '@/services/api.service';
import driveService from '@/services/drive.service';
import fileService from '@/services/file.service';
import loggerService from '@/services/logger.service';
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface RequestQuery {
company_id: string;
file_id: string;
token: string;
}
interface SaveRequestBody {
filetype: string;
key: string;
status: number;
url: string;
users: string[];
}
class OnlyOfficeController {
public read = async (req: Request<{}, {}, {}, RequestQuery>, res: Response, next: NextFunction): Promise<void> => {
try {
const { token } = req.query;
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
const { company_id, drive_file_id, file_id, in_page_token } = officeTokenPayload;
let fileId = file_id;
// check token is an in_page_token
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
if (drive_file_id) {
//Get the drive file
const driveFile = await driveService.get({
company_id,
drive_file_id,
});
if (driveFile) {
fileId = driveFile?.item?.last_version_cache?.file_metadata?.external_id;
}
}
const file = await fileService.download({
company_id,
file_id: fileId,
});
file.pipe(res);
} catch (error) {
next(error);
}
};
public save = async (req: Request<{}, {}, SaveRequestBody, RequestQuery>, res: Response, next: NextFunction): Promise<void> => {
try {
const { url, key } = req.body;
const { token } = req.query;
loggerService.info('Save request', { key, url, token });
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
const { preview, company_id, file_id, user_id, drive_file_id, in_page_token } = officeTokenPayload;
// check token is an in_page_token and allow save
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
if (preview) throw new Error('Invalid token, must not be a preview token for save operation');
if (url) {
loggerService.info('URL present, saving file');
// If token indicate a drive_file_id then check if we want to create a new version or not
if (drive_file_id) {
loggerService.info('Drive file id present, checking if we need to create a new version');
//Get the drive file
const driveFile = await driveService.get({
company_id,
drive_file_id,
});
// const createNewVersion = !!driveFile; //Always create a new version because needed by OnlyOffice // driveFile.item.last_version_cache.date_added < Date.now() - 1000 * 60 * 60 * 3;
const createNewVersion = true;
if (createNewVersion) {
const newVersionFile = await fileService.save({
company_id,
file_id,
url,
create_new: true,
});
await driveService.createVersion({
company_id,
drive_file_id,
file_id: newVersionFile?.resource?.id,
});
loggerService.info('New version created');
res.send({
error: 0,
});
return;
}
}
loggerService.info('Saving file');
await fileService.save({
company_id,
file_id,
url,
user_id: user_id,
});
} else {
loggerService.error('URL not present, force saving file');
await apiService.runCommand('forcesave', key);
}
res.send({
error: 0,
});
} catch (error) {
next(error);
}
};
}
export default OnlyOfficeController;
@@ -0,0 +1,31 @@
import { ResponseType } from 'axios';
export interface IApiServiceRequestParams<T> {
url: string;
payload?: T;
token?: string;
responseType?: ResponseType;
headers?: any;
}
export interface IApiService {
get<T>(params: IApiServiceRequestParams<T>): Promise<T>;
post<T>(params: IApiServiceRequestParams<T>): Promise<T>;
runCommand<T>(c: string, key: string): Promise<void>;
}
export interface IApiServiceApplicationTokenRequestParams {
id: string;
secret: string;
}
export interface IApiServiceApplicationTokenResponse {
resource: {
access_token: {
time: number;
expiration: number;
value: string;
type: string;
};
};
}
@@ -0,0 +1,22 @@
export type DriveFileType = {
access: 'manage' | 'write' | 'read' | 'none';
item: {
last_version_cache: {
id: string;
date_added: number;
file_metadata: {
external_id: string;
};
};
};
};
export type DriveRequestParams = {
drive_file_id: string;
company_id: string;
};
export interface IDriveService {
get: (params: DriveRequestParams) => Promise<DriveFileType>;
createVersion: (params: { company_id: string; drive_file_id: string; file_id: string }) => Promise<DriveFileType['item']['last_version_cache']>;
}
@@ -0,0 +1,42 @@
import { UserType } from './user.interface';
export type EditorInitRequestParams = {
file_id: string;
company_id: string;
token: string;
user: UserType;
preview: string;
};
export type EditConfigInitResult = {
user_id: string;
username: string;
language: string;
user_image: string;
mode: string;
onlyoffice_server: string;
color: string;
company_id: string;
file_id: string;
file_version_id: string;
filename: string;
file_type: string;
editable: boolean;
preview: boolean;
};
export interface IEditorService {
init: (
company_id: string,
file_name: string,
file_version_id: string,
user: UserType,
preview: boolean,
file_id: string,
) => Promise<EditConfigInitResult>;
}
export type ModeParametersType = {
mode: string;
color: string;
};
@@ -0,0 +1,23 @@
export type FileType = {
id: string;
filename: string;
company_id: string;
user_id: string;
metadata: {
name: string;
mime: string;
};
};
export type FileRequestParams = {
file_id: string;
company_id: string;
url?: string;
user_id?: string;
create_new?: boolean;
};
export interface IFileService {
get: (params: FileRequestParams) => Promise<FileType>;
download: (params: FileRequestParams) => Promise<any>;
}
@@ -0,0 +1,16 @@
import { Router } from 'express';
export interface Routes {
path?: string;
router: Router;
}
export interface OfficeToken {
user_id: string;
company_id: string;
file_id: string;
file_name: string;
preview: boolean;
drive_file_id?: string;
in_page_token?: boolean;
}
@@ -0,0 +1,14 @@
export type UserType = {
id: string;
username: string;
thumbnail: string;
picture: string;
email: string;
preferences: {
locale: string;
};
};
export interface IuserService {
getCurrentUser: (token: string) => Promise<UserType>;
}
@@ -0,0 +1,70 @@
import { CREDENTIALS_SECRET } from '@/config';
import loggerService from '@/services/logger.service';
import userService from '@/services/user.service';
import { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
interface RequestQuery {
mode: string;
company_id: string;
preview: string;
token: string;
file_id: string;
}
export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, next: NextFunction) => {
try {
const token = req.query?.token;
if (!token && req.cookies.token) {
try {
const userJwt = jwt.verify(req.cookies.token, CREDENTIALS_SECRET) as any;
if (userJwt.id) {
req.user = userJwt;
next();
return;
}
} catch (error) {
loggerService.error('invalid token', error);
res.clearCookie('token');
}
}
if (!token) {
return res.status(401).send({ message: 'invalid token' });
}
const user = await userService.getCurrentUser(token);
if (!user || !user.id) {
return res.status(401).json({ message: 'invalid token' });
}
res.cookie(
'token',
jwt.sign(
{
id: user.id,
username: user.username,
email: user.email,
thumbnail: user.thumbnail,
picture: user.picture,
preferences: {
locale: user?.preferences?.locale,
},
},
CREDENTIALS_SECRET,
{
expiresIn: 60 * 60 * 24 * 30,
},
),
{ maxAge: 60 * 60 * 24 * 30 },
);
req.user = user;
next();
} catch (error) {
console.error(error);
return res.status(500).send({ message: 'invalid token' });
}
};
@@ -0,0 +1,15 @@
import loggerService from '@/services/logger.service';
import { NextFunction, Request, Response } from 'express';
export default (error: Error & { status?: number }, req: Request, res: Response, next: NextFunction): void => {
try {
const status: number = error.status || 500;
const message: string = error.message || 'something went wrong';
loggerService.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`);
res.status(status).json({ message });
} catch (error) {
next(error);
}
};
@@ -0,0 +1,16 @@
import { NextFunction, Request, Response } from 'express';
interface RequestQuery {
company_id: string;
token: string;
}
export default (req: Request<{}, {}, {}, RequestQuery>, res: Response, next: NextFunction) => {
const { company_id } = req.query;
if (!company_id) {
return res.status(400).json({ message: 'invalid request' });
}
next();
};
@@ -0,0 +1,23 @@
import IndexController from '@/controllers/index.controller';
import { Routes } from '@/interfaces/routes.interface';
import authMiddleware from '@/middlewares/auth.middleware';
import requirementsMiddleware from '@/middlewares/requirements.middleware';
import { Router } from 'express';
class IndexRoute implements Routes {
public path = '/';
public router = Router();
public indexController: IndexController;
constructor() {
this.indexController = new IndexController();
this.initRoutes();
}
private initRoutes = () => {
this.router.get(this.path, requirementsMiddleware, authMiddleware, this.indexController.index);
this.router.get(this.path + 'editor', requirementsMiddleware, authMiddleware, this.indexController.editor);
};
}
export default IndexRoute;
@@ -0,0 +1,22 @@
import OnlyOfficeController from '@/controllers/onlyoffice.controller';
import { Routes } from '@/interfaces/routes.interface';
import requirementsMiddleware from '@/middlewares/requirements.middleware';
import { Router } from 'express';
class OnlyOfficeRoute implements Routes {
public path = '/';
public router = Router();
public onlyOfficeController: OnlyOfficeController;
constructor() {
this.onlyOfficeController = new OnlyOfficeController();
this.initRoutes();
}
private initRoutes = () => {
this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read);
this.router.post(`${this.path}:mode/save`, requirementsMiddleware, this.onlyOfficeController.save);
};
}
export default OnlyOfficeRoute;
@@ -0,0 +1,7 @@
import App from '@/app';
import IndexRoute from './routes/index.route';
import OnlyOfficeRoute from './routes/onlyoffice.route';
const app = new App([new IndexRoute(), new OnlyOfficeRoute()]);
app.listen();
@@ -0,0 +1,152 @@
import {
IApiServiceRequestParams,
IApiService,
IApiServiceApplicationTokenRequestParams,
IApiServiceApplicationTokenResponse,
} from '@/interfaces/api.interface';
import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios';
import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET, ONLY_OFFICE_SERVER } from '@config';
import loggerService from './logger.service';
class ApiService implements IApiService {
private axios: Axios;
private initialized: Promise<string>;
constructor() {
this.initialized = this.refreshToken();
this.initialized.catch(error => {
loggerService.error('failed to init API', error);
});
setInterval(() => {
this.initialized = this.refreshToken();
loggerService.info('Refreshing token 🪙');
}, 1000 * 60); //Every 10 minutes
}
public get = async <T>(params: IApiServiceRequestParams<T>): Promise<T> => {
const { url, token, responseType, headers } = params;
await this.initialized;
const config: AxiosRequestConfig = {};
if (token) {
config['headers'] = {
Authorization: `Bearer ${token}`,
...headers,
};
}
if (responseType) {
config['responseType'] = responseType;
}
return await this.axios.get(url, config);
};
public post = async <T, R>(params: IApiServiceRequestParams<T>): Promise<R> => {
const { url, payload, headers } = params;
await this.initialized;
try {
return await this.axios.post(url, payload, {
headers: {
...headers,
},
});
} catch (error) {
loggerService.error('Failed to post: ', error.message);
this.refreshToken();
}
};
private handleErrors = (error: any): Promise<any> => {
loggerService.error('Failed Request', error.message);
return Promise.reject(error);
};
private handleResponse = <T>({ data }: AxiosResponse): T => data;
private refreshToken = async (): Promise<string> => {
try {
const response = await axios.post<IApiServiceApplicationTokenRequestParams, { data: IApiServiceApplicationTokenResponse }>(
`${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`,
{
id: CREDENTIALS_ID,
secret: CREDENTIALS_SECRET,
},
{
headers: {
Authorization: `Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`,
},
},
);
const {
resource: {
access_token: { value },
},
} = response.data;
this.axios = axios.create({
baseURL: CREDENTIALS_ENDPOINT,
headers: {
Authorization: `Bearer ${value}`,
},
});
this.axios.interceptors.response.use(this.handleResponse, this.handleErrors);
return value;
} catch (error) {
loggerService.error('failed to get application token', error.message);
loggerService.info('Using token ', CREDENTIALS_ID, CREDENTIALS_SECRET);
loggerService.info(`POST ${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`);
loggerService.info(`Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`);
throw Error(error);
}
};
public runCommand = async (c: string, key: string): Promise<void> => {
try {
loggerService.info('SENDING COMMAND TO: ', `${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`);
const response = await axios.post(`${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`, {
c,
key,
userdata: '',
});
const { data } = response;
switch (data.error) {
case 0:
loggerService.info('File saved successfully');
break;
case 1:
loggerService.error('Document key is missing or no document with such key could be found.');
throw new Error('Document key is missing or no document with such key could be found.');
case 2:
loggerService.error('Callback url not correct.');
throw new Error('Callback url not correct.');
case 3:
loggerService.error('Internal server error.');
throw new Error('Internal server error.');
case 4:
loggerService.error('No changes were applied to the document before the forcesave command was received.');
throw new Error('No changes were applied to the document before the forcesave command was received.');
case 5:
loggerService.error('Command not correct.');
throw new Error('Command not correct.');
case 6:
loggerService.error('Invalid token.');
throw new Error('Invalid token.');
default:
loggerService.error('Unknown error occurred.');
throw new Error('Unknown error occurred.');
}
} catch (error) {
loggerService.error(`Error executing command: ${c}, ${error}`);
}
};
}
export default new ApiService();
@@ -0,0 +1,49 @@
import { DriveFileType, IDriveService } from '@/interfaces/drive.interface';
import apiService from './api.service';
import loggerService from './logger.service';
class DriveService implements IDriveService {
public get = async (params: { company_id: string; drive_file_id: string; user_token?: string }): Promise<DriveFileType> => {
try {
const { company_id, drive_file_id } = params;
const resource = await apiService.get<DriveFileType>({
url: `/internal/services/documents/v1/companies/${company_id}/item/${drive_file_id}`,
token: params.user_token,
});
return resource;
} catch (error) {
loggerService.error('Failed to fetch file metadata: ', error.message);
return Promise.reject();
}
};
public createVersion = async (params: {
company_id: string;
drive_file_id: string;
file_id: string;
}): Promise<DriveFileType['item']['last_version_cache']> => {
try {
const { company_id, drive_file_id, file_id } = params;
const resource = await apiService.post<{}, DriveFileType['item']['last_version_cache']>({
url: `/internal/services/documents/v1/companies/${company_id}/item/${drive_file_id}/version`,
payload: {
drive_item_id: drive_file_id,
provider: 'internal',
file_metadata: {
external_id: file_id,
source: 'internal',
},
},
});
return resource;
} catch (error) {
loggerService.error('Failed to create version: ', error.message);
return Promise.reject();
}
};
}
export default new DriveService();
@@ -0,0 +1,91 @@
import { EditConfigInitResult, IEditorService, ModeParametersType } from '@/interfaces/editor.interface';
import { UserType } from '@/interfaces/user.interface';
import { ONLY_OFFICE_SERVER } from '@config';
class EditorService implements IEditorService {
public init = async (
company_id: string,
file_name: string,
file_version_id: string,
user: UserType,
preview: boolean,
file_id: string,
): Promise<EditConfigInitResult> => {
const { color, mode: fileMode } = this.getFileMode(file_name);
return {
color,
file_id,
file_version_id,
file_type: file_name.split('.').pop(),
filename: file_name,
language: user.preferences.locale || 'en',
mode: fileMode,
onlyoffice_server: ONLY_OFFICE_SERVER,
user_id: user.id,
user_image: user.thumbnail || user.picture || '',
username: user.username,
company_id,
preview,
editable: !preview,
};
};
private getFileMode = (filename: string): ModeParametersType => {
const extension = filename.split('.').pop();
if (
[
'doc',
'docm',
'docx',
'docxf',
'dot',
'dotm',
'dotx',
'epub',
'fodt',
'fb2',
'htm',
'html',
'mht',
'odt',
'oform',
'ott',
'oxps',
'pdf',
'rtf',
'txt',
'djvu',
'xml',
'xps',
].includes(extension)
) {
return {
mode: 'word',
color: '#aa5252',
};
}
if (['csv', 'fods', 'ods', 'ots', 'xls', 'xlsb', 'xlsm', 'xlsx', 'xlt', 'xltm', 'xltx'].includes(extension)) {
return {
mode: 'cell',
color: '#40865c',
};
}
if (['fodp', 'odp', 'otp', 'pot', 'potm', 'potx', 'pps', 'ppsm', 'ppsx', 'ppt', 'pptm', 'pptx'].includes(extension)) {
return {
mode: 'slide',
color: '#aa5252',
};
}
return {
mode: 'text',
color: 'grey',
};
};
}
export default new EditorService();
@@ -0,0 +1,82 @@
import { FileRequestParams, FileType, IFileService } from '@/interfaces/file.interface';
import apiService from './api.service';
import loggerService from './logger.service';
import { Stream } from 'stream';
import FormData from 'form-data';
class FileService implements IFileService {
public get = async (params: FileRequestParams): Promise<FileType> => {
try {
const { company_id, file_id } = params;
const { resource } = await apiService.get<{ resource: FileType }>({
url: `/internal/services/files/v1/companies/${company_id}/files/${file_id}`,
});
return resource;
} catch (error) {
loggerService.error('Failed to fetch file metadata: ', error.message);
return Promise.reject();
}
};
public download = async (params: FileRequestParams): Promise<any> => {
try {
const { company_id, file_id } = params;
const file = await apiService.get({
url: `/internal/services/files/v1/companies/${company_id}/files/${file_id}/download`,
responseType: 'stream',
});
return file;
} catch (error) {
loggerService.error('Failed to download file: ', error.message);
}
};
public save = async (params: FileRequestParams): Promise<{ resource: FileType }> => {
try {
const { company_id, file_id, url, create_new } = params;
if (!url) {
throw Error('no url found');
}
const originalFile = await this.get(params);
if (!originalFile) {
throw Error('original file not found');
}
const newFile = await apiService.get<Stream>({
url,
responseType: 'stream',
});
const form = new FormData();
const nameSplit = (originalFile.metadata.name || '').split('.');
const filename =
nameSplit[0].replace(/-[0-9]{8}-[0-9]{4}$/, '') +
(!create_new ? '.' : `-${new Date().toISOString().split('.')[0].split(':').slice(0, 2).join('').replace(/-/gm, '').split('T').join('-')}.`) +
nameSplit.slice(1).join('.');
form.append('file', newFile, {
filename,
});
loggerService.info('Saving file version: ', filename);
return await apiService.post<any, { resource: FileType }>({
url: create_new
? `/internal/services/files/v1/companies/${company_id}/files?thumbnail_sync=1`
: `/internal/services/files/v1/companies/${company_id}/files/${file_id}?thumbnail_sync=1`,
payload: form,
headers: form.getHeaders(),
});
} catch (error) {
loggerService.error('Failed to save file: ', error.message);
}
};
}
export default new FileService();
@@ -0,0 +1,5 @@
import { Logger } from 'tslog';
export default new Logger({
name: 'twake-onlyoffice-plugin',
});
@@ -0,0 +1,22 @@
import { IuserService, UserType } from '@/interfaces/user.interface';
import apiService from './api.service';
import loggerService from './logger.service';
class UserService implements IuserService {
public getCurrentUser = async (token: string): Promise<UserType> => {
try {
const { resource } = await apiService.get<{ resource: UserType }>({
url: '/internal/services/users/v1/users/me',
token,
});
return resource;
} catch (error) {
loggerService.error('Failed to fetch the current user', error.message);
return null;
}
};
}
export default new UserService();
@@ -0,0 +1,9 @@
import { UserType } from '@/interfaces/user.interface';
declare global {
namespace Express {
interface Request {
user?: UserType;
}
}
}
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
<script type="text/javascript" src="<%= it.onlyoffice_server %>web-apps/apps/api/documents/api.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,shrink-to-fit=no" />
</head>
<body style="height: 100%">
<div id="onlyoffice_container" style="top: 0; left: 0; width: 100%; height: 100%; position: absolute; color: #fff; text-align: center; background-color: <%= it.color %>"></div>
<script type="text/javascript">
$(document).ready(function() {
window.user = {
id: "<%= it.user_id %>",
username: "<%= it.username %>",
language: "<%= it.language %>",
userimage: "<%= it.user_image %>"
};
window.mode = "<%= it.mode %>"
window.baseURL = `<%= it.server %>/${window.mode}/`;
$('#onlyoffice_container').html("<div id='onlyoffice_container_instance'></div>");
let doc = {
title: "<%= it.filename %>",
url: `${window.baseURL}read?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
fileType: "<%= it.file_type %>",
key: "<%= it.file_version_id %>",
token: "<%= it.file_id %>",
permissions: {
download: true,
edit: <%= it.editable %>,
preview: <%= it.preview %>,
}
}
const documentChangeHandler = function (event) {
$.ajax({
url: `${window.baseURL}save?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
file_id: "<%= it.file_id %>",
key: "<%= it.file_version_id %>",
token: "<%= it.token %>"
}),
success: function() {
console.log('save success');
},
error: function() {
console.log('save error');
}
});
}
window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', {
scrollSensitivity: window.mode === 'text' ? 100 : 40,
width: '100%',
height: '100%',
documentType: window.mode,
document: doc,
token: "<%= it.file_id %>",
type: screen.width < 600 ? 'mobile' : 'desktop',
editorConfig: {
callbackUrl: `${window.baseURL}save?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
lang: window.user.language,
user: {
id: window.user.id,
name: window.user.username,
},
customization: {
chat: false,
compactToolbar: true,
about: false,
feedback: false,
goback: {
text: '',
blank: false,
url: '#',
},
},
},
events: {
onDocumentStateChange: documentChangeHandler,
}
});
});
</script>
</body>
</html>
@@ -0,0 +1,36 @@
{
"compileOnSave": false,
"compilerOptions": {
"target": "es2017",
"lib": ["es2017", "esnext.asynciterable"],
"typeRoots": ["node_modules/@types"],
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"module": "commonjs",
"pretty": true,
"sourceMap": true,
"declaration": true,
"outDir": "dist",
"allowJs": true,
"noEmit": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"importHelpers": true,
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"@config": ["config"],
"@controllers/*": ["controllers/*"],
"@interfaces/*": ["interfaces/*"],
"@middlewares/*": ["middlewares/*"],
"@routes/*": ["routes/*"],
"@services/*": ["services/*"],
"@utils/*": ["utils/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.json", ".env"],
"exclude": ["node_modules", "src/http", "src/logs", "src/tests"]
}
@@ -0,0 +1,57 @@
{
"services": {
"CoAuthoring": {
"request-filtering-agent": {
"allowPrivateIPAddress": true,
"allowMetaIPAddress": true
},
"sql": {
"type": "postgres",
"dbHost": "onlyoffice-postgresql",
"dbPort": "5432",
"dbName": "onlyoffice",
"dbUser": "onlyoffice",
"dbPass": "onlyoffice"
},
"token": {
"enable": {
"request": {
"inbox": false,
"outbox": false
},
"browser": false
},
"inbox": {
"header": "Authorization",
"inBody": false
},
"outbox": {
"header": "Authorization",
"inBody": false
}
},
"secret": {
"inbox": {
"string": "TKBsR5SQaA2il4kvaf6x4IP9A0k1rWi4"
},
"outbox": {
"string": "TKBsR5SQaA2il4kvaf6x4IP9A0k1rWi4"
},
"session": {
"string": "TKBsR5SQaA2il4kvaf6x4IP9A0k1rWi4"
}
}
}
},
"rabbitmq": {
"url": "amqp://guest:guest@onlyoffice-rabbitmq"
},
"queue": {
"type": "rabbitmq"
},
"storage": {
"fs": {
"secretString": "hDcqEmLCiJ5nWEK1lt2b"
}
}
}
+4 -3
View File
@@ -62,10 +62,12 @@ services:
- tdrive_network
onlyoffice-connector:
image: onlyoffice-connector
build:
context: .
dockerfile: docker/onlyoffice-connector/Dockerfile
environment:
- CREDENTIALS_ENDPOINT=http://tdrive_node:4000
- ONLY_OFFICE_SERVER=http://localhost:8090/
- ONLY_OFFICE_SERVER=http://onlyoffice:8090/
- SERVER_ORIGIN=http://onlyoffice-connector:5000
- SERVER_PORT=5000
- SERVER_PREFIX=/plugins/onlyoffice
@@ -110,7 +112,6 @@ services:
onlyoffice:
image: docker.io/onlyoffice/documentserver
hostname: onlyoffice
ports:
- 8090:80
networks:
@@ -0,0 +1,11 @@
FROM node:16
WORKDIR /usr/src/app
COPY connectors/onlyoffice-connector/package*.json ./
RUN npm install
COPY connectors/onlyoffice-connector/ .
RUN npm run build
CMD [ "npm", "start" ]