mirror of https://github.com/Requarks/wiki.git
46 changed files with 1258 additions and 957 deletions
Split View
Diff Options
-
9client/components/setup.vue
-
5config.sample.yml
-
54package.json
-
53server/core/auth.js
-
23server/core/config.js
-
132server/core/db.js
-
6server/core/localization.js
-
75server/db/migrations/2.0.0.js
-
48server/db/models/groups.js
-
34server/db/models/locales.js
-
31server/db/models/settings.js
-
235server/db/models/users.js
-
11server/db/seeds/settings.js
-
4server/graph/resolvers/authentication.js
-
50server/graph/resolvers/group.js
-
7server/graph/resolvers/localization.js
-
9server/graph/resolvers/system.js
-
41server/graph/resolvers/user.js
-
3server/jobs/fetch-graph-locale.js
-
4server/jobs/sync-graph-locales.js
-
210server/models/user.js
-
0server/models_old/_relations.js
-
0server/models_old/comment.js
-
0server/models_old/document.js
-
0server/models_old/file.js
-
0server/models_old/folder.js
-
0server/models_old/group.js
-
0server/models_old/locale.js
-
0server/models_old/right.js
-
0server/models_old/setting.js
-
0server/models_old/tag.js
-
2server/modules/authentication/auth0.js
-
2server/modules/authentication/azure.js
-
2server/modules/authentication/discord.js
-
2server/modules/authentication/dropbox.js
-
2server/modules/authentication/facebook.js
-
2server/modules/authentication/github.js
-
2server/modules/authentication/google.js
-
2server/modules/authentication/ldap.js
-
10server/modules/authentication/local.js
-
2server/modules/authentication/microsoft.js
-
2server/modules/authentication/oauth2.js
-
2server/modules/authentication/slack.js
-
2server/modules/authentication/twitch.js
-
36server/setup.js
-
1101yarn.lock
@ -0,0 +1,75 @@ |
|||
exports.up = knex => { |
|||
return knex.schema |
|||
// -------------------------------------
|
|||
// GROUPS
|
|||
// -------------------------------------
|
|||
.createTable('groups', table => { |
|||
table.increments('id').primary() |
|||
|
|||
table.string('name').notNullable() |
|||
table.string('createdAt').notNullable() |
|||
table.string('updatedAt').notNullable() |
|||
}) |
|||
// -------------------------------------
|
|||
// LOCALES
|
|||
// -------------------------------------
|
|||
.createTable('locales', table => { |
|||
table.increments('id').primary() |
|||
|
|||
table.string('code', 2).notNullable().unique() |
|||
table.json('strings') |
|||
table.boolean('isRTL').notNullable().defaultTo(false) |
|||
table.string('name').notNullable() |
|||
table.string('nativeName').notNullable() |
|||
table.string('createdAt').notNullable() |
|||
table.string('updatedAt').notNullable() |
|||
}) |
|||
// -------------------------------------
|
|||
// SETTINGS
|
|||
// -------------------------------------
|
|||
.createTable('settings', table => { |
|||
table.increments('id').primary() |
|||
|
|||
table.string('key').notNullable().unique() |
|||
table.json('value') |
|||
table.string('createdAt').notNullable() |
|||
table.string('updatedAt').notNullable() |
|||
}) |
|||
// -------------------------------------
|
|||
// USERS
|
|||
// -------------------------------------
|
|||
.createTable('users', table => { |
|||
table.increments('id').primary() |
|||
|
|||
table.string('email').notNullable() |
|||
table.string('name').notNullable() |
|||
table.string('provider').notNullable().defaultTo('local') |
|||
table.string('providerId') |
|||
table.string('password') |
|||
table.boolean('tfaIsActive').notNullable().defaultTo(false) |
|||
table.string('tfaSecret') |
|||
table.enum('role', ['admin', 'guest', 'user']).notNullable().defaultTo('guest') |
|||
table.string('createdAt').notNullable() |
|||
table.string('updatedAt').notNullable() |
|||
|
|||
table.unique(['provider', 'email']) |
|||
}) |
|||
// -------------------------------------
|
|||
// USER GROUPS
|
|||
// -------------------------------------
|
|||
.createTable('userGroups', table => { |
|||
table.increments('id').primary() |
|||
|
|||
table.integer('userId').unsigned().references('id').inTable('users') |
|||
table.integer('groupId').unsigned().references('id').inTable('groups') |
|||
}) |
|||
} |
|||
|
|||
exports.down = knex => { |
|||
return knex.schema |
|||
.dropTableIfExists('userGroups') |
|||
.dropTableIfExists('groups') |
|||
.dropTableIfExists('locales') |
|||
.dropTableIfExists('settings') |
|||
.dropTableIfExists('users') |
|||
} |
@ -0,0 +1,48 @@ |
|||
const Model = require('objection').Model |
|||
|
|||
/** |
|||
* Settings model |
|||
*/ |
|||
module.exports = class Group extends Model { |
|||
static get tableName() { return 'groups' } |
|||
|
|||
static get jsonSchema () { |
|||
return { |
|||
type: 'object', |
|||
required: ['name'], |
|||
|
|||
properties: { |
|||
id: {type: 'integer'}, |
|||
name: {type: 'string'}, |
|||
createdAt: {type: 'string'}, |
|||
updatedAt: {type: 'string'} |
|||
} |
|||
} |
|||
} |
|||
|
|||
static get relationMappings() { |
|||
const User = require('./users') |
|||
return { |
|||
users: { |
|||
relation: Model.ManyToManyRelation, |
|||
modelClass: User, |
|||
join: { |
|||
from: 'groups.id', |
|||
through: { |
|||
from: 'userGroups.groupId', |
|||
to: 'userGroups.userId' |
|||
}, |
|||
to: 'users.id' |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
$beforeUpdate() { |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
$beforeInsert() { |
|||
this.createdAt = new Date().toISOString() |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
const Model = require('objection').Model |
|||
|
|||
/** |
|||
* Locales model |
|||
*/ |
|||
module.exports = class User extends Model { |
|||
static get tableName() { return 'locales' } |
|||
|
|||
static get jsonSchema () { |
|||
return { |
|||
type: 'object', |
|||
required: ['code', 'name'], |
|||
|
|||
properties: { |
|||
id: {type: 'integer'}, |
|||
code: {type: 'string'}, |
|||
strings: {type: 'object'}, |
|||
isRTL: {type: 'boolean', default: false}, |
|||
name: {type: 'string'}, |
|||
nativeName: {type: 'string'}, |
|||
createdAt: {type: 'string'}, |
|||
updatedAt: {type: 'string'} |
|||
} |
|||
} |
|||
} |
|||
|
|||
$beforeUpdate() { |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
$beforeInsert() { |
|||
this.createdAt = new Date().toISOString() |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
} |
@ -0,0 +1,31 @@ |
|||
const Model = require('objection').Model |
|||
|
|||
/** |
|||
* Settings model |
|||
*/ |
|||
module.exports = class User extends Model { |
|||
static get tableName() { return 'settings' } |
|||
|
|||
static get jsonSchema () { |
|||
return { |
|||
type: 'object', |
|||
required: ['key', 'value'], |
|||
|
|||
properties: { |
|||
id: {type: 'integer'}, |
|||
key: {type: 'string'}, |
|||
value: {type: 'object'}, |
|||
createdAt: {type: 'string'}, |
|||
updatedAt: {type: 'string'} |
|||
} |
|||
} |
|||
} |
|||
|
|||
$beforeUpdate() { |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
$beforeInsert() { |
|||
this.createdAt = new Date().toISOString() |
|||
this.updatedAt = new Date().toISOString() |
|||
} |
|||
} |
@ -0,0 +1,235 @@ |
|||
/* global WIKI */ |
|||
|
|||
const bcrypt = require('bcryptjs-then') |
|||
const _ = require('lodash') |
|||
const tfa = require('node-2fa') |
|||
const securityHelper = require('../../helpers/security') |
|||
const Model = require('objection').Model |
|||
|
|||
const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/ |
|||
|
|||
/** |
|||
* Users model |
|||
*/ |
|||
module.exports = class User extends Model { |
|||
static get tableName() { return 'users' } |
|||
|
|||
static get jsonSchema () { |
|||
return { |
|||
type: 'object', |
|||
required: ['email', 'name', 'provider'], |
|||
|
|||
properties: { |
|||
id: {type: 'integer'}, |
|||
email: {type: 'string', format: 'email'}, |
|||
name: {type: 'string', minLength: 1, maxLength: 255}, |
|||
provider: {type: 'string', minLength: 1, maxLength: 255}, |
|||
providerId: {type: 'number'}, |
|||
password: {type: 'string'}, |
|||
role: {type: 'string', enum: ['admin', 'guest', 'user']}, |
|||
tfaIsActive: {type: 'boolean', default: false}, |
|||
tfaSecret: {type: 'string'}, |
|||
createdAt: {type: 'string'}, |
|||
updatedAt: {type: 'string'} |
|||
} |
|||
} |
|||
} |
|||
|
|||
static get relationMappings() { |
|||
const Group = require('./groups') |
|||
return { |
|||
groups: { |
|||
relation: Model.ManyToManyRelation, |
|||
modelClass: Group, |
|||
join: { |
|||
from: 'users.id', |
|||
through: { |
|||
from: 'userGroups.userId', |
|||
to: 'userGroups.groupId' |
|||
}, |
|||
to: 'groups.id' |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
async $beforeUpdate(opt, context) { |
|||
await super.$beforeUpdate(opt, context) |
|||
|
|||
this.updatedAt = new Date().toISOString() |
|||
|
|||
if (!(opt.patch && this.password === undefined)) { |
|||
await this.generateHash() |
|||
} |
|||
} |
|||
async $beforeInsert(context) { |
|||
await super.$beforeInsert(context) |
|||
|
|||
this.createdAt = new Date().toISOString() |
|||
this.updatedAt = new Date().toISOString() |
|||
|
|||
await this.generateHash() |
|||
} |
|||
|
|||
async generateHash() { |
|||
if (this.password) { |
|||
if (bcryptRegexp.test(this.password)) { return } |
|||
this.password = await bcrypt.hash(this.password, 12) |
|||
} |
|||
} |
|||
|
|||
async verifyPassword(pwd) { |
|||
if (await bcrypt.compare(this.password, pwd) === true) { |
|||
return true |
|||
} else { |
|||
throw new WIKI.Error.AuthLoginFailed() |
|||
} |
|||
} |
|||
|
|||
async enableTFA() { |
|||
let tfaInfo = tfa.generateSecret({ |
|||
name: WIKI.config.site.title |
|||
}) |
|||
return this.$query.patch({ |
|||
tfaIsActive: true, |
|||
tfaSecret: tfaInfo.secret |
|||
}) |
|||
} |
|||
|
|||
async disableTFA() { |
|||
return this.$query.patch({ |
|||
tfaIsActive: false, |
|||
tfaSecret: '' |
|||
}) |
|||
} |
|||
|
|||
async verifyTFA(code) { |
|||
let result = tfa.verifyToken(this.tfaSecret, code) |
|||
return (result && _.has(result, 'delta') && result.delta === 0) |
|||
} |
|||
|
|||
static async processProfile(profile) { |
|||
let primaryEmail = '' |
|||
if (_.isArray(profile.emails)) { |
|||
let e = _.find(profile.emails, ['primary', true]) |
|||
primaryEmail = (e) ? e.value : _.first(profile.emails).value |
|||
} else if (_.isString(profile.email) && profile.email.length > 5) { |
|||
primaryEmail = profile.email |
|||
} else if (_.isString(profile.mail) && profile.mail.length > 5) { |
|||
primaryEmail = profile.mail |
|||
} else if (profile.user && profile.user.email && profile.user.email.length > 5) { |
|||
primaryEmail = profile.user.email |
|||
} else { |
|||
return Promise.reject(new Error(WIKI.lang.t('auth:errors.invaliduseremail'))) |
|||
} |
|||
|
|||
profile.provider = _.lowerCase(profile.provider) |
|||
primaryEmail = _.toLower(primaryEmail) |
|||
|
|||
let user = await WIKI.db.users.query().findOne({ |
|||
email: primaryEmail, |
|||
provider: profile.provider |
|||
}) |
|||
if (user) { |
|||
user.$query().patchAdnFetch({ |
|||
email: primaryEmail, |
|||
provider: profile.provider, |
|||
providerId: profile.id, |
|||
name: profile.displayName || _.split(primaryEmail, '@')[0] |
|||
}) |
|||
} else { |
|||
user = await WIKI.db.users.query().insertAndFetch({ |
|||
email: primaryEmail, |
|||
provider: profile.provider, |
|||
providerId: profile.id, |
|||
name: profile.displayName || _.split(primaryEmail, '@')[0] |
|||
}) |
|||
} |
|||
|
|||
// Handle unregistered accounts
|
|||
// if (!user && profile.provider !== 'local' && (WIKI.config.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) {
|
|||
// let nUsr = {
|
|||
// email: primaryEmail,
|
|||
// provider: profile.provider,
|
|||
// providerId: profile.id,
|
|||
// password: '',
|
|||
// name: profile.displayName || profile.name || profile.cn,
|
|||
// rights: [{
|
|||
// role: 'read',
|
|||
// path: '/',
|
|||
// exact: false,
|
|||
// deny: false
|
|||
// }]
|
|||
// }
|
|||
// return WIKI.db.users.query().insert(nUsr)
|
|||
// }
|
|||
|
|||
return user |
|||
} |
|||
|
|||
static async login (opts, context) { |
|||
if (_.has(WIKI.config.auth.strategies, opts.provider)) { |
|||
_.set(context.req, 'body.email', opts.username) |
|||
_.set(context.req, 'body.password', opts.password) |
|||
|
|||
// Authenticate
|
|||
return new Promise((resolve, reject) => { |
|||
WIKI.auth.passport.authenticate(opts.provider, async (err, user, info) => { |
|||
if (err) { return reject(err) } |
|||
if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) } |
|||
|
|||
// Is 2FA required?
|
|||
if (user.tfaIsActive) { |
|||
try { |
|||
let loginToken = await securityHelper.generateToken(32) |
|||
await WIKI.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600) |
|||
return resolve({ |
|||
tfaRequired: true, |
|||
tfaLoginToken: loginToken |
|||
}) |
|||
} catch (err) { |
|||
WIKI.logger.warn(err) |
|||
return reject(new WIKI.Error.AuthGenericError()) |
|||
} |
|||
} else { |
|||
// No 2FA, log in user
|
|||
return context.req.logIn(user, err => { |
|||
if (err) { return reject(err) } |
|||
resolve({ |
|||
tfaRequired: false |
|||
}) |
|||
}) |
|||
} |
|||
})(context.req, context.res, () => {}) |
|||
}) |
|||
} else { |
|||
throw new WIKI.Error.AuthProviderInvalid() |
|||
} |
|||
} |
|||
|
|||
static async loginTFA(opts, context) { |
|||
if (opts.securityCode.length === 6 && opts.loginToken.length === 64) { |
|||
let result = await WIKI.redis.get(`tfa:${opts.loginToken}`) |
|||
if (result) { |
|||
let userId = _.toSafeInteger(result) |
|||
if (userId && userId > 0) { |
|||
let user = await WIKI.db.users.query().findById(userId) |
|||
if (user && user.verifyTFA(opts.securityCode)) { |
|||
return Promise.fromCallback(clb => { |
|||
context.req.logIn(user, clb) |
|||
}).return({ |
|||
succeeded: true, |
|||
message: 'Login Successful' |
|||
}).catch(err => { |
|||
WIKI.logger.warn(err) |
|||
throw new WIKI.Error.AuthGenericError() |
|||
}) |
|||
} else { |
|||
throw new WIKI.Error.AuthTFAFailed() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
throw new WIKI.Error.AuthTFAInvalid() |
|||
} |
|||
} |
@ -0,0 +1,11 @@ |
|||
exports.seed = (knex, Promise) => { |
|||
return knex('settings') |
|||
.insert([ |
|||
{ key: 'auth', value: {} }, |
|||
{ key: 'features', value: {} }, |
|||
{ key: 'logging', value: {} }, |
|||
{ key: 'site', value: {} }, |
|||
{ key: 'theme', value: {} }, |
|||
{ key: 'uploads', value: {} } |
|||
]) |
|||
} |
@ -1,210 +0,0 @@ |
|||
/* global WIKI */ |
|||
|
|||
const Promise = require('bluebird') |
|||
const bcrypt = require('bcryptjs-then') |
|||
const _ = require('lodash') |
|||
const tfa = require('node-2fa') |
|||
const securityHelper = require('../helpers/security') |
|||
|
|||
/** |
|||
* Users schema |
|||
*/ |
|||
module.exports = (sequelize, DataTypes) => { |
|||
let userSchema = sequelize.define('user', { |
|||
email: { |
|||
type: DataTypes.STRING, |
|||
allowNull: false, |
|||
validate: { |
|||
isEmail: true |
|||
} |
|||
}, |
|||
provider: { |
|||
type: DataTypes.STRING, |
|||
allowNull: false |
|||
}, |
|||
providerId: { |
|||
type: DataTypes.STRING, |
|||
allowNull: true |
|||
}, |
|||
password: { |
|||
type: DataTypes.STRING, |
|||
allowNull: true |
|||
}, |
|||
name: { |
|||
type: DataTypes.STRING, |
|||
allowNull: true |
|||
}, |
|||
role: { |
|||
type: DataTypes.ENUM('admin', 'user', 'guest'), |
|||
allowNull: false |
|||
}, |
|||
tfaIsActive: { |
|||
type: DataTypes.BOOLEAN, |
|||
allowNull: false, |
|||
defaultValue: false |
|||
}, |
|||
tfaSecret: { |
|||
type: DataTypes.STRING, |
|||
allowNull: true |
|||
} |
|||
}, { |
|||
timestamps: true, |
|||
version: true, |
|||
indexes: [ |
|||
{ |
|||
unique: true, |
|||
fields: ['provider', 'email'] |
|||
} |
|||
] |
|||
}) |
|||
|
|||
userSchema.prototype.validatePassword = async function (rawPwd) { |
|||
if (await bcrypt.compare(rawPwd, this.password) === true) { |
|||
return true |
|||
} else { |
|||
throw new WIKI.Error.AuthLoginFailed() |
|||
} |
|||
} |
|||
|
|||
userSchema.prototype.enableTFA = async function () { |
|||
let tfaInfo = tfa.generateSecret({ |
|||
name: WIKI.config.site.title |
|||
}) |
|||
this.tfaIsActive = true |
|||
this.tfaSecret = tfaInfo.secret |
|||
return this.save() |
|||
} |
|||
|
|||
userSchema.prototype.disableTFA = async function () { |
|||
this.tfaIsActive = false |
|||
this.tfaSecret = '' |
|||
return this.save() |
|||
} |
|||
|
|||
userSchema.prototype.verifyTFA = function (code) { |
|||
let result = tfa.verifyToken(this.tfaSecret, code) |
|||
return (result && _.has(result, 'delta') && result.delta === 0) |
|||
} |
|||
|
|||
userSchema.login = async (opts, context) => { |
|||
if (_.has(WIKI.config.auth.strategies, opts.provider)) { |
|||
_.set(context.req, 'body.email', opts.username) |
|||
_.set(context.req, 'body.password', opts.password) |
|||
|
|||
// Authenticate
|
|||
return new Promise((resolve, reject) => { |
|||
WIKI.auth.passport.authenticate(opts.provider, async (err, user, info) => { |
|||
if (err) { return reject(err) } |
|||
if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) } |
|||
|
|||
// Is 2FA required?
|
|||
if (user.tfaIsActive) { |
|||
try { |
|||
let loginToken = await securityHelper.generateToken(32) |
|||
await WIKI.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600) |
|||
return resolve({ |
|||
tfaRequired: true, |
|||
tfaLoginToken: loginToken |
|||
}) |
|||
} catch (err) { |
|||
WIKI.logger.warn(err) |
|||
return reject(new WIKI.Error.AuthGenericError()) |
|||
} |
|||
} else { |
|||
// No 2FA, log in user
|
|||
return context.req.logIn(user, err => { |
|||
if (err) { return reject(err) } |
|||
resolve({ |
|||
tfaRequired: false |
|||
}) |
|||
}) |
|||
} |
|||
})(context.req, context.res, () => {}) |
|||
}) |
|||
} else { |
|||
throw new WIKI.Error.AuthProviderInvalid() |
|||
} |
|||
} |
|||
|
|||
userSchema.loginTFA = async (opts, context) => { |
|||
if (opts.securityCode.length === 6 && opts.loginToken.length === 64) { |
|||
let result = await WIKI.redis.get(`tfa:${opts.loginToken}`) |
|||
if (result) { |
|||
let userId = _.toSafeInteger(result) |
|||
if (userId && userId > 0) { |
|||
let user = await WIKI.db.User.findById(userId) |
|||
if (user && user.verifyTFA(opts.securityCode)) { |
|||
return Promise.fromCallback(clb => { |
|||
context.req.logIn(user, clb) |
|||
}).return({ |
|||
succeeded: true, |
|||
message: 'Login Successful' |
|||
}).catch(err => { |
|||
WIKI.logger.warn(err) |
|||
throw new WIKI.Error.AuthGenericError() |
|||
}) |
|||
} else { |
|||
throw new WIKI.Error.AuthTFAFailed() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
throw new WIKI.Error.AuthTFAInvalid() |
|||
} |
|||
|
|||
userSchema.processProfile = (profile) => { |
|||
let primaryEmail = '' |
|||
if (_.isArray(profile.emails)) { |
|||
let e = _.find(profile.emails, ['primary', true]) |
|||
primaryEmail = (e) ? e.value : _.first(profile.emails).value |
|||
} else if (_.isString(profile.email) && profile.email.length > 5) { |
|||
primaryEmail = profile.email |
|||
} else if (_.isString(profile.mail) && profile.mail.length > 5) { |
|||
primaryEmail = profile.mail |
|||
} else if (profile.user && profile.user.email && profile.user.email.length > 5) { |
|||
primaryEmail = profile.user.email |
|||
} else { |
|||
return Promise.reject(new Error(WIKI.lang.t('auth:errors.invaliduseremail'))) |
|||
} |
|||
|
|||
profile.provider = _.lowerCase(profile.provider) |
|||
primaryEmail = _.toLower(primaryEmail) |
|||
|
|||
return WIKI.db.User.findOneAndUpdate({ |
|||
email: primaryEmail, |
|||
provider: profile.provider |
|||
}, { |
|||
email: primaryEmail, |
|||
provider: profile.provider, |
|||
providerId: profile.id, |
|||
name: profile.displayName || _.split(primaryEmail, '@')[0] |
|||
}, { |
|||
new: true |
|||
}).then((user) => { |
|||
// Handle unregistered accounts
|
|||
if (!user && profile.provider !== 'local' && (WIKI.config.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) { |
|||
let nUsr = { |
|||
email: primaryEmail, |
|||
provider: profile.provider, |
|||
providerId: profile.id, |
|||
password: '', |
|||
name: profile.displayName || profile.name || profile.cn, |
|||
rights: [{ |
|||
role: 'read', |
|||
path: '/', |
|||
exact: false, |
|||
deny: false |
|||
}] |
|||
} |
|||
return WIKI.db.User.create(nUsr) |
|||
} |
|||
return user || Promise.reject(new Error(WIKI.lang.t('auth:errors:notyetauthorized'))) |
|||
}) |
|||
} |
|||
|
|||
userSchema.hashPassword = (rawPwd) => { |
|||
return bcrypt.hash(rawPwd) |
|||
} |
|||
|
|||
return userSchema |
|||
} |
1101
yarn.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save