mirror of https://github.com/Requarks/wiki.git
20 changed files with 727 additions and 0 deletions
Split View
Diff Options
-
3.babelrc
-
17.gitattributes
-
9.gitignore
-
4.snyk
-
30.travis.yml
-
54config.sample.yml
-
94gulpfile.js
-
10inch.json
-
11locales/en/common.js
-
11locales/fr/common.js
-
34middlewares/auth.js
-
28middlewares/security.js
-
34models/config.js
-
158models/db/user.js
-
53models/mongodb.js
-
41models/redis.js
-
95package.json
-
18server.js
-
11tests/index.js
-
12wiki.sublime-project
@ -0,0 +1,3 @@ |
|||
{ |
|||
"presets": ["es2015"] |
|||
} |
@ -0,0 +1,17 @@ |
|||
# Auto detect text files and perform LF normalization |
|||
* text=auto |
|||
|
|||
# Custom for Visual Studio |
|||
*.cs diff=csharp |
|||
|
|||
# Standard to msysgit |
|||
*.doc diff=astextplain |
|||
*.DOC diff=astextplain |
|||
*.docx diff=astextplain |
|||
*.DOCX diff=astextplain |
|||
*.dot diff=astextplain |
|||
*.DOT diff=astextplain |
|||
*.pdf diff=astextplain |
|||
*.PDF diff=astextplain |
|||
*.rtf diff=astextplain |
|||
*.RTF diff=astextplain |
@ -0,0 +1,4 @@ |
|||
failThreshold: high |
|||
version: v1.5.2 |
|||
ignore: {} |
|||
patch: {} |
@ -0,0 +1,30 @@ |
|||
language: node_js |
|||
node_js: |
|||
- '6' |
|||
- '5' |
|||
- '4.4' |
|||
services: |
|||
- redis-server |
|||
- mongodb |
|||
cache: |
|||
directories: |
|||
- node_modules |
|||
before_script: |
|||
- npm install -g snyk |
|||
before_deploy: |
|||
- npm install -g gulp |
|||
- gulp deploy |
|||
- snyk auth $SNYK_TOKEN |
|||
- snyk monitor |
|||
deploy: |
|||
provider: releases |
|||
file: |
|||
- dist/requarks-wiki.zip |
|||
- dist/requarks-wiki.tar.gz |
|||
skip_cleanup: true |
|||
overwrite: true |
|||
on: |
|||
branch: master |
|||
repo: requarks/wiki |
|||
tags: true |
|||
node: '6' |
@ -0,0 +1,54 @@ |
|||
################################################### |
|||
# REQUARKS WIKI - CONFIGURATION # |
|||
################################################### |
|||
|
|||
# ------------------------------------------------- |
|||
# Title of this site |
|||
# ------------------------------------------------- |
|||
|
|||
title: Wiki |
|||
|
|||
# ------------------------------------------------- |
|||
# Full path to the site, without the trailing slash |
|||
# ------------------------------------------------- |
|||
|
|||
host: http://localhost |
|||
|
|||
# ------------------------------------------------- |
|||
# Port the server should listen to (80 by default) |
|||
# ------------------------------------------------- |
|||
# To use process.env.PORT, comment the line below: |
|||
|
|||
port: 80 |
|||
|
|||
# ------------------------------------------------- |
|||
# MongoDB Connection String |
|||
# ------------------------------------------------- |
|||
# Full explanation + examples in the documentation (https://opsstatus.readme.io/) |
|||
|
|||
db: mongodb://localhost/wiki |
|||
|
|||
# ------------------------------------------------- |
|||
# Redis Connection Info |
|||
# ------------------------------------------------- |
|||
# Full explanation + examples in the documentation (https://opsstatus.readme.io/) |
|||
|
|||
redis: |
|||
host: localhost |
|||
port: 6379 |
|||
db: 0 |
|||
|
|||
# ------------------------------------------------- |
|||
# Secret key to use when encrypting sessions |
|||
# ------------------------------------------------- |
|||
# Use a long and unique random string (256-bit keys are perfect!) |
|||
|
|||
sessionSecret: 1234567890abcdefghijklmnopqrstuvxyz |
|||
|
|||
# ------------------------------------------------- |
|||
# Administrator email |
|||
# ------------------------------------------------- |
|||
# An account will be created using the email specified here. |
|||
# The password is set to "admin123" by default. Change it immediately upon login!!! |
|||
|
|||
admin: admin@company.com |
@ -0,0 +1,94 @@ |
|||
var gulp = require("gulp"); |
|||
var merge = require('merge-stream'); |
|||
var babel = require("gulp-babel"); |
|||
var uglify = require('gulp-uglify'); |
|||
var concat = require('gulp-concat'); |
|||
var nodemon = require('gulp-nodemon'); |
|||
var plumber = require('gulp-plumber'); |
|||
var zip = require('gulp-zip'); |
|||
var tar = require('gulp-tar'); |
|||
var gzip = require('gulp-gzip'); |
|||
var sass = require('gulp-sass'); |
|||
var cleanCSS = require('gulp-clean-css'); |
|||
var include = require("gulp-include"); |
|||
|
|||
/** |
|||
* Paths |
|||
* |
|||
* @type {Object} |
|||
*/ |
|||
var paths = { |
|||
scriptlibs: { |
|||
|
|||
}, |
|||
scriptapps: [ |
|||
'./client/js/components/*.js', |
|||
'./client/js/app.js' |
|||
], |
|||
scriptappswatch: [ |
|||
'./client/js/**/*.js' |
|||
], |
|||
csslibs: [ |
|||
|
|||
], |
|||
cssapps: [ |
|||
'./client/css/app.scss' |
|||
], |
|||
cssappswatch: [ |
|||
'./client/css/**/*.scss' |
|||
], |
|||
fonts: [ |
|||
'./node_modules/font-awesome/fonts/*-webfont.*', |
|||
'!./node_modules/font-awesome/fonts/*-webfont.svg' |
|||
], |
|||
deploypackage: [ |
|||
'./**/*', |
|||
'!node_modules', '!node_modules/**', |
|||
'!coverage', '!coverage/**', |
|||
'!client/js', '!client/js/**', |
|||
'!dist', '!dist/**', |
|||
'!tests', '!tests/**', |
|||
'!gulpfile.js', '!inch.json', '!config.json', '!wiki.sublime-project' |
|||
] |
|||
}; |
|||
|
|||
/** |
|||
* TASK - Starts server in development mode |
|||
*/ |
|||
gulp.task('server', ['scripts', 'css', 'fonts'], function() { |
|||
nodemon({ |
|||
script: './server', |
|||
ignore: ['public/', 'client/', 'tests/'], |
|||
ext: 'js json', |
|||
env: { 'NODE_ENV': 'development' } |
|||
}); |
|||
}); |
|||
|
|||
/** |
|||
* TASK - Start dev watchers |
|||
*/ |
|||
gulp.task('watch', function() { |
|||
gulp.watch([paths.scriptappswatch], ['scripts-app']); |
|||
gulp.watch([paths.cssappswatch], ['css-app']); |
|||
}); |
|||
|
|||
/** |
|||
* TASK - Starts development server with watchers |
|||
*/ |
|||
gulp.task('default', ['watch', 'server']); |
|||
|
|||
/** |
|||
* TASK - Creates deployment packages |
|||
*/ |
|||
gulp.task('deploy', ['scripts', 'css', 'fonts'], function() { |
|||
var zipStream = gulp.src(paths.deploypackage) |
|||
.pipe(zip('requarks-wiki.zip')) |
|||
.pipe(gulp.dest('dist')); |
|||
|
|||
var targzStream = gulp.src(paths.deploypackage) |
|||
.pipe(tar('requarks-wiki.tar')) |
|||
.pipe(gzip()) |
|||
.pipe(gulp.dest('dist')); |
|||
|
|||
return merge(zipStream, targzStream); |
|||
}); |
@ -0,0 +1,10 @@ |
|||
{ |
|||
"files": { |
|||
"included": [ |
|||
"controllers/**/*.js", |
|||
"middlewares/**/*.js", |
|||
"models/**/*.js", |
|||
], |
|||
"excluded": [] |
|||
} |
|||
} |
@ -0,0 +1,11 @@ |
|||
{ |
|||
"wiki": "Wiki", |
|||
"headers": { |
|||
"overview": "Overview" |
|||
}, |
|||
"footer": { |
|||
"poweredby": "Powered by", |
|||
"home": "Home", |
|||
"admin": "Administration" |
|||
} |
|||
} |
@ -0,0 +1,11 @@ |
|||
{ |
|||
"wiki": "Wiki", |
|||
"headers": { |
|||
"overview": "Vue d'ensemble" |
|||
}, |
|||
"footer": { |
|||
"poweredby": "Propulsé par", |
|||
"home": "Accueil", |
|||
"admin": "Administration" |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
"use strict"; |
|||
|
|||
var Promise = require('bluebird'), |
|||
moment = require('moment-timezone'); |
|||
|
|||
/** |
|||
* Authentication middleware |
|||
* |
|||
* @param {Express Request} req Express Request object |
|||
* @param {Express Response} res Express Response object |
|||
* @param {Function} next Next callback function |
|||
* @return {any} void |
|||
*/ |
|||
module.exports = (req, res, next) => { |
|||
|
|||
// Is user authenticated ?
|
|||
|
|||
if (!req.isAuthenticated()) { |
|||
return res.redirect('/login'); |
|||
} |
|||
|
|||
// Set i18n locale
|
|||
|
|||
req.i18n.changeLanguage(req.user.lang); |
|||
res.locals.userMoment = moment; |
|||
res.locals.userMoment.locale(req.user.lang); |
|||
|
|||
// Expose user data
|
|||
|
|||
res.locals.user = req.user; |
|||
|
|||
return next(); |
|||
|
|||
}; |
@ -0,0 +1,28 @@ |
|||
/** |
|||
* Security Middleware |
|||
* |
|||
* @param {Express Request} req Express request object |
|||
* @param {Express Response} res Express response object |
|||
* @param {Function} next next callback function |
|||
* @return {any} void |
|||
*/ |
|||
module.exports = function(req, res, next) { |
|||
|
|||
//-> Disable X-Powered-By
|
|||
app.disable('x-powered-by'); |
|||
|
|||
//-> Disable Frame Embedding
|
|||
res.set('X-Frame-Options', 'deny'); |
|||
|
|||
//-> Re-enable XSS Fitler if disabled
|
|||
res.set('X-XSS-Protection', '1; mode=block'); |
|||
|
|||
//-> Disable MIME-sniffing
|
|||
res.set('X-Content-Type-Options', 'nosniff'); |
|||
|
|||
//-> Disable IE Compatibility Mode
|
|||
res.set('X-UA-Compatible', 'IE=edge'); |
|||
|
|||
return next(); |
|||
|
|||
}; |
@ -0,0 +1,34 @@ |
|||
"use strict"; |
|||
|
|||
var fs = require('fs'), |
|||
yaml = require('js-yaml'), |
|||
_ = require('lodash'); |
|||
|
|||
/** |
|||
* Load Application Configuration |
|||
* |
|||
* @param {String} confPath Path to the configuration file |
|||
* @return {Object} Application Configuration |
|||
*/ |
|||
module.exports = (confPath) => { |
|||
|
|||
var appconfig = {}; |
|||
|
|||
try { |
|||
appconfig = yaml.safeLoad(fs.readFileSync(confPath, 'utf8')); |
|||
} catch (ex) { |
|||
winston.error(ex); |
|||
process.exit(1); |
|||
} |
|||
|
|||
return _.defaultsDeep(appconfig, { |
|||
title: "Requarks Wiki", |
|||
host: "http://localhost", |
|||
port: process.env.PORT, |
|||
db: "mongodb://localhost/wiki", |
|||
redis: null, |
|||
sessionSecret: null, |
|||
admin: null |
|||
}); |
|||
|
|||
}; |
@ -0,0 +1,158 @@ |
|||
"use strict"; |
|||
|
|||
var modb = require('mongoose'); |
|||
var bcrypt = require('bcryptjs-then'); |
|||
var Promise = require('bluebird'); |
|||
var _ = require('lodash'); |
|||
|
|||
/** |
|||
* User Schema |
|||
* |
|||
* @type {Object} |
|||
*/ |
|||
var userSchema = modb.Schema({ |
|||
|
|||
email: { |
|||
type: String, |
|||
required: true, |
|||
index: true, |
|||
minlength: 6 |
|||
}, |
|||
password: { |
|||
type: String, |
|||
required: true |
|||
}, |
|||
firstName: { |
|||
type: String, |
|||
required: true, |
|||
minlength: 1 |
|||
}, |
|||
lastName: { |
|||
type: String, |
|||
required: true, |
|||
minlength: 1 |
|||
}, |
|||
timezone: { |
|||
type: String, |
|||
required: true, |
|||
default: 'UTC' |
|||
}, |
|||
lang: { |
|||
type: String, |
|||
required: true, |
|||
default: 'en' |
|||
}, |
|||
rights: [{ |
|||
type: String, |
|||
required: true |
|||
}] |
|||
|
|||
}, |
|||
{ |
|||
timestamps: {} |
|||
}); |
|||
|
|||
/** |
|||
* VIRTUAL - Full Name |
|||
*/ |
|||
userSchema.virtual('fullName').get(function() { |
|||
return this.firstName + ' ' + this.lastName; |
|||
}); |
|||
|
|||
/** |
|||
* INSTANCE - Validate password against hash |
|||
* |
|||
* @param {string} uPassword The user password |
|||
* @return {Promise<Boolean>} Promise with valid / invalid boolean |
|||
*/ |
|||
userSchema.methods.validatePassword = function(uPassword) { |
|||
let self = this; |
|||
return bcrypt.compare(uPassword, self.password); |
|||
}; |
|||
|
|||
/** |
|||
* MODEL - Generate hash from password |
|||
* |
|||
* @param {string} uPassword The user password |
|||
* @return {Promise<String>} Promise with generated hash |
|||
*/ |
|||
userSchema.statics.generateHash = function(uPassword) { |
|||
return bcrypt.hash(uPassword, 10); |
|||
}; |
|||
|
|||
/** |
|||
* MODEL - Create a new user |
|||
* |
|||
* @param {Object} nUserData User data |
|||
* @return {Promise} Promise of the create operation |
|||
*/ |
|||
userSchema.statics.new = function(nUserData) { |
|||
|
|||
let self = this; |
|||
|
|||
return self.generateHash(nUserData.password).then((passhash) => { |
|||
return this.create({ |
|||
_id: db.ObjectId(), |
|||
email: nUserData.email, |
|||
firstName: nUserData.firstName, |
|||
lastName: nUserData.lastName, |
|||
password: passhash, |
|||
rights: ['admin'] |
|||
}); |
|||
}); |
|||
|
|||
}; |
|||
|
|||
/** |
|||
* MODEL - Edit a user |
|||
* |
|||
* @param {String} userId The user identifier |
|||
* @param {Object} data The user data |
|||
* @return {Promise} Promise of the update operation |
|||
*/ |
|||
userSchema.statics.edit = function(userId, data) { |
|||
|
|||
let self = this; |
|||
|
|||
// Change basic info
|
|||
|
|||
let fdata = { |
|||
email: data.email, |
|||
firstName: data.firstName, |
|||
lastName: data.lastName, |
|||
timezone: data.timezone, |
|||
lang: data.lang, |
|||
rights: data.rights |
|||
}; |
|||
let waitTask = null; |
|||
|
|||
// Change password?
|
|||
|
|||
if(!_.isEmpty(data.password) && _.trim(data.password) !== '********') { |
|||
waitTask = self.generateHash(data.password).then((passhash) => { |
|||
fdata.password = passhash; |
|||
return fdata; |
|||
}); |
|||
} else { |
|||
waitTask = Promise.resolve(fdata); |
|||
} |
|||
|
|||
// Update user
|
|||
|
|||
return waitTask.then((udata) => { |
|||
return this.findByIdAndUpdate(userId, udata, { runValidators: true }); |
|||
}); |
|||
|
|||
}; |
|||
|
|||
/** |
|||
* MODEL - Delete a user |
|||
* |
|||
* @param {String} userId The user ID |
|||
* @return {Promise} Promise of the delete operation |
|||
*/ |
|||
userSchema.statics.erase = function(userId) { |
|||
return this.findByIdAndRemove(userId); |
|||
}; |
|||
|
|||
module.exports = modb.model('User', userSchema); |
@ -0,0 +1,53 @@ |
|||
"use strict"; |
|||
|
|||
var modb = require('mongoose'), |
|||
fs = require("fs"), |
|||
path = require("path"), |
|||
_ = require('lodash'); |
|||
|
|||
/** |
|||
* MongoDB module |
|||
* |
|||
* @param {Object} appconfig Application config |
|||
* @return {Object} Mongoose instance |
|||
*/ |
|||
module.exports = function(appconfig) { |
|||
|
|||
modb.Promise = require('bluebird'); |
|||
|
|||
let dbModels = {}; |
|||
let dbModelsPath = path.join(ROOTPATH, 'models/db'); |
|||
|
|||
// Event handlers
|
|||
|
|||
modb.connection.on('error', (err) => { |
|||
winston.error('Failed to connect to MongoDB instance.'); |
|||
}); |
|||
modb.connection.once('open', function() { |
|||
winston.log('Connected to MongoDB instance.'); |
|||
}); |
|||
|
|||
// Store connection handle
|
|||
|
|||
dbModels.connection = modb.connection; |
|||
dbModels.ObjectId = modb.Types.ObjectId; |
|||
|
|||
// Load Models
|
|||
|
|||
fs |
|||
.readdirSync(dbModelsPath) |
|||
.filter(function(file) { |
|||
return (file.indexOf(".") !== 0); |
|||
}) |
|||
.forEach(function(file) { |
|||
let modelName = _.upperFirst(_.split(file,'.')[0]); |
|||
dbModels[modelName] = require(path.join(dbModelsPath, file)); |
|||
}); |
|||
|
|||
// Connect
|
|||
|
|||
dbModels.connectPromise = modb.connect(appconfig.db); |
|||
|
|||
return dbModels; |
|||
|
|||
}; |
@ -0,0 +1,41 @@ |
|||
"use strict"; |
|||
|
|||
var Redis = require('ioredis'), |
|||
_ = require('lodash'); |
|||
|
|||
/** |
|||
* Redis module |
|||
* |
|||
* @param {Object} appconfig Application config |
|||
* @return {Redis} Redis instance |
|||
*/ |
|||
module.exports = (appconfig) => { |
|||
|
|||
let rd = null; |
|||
|
|||
if(_.isArray(appconfig.redis)) { |
|||
rd = new Redis.Cluster(appconfig.redis, { |
|||
scaleReads: 'master', |
|||
redisOptions: { |
|||
lazyConnect: false |
|||
} |
|||
}); |
|||
} else { |
|||
rd = new Redis(_.defaultsDeep(appconfig.redis), { |
|||
lazyConnect: false |
|||
}); |
|||
} |
|||
|
|||
// Handle connection errors
|
|||
|
|||
rd.on('error', (err) => { |
|||
winston.error('Failed to connect to Redis instance(s). [err-1]'); |
|||
}); |
|||
|
|||
rd.on('node error', (err) => { |
|||
winston.error('Failed to connect to Redis instance(s). [err-2]'); |
|||
}); |
|||
|
|||
return rd; |
|||
|
|||
}; |
@ -0,0 +1,95 @@ |
|||
{ |
|||
"name": "wiki", |
|||
"version": "1.0.0", |
|||
"description": "A modern, lightweight and powerful wiki app built on NodeJS, Git and Markdown", |
|||
"main": "server.js", |
|||
"scripts": { |
|||
"start": "node server", |
|||
"dev": "gulp", |
|||
"test": "snyk test && istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec ./tests/index.js && cat ./coverage/lcov.info | ./node_modules/.bin/codacy-coverage && rm -rf ./coverage" |
|||
}, |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/Requarks/wiki.git" |
|||
}, |
|||
"keywords": [ |
|||
"wiki", |
|||
"wikis", |
|||
"docs", |
|||
"documentation", |
|||
"markdown", |
|||
"guides" |
|||
], |
|||
"author": "Nicolas Giard", |
|||
"license": "AGPL-3.0", |
|||
"bugs": { |
|||
"url": "https://github.com/Requarks/wiki/issues" |
|||
}, |
|||
"homepage": "https://github.com/Requarks/wiki#readme", |
|||
"engines": { |
|||
"node": ">=4.4.5" |
|||
}, |
|||
"dependencies": { |
|||
"auto-load": "^2.1.0", |
|||
"bluebird": "^3.4.1", |
|||
"body-parser": "^1.15.2", |
|||
"compression": "^1.6.2", |
|||
"connect-flash": "^0.1.1", |
|||
"connect-redis": "^3.1.0", |
|||
"cookie-parser": "^1.4.3", |
|||
"express": "^4.14.0", |
|||
"express-brute": "^0.7.0-beta.0", |
|||
"express-brute-redis": "0.0.1", |
|||
"express-session": "^1.14.0", |
|||
"express-validator": "^2.20.8", |
|||
"gridlex": "^2.1.1", |
|||
"i18next": "^3.4.1", |
|||
"i18next-express-middleware": "^1.0.1", |
|||
"i18next-node-fs-backend": "^0.1.2", |
|||
"ioredis": "^2.3.0", |
|||
"js-yaml": "^3.6.1", |
|||
"lodash": "^4.15.0", |
|||
"markdown-it": "^7.0.1", |
|||
"moment": "^2.14.1", |
|||
"moment-timezone": "^0.5.5", |
|||
"mongoose": "^4.5.9", |
|||
"mongoose-delete": "^0.3.4", |
|||
"node-bcrypt": "0.0.1", |
|||
"passport": "^0.3.2", |
|||
"passport-local": "^1.0.0", |
|||
"pug": "^2.0.0-beta5", |
|||
"serve-favicon": "^2.3.0", |
|||
"simplemde": "^1.11.2", |
|||
"validator": "^5.5.0", |
|||
"validator-as-promised": "^1.0.2", |
|||
"winston": "^2.2.0" |
|||
}, |
|||
"devDependencies": { |
|||
"babel-preset-es2015": "^6.13.2", |
|||
"chai": "^3.5.0", |
|||
"chai-as-promised": "^5.3.0", |
|||
"codacy-coverage": "^2.0.0", |
|||
"font-awesome": "^4.6.3", |
|||
"gridlex": "^2.1.1", |
|||
"gulp": "^3.9.1", |
|||
"gulp-babel": "^6.1.2", |
|||
"gulp-clean-css": "^2.0.12", |
|||
"gulp-concat": "^2.6.0", |
|||
"gulp-gzip": "^1.4.0", |
|||
"gulp-include": "^2.3.1", |
|||
"gulp-nodemon": "^2.1.0", |
|||
"gulp-plumber": "^1.1.0", |
|||
"gulp-sass": "^2.3.2", |
|||
"gulp-tar": "^1.9.0", |
|||
"gulp-uglify": "^2.0.0", |
|||
"gulp-zip": "^3.2.0", |
|||
"istanbul": "^0.4.4", |
|||
"jquery": "^3.1.0", |
|||
"merge-stream": "^1.0.0", |
|||
"mocha": "^3.0.2", |
|||
"mocha-lcov-reporter": "^1.2.0", |
|||
"nodemon": "^1.10.0", |
|||
"snyk": "^1.18.0", |
|||
"vue": "^1.0.26" |
|||
} |
|||
} |
@ -0,0 +1,18 @@ |
|||
// ===========================================
|
|||
// REQUARKS WIKI
|
|||
// 1.0.0
|
|||
// Licensed under AGPLv3
|
|||
// ===========================================
|
|||
|
|||
// ----------------------------------------
|
|||
// Load modules
|
|||
// ----------------------------------------
|
|||
|
|||
global.winston = require('winston'); |
|||
winston.info('Requarks Wiki is initializing...'); |
|||
|
|||
global.ROOTPATH = __dirname; |
|||
|
|||
var appconfig = require('./models/config')('./config.yml'); |
|||
global.db = require('./models/db')(appconfig); |
|||
global.red = require('./models/redis')(appconfig); |
@ -0,0 +1,11 @@ |
|||
"use strict"; |
|||
|
|||
let path = require('path'), |
|||
fs = require('fs'); |
|||
|
|||
// ========================================
|
|||
// Load global modules
|
|||
// ========================================
|
|||
|
|||
global._ = require('lodash'); |
|||
global.winston = require('winston'); |
@ -0,0 +1,12 @@ |
|||
{ |
|||
"folders": |
|||
[ |
|||
{ |
|||
"file_exclude_patterns": |
|||
[ |
|||
"wiki.sublime-project" |
|||
], |
|||
"path": "." |
|||
} |
|||
] |
|||
} |
Write
Preview
Loading…
Cancel
Save