1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-19 13:19:42 +02:00

Typescript rewrite (#147)

* Updated highlight.js

* Update .codexdocsrc.sample

remove undefined page for a fresh new install

* backend rewritten in TS

* test -> TS, .added dockerignore, bug fixed

* Removed compiled js files, eslint codex/ts added

* fixed jsdocs warning, leaving editor confirmation

* use path.resolve for DB paths

* db drives updated + fixed User model

* redundant cleared + style fixed

* explicit type fixing

* fixing testing code

* added body block type

* compiled JS files -> dist, fixed compiling errors

* fixed compiling error, re-organized ts source code

* updated Dockerfile

* fixed link to parent page

* up nodejs version

* fix package name

* fix deps

Co-authored-by: nvc8996 <nvc.8996@gmail.com>
Co-authored-by: Taly <vitalik7tv@yandex.ru>
This commit is contained in:
Nikita Melnikov 2022-03-05 22:57:23 +04:00 committed by GitHub
parent 059cfb96f9
commit 34514761f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 3817 additions and 2249 deletions

38
src/backend/app.ts Normal file
View file

@ -0,0 +1,38 @@
import express, { Request, Response } from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import morgan from 'morgan';
import rcParser from './utils/rcparser';
import routes from './routes';
import HttpException from './exceptions/httpException';
const app = express();
const config = rcParser.getConfiguration();
app.locals.config = config;
// view engine setup
app.set('views', path.join(__dirname, '../../src/backend/', 'views'));
app.set('view engine', 'twig');
require('./utils/twig');
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, '../../public')));
app.use('/', routes);
// error handler
app.use(function (err: HttpException, req: Request, res: Response) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
export default app;

View file

@ -0,0 +1,25 @@
import Alias from '../models/alias';
/**
* @class Aliases
* @classdesc Aliases controller
*/
class Aliases {
/**
* Find and return entity with given alias
*
* @param {string} aliasName - alias name of entity
* @returns {Promise<Alias>}
*/
public static async get(aliasName: string): Promise<Alias> {
const alias = await Alias.get(aliasName);
if (!alias.id) {
throw new Error('Entity with given alias does not exist');
}
return alias;
}
}
export default Aliases;

View file

@ -0,0 +1,208 @@
import Page, { PageData } from '../models/page';
import Alias from '../models/alias';
type PageDataFields = keyof PageData;
/**
* @class Pages
* @classdesc Pages controller
*/
class Pages {
/**
* Fields required for page model creation
*
* @returns {['title', 'body']}
*/
public static get REQUIRED_FIELDS(): Array<PageDataFields> {
return [ 'body' ];
}
/**
* Find and return page model with passed id
*
* @param {string} id - page id
* @returns {Promise<Page>}
*/
public static async get(id: string): Promise<Page> {
const page = await Page.get(id);
if (!page._id) {
throw new Error('Page with given id does not exist');
}
return page;
}
/**
* Return all pages
*
* @returns {Promise<Page[]>}
*/
public static async getAll(): Promise<Page[]> {
return Page.getAll();
}
/**
* Return all pages without children of passed page
*
* @param {string} parent - id of current page
* @returns {Promise<Page[]>}
*/
public static async getAllExceptChildren(parent: string): Promise<Page[]> {
const pagesAvailable = this.removeChildren(await Pages.getAll(), parent);
const nullFilteredPages: Page[] = [];
pagesAvailable.forEach(async item => {
if (item instanceof Page) {
nullFilteredPages.push(item);
}
});
return nullFilteredPages;
}
/**
* Set all children elements to null
*
* @param {Array<Page|null>} [pagesAvailable] - Array of all pages
* @param {string} parent - id of parent page
* @returns {Array<?Page>}
*/
public static removeChildren(pagesAvailable: Array<Page|null>, parent: string | undefined): Array<Page | null> {
pagesAvailable.forEach(async (item, index) => {
if (item === null || item._parent !== parent) {
return;
}
pagesAvailable[index] = null;
pagesAvailable = Pages.removeChildren(pagesAvailable, item._id);
});
return pagesAvailable;
}
/**
* Create new page model and save it in the database
*
* @param {PageData} data - info about page
* @returns {Promise<Page>}
*/
public static async insert(data: PageData): Promise<Page> {
try {
Pages.validate(data);
const page = new Page(data);
const insertedPage = await page.save();
if (insertedPage.uri) {
const alias = new Alias({
id: insertedPage._id,
type: Alias.types.PAGE,
}, insertedPage.uri);
alias.save();
}
return insertedPage;
} catch (e) {
throw new Error('validationError');
}
}
/**
* Update page with given id in the database
*
* @param {string} id - page id
* @param {PageData} data - info about page
* @returns {Promise<Page>}
*/
public static async update(id: string, data: PageData): Promise<Page> {
const page = await Page.get(id);
const previousUri = page.uri;
if (!page._id) {
throw new Error('Page with given id does not exist');
}
if (data.uri && !data.uri.match(/^[a-z0-9'-]+$/i)) {
throw new Error('Uri has unexpected characters');
}
page.data = data;
const updatedPage = await page.save();
if (updatedPage.uri !== previousUri) {
if (updatedPage.uri) {
const alias = new Alias({
id: updatedPage._id,
type: Alias.types.PAGE,
}, updatedPage.uri);
alias.save();
}
if (previousUri) {
Alias.markAsDeprecated(previousUri);
}
}
return updatedPage;
}
/**
* Remove page with given id from the database
*
* @param {string} id - page id
* @returns {Promise<Page>}
*/
public static async remove(id: string): Promise<Page> {
const page = await Page.get(id);
if (!page._id) {
throw new Error('Page with given id does not exist');
}
if (page.uri) {
const alias = await Alias.get(page.uri);
await alias.destroy();
}
return page.destroy();
}
/**
* Check PageData object for required fields
*
* @param {PageData} data - info about page
* @throws {Error} - validation error
*/
private static validate(data: PageData): void {
const allRequiredFields = Pages.REQUIRED_FIELDS.every(field => typeof data[field] !== 'undefined');
if (!allRequiredFields) {
throw new Error('Some of required fields is missed');
}
const hasBlocks = data.body && data.body.blocks && Array.isArray(data.body.blocks) && data.body.blocks.length > 0;
if (!hasBlocks) {
throw new Error('Page body is invalid');
}
const hasHeaderAsFirstBlock = data.body.blocks[0].type === 'header';
if (!hasHeaderAsFirstBlock) {
throw new Error('First page Block must be a Header');
}
const headerIsNotEmpty = data.body.blocks[0].data.text.replace('<br>', '').trim() !== '';
if (!headerIsNotEmpty) {
throw new Error('Please, fill page Header');
}
}
}
export default Pages;

View file

@ -0,0 +1,127 @@
import PageOrder from '../models/pageOrder';
import Page from '../models/page';
/**
* @class PagesOrder
* @classdesc PagesOrder controller
*
* Manipulates with Pages: changes the order, deletes, updates and so on...
*/
class PagesOrder {
/**
* Returns Page's order
*
* @param {string} parentId - of which page we want to get children order
* @returns {Promise<PageOrder>}
*/
public static async get(parentId: string): Promise<PageOrder> {
const order = await PageOrder.get(parentId);
if (!order._id) {
throw new Error('Page with given id does not contain order');
}
return order;
}
/**
* Returns all records about page's order
*
* @returns {Promise<PageOrder[]>}
*/
public static async getAll(): Promise<PageOrder[]> {
return PageOrder.getAll();
}
/**
* Pushes the child page to the parent's order list
*
* @param {string} parentId - parent page's id
* @param {string} childId - new page pushed to the order
*/
public static async push(parentId: string, childId: string): Promise<void> {
const order = await PageOrder.get(parentId);
order.push(childId);
await order.save();
}
/**
* Move one page to another Page's order
*
* @param {string} oldParentId - old parent page's id
* @param {string} newParentId - new parent page's id
* @param {string} targetPageId - page's id which is changing the parent page
*/
public static async move(oldParentId: string, newParentId: string, targetPageId: string): Promise<void> {
const oldParentOrder = await PageOrder.get(oldParentId);
oldParentOrder.remove(targetPageId);
await oldParentOrder.save();
const newParentOrder = await PageOrder.get(newParentId);
newParentOrder.push(targetPageId);
await newParentOrder.save();
}
/**
* Returns new array with ordered pages
*
* @param {Page[]} pages - list of all available pages
* @param {string} currentPageId - page's id around which we are ordering
* @param {string} parentPageId - parent page's id that contains page above
* @param {boolean} ignoreSelf - should we ignore current page in list or not
* @returns {Page[]}
*/
public static async getOrderedChildren(pages: Page[], currentPageId: string, parentPageId: string, ignoreSelf = false): Promise<Page[]> {
const children = await PageOrder.get(parentPageId);
const unordered = pages.filter(page => page._parent === parentPageId).map(page => page._id);
// Create unique array with ordered and unordered pages id
const ordered = Array.from(new Set([...children.order, ...unordered]));
const result: Page[] = [];
ordered.forEach(pageId => {
pages.forEach(page => {
if (page._id === pageId && (pageId !== currentPageId || !ignoreSelf)) {
result.push(page);
}
});
});
return result;
}
/**
* @param {string[]} unordered - list of pages
* @param {string} currentPageId - page's id that changes the order
* @param {string} parentPageId - parent page's id that contains both two pages
* @param {string} putAbovePageId - page's id above which we put the target page
*/
public static async update(unordered: string[], currentPageId: string, parentPageId: string, putAbovePageId: string): Promise<void> {
const pageOrder = await PageOrder.get(parentPageId);
// Create unique array with ordered and unordered pages id
pageOrder.order = Array.from(new Set([...pageOrder.order, ...unordered]));
pageOrder.putAbove(currentPageId, putAbovePageId);
await pageOrder.save();
}
/**
* @param {string} parentId - identity of parent page
* @returns {Promise<void>}
*/
public static async remove(parentId: string): Promise<void> {
const order = await PageOrder.get(parentId);
if (!order._id) {
throw new Error('Page with given id does not contain order');
}
return order.destroy();
}
}
export default PagesOrder;

View file

@ -0,0 +1,144 @@
import fileType from 'file-type';
import fetch from 'node-fetch';
import fs from 'fs';
import nodePath from 'path';
import config from 'config';
import File, { FileData } from '../models/file';
import crypto from '../utils/crypto';
import deepMerge from '../utils/objects';
const random16 = crypto.random16;
interface Dict {
[key: string]: any;
}
/**
* @class Transport
* @classdesc Transport controller
*
* Allows to save files from client or fetch them by URL
*/
class Transport {
/**
* Saves file passed from client
*
* @param {object} multerData - file data from multer
* @param {string} multerData.originalname - original name of the file
* @param {string} multerData.filename - name of the uploaded file
* @param {string} multerData.path - path to the uploaded file
* @param {number} multerData.size - size of the uploaded file
* @param {string} multerData.mimetype - MIME type of the uploaded file
*
* @param {object} map - object that represents how should fields of File object should be mapped to response
* @returns {Promise<FileData>}
*/
public static async save(multerData: Dict, map: Dict): Promise<FileData> {
const { originalname: name, path, filename, size, mimetype } = multerData;
const file = new File({
name,
filename,
path,
size,
mimetype,
});
await file.save();
let response = file.data;
if (map) {
response = Transport.composeResponse(file, map);
}
return response;
}
/**
* Fetches file by passed URL
*
* @param {string} url - URL of the file
* @param {object} map - object that represents how should fields of File object should be mapped to response
* @returns {Promise<FileData>}
*/
public static async fetch(url: string, map: Dict): Promise<FileData> {
const fetchedFile = await fetch(url);
const buffer = await fetchedFile.buffer();
const filename = await random16();
const type = await fileType.fromBuffer(buffer);
const ext = type ? type.ext : nodePath.extname(url).slice(1);
fs.writeFileSync(`${config.get('uploads')}/${filename}.${ext}`, buffer);
const fetchedContentType: string | null = fetchedFile.headers.get('content-type');
let fetchedMimeType: string|undefined;
if (fetchedContentType === null) {
fetchedMimeType = undefined;
} else {
fetchedMimeType = fetchedContentType;
}
const mimeType = type ? type.mime : fetchedMimeType;
const file = new File({
name: url,
filename: `${filename}.${ext}`,
path: `${config.get('uploads')}/${filename}.${ext}`,
size: buffer.length,
mimetype: mimeType,
});
await file.save();
let response = file.data;
if (map) {
response = Transport.composeResponse(file, map);
}
return response;
}
/**
* Map fields of File object to response by provided map object
*
* @param {File} file - file object
* @param {object} map - object that represents how should fields of File object should be mapped to response
*
*/
public static composeResponse(file: File, map: Dict): Dict {
const response: Dict = {};
const { data } = file;
Object.entries(map).forEach(([name, path]) => {
const fields: string[] = path.split(':');
if (fields.length > 1) {
let object: Dict = {};
const result = object;
fields.forEach((field, i) => {
if (i === fields.length - 1) {
object[field] = data[name];
return;
}
object[field] = {};
object = object[field];
});
deepMerge(response, result);
} else {
response[fields[0]] = data[name];
}
});
return response;
}
}
export default Transport;

View file

@ -0,0 +1,20 @@
import User from '../models/user';
/**
* @class Users
* @classdesc Users controller
*/
class Users {
/**
* Find and return user model.
*
* @returns {Promise<User>}
*/
public static async get(): Promise<User> {
const userData: User = await User.get();
return userData;
}
}
export default Users;

View file

@ -0,0 +1,21 @@
/**
* HttpException class for middleware
*
* @property {number} status - exception status code
* @property {string} message - detail about the exception
*/
class HttpException extends Error {
public status: number;
public message: string;
/**
* @param status - status of the exception
* @param message - message about the exception
*/
constructor(status: number, message: string) {
super(message);
this.status = status;
this.message = message;
}
}
export default HttpException;

162
src/backend/models/alias.ts Normal file
View file

@ -0,0 +1,162 @@
import crypto from '../utils/crypto';
import database from '../utils/database/index';
const binaryMD5 = crypto.binaryMD5;
const aliasesDb = database['aliases'];
/**
* @typedef {object} AliasData
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity id
*
*/
export interface AliasData {
_id?: string;
hash?: string;
type?: string;
deprecated?: boolean;
id?: string;
}
/**
* @class Alias
* @classdesc Alias model
*
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity title
*/
class Alias {
public _id?: string;
public hash?: string;
public type?: string;
public deprecated?: boolean;
public id?: string;
/**
* @class
*
* @param {AliasData} data - info about alias
* @param {string} aliasName - alias of entity
*/
constructor(data: AliasData = {}, aliasName = '') {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
if (aliasName) {
this.hash = binaryMD5(aliasName);
}
this.data = data;
}
/**
* Return Alias types
*
* @returns {object}
*/
public static get types(): { PAGE: string } {
return {
PAGE: 'page',
};
}
/**
* Find and return alias with given alias
*
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
public static async get(aliasName: string): Promise<Alias> {
const hash = binaryMD5(aliasName);
let data = await aliasesDb.findOne({
hash: hash,
deprecated: false,
});
if (!data) {
data = await aliasesDb.findOne({ hash: hash });
}
return new Alias(data);
}
/**
* Mark alias as deprecated
*
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
public static async markAsDeprecated(aliasName: string): Promise<Alias> {
const alias = await Alias.get(aliasName);
alias.deprecated = true;
return alias.save();
}
/**
* Save or update alias data in the database
*
* @returns {Promise<Alias>}
*/
public async save(): Promise<Alias> {
if (!this._id) {
const insertedRow = await aliasesDb.insert(this.data) as { _id: string };
this._id = insertedRow._id;
} else {
await aliasesDb.update({ _id: this._id }, this.data);
}
return this;
}
/**
* Set AliasData object fields to internal model fields
*
* @param {AliasData} aliasData - info about alias
*/
public set data(aliasData: AliasData) {
const { id, type, hash, deprecated } = aliasData;
this.id = id || this.id;
this.type = type || this.type;
this.hash = hash || this.hash;
this.deprecated = deprecated || false;
}
/**
* Return AliasData object
*
* @returns {AliasData}
*/
public get data(): AliasData {
return {
_id: this._id,
id: this.id,
type: this.type,
hash: this.hash,
deprecated: this.deprecated,
};
}
/**
* @returns {Promise<Alias>}
*/
public async destroy(): Promise<Alias> {
await aliasesDb.remove({ _id: this._id });
delete this._id;
return this;
}
}
export default Alias;

177
src/backend/models/file.ts Normal file
View file

@ -0,0 +1,177 @@
import database from '../utils/database/index';
const filesDb = database['files'];
/**
* @typedef {object} FileData
*
* @property {string} _id - file id
* @property {string} name - original file name
* @property {string} filename - name of uploaded file
* @property {string} path - path to uploaded file
* @property {string} mimetype - file MIME type
* @property {number} size - size of the file in
*/
export interface FileData {
_id?: string;
name?: string;
filename?: string;
path?: string;
mimetype?: string;
size?: number;
[key: string]: string | number | undefined;
}
/**
* @class File
* @class File model
*
* @property {string} _id - file id
* @property {string} name - original file name
* @property {string} filename - name of uploaded file
* @property {string} path - path to uploaded file
* @property {string} mimetype - file MIME type
* @property {number} size - size of the file in
*/
class File {
public _id?: string;
public name?: string;
public filename?: string;
public path?: string;
public mimetype?: string;
public size?: number;
/**
* @class
*
* @param {FileData} data - info about file
*/
constructor(data: FileData = {}) {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
this.data = data;
}
/**
* Find and return model of file with given id
*
* @param {string} _id - file id
* @returns {Promise<File>}
*/
public static async get(_id: string): Promise<File> {
const data: FileData = await filesDb.findOne({ _id });
return new File(data);
}
/**
* Find and return model of file with given id
*
* @param {string} filename - uploaded filename
* @returns {Promise<File>}
*/
public static async getByFilename(filename: string): Promise<File> {
const data = await filesDb.findOne({ filename });
return new File(data);
}
/**
* Find all files which match passed query object
*
* @param {object} query - input query
* @returns {Promise<File[]>}
*/
public static async getAll(query: Record<string, unknown> = {}): Promise<File[]> {
const docs = await filesDb.find(query);
return Promise.all(docs.map(doc => new File(doc)));
}
/**
* Set FileData object fields to internal model fields
*
* @param {FileData} fileData - info about file
*/
public set data(fileData: FileData) {
const { name, filename, path, mimetype, size } = fileData;
this.name = name || this.name;
this.filename = filename || this.filename;
this.path = path ? this.processPath(path) : this.path;
this.mimetype = mimetype || this.mimetype;
this.size = size || this.size;
}
/**
* Return FileData object
*
* @returns {FileData}
*/
public get data(): FileData {
return {
_id: this._id,
name: this.name,
filename: this.filename,
path: this.path,
mimetype: this.mimetype,
size: this.size,
};
}
/**
* Save or update file data in the database
*
* @returns {Promise<File>}
*/
public async save(): Promise<File> {
if (!this._id) {
const insertedRow = await filesDb.insert(this.data) as { _id: string };
this._id = insertedRow._id;
} else {
await filesDb.update({ _id: this._id }, this.data);
}
return this;
}
/**
* Remove file data from the database
*
* @returns {Promise<File>}
*/
public async destroy(): Promise<File> {
await filesDb.remove({ _id: this._id });
delete this._id;
return this;
}
/**
* Return readable file data
*
* @returns {FileData}
*/
public toJSON(): FileData {
return this.data;
}
/**
* Removes unnecessary public folder prefix
*
* @param {string} path - input path to be processed
* @returns {string}
*/
private processPath(path: string): string {
return path.replace(/^public/, '');
}
}
export default File;

246
src/backend/models/page.ts Normal file
View file

@ -0,0 +1,246 @@
import urlify from '../utils/urlify';
import database from '../utils/database/index';
const pagesDb = database['pages'];
/**
* @typedef {object} PageData
* @property {string} _id - page id
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
* @property {string} parent - id of parent page
*/
export interface PageData {
_id?: string;
title?: string;
uri?: string;
body?: any;
parent?: string;
}
/**
* @class Page
* @class Page model
*
* @property {string} _id - page id
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
* @property {string} _parent - id of parent page
*/
class Page {
public _id?: string;
public body?: any;
public title?: string;
public uri?: string;
public _parent?: string;
/**
* @class
*
* @param {PageData} data - page's data
*/
constructor(data: PageData = {}) {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
this.data = data;
}
/**
* Find and return model of page with given id
*
* @param {string} _id - page id
* @returns {Promise<Page>}
*/
public static async get(_id: string): Promise<Page> {
const data = await pagesDb.findOne({ _id });
return new Page(data);
}
/**
* Find and return model of page with given uri
*
* @param {string} uri - page uri
* @returns {Promise<Page>}
*/
public static async getByUri(uri: string): Promise<Page> {
const data = await pagesDb.findOne({ uri });
return new Page(data);
}
/**
* Find all pages which match passed query object
*
* @param {object} query - input query
* @returns {Promise<Page[]>}
*/
public static async getAll(query: Record<string, unknown> = {}): Promise<Page[]> {
const docs = await pagesDb.find(query);
return Promise.all(docs.map(doc => new Page(doc)));
}
/**
* Set PageData object fields to internal model fields
*
* @param {PageData} pageData - page's data
*/
public set data(pageData: PageData) {
const { body, parent, uri } = pageData;
this.body = body || this.body;
this.title = this.extractTitleFromBody();
this.uri = uri || '';
this._parent = parent || this._parent || '0';
}
/**
* Return PageData object
*
* @returns {PageData}
*/
public get data(): PageData {
return {
_id: this._id,
title: this.title,
uri: this.uri,
body: this.body,
parent: this._parent,
};
}
/**
* Link given page as parent
*
* @param {Page} parentPage - the page to be set as parent
*/
public set parent(parentPage: Page) {
this._parent = parentPage._id;
}
/**
* Return parent page model
*
* @returns {Promise<Page>}
*/
public async getParent(): Promise<Page> {
const data = await pagesDb.findOne({ _id: this._parent });
return new Page(data);
}
/**
* Return child pages models
*
* @returns {Promise<Page[]>}
*/
public get children(): Promise<Page[]> {
return pagesDb.find({ parent: this._id })
.then(data => {
return data.map(page => new Page(page));
});
}
/**
* Save or update page data in the database
*
* @returns {Promise<Page>}
*/
public async save(): Promise<Page> {
if (this.uri !== undefined) {
this.uri = await this.composeUri(this.uri);
}
if (!this._id) {
const insertedRow = await pagesDb.insert(this.data) as { _id: string };
this._id = insertedRow._id;
} else {
await pagesDb.update({ _id: this._id }, this.data);
}
return this;
}
/**
* Remove page data from the database
*
* @returns {Promise<Page>}
*/
public async destroy(): Promise<Page> {
await pagesDb.remove({ _id: this._id });
delete this._id;
return this;
}
/**
* Return readable page data
*
* @returns {PageData}
*/
public toJSON(): PageData {
return this.data;
}
/**
* Find and return available uri
*
* @returns {Promise<string>}
* @param uri - input uri to be composed
*/
private async composeUri(uri: string): Promise<string> {
let pageWithSameUriCount = 0;
if (!this._id) {
uri = this.transformTitleToUri();
}
if (uri) {
let pageWithSameUri = await Page.getByUri(uri);
while (pageWithSameUri._id && pageWithSameUri._id !== this._id) {
pageWithSameUriCount++;
pageWithSameUri = await Page.getByUri(uri + `-${pageWithSameUriCount}`);
}
}
return pageWithSameUriCount ? uri + `-${pageWithSameUriCount}` : uri;
}
/**
* Extract first header from editor data
*
* @returns {string}
*/
private extractTitleFromBody(): string {
const headerBlock = this.body ? this.body.blocks.find((block: Record<string, unknown>) => block.type === 'header') : '';
return headerBlock ? headerBlock.data.text : '';
}
/**
* Transform title for uri
*
* @returns {string}
*/
private transformTitleToUri(): string {
if (this.title === undefined) {
return '';
}
return urlify(this.title);
}
}
export default Page;

View file

@ -0,0 +1,247 @@
import database from '../utils/database/index';
const db = database['pagesOrder'];
/**
* @typedef {object} PageOrderData
* @property {string} _id - row unique id
* @property {string} page - page id
* @property {Array<string>} order - list of ordered pages
*/
export interface PageOrderData {
_id?: string;
page?: string;
order?: string[];
}
/**
* @class PageOrder
* @classdesc PageOrder
*
* Creates order for Pages with children
*/
class PageOrder {
public _id?: string;
public page?: string;
private _order?: string[];
/**
* @class
*
* @param {PageOrderData} data - info about pageOrder
*/
constructor(data: PageOrderData = {}) {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
this.data = data;
}
/**
* Returns current Page's children order
*
* @param {string} pageId - page's id
* @returns {Promise<PageOrder>}
*/
public static async get(pageId: string): Promise<PageOrder> {
const order = await db.findOne({ page: pageId });
let data: PageOrderData = {};
if (order === null) {
data.page = pageId;
} else {
data = order;
}
return new PageOrder(data);
}
/**
* Find all pages which match passed query object
*
* @param {object} query - input query
* @returns {Promise<PageOrder[]>}
*/
public static async getAll(query: Record<string, unknown> = {}): Promise<PageOrder[]> {
const docs = await db.find(query);
return Promise.all(docs.map(doc => new PageOrder(doc)));
}
/**
* constructor data setter
*
* @param {PageOrderData} pageOrderData - info about pageOrder
*/
public set data(pageOrderData: PageOrderData) {
this.page = pageOrderData.page || '0';
this.order = pageOrderData.order || [];
}
/**
* Return Page Children order
*
* @returns {PageOrderData}
*/
public get data(): PageOrderData {
return {
_id: this._id,
page: '' + this.page,
order: this.order,
};
}
/**
* Pushes page id to the orders array
*
* @param {string} pageId - page's id
*/
public push(pageId: string | number): void {
if (typeof pageId === 'string') {
if (this.order === undefined) {
this.order = [];
}
this.order.push(pageId);
} else {
throw new Error('given id is not string');
}
}
/**
* Removes page id from orders array
*
* @param {string} pageId - page's id
*/
public remove(pageId: string): void {
if (this.order === undefined) {
return;
}
const found = this.order.indexOf(pageId);
if (found >= 0) {
this.order.splice(found, 1);
}
}
/**
* @param {string} currentPageId - page's id that changes the order
* @param {string} putAbovePageId - page's id above which we put the target page
*
* @returns {void}
*/
public putAbove(currentPageId: string, putAbovePageId: string): void {
if (this.order === undefined) {
return;
}
const found1 = this.order.indexOf(putAbovePageId);
const found2 = this.order.indexOf(currentPageId);
if (found1 === -1 || found2 === -1) {
return;
}
const margin = found1 < found2 ? 1 : 0;
this.order.splice(found1, 0, currentPageId);
this.order.splice(found2 + margin, 1);
}
/**
* Returns page before passed page with id
*
* @param {string} pageId - identity of page
*/
public getPageBefore(pageId: string): string | null {
if (this.order === undefined) {
return null;
}
const currentPageInOrder = this.order.indexOf(pageId);
/**
* If page not found or first return nothing
*/
if (currentPageInOrder <= 0) {
return null;
}
return this.order[currentPageInOrder - 1];
}
/**
* Returns page before passed page with id
*
* @param pageId - identity of page
*/
public getPageAfter(pageId: string): string | null {
if (this.order === undefined) {
return null;
}
const currentPageInOrder = this.order.indexOf(pageId);
/**
* If page not found or is last
*/
if (currentPageInOrder === -1 || currentPageInOrder === this.order.length - 1) {
return null;
}
return this.order[currentPageInOrder + 1];
}
/**
* @param {string[]} order - define new order
*/
public set order(order: string[]) {
this._order = order;
}
/**
* Returns ordered list
*
* @returns {string[]}
*/
public get order(): string[] {
return this._order || [];
}
/**
* Save or update page data in the database
*
* @returns {Promise<PageOrder>}
*/
public async save(): Promise<PageOrder> {
if (!this._id) {
const insertedRow = await db.insert(this.data) as { _id: string};
this._id = insertedRow._id;
} else {
await db.update({ _id: this._id }, this.data);
}
return this;
}
/**
* Remove page data from the database
*
* @returns {Promise<void>}
*/
public async destroy(): Promise<void> {
await db.remove({ _id: this._id });
delete this._id;
}
}
export default PageOrder;

View file

@ -0,0 +1,40 @@
import database from '../utils/database/index';
const db = database['password'];
export interface UserData {
passHash?: string;
}
/**
* @class User
* @class User model
*
* @property {string} passHash - hashed password
*/
class User {
public passHash?: string;
/**
* @class
*
* @param {UserData} userData - user data for construct new object
*/
constructor(userData: UserData) {
this.passHash = userData.passHash;
}
/**
* Find and return model of user.
* User is only one.
*
* @returns {Promise<User>}
*/
public static async get(): Promise<User> {
const userData: UserData = await db.findOne({});
return new User(userData);
}
}
export default User;

View file

@ -0,0 +1,50 @@
import express, { Request, Response } from 'express';
import Aliases from '../controllers/aliases';
import Pages from '../controllers/pages';
import Alias from '../models/alias';
import verifyToken from './middlewares/token';
const router = express.Router();
/**
* GET /*
*
* Return document with given alias
*/
router.get('*', verifyToken, async (req: Request, res: Response) => {
try {
let url = req.originalUrl.slice(1); // Cuts first '/' character
const queryParamsIndex = url.indexOf('?');
if (queryParamsIndex !== -1) {
url = url.slice(0, queryParamsIndex); // Cuts off query params
}
const alias = await Aliases.get(url);
if (alias.id === undefined) {
throw new Error('Alias not found');
}
switch (alias.type) {
case Alias.types.PAGE: {
const page = await Pages.get(alias.id);
const pageParent = await page.getParent();
res.render('pages/page', {
page,
pageParent,
config: req.app.locals.config,
});
}
}
} catch (err) {
res.status(400).json({
success: false,
error: err,
});
}
});
export default router;

View file

@ -0,0 +1,12 @@
import express from 'express';
import pagesAPI from './pages';
import transportAPI from './transport';
import linksAPI from './links';
const router = express.Router();
router.use('/', pagesAPI);
router.use('/', transportAPI);
router.use('/', linksAPI);
export default router;

View file

@ -0,0 +1,62 @@
import express, { Request, Response } from 'express';
import ogs from 'open-graph-scraper';
const router = express.Router();
interface ResponseData {
success: number;
meta?: {
title: string | undefined;
description: string | undefined;
siteName: string | undefined;
image: { url: string | undefined }
}
}
/**
* Accept file url to fetch
*/
router.get('/fetchUrl', async (req: Request, res: Response) => {
const response: ResponseData = {
success: 0,
};
if (!req.query.url) {
res.status(400).json(response);
return;
}
if (typeof req.query.url !== 'string') {
return;
}
try {
const linkData = (await ogs({ url: req.query.url })).result;
if (!linkData.success) {
return;
}
response.success = 1;
response.meta = {
title: linkData.ogTitle,
description: linkData.ogDescription,
siteName: linkData.ogSiteName,
image: {
url: undefined,
},
};
if (linkData.ogImage !== undefined) {
response.meta.image = { url: linkData.ogImage.toString() };
}
res.status(200).json(response);
} catch (e) {
console.log(e);
res.status(500).json(response);
}
});
export default router;

View file

@ -0,0 +1,221 @@
import express, { Request, Response } from 'express';
import multerFunc from 'multer';
import Pages from '../../controllers/pages';
import PagesOrder from '../../controllers/pagesOrder';
const router = express.Router();
const multer = multerFunc();
/**
* GET /page/:id
*
* Return PageData of page with given id
*/
router.get('/page/:id', async (req: Request, res: Response) => {
try {
const page = await Pages.get(req.params.id);
res.json({
success: true,
result: page.data,
});
} catch (err) {
res.status(400).json({
success: false,
error: (err as Error).message,
});
}
});
/**
* GET /pages
*
* Return PageData for all pages
*/
router.get('/pages', async (req: Request, res: Response) => {
try {
const pages = await Pages.getAll();
res.json({
success: true,
result: pages,
});
} catch (err) {
res.status(400).json({
success: false,
error: (err as Error).message,
});
}
});
/**
* PUT /page
*
* Create new page in the database
*/
router.put('/page', multer.none(), async (req: Request, res: Response) => {
try {
const { title, body, parent } = req.body;
const page = await Pages.insert({
title,
body,
parent,
});
if (page._id === undefined) {
throw new Error('Page not found');
}
/** push to the orders array */
await PagesOrder.push(parent, page._id);
res.json({
success: true,
result: page,
});
} catch (err) {
res.status(400).json({
success: false,
error: (err as Error).message,
});
}
});
/**
* POST /page/:id
*
* Update page data in the database
*/
router.post('/page/:id', multer.none(), async (req: Request, res: Response) => {
const { id } = req.params;
try {
const { title, body, parent, putAbovePageId, uri } = req.body;
const pages = await Pages.getAll();
let page = await Pages.get(id);
if (page._id === undefined) {
throw new Error('Page not found');
}
if (!page._parent) {
throw new Error('Parent not found');
}
if (page._parent !== parent) {
await PagesOrder.move(page._parent, parent, id);
} else {
if (putAbovePageId && putAbovePageId !== '0') {
const unordered = pages.filter(_page => _page._parent === page._parent).map(_page => _page._id);
const unOrdered: string[] = [];
unordered.forEach(item => {
if (typeof item === 'string') {
unOrdered.push(item);
}
});
await PagesOrder.update(unOrdered, page._id, page._parent, putAbovePageId);
}
}
page = await Pages.update(id, {
title,
body,
parent,
uri,
});
res.json({
success: true,
result: page,
});
} catch (err) {
res.status(400).json({
success: false,
error: (err as Error).message,
});
}
});
/**
* DELETE /page/:id
*
* Remove page from the database
*/
router.delete('/page/:id', async (req: Request, res: Response) => {
try {
const pageId = req.params.id;
const page = await Pages.get(pageId);
if (page._id === undefined) {
throw new Error('Page not found');
}
if (!page._parent) {
throw new Error('Parent not found');
}
const parentPageOrder = await PagesOrder.get(page._parent);
const pageBeforeId = parentPageOrder.getPageBefore(page._id);
const pageAfterId = parentPageOrder.getPageAfter(page._id);
let pageToRedirect;
if (pageBeforeId) {
pageToRedirect = await Pages.get(pageBeforeId);
} else if (pageAfterId) {
pageToRedirect = await Pages.get(pageAfterId);
} else {
pageToRedirect = page._parent !== '0' ? await Pages.get(page._parent) : null;
}
/**
* remove current page and go deeper to remove children with orders
*
* @param {string} startFrom - start point to delete
* @returns {Promise<void>}
*/
const deleteRecursively = async (startFrom: string): Promise<void> => {
let order: string[] = [];
try {
const children = await PagesOrder.get(startFrom);
order = children.order;
} catch (e) {
order = [];
}
order.forEach(async id => {
await deleteRecursively(id);
});
await Pages.remove(startFrom);
try {
await PagesOrder.remove(startFrom);
} catch (e) {
order = [];
}
};
await deleteRecursively(req.params.id);
// remove also from parent's order
parentPageOrder.remove(req.params.id);
await parentPageOrder.save();
res.json({
success: true,
result: pageToRedirect,
});
} catch (err) {
res.status(400).json({
success: false,
error: (err as Error).message,
});
}
});
export default router;

View file

@ -0,0 +1,146 @@
import { Request, Response, Router } from 'express';
import multer, { StorageEngine } from 'multer';
import mime from 'mime';
import mkdirp from 'mkdirp';
import config from 'config';
import Transport from '../../controllers/transport';
import { random16 } from '../../utils/crypto';
const router = Router();
/**
* Multer storage for uploaded files and images
*
* @type {StorageEngine}
*/
const storage: StorageEngine = multer.diskStorage({
destination: (req, file, cb) => {
const dir: string = config.get('uploads') || 'public/uploads';
mkdirp(dir);
cb(null, dir);
},
filename: async (req, file, cb) => {
const filename = await random16();
cb(null, `${filename}.${mime.getExtension(file.mimetype)}`);
},
});
/**
* Multer middleware for image uploading
*/
const imageUploader = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (!/image/.test(file.mimetype) && !/video\/mp4/.test(file.mimetype)) {
cb(null, false);
return;
}
cb(null, true);
},
}).fields([ {
name: 'image',
maxCount: 1,
} ]);
/**
* Multer middleware for file uploading
*/
const fileUploader = multer({
storage: storage,
}).fields([ {
name: 'file',
maxCount: 1,
} ]);
/**
* Accepts images to upload
*/
router.post('/transport/image', imageUploader, async (req: Request, res: Response) => {
const response = {
success: 0,
message: '',
};
if (req.files === undefined) {
response.message = 'No files found';
res.status(400).json(response);
return;
}
if (!('image' in req.files)) {
res.status(400).json(response);
return;
}
try {
Object.assign(
response,
await Transport.save(req.files.image[0], req.body.map ? JSON.parse(req.body.map) : undefined)
);
response.success = 1;
res.status(200).json(response);
} catch (e) {
res.status(500).json(response);
}
});
/**
* Accepts files to upload
*/
router.post('/transport/file', fileUploader, async (req: Request, res: Response) => {
const response = { success: 0 };
if (req.files === undefined) {
res.status(400).json(response);
return;
}
if (!('file' in req.files)) {
res.status(400).json(response);
return;
}
try {
Object.assign(
response,
await Transport.save(req.files.file[0], req.body.map ? JSON.parse(req.body.map) : undefined)
);
response.success = 1;
res.status(200).json(response);
} catch (e) {
res.status(500).json(response);
}
});
/**
* Accept file url to fetch
*/
router.post('/transport/fetch', multer().none(), async (req: Request, res: Response) => {
const response = { success: 0 };
if (!req.body.url) {
res.status(400).json(response);
return;
}
try {
Object.assign(response, await Transport.fetch(req.body.url, req.body.map ? JSON.parse(req.body.map) : undefined));
response.success = 1;
res.status(200).json(response);
} catch (e) {
console.log(e);
res.status(500).json(response);
}
});
export default router;

View file

@ -0,0 +1,78 @@
import express, { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import config from 'config';
import bcrypt from 'bcrypt';
import csrf from 'csurf';
import * as dotenv from 'dotenv';
import Users from '../controllers/users';
dotenv.config();
const router = express.Router();
const csrfProtection = csrf({ cookie: true });
const parseForm = express.urlencoded({ extended: false });
/**
* Authorization page
*/
router.get('/auth', csrfProtection, function (req: Request, res: Response) {
res.render('auth', {
title: 'Login page',
csrfToken: req.csrfToken(),
});
});
/**
* Process given password
*/
router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Response) => {
try {
const userDoc = await Users.get();
const passHash = userDoc.passHash;
if (!passHash) {
res.render('auth', {
title: 'Login page',
header: 'Password not set',
csrfToken: req.csrfToken(),
});
return;
}
bcrypt.compare(req.body.password, passHash, async (err, result) => {
if (err || result === false) {
res.render('auth', {
title: 'Login page',
header: 'Wrong password',
csrfToken: req.csrfToken(),
});
return;
}
const token = jwt.sign({
iss: 'Codex Team',
sub: 'auth',
iat: Date.now(),
}, passHash + config.get('secret'));
res.cookie('authToken', token, {
httpOnly: true,
expires: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
});
res.redirect('/');
});
} catch (err) {
res.render('auth', {
title: 'Login page',
header: 'Password not set',
csrfToken: req.csrfToken(),
});
return;
}
});
export default router;

View file

@ -0,0 +1,16 @@
import express, { Request, Response } from 'express';
import verifyToken from './middlewares/token';
const router = express.Router();
/* GET home page. */
router.get('/', verifyToken, async (req: Request, res: Response) => {
const config = req.app.locals.config;
if (config.startPage) {
return res.redirect(config.startPage);
}
res.render('pages/index', { isAuthorized: res.locals.isAuthorized });
});
export default router;

View file

@ -0,0 +1,17 @@
import express from 'express';
import home from './home';
import pages from './pages';
import auth from './auth';
import aliases from './aliases';
import api from './api';
import pagesMiddleware from './middlewares/pages';
const router = express.Router();
router.use('/', pagesMiddleware, home);
router.use('/', pagesMiddleware, pages);
router.use('/', pagesMiddleware, auth);
router.use('/api', api);
router.use('/', aliases);
export default router;

View file

@ -0,0 +1,16 @@
import { NextFunction, Request, Response } from 'express';
/**
* Middleware for checking locals.isAuthorized property, which allows to edit/create pages
*
* @param req - request object
* @param res - response object
* @param next - next function
*/
export default function allowEdit(req: Request, res: Response, next: NextFunction): void {
if (res.locals.isAuthorized) {
next();
} else {
res.redirect('/auth');
}
}

View file

@ -0,0 +1,79 @@
import { NextFunction, Request, Response } from 'express';
import Pages from '../../controllers/pages';
import PagesOrder from '../../controllers/pagesOrder';
import Page from '../../models/page';
import asyncMiddleware from '../../utils/asyncMiddleware';
import PageOrder from '../../models/pageOrder';
/**
* Process one-level pages list to parent-children list
*
* @param {string} parentPageId - parent page id
* @param {Page[]} pages - list of all available pages
* @param {PagesOrder[]} pagesOrder - list of pages order
* @param {number} level - max level recursion
* @param {number} currentLevel - current level of element
*
* @returns {Page[]}
*/
function createMenuTree(parentPageId: string, pages: Page[], pagesOrder: PageOrder[], level = 1, currentLevel = 1): Page[] {
const childrenOrder = pagesOrder.find(order => order.data.page === parentPageId);
/**
* branch is a page children in tree
* if we got some children order on parents tree, then we push found pages in order sequence
* otherwise just find all pages includes parent tree
*/
let ordered: any[] = [];
if (childrenOrder) {
ordered = childrenOrder.order.map((pageId: string) => {
return pages.find(page => page._id === pageId);
});
}
const unordered = pages.filter(page => page._parent === parentPageId);
const branch = Array.from(new Set([...ordered, ...unordered]));
/**
* stop recursion when we got the passed max level
*/
if (currentLevel === level + 1) {
return [];
}
/**
* Each parents children can have subbranches
*/
return branch.filter(page => page && page._id).map(page => {
return Object.assign({
children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1),
}, page.data);
});
}
/**
* Middleware for all /page/... routes
*
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
export default asyncMiddleware(async (req: Request, res: Response, next: NextFunction) => {
/**
* Pages without parent
*
* @type {string}
*/
const parentIdOfRootPages = '0';
try {
const pages = await Pages.getAll();
const pagesOrder = await PagesOrder.getAll();
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 2);
} catch (error) {
console.log('Can not load menu:', error);
}
next();
});

View file

@ -0,0 +1,38 @@
import * as dotenv from 'dotenv';
import config from 'config';
import { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import Users from '../../controllers/users';
dotenv.config();
/**
* Middleware for checking jwt token
*
* @param req - request object
* @param res - response object
* @param next - next function
*/
export default async function verifyToken(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = req.cookies.authToken;
try {
const userDoc = await Users.get();
if (!userDoc.passHash) {
res.locals.isAuthorized = false;
next();
return;
}
const decodedToken = jwt.verify(token, userDoc.passHash + config.get('secret'));
res.locals.isAuthorized = !!decodedToken;
next();
} catch (err) {
res.locals.isAuthorized = false;
next();
}
}

View file

@ -0,0 +1,75 @@
import express, { NextFunction, Request, Response } from 'express';
import Pages from '../controllers/pages';
import PagesOrder from '../controllers/pagesOrder';
import verifyToken from './middlewares/token';
import allowEdit from './middlewares/locals';
const router = express.Router();
/**
* Create new page form
*/
router.get('/page/new', verifyToken, allowEdit, async (req: Request, res: Response, next: NextFunction) => {
try {
const pagesAvailable = await Pages.getAll();
res.render('pages/form', {
pagesAvailable,
page: null,
});
} catch (error) {
res.status(404);
next(error);
}
});
/**
* Edit page form
*/
router.get('/page/edit/:id', verifyToken, allowEdit, async (req: Request, res: Response, next: NextFunction) => {
const pageId = req.params.id;
try {
const page = await Pages.get(pageId);
const pagesAvailable = await Pages.getAllExceptChildren(pageId);
if (!page._parent) {
throw new Error('Parent not found');
}
const parentsChildrenOrdered = await PagesOrder.getOrderedChildren(pagesAvailable, pageId, page._parent, true);
res.render('pages/form', {
page,
parentsChildrenOrdered,
pagesAvailable,
});
} catch (error) {
res.status(404);
next(error);
}
});
/**
* View page
*/
router.get('/page/:id', verifyToken, async (req: Request, res: Response, next: NextFunction) => {
const pageId = req.params.id;
try {
const page = await Pages.get(pageId);
const pageParent = await page.parent;
res.render('pages/page', {
page,
pageParent,
config: req.app.locals.config,
});
} catch (error) {
res.status(404);
next(error);
}
});
export default router;

View file

@ -0,0 +1,18 @@
import { NextFunction, Request, Response } from 'express';
interface InputFunction {
(req: Request, res: Response, next: NextFunction): void;
}
/**
* Helper for making async middlewares for express router
*
* @param {Function} fn - input function
* @returns {function(*=, *=, *=)}
*/
export default function asyncMiddleware(fn: InputFunction): (req: Request, res: Response, next: NextFunction) => void {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
}

View file

@ -0,0 +1,45 @@
import crypto from 'crypto';
/**
*
* @param {string} hexStr - input hex string
* @returns {string} - output binary string
*/
function hexToBinary(hexStr: string): string {
return (parseInt(hexStr, 16).toString(2))
.padStart(8, '0');
}
/**
* Create binary md5
*
* @param stringToHash - string to hash
* @returns {string} - binary hash of argument
*/
export function binaryMD5(stringToHash: string): string {
return hexToBinary(crypto.createHash('md5')
.update(stringToHash)
.digest('hex'));
}
/**
* Returns 16 random bytes in hex format
*
* @returns {Promise<string>}
*/
export function random16(): Promise<string> {
return new Promise((resolve, reject) => {
crypto.randomBytes(16, (err, raw) => {
if (err) {
reject(err);
}
resolve(raw.toString('hex'));
});
});
}
export default {
binaryMD5,
random16,
};

View file

@ -0,0 +1,177 @@
import Datastore from 'nedb';
import { AliasData } from '../../models/alias';
import { FileData } from '../../models/file';
import { PageData } from '../../models/page';
import { PageOrderData } from '../../models/pageOrder';
import { UserData } from '../../models/user';
import initDb from './initDb';
/**
* @typedef Options - optional params
* @param {boolean} multi - (false) allows to take action to several documents
* @param {boolean} upsert - (false) if true, upsert document with update fields.
* Method will return inserted doc or number of affected docs if doc hasn't been inserted
* @param {boolean} returnUpdatedDocs - (false) if true, returns affected docs
*/
interface Options {
multi?: boolean;
upsert?: boolean;
returnUpdatedDocs?: boolean;
}
interface ResolveFunction {
(value: any): void;
}
interface RejectFunction {
(reason?: unknown): void;
}
/**
* @class Database
* @classdesc Simple decorator class to work with nedb datastore
*
* @property {Datastore} db - nedb Datastore object
*/
export class Database<DocType> {
private db: Datastore;
/**
* @class
*
* @param {Object} nedbInstance - nedb Datastore object
*/
constructor(nedbInstance: Datastore) {
this.db = nedbInstance;
}
/**
* Insert new document into the database
*
* @see https://github.com/louischatriot/nedb#inserting-documents
*
* @param {Object} doc - object to insert
* @returns {Promise<Object|Error>} - inserted doc or Error object
*/
public async insert(doc: DocType): Promise<DocType> {
return new Promise((resolve, reject) => this.db.insert(doc, (err, newDoc) => {
if (err) {
reject(err);
}
resolve(newDoc);
}));
}
/**
* Find documents that match passed query
*
* @see https://github.com/louischatriot/nedb#finding-documents
*
* @param {Object} query - query object
* @param {Object} projection - projection object
* @returns {Promise<Array<Object>|Error>} - found docs or Error object
*/
public async find(query: Record<string, unknown>, projection?: DocType): Promise<Array<DocType>> {
const cbk = (resolve: ResolveFunction, reject: RejectFunction) => (err: Error | null, docs: DocType[]) => {
if (err) {
reject(err);
}
resolve(docs);
};
return new Promise((resolve, reject) => {
if (projection) {
this.db.find(query, projection, cbk(resolve, reject));
} else {
this.db.find(query, cbk(resolve, reject));
}
});
}
/**
* Find one document matches passed query
*
* @see https://github.com/louischatriot/nedb#finding-documents
*
* @param {Object} query - query object
* @param {Object} projection - projection object
* @returns {Promise<Object|Error>} - found doc or Error object
*/
public async findOne(query: Record<string, unknown>, projection?: DocType): Promise<DocType> {
const cbk = (resolve: ResolveFunction, reject: RejectFunction) => (err: Error | null, doc: DocType) => {
if (err) {
reject(err);
}
resolve(doc);
};
return new Promise((resolve, reject) => {
if (projection) {
this.db.findOne(query, projection, cbk(resolve, reject));
} else {
this.db.findOne(query, cbk(resolve, reject));
}
});
}
/**
* Update document matches query
*
* @see https://github.com/louischatriot/nedb#updating-documents
*
* @param {Object} query - query object
* @param {Object} update - fields to update
* @param {Options} options - optional params
* @returns {Promise<number|Object|Object[]|Error>} - number of updated rows or affected docs or Error object
*/
public async update(query: Record<string, unknown>, update: DocType, options: Options = {}): Promise<number|boolean|Array<DocType>> {
return new Promise((resolve, reject) => this.db.update(query, update, options, (err, result, affectedDocs) => {
if (err) {
reject(err);
}
switch (true) {
case options.returnUpdatedDocs:
resolve(affectedDocs);
break;
case options.upsert:
if (affectedDocs) {
resolve(affectedDocs);
}
resolve(result);
break;
default:
resolve(result);
}
}));
}
/**
* Remove document matches passed query
*
* @see https://github.com/louischatriot/nedb#removing-documents
*
* @param {Object} query - query object
* @param {Options} options - optional params
* @returns {Promise<number|Error>} - number of removed rows or Error object
*/
public async remove(query: Record<string, unknown>, options: Options = {}): Promise<number> {
return new Promise((resolve, reject) => this.db.remove(query, options, (err, result) => {
if (err) {
reject(err);
}
resolve(result);
}));
}
}
export default {
pages: new Database<PageData>(initDb('pages')),
password: new Database<UserData>(initDb('password')),
aliases: new Database<AliasData>(initDb('aliases')),
pagesOrder: new Database<PageOrderData>(initDb('pagesOrder')),
files: new Database<FileData>(initDb('files')),
};

View file

@ -0,0 +1,16 @@
import Datastore from 'nedb';
import config from 'config';
import path from 'path';
/**
* Init function for nedb instance
*
* @param {string} name - name of the data file
* @returns {Datastore} db - nedb instance
*/
export default function initDb(name: string): Datastore {
return new Datastore({
filename: path.resolve(`./${config.get('database')}/${name}.db`),
autoload: true,
});
}

View file

@ -0,0 +1,38 @@
/**
* Merge to objects recursively
*
* @param {object} target
* @param {object[]} sources
* @returns {object}
*/
/**
* @param {Record<string, any>} target - target to merge into
* @param {...any[]} sources - sources to merge from
*/
function deepMerge(target: Record<string, any>, ...sources: any[]): Record<string, unknown> {
const isObject = (item: unknown): boolean => !!item && typeof item === 'object' && !Array.isArray(item);
if (!sources.length) {
return target;
}
const source = sources.shift();
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) {
Object.assign(target, { [key]: {} });
}
deepMerge(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
return deepMerge(target, ...sources);
}
export default deepMerge;

View file

@ -0,0 +1,124 @@
import fs from 'fs';
import path from 'path';
import config from 'config';
const rcPath = path.resolve(__dirname, '../../../', config.get('rcFile') || './.codexdocsrc');
/**
* @typedef {object} menu
* @property {string} title - menu option title
* @property {string} uri - menu option href
*/
interface Menu {
title: string;
uri: string;
[key: string]: string;
}
/**
* @typedef {object} RCData
* @property {string} title - website title
* @property {Menu[]} menu - options for website menu
*/
interface RCData {
title: string;
menu: Menu[];
[key: string]: string | Menu[];
}
/**
* @class RCParser
* @classdesc Class to parse runtime configuration file for CodeX Docs engine
*/
export default class RCParser {
/**
* Default CodeX Docs configuration
*
* @static
* @returns {{title: string, menu: Array}}
*/
public static get DEFAULTS():RCData {
return {
title: 'CodeX Docs',
menu: [],
};
}
/**
* Find and parse runtime configuration file
*
* @static
* @returns {{title: string, menu: []}}
*/
public static getConfiguration(): RCData {
if (!fs.existsSync(rcPath)) {
return RCParser.DEFAULTS;
}
const file = fs.readFileSync(rcPath, 'utf-8');
const rConfig = RCParser.DEFAULTS;
let userConfig;
try {
userConfig = JSON.parse(file);
} catch (e) {
console.log('CodeX Docs rc file should be in JSON format.');
return RCParser.DEFAULTS;
}
for (const option in userConfig) {
if (Object.prototype.hasOwnProperty.call(userConfig, option)) {
rConfig[option] = userConfig[option] || RCParser.DEFAULTS[option] || undefined;
}
}
if (!(rConfig.menu instanceof Array)) {
console.log('Menu section in the rc file must be an array.');
rConfig.menu = RCParser.DEFAULTS.menu;
}
rConfig.menu = rConfig.menu.filter((option: string | Menu, i:number) => {
i = i + 1;
if (typeof option === 'string') {
return true;
}
if (!option || option instanceof Array || typeof option !== 'object') {
console.log(`Menu option #${i} in rc file must be a string or an object`);
return false;
}
const { title, uri } = option;
if (!title || typeof title !== 'string') {
console.log(`Menu option #${i} title must be a string.`);
return false;
}
if (!uri || typeof uri !== 'string') {
console.log(`Menu option #${i} uri must be a string.`);
return false;
}
return true;
});
rConfig.menu = rConfig.menu.map((option: string | Menu) => {
if (typeof option === 'string') {
return {
title: option,
/* Replace all non alpha- and numeric-symbols with '-' */
uri: '/' + option.toLowerCase().replace(/[ -/:-@[-`{-~]+/, '-'),
};
}
return option;
});
return rConfig;
}
}

View file

@ -0,0 +1,85 @@
interface TransTable {
[key: string]: string;
}
const translationTable: TransTable = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ж: 'g',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
ы: 'i',
э: 'e',
А: 'A',
Б: 'B',
В: 'V',
Г: 'G',
Д: 'D',
Е: 'E',
Ж: 'G',
З: 'Z',
И: 'I',
Й: 'Y',
К: 'K',
Л: 'L',
М: 'M',
Н: 'N',
О: 'O',
П: 'P',
Р: 'R',
С: 'S',
Т: 'T',
У: 'U',
Ф: 'F',
Ы: 'I',
Э: 'E',
ё: 'yo',
х: 'h',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'shch',
ъ: "''",
ь: "'",
ю: 'yu',
я: 'ya',
Ё: 'YO',
Х: 'H',
Ц: 'TS',
Ч: 'CH',
Ш: 'SH',
Щ: 'SHCH',
Ъ: "''",
Ь: "'",
Ю: 'YU',
Я: 'YA',
};
/**
* Function to translate string
*
* @param string - string to translate
* @returns {string} - translated string
*/
/**
* @param {string} string - input text to be translated
* @returns {string} text - translated text
*/
export default function translateString(string: string): string {
return string.replace(/[А-яёЁ]/g, (char) => translationTable[char] || char);
}

48
src/backend/utils/twig.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* Twig extensions
*/
import twig from 'twig';
import fs from 'fs';
import urlify from './urlify';
export default (function () {
'use strict';
/**
* Function for include svg on page
*
* @example svg('path/from/root/dir')
* @param {string} filename - name of icon
* @returns {string} - svg code
*/
twig.extendFunction('svg', function (filename: string) {
return fs.readFileSync(`./src/frontend/svg/${filename}.svg`, 'utf-8');
});
/**
* Convert text to URL-like string
* Example: "What is <mark>clean data</mark>" -> "what-is-clean-data"
*
* @param {string} string - source string with HTML
* @returns {string} alias-like string
*/
twig.extendFilter('urlify', function (string: string) {
return urlify(string);
});
/**
* Parse link as URL object
*
* @param {string} linkUrl - link to be processed
* @returns {string} url url data
*/
twig.extendFunction('parseLink', function (linkUrl: string): string {
try {
return new URL(linkUrl).toString();
} catch (e) {
console.log(e);
return '';
}
});
}());

View file

@ -0,0 +1,33 @@
import translateString from './translation';
/**
* Convert text to URL-like string
* Example: "What is <mark>clean data</mark>" -> "what-is-clean-data"
*
* @param {string} string - source string with HTML
* @returns {string} alias-like string
*/
export default function urlify(string: string): string {
// strip tags
string = string.replace(/(<([^>]+)>)/ig, '');
// remove nbsp
string = string.replace(/&nbsp;/g, ' ');
// remove all symbols except chars
string = string.replace(/[^a-zA-Z0-9А-Яа-яЁё ]/g, ' ');
// remove whitespaces
string = string.replace(/ +/g, ' ').trim();
// lowercase
string = string.toLowerCase();
// join words with hyphens
string = string.split(' ').join('-');
// translate
string = translateString(string);
return string;
}

View file

@ -0,0 +1,16 @@
{% extends 'layout.twig' %}
{% block body %}
<form class="auth-form" method="post" action="/auth">
<h1>
┬┴┬┴┤ ͜ʖ ͡°) ├┬┴┬┴
</h1>
<p>
Enter a password to access pages editing
</p>
<input type="hidden" name="_csrf" value={{ csrfToken }}>
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Login">
</form>
{% endblock %}

View file

@ -0,0 +1,41 @@
<div class="docs-aside-toggler" onclick="document.querySelector('.docs-aside').classList.toggle('docs-aside--toggled')">
{{ svg('menu') }} Table of contents
</div>
<div class="docs-aside">
{% for firstLevelPage in menu %}
<section class="docs-aside__section">
<a
{% if page is defined and page._id == firstLevelPage._id%}
class="docs-aside__section-title docs-aside__current"
{% else %}
class="docs-aside__section-title"
{% endif %}
{% if firstLevelPage.uri %}
href="/{{ firstLevelPage.uri }}"
{% else %}
href="/page/{{ firstLevelPage._id }}"
{% endif %}>
{{ firstLevelPage.title | striptags }}
</a>
{% if firstLevelPage.children is not empty %}
<ul class="docs-aside__section-list">
{% for child in firstLevelPage.children %}
<li>
<a
{% if page is defined and page._id == child._id %}
class="docs-aside__current"
{% endif %}
{% if child.uri %}
href="/{{ child.uri }}"
{% else %}
href="/page/{{ child._id }}"
{% endif %}>
{{ child.title | striptags }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endfor %}
</div>

View file

@ -0,0 +1,27 @@
<header class="docs-header">
<a href="/" class="docs-header__logo">
{{ config.title | striptags }}
</a>
<ul class="docs-header__menu">
{% if isAuthorized == true %}
<li class="docs-header__menu-add">
<a class="docs-header__button" href="/page/new">
{{ svg('plus') }}
Add Page
</a>
</li>
{% endif %}
{% for option in config.menu %}
<li>
<a
{% if option.uri %}
href="{{ option.uri }}"
{% else %}
href="/page/{{ option._id }}"
{% endif %}>
{{ option.title | striptags }}
</a>
</li>
{% endfor %}
</ul>
</header>

View file

@ -0,0 +1,7 @@
{% extends 'layout.twig' %}
{% block body %}
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
{% endblock %}

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ (page.title ?: config.title) | striptags }}</title>
<link rel="stylesheet" href="/dist/main.css" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ page.title | striptags }}" />
<meta property="article:modified_time" content="{{ (page.body.time / 1000) | date("c") }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<script>
window.config = {
misprintsChatId: "{{ config.misprintsChatId }}"
};
</script>
<body>
{% include "components/header.twig" with res.locals.isAuthorized %}
<div class="docs">
<aside class="docs__aside">
{% include "components/aside.twig" %}
</aside>
<div class="docs__content">
<div class="docs__content-inner">
{% block body %}{% endblock %}
</div>
</div>
</div>
<script src="/dist/main.bundle.js"></script>
{% if config.yandexMetrikaId is not empty %}
<script type="text/javascript" >
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym({{ config.yandexMetrikaId }}, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/{{ config.yandexMetrikaId }}" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,12 @@
<div class="block-checklist">
{% for item in items %}
<div class="block-checklist__item">
{% if item.checked %}
<span class="block-checklist__item-checkbox block-checklist__item-checkbox--checked"></span>
{% else %}
<span class="block-checklist__item-checkbox"></span>
{% endif %}
<div class="block-checklist__item-text">{{ item.text }}</div>
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,4 @@
<div class="block-code">
<div class="block-code__content">{{ code|escape }}</div>
</div>

View file

@ -0,0 +1 @@
<div class="block-delimiter"></div>

View file

@ -0,0 +1,12 @@
<figure class="block-embed">
<iframe
class="block-embed__iframe"
src="{{ embed }}"
frameborder="0"
></iframe>
{% if caption %}
<footer class="block-iframe__caption">
{{ caption }}
</footer>
{% endif %}
</figure>

View file

@ -0,0 +1,7 @@
<a name="{{ text | urlify }}" style="display: inline-block; position: absolute; margin-top: -20px;"></a>
<h{{ level }} class="block-header block-header--{{ level }} block-header--anchor">
<a href="#{{ text | urlify }}">
{{ text }}
</a>
</h{{ level }}>

View file

@ -0,0 +1,31 @@
{% set classes = ['block-image__content'] %}
{% if withBorder %}
{% set classes = classes|merge(['block-image__content--bordered']) %}
{% endif %}
{% if stretched %}
{% set classes = classes|merge(['block-image__content--stretched']) %}
{% endif %}
{% if withBackground %}
{% set classes = classes|merge(['block-image__content--with-background']) %}
{% endif %}
<figure class="block-image">
<div class="{{ classes.join(' ') }}">
{% if file.mime and file.mime == 'video/mp4' %}
<video autoplay loop muted playsinline>
<source src="{{ file.url }}" type="video/mp4">
</video>
{% else %}
<img src="{{ file.url }}" alt="{{ caption ? caption | striptags : '' }}">
{% endif %}
</div>
{% if caption %}
<footer class="block-image__caption">
{{ caption }}
</footer>
{% endif %}
</figure>

View file

@ -0,0 +1,17 @@
<a class="block-link" href="{{ link }}" target="_blank" rel="nofollow">
{% if meta.image.url %}
<img class="block-link__image" src="{{ meta.image.url }}">
{% endif %}
<div class="block-link__title">
{{ meta.title }}
</div>
<div class="block-link__description">
{{ meta.description }}
</div>
<span class="block-link__domain">
{{ parseLink(link).hostname }}
</span>
</a>

View file

@ -0,0 +1,13 @@
{% set tag = 'ul' %}
{% if style == 'ordered' %}
{% set tag = 'ol' %}
{% endif %}
<{{ tag }} class="block-list block-list--{{ style }}">
{% for item in items %}
<li>
{{ item }}
</li>
{% endfor %}
</{{ tag }}>

View file

@ -0,0 +1,3 @@
<p class="block-paragraph">
{{ text }}
</p>

View file

@ -0,0 +1 @@
{{ html }}

View file

@ -0,0 +1,11 @@
<table class="block-table">
{% for row in content %}
<tr>
{% for cell in row %}
<td>
{{ cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>

View file

@ -0,0 +1,13 @@
<div class="block-warning">
<div class="block-warning__icon">
☝️
</div>
{% if title is not empty %}
<div class="block-warning__title">
{{ title }}
</div>
{% endif %}
<div class="block-warning__message">
{{ message }}
</div>
</div>

View file

@ -0,0 +1,67 @@
{% extends 'layout.twig' %}
{% block body %}
<style>
.docs-header__button {
visibility: hidden;
}
</style>
<section data-module="writing">
<textarea name="module-settings" hidden>
{
"page": {{ page | json_encode | escape }}
}
</textarea>
<header class="writing-header">
<span class="writing-header__left">
<span>
New Page at the
{% set currentPageId = 0 %}
{% if page is not empty %}
{% set currentPageId = page._id %}
{% endif %}
<select name="parent">
<option value="0">Root</option>
{% for _page in pagesAvailable %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% endif %}
{% endfor %}
</select>
</span>
{% if parentsChildrenOrdered is not empty %}
<span>
Put Above
<select name="above">
<option value="0">—</option>
{% for _page in parentsChildrenOrdered %}
<option value="{{ _page._id }}">{{ _page.title }}</option>
{% endfor %}
</select>
</span>
{% endif %}
</span>
{% if page is not empty %}
<p><input type="text" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}"></p>
{% endif %}
</header>
<div class="writing-editor">
<div id="editorjs"></div>
</div>
<div class="writing-buttons">
<span class="writing-header__save" name="js-submit-save">Save</span>
{% if page._id is not empty %}
<span class="writing-buttons__remove" name="js-submit-remove">Remove</span>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html style="height: 100%">
<head>
<title>{{ config.title }}</title>
<link rel="stylesheet" href="/dist/main.css" />
<link rel="preload" href="{{ config.landingFrameSrc }}" as="document">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta property="og:title" content="{{ config.title }}" />
<meta property="og:site_name" content="{{ config.title }}" />
<meta name="description" property="og:description" content="{{ config.description }}">
</head>
<body class="landing-body">
{% include "components/header.twig" %}
<div class="landing-loader" id="frame-loader">
{{ svg('loader') }}
</div>
<iframe class="landing-frame" src="{{ config.landingFrameSrc }}" seamless frameborder="0" onload="this.style.opacity = 1; setTimeout(document.getElementById('frame-loader').remove(), 500)"></iframe>
{% if config.yandexMetrikaId is not empty %}
<script type="text/javascript" >
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym({{ config.yandexMetrikaId }}, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/{{ config.yandexMetrikaId }}" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,48 @@
{% extends 'layout.twig' %}
{% block body %}
<article class="page" data-module="page">
<header class="page__header">
<a href="/" class="page__header-nav">
Documentation
</a>
{% if page._parent %}
<a class="page__header-nav"
{% if pageParent.uri %}
href="/{{ pageParent.uri }}"
{% else %}
href="/page/{{ pageParent._id }}"
{% endif %}>
{{ pageParent.title }}
</a>
{% endif %}
<time class="page__header-time">
Last edit {{ (page.body.time / 1000) | date("M d Y") }}
{% if isAuthorized == true %}
<a href="/page/edit/{{ page._id }}" class="page__header-button">
Edit
</a>
{% endif %}
</time>
</header>
<h1 class="page__title">
{{ page.title }}
</h1>
{% if (config.carbon and config.carbon.placement and config.carbon.serve) %}
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?serve={{ config.carbon.serve }}&placement={{ config.carbon.placement }}" id="_carbonads_js"></script>
{% endif %}
<section class="page__content">
{% for block in page.body.blocks %}
{# Skip first header, because it is already showed as a Title #}
{% if not (loop.first and block.type == 'header') %}
{% if block.type in ['paragraph', 'header', 'image', 'code', 'list', 'delimiter', 'table', 'warning', 'checklist', 'linkTool', 'raw', 'embed'] %}
{% include './blocks/' ~ block.type ~ '.twig' with block.data %}
{% endif %}
{% endif %}
{% endfor %}
</section>
<footer>
</footer>
</article>
{% endblock %}