Browse Source

Merge pull request 'Templates: Improved handling to support other modules' (#133) from monofox/base:feature/ticket_90 into dev

Reviewed-on: https://codeberg.org/lerntools/base/pulls/133
webpack5
pw 2 months ago
parent
commit
3739a42f8f
  1. 33
      main/components/TemplateEditor.vue
  2. 47
      main/config.js
  3. 6
      main/locales/Templates.json
  4. 22
      main/server/controller.js
  5. 5
      main/server/init.js
  6. 18
      main/server/server.mjs
  7. 12
      main/server/templates/controller.js
  8. 102
      main/server/templates/templates.js
  9. 2
      main/views/TemplatesList.vue
  10. 28
      main/views/TextPage.vue
  11. 21
      server.js
  12. 59
      server/init.js

33
main/components/TemplateEditor.vue

@ -7,12 +7,12 @@
<form>
<div class="form-group row">
<label class="col-form-label">{{$t('type')}}</label>
<select class="form-control" v-model="tplModel.type"
<select class="form-control" v-model="tplModel.type" v-on:change="checkLoadDefault"
:class="{'is-invalid':!isTypeValid}">
<option v-for="(type, index) in templateTypes" v-bind:key="index" :value="type">{{type}}</option>
</select>
<label class="col-form-label">{{$t('language')}}</label>
<select class="form-control" v-model="tplModel.language"
<select class="form-control" v-model="tplModel.language" v-on:change="checkLoadDefault"
:class="{'is-invalid':!$v(tplModel.language,true,$consts.REGEX_LANGUAGE)}">
<option v-for="(lng, index) in $consts.SUPPORTED_LANGUAGES" v-bind:key="index" :value="lng">{{lng}}</option>
</select>
@ -20,7 +20,7 @@
<input class="form-control" v-model="tplModel.title">
<label class="col-form-label">{{$t('content')}}</label>
<div class="form-group column markdown-pane">
<textarea class="form-control" v-model="tplModel.content"></textarea>
<textarea class="form-control" v-model="tplModel.content" v-on:change="bodyModified = true;"></textarea>
<div class="markdown-preview" v-html="mdPreview"></div>
</div>
</div>
@ -47,7 +47,7 @@
tplWrk: undefined,
isLoading: true,
templateTypes: [],
bodyModified: false
}
},
computed: {
@ -99,11 +99,34 @@
this.warningList.push({msg: 'unknown-error'});
}
});
},
checkLoadDefault: function() {
if (this.tplModel.content.length <= 0 || !this.bodyModified) {
if (this.tplModel.language && this.tplModel.type) {
this.$axios.get('main/admin/templates', {params: {
lang: this.tplModel.language,
type: this.tplModel.type,
include_default: true,
only_default: true
}}).then(response => {
if (response.status < 400 && response.data && response.data.length > 0) {
this.tplModel.content = response.data[0].content;
this.tplModel.title = response.data[0].title;
// Ensure, we can load again!
} else {
this.tplModel.content = '';
this.tplModel.title = '';
}
this.bodyModified = false;
});
}
}
}
},
mounted: function() {
this.bodyModified = false;
this.$axios.get('main/admin/templatesAvailable').then(response=> {
if (response.status == 204 || response.status == 200) {
if (response.status < 400) {
this.templateTypes = response.data;
this.isLoading = false;
}

47
main/config.js

@ -1,39 +1,40 @@
const MAIN_ROUTE='main-index';
const MAIN_ROUTE = 'main-index';
export default {
id: "main",
meta: {
title: {
title: {
"de": "Lerntools",
"en": "Learning Tools",
},
text: {
text: {
"de": "Sammlung datensparsamer Tools für den interaktiven digitalen Unterricht",
"en": "Collection of tools to support digital teaching with a focus on privacy"
}
},
routes: [
//basic navigation
{ path: '/', redirect: { name: MAIN_ROUTE } },
{ path: '/main-index', name:'main-index', component: () => import('./views/Home.vue') },
{ path: '/main-admin', name: 'main-admin', component: () => import('./views/Admin.vue')},
{ path: '/main-error', name: 'main-error', component: () => import('./views/Error.vue')},
{ path: '/main-imprint', name: 'main-imprint', meta: {tplId: 'imprint'}, component: () => import('./views/TextPage.vue')},
{ path: '/main-privacy', name: 'main-privacy', meta: {tplId: 'privacy'}, component: () => import('./views/TextPage.vue')},
{ path: '/main-terms', name: 'main-terms', meta: {tplId: 'terms'}, component: () => import('./views/TextPage.vue')},
{ path: '/main-forbidden', name: 'main-forbidden', component: () => import('./views/Forbidden.vue')},
{ path: '/main-version', name: 'main-version', component: () => import('./views/Version.vue')},
{ path: '/main-templates', name: 'main-templates', component: () => import('./views/TemplatesList.vue')},
{ path: '/text-:module-:file-:lang', name: 'main-text-lang', component: () => import('./views/TextPage.vue')},
{ path: '/text-:module-:file', name: 'main-text', component: () => import('./views/TextPage.vue')},
{ path: '/', redirect: { name: MAIN_ROUTE } },
{ path: '/main-index', name: 'main-index', component: () => import('./views/Home.vue') },
{ path: '/main-admin', name: 'main-admin', component: () => import('./views/Admin.vue') },
{ path: '/main-error', name: 'main-error', component: () => import('./views/Error.vue') },
{ path: '/main-imprint', name: 'main-imprint', meta: { tplId: 'main::imprint' }, component: () => import('./views/TextPage.vue') },
{ path: '/main-privacy', name: 'main-privacy', meta: { tplId: 'main::privacy' }, component: () => import('./views/TextPage.vue') },
{ path: '/main-terms', name: 'main-terms', meta: { tplId: 'main::terms' }, component: () => import('./views/TextPage.vue') },
{ path: '/main-forbidden', name: 'main-forbidden', component: () => import('./views/Forbidden.vue') },
{ path: '/main-version', name: 'main-version', component: () => import('./views/Version.vue') },
{ path: '/main-templates', name: 'main-templates', component: () => import('./views/TemplatesList.vue') },
{ path: '/text-:module-:file-:lang', name: 'main-text-lang', component: () => import('./views/TextPage.vue') },
{ path: '/text-:module-:file', name: 'main-text', component: () => import('./views/TextPage.vue') },
{ path: '/page-:module-:file', name: 'main-text-tpl', component: () => import('./views/TextPage.vue') },
//user management
{ path: '/main-login', name:'main-login', component: () => import('./views/Login.vue') },
{ path: '/main-logout', name:'main-logout', component: () => import('./views/Logout.vue') },
{ path: '/main-profile', name: 'main-profile', component: () => import('./views/Profile.vue')},
{ path: '/main-user-list', name: 'main-user-list', component: () => import('./views/UserList.vue')},
{ path: '/main-reset-request', name: 'main-reset-request', component: () => import ('./views/ResetRequest.vue')},
{ path: '/main-reset-confirm/:email/:ticket', name: 'main-reset-confirm', component: () => import ('./views/ResetConfirm.vue')},
{ path: '/main-register-request', name:'main-register-request', component: () => import ('./views/RegisterRequest.vue')},
{ path: '/main-register-confirm/:ticket', name: 'main-register-confirm', component: () => import ('./views/RegisterConfirm.vue')}
{ path: '/main-login', name: 'main-login', component: () => import('./views/Login.vue') },
{ path: '/main-logout', name: 'main-logout', component: () => import('./views/Logout.vue') },
{ path: '/main-profile', name: 'main-profile', component: () => import('./views/Profile.vue') },
{ path: '/main-user-list', name: 'main-user-list', component: () => import('./views/UserList.vue') },
{ path: '/main-reset-request', name: 'main-reset-request', component: () => import('./views/ResetRequest.vue') },
{ path: '/main-reset-confirm/:email/:ticket', name: 'main-reset-confirm', component: () => import('./views/ResetConfirm.vue') },
{ path: '/main-register-request', name: 'main-register-request', component: () => import('./views/RegisterRequest.vue') },
{ path: '/main-register-confirm/:ticket', name: 'main-register-confirm', component: () => import('./views/RegisterConfirm.vue') }
]
}

6
main/locales/Templates.json

@ -19,7 +19,8 @@
"confirm-delete-template": "Wollen Sie wirklich die Vorlage löschen?",
"default-tpl": "Standard",
"no": "Nein",
"yes": "Ja"
"yes": "Ja",
"back": "Zurück"
},
"en": {
"templates-title": "Templates management",
@ -41,6 +42,7 @@
"confirm-delete-template": "Do you really want to delete the template?",
"default-tpl": "Standard",
"no": "no",
"yes": "yes"
"yes": "yes",
"back": "Back"
}
}

22
main/server/controller.js

@ -216,9 +216,9 @@ exports.startTanProcess=function(req,res,next) {
instanceUrl: config.URL_HOST
};
if (user.email) {
MailHandler.sendMailTplToUser(user, "tan::request", tplOptions);
MailHandler.sendMailTplToUser(user, "main::tan::request", tplOptions);
} else {
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "tan::request", tplOptions);
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "main::tan::request", tplOptions);
}
}
res.json({});
@ -496,7 +496,7 @@ function cleanAccounts() {
user.save();
debug('Sending expire info to '+user.login);
//send mail
MailHandler.sendMailTplToUser(user, "user::expire", {
MailHandler.sendMailTplToUser(user, "main::user::expire", {
user: user,
expirePeriod: config.ACCOUNT_CLEANUP.EXPIRE_PERIOD,
instanceUrl: config.URL_HOST
@ -505,7 +505,7 @@ function cleanAccounts() {
else {
debug('Deleting '+user.login);
//info to user
MailHandler.sendMailTplToUser(user, "user::deleted", {
MailHandler.sendMailTplToUser(user, "main::user::deleted", {
user: user,
instanceUrl: config.URL_HOST
});
@ -513,7 +513,7 @@ function cleanAccounts() {
//send mail to admins
User.find({roles: "admin"}, (err, userList) => {
userList.forEach((adminUser) => {
MailHandler.sendMailTplToUser(adminUser, "user::deleted::admin", {
MailHandler.sendMailTplToUser(adminUser, "main::user::deleted::admin", {
user: user,
instanceUrl: config.URL_HOST
});
@ -635,7 +635,7 @@ exports.startResetProcess=[
}
//send mail
var url=config.URL_PROTO+path.join(config.URL_HOST,config.URL_PREFIX,'app','#',"main-reset-confirm",encodeURIComponent(email),ticket);
MailHandler.sendMailTplToUser(dbUser, "user::pwreset", {
MailHandler.sendMailTplToUser(dbUser, "main::user::pwreset", {
user: dbUser,
instanceUrl: config.URL_HOST,
confirmUrl: url
@ -728,7 +728,7 @@ exports.startRegisterProcess=[
checkMaxUser();
//send mail
var url=config.URL_PROTO+path.join(config.URL_HOST,config.URL_PREFIX,'app','#',"main-register-confirm",ticket);
MailHandler.sendMailTplToUser(userData, "register::user", {
MailHandler.sendMailTplToUser(userData, "main::register::user", {
user: userData,
instanceUrl: config.URL_HOST,
confirmUrl: url
@ -746,7 +746,7 @@ function checkMaxUser() {
var html="";
if (userList+config.REGISTRATION.USER_HARD_WARN >=config.REGISTRATION.MAX_USERS)
{
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "user::limit::hard", {
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "main::user::limit::hard", {
user: userData,
limit: config.REGISTRATION.MAX_USERS,
used: userList+config.REGISTRATION.USER_HARD_WARN,
@ -755,7 +755,7 @@ function checkMaxUser() {
return false;
}
else if(userList+config.REGISTRATION.USER_SOFT_WARN >=config.REGISTRATION.MAX_USERS) {
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "user::limit::soft", {
MailHandler.sendMailTplToAddress(config.USER_ADMIN_MAIL, "main::user::limit::soft", {
user: userData,
limit: config.REGISTRATION.MAX_USERS,
used: userList+config.REGISTRATION.USER_HARD_WARN,
@ -802,14 +802,14 @@ exports.completeRegisterProcess=[
}
register.delete();
// Send welcome mail.
MailHandler.sendMailTplToUser(user, "user::welcome", {
MailHandler.sendMailTplToUser(user, "main::user::welcome", {
user: user
});
checkMaxUser();
//send mail to admins
User.find({roles: "admin"}, (err, userList) => {
userList.forEach((adminUser) => {
MailHandler.sendMailTplToUser(adminUser, "register::admin", {
MailHandler.sendMailTplToUser(adminUser, "main::register::admin", {
user: user,
adminUser: adminUser
});

5
main/server/init.js

@ -1,5 +0,0 @@
var Templates=require('./templates/templates.js');
exports.init = async function() {
await Templates.loadDefault();
}

18
main/server/server.mjs

@ -0,0 +1,18 @@
export const server = {
moduleId: 'main',
templates: [
'mails::register::admin',
'mails::register::user',
'mails::tan::request',
'mails::user::deleted::admin',
'mails::user::deleted',
'mails::user::expire',
'mails::user::limit::hard',
'mails::user::limit::soft',
'mails::user::pwreset',
'mails::user::welcome',
'pages::imprint',
'pages::privacy',
'pages::terms'
]
}

12
main/server/templates/controller.js

@ -23,6 +23,12 @@ exports.getList = function (req, res, next) {
if (!defaultTpl) {
filter.defaultTpl = false;
}
if (req.query.only_default) {
filter.defaultTpl = true;
}
if (req.query.type) {
filter.type = req.query.type;
}
Template.find(filter).sort({ type: 'asc', language: 'asc', defaultTpl: 'asc' }).exec(function (err, tplList) {
if (err) return res.status(500).json([{ msg: 'database-error' }]);
return res.status(200).json(tplList);
@ -47,7 +53,6 @@ exports.getByTypeLang = async function (req, res, next) {
var lang = req.params.lang;
var firstOnly = !req.query.firstOnly || req.query.firstOnly === 'true';
var convert = req.query.convert === 'true';
debug(convert, firstOnly);
var filter = {
type: tplId,
language: lang
@ -115,7 +120,10 @@ exports.update = async function (req, res, next) {
// Get object.
var tpl = await Templates.getById(req.params.id);
if (!tpl) {debug(err); return res.status(500).json([{msg:'unknown-error'}]);}
// If it is a standard template, we want to export it to local file storage.
if (tpl.defaultTpl) {
Templates.exportDefaultTemplate(tpl);
}
return res.status(200).json(tpl);
});
}

102
main/server/templates/templates.js

@ -13,22 +13,38 @@ var Template = require('./model');
const consts=require ('../../../consts.js');
const path=require('path');
var fs = require('fs');
const MAIN_MODULE = 'main';
const TEMPLATE_TYPES=[
'mails::register::admin',
'mails::register::user',
'mails::tan::request',
'mails::user::deleted::admin',
'mails::user::deleted',
'mails::user::expire',
'mails::user::limit::hard',
'mails::user::limit::soft',
'mails::user::pwreset',
'mails::user::welcome',
'pages::imprint',
'pages::privacy',
'pages::terms'
];
const TEMPLATE_TYPES=[];
function getTypeInformation(tplType) {
var t = tplType.split('::');
if (t.length < 3) return;
return {
category: t[0],
module: t[1],
type: t.slice(2).join('::')
}
}
function addTemplateType(tplType) {
if (TEMPLATE_TYPES.indexOf(tplType) < 0 && tplType.split('::').length > 2) {
TEMPLATE_TYPES.push(tplType);
}
}
function getPathToTemplate(tplType, lang) {
var tplInfo = (typeof tplType === 'object') ? tplType : getTypeInformation(tplType);
if (!tplInfo) return null;
var basePath = path.join(__dirname, '..', '..');
if (tplInfo.module != MAIN_MODULE) {
basePath = path.join(basePath, '..', 'modules', tplInfo.module);
}
basePath = path.join(basePath, 'templates', tplInfo.category, lang, tplInfo.type + '.md');
return basePath;
}
exports.getValidTypes = function() {
return TEMPLATE_TYPES;
@ -38,13 +54,29 @@ exports.isValidType=function(tplId) {
return TEMPLATE_TYPES.indexOf(tplId) > -1;
}
exports.getTypeInformation = getTypeInformation;
exports.addTemplateType = addTemplateType;
exports.registerTemplateTypes=function(tplTypeList) {
tplTypeList.forEach((tplType) => addTemplateType(tplType));
}
exports.registerModuleTemplate = function(moduleId, templateType) {
try {
var tplId = templateType.split('::')[0] + '::' + moduleId + '::' + templateType.split('::').slice(1).join('::');
debug('Register module template: ', tplId);
if (tplId) {
addTemplateType(tplId);
}
} catch(error){
debug('Failed registering module template: ', error);
};
}
exports.loadDefault=async function() {
debug('Find default templates.');
debug('Find default templates.', TEMPLATE_TYPES);
TEMPLATE_TYPES.forEach((element) => {
var basePath = element.substring(0, element.indexOf('::'));
var tplId = element.substring(element.indexOf('::')+2);
var tplInfo = getTypeInformation(element);
consts.SUPPORTED_LANGUAGES.forEach((lang) => {
var mdPath = path.join(__dirname, '..', '..', 'templates', basePath, lang, tplId + '.md');
var mdPath = getPathToTemplate(tplInfo, lang);
fs.stat(mdPath, (error, stats) => {
if (error === null) {
var tplFile = {
@ -94,26 +126,30 @@ exports.loadDefault=async function() {
});
}
function saveTemplate(tpl) {
var mdPath = getPathToTemplate(tpl.type, tpl.language);
var content = '% Title: ' + tpl.title + "\n\n" + tpl.content;
try {
fs.writeFileSync(mdPath, content)
console.log('File %s was written', mdPath);
} catch (err) {
if (err) {
console.error('File %s could not be written: %s', mdPath, err);
}
};
}
exports.exportDefaultTemplate=saveTemplate;
exports.exportDefaultTemplates=async function(callback = undefined) {
return Template.find({defaultTpl: true}, (err, tplObject) => {
tplObject.forEach((tpl) => {
var basePath = tpl.type.substring(0, tpl.type.indexOf('::'));
var tplId = tpl.type.substring(tpl.type.indexOf('::')+2);
var mdPath = path.join(__dirname, '..', '..', 'templates', basePath, tpl.language, tplId + '.md');
var content = '% Title: ' + tpl.title + "\n\n" + tpl.content;
try {
fs.writeFileSync(mdPath, content)
console.log('File %s was written', mdPath);
} catch (err) {
if (err) {
console.error('File %s could not be written: %s', mdPath, err);
}
};
});
tplObject.forEach((tpl) => {saveTemplate(tpl);});
})
}
exports.getTemplate=async function(tplId, language) {
debug('Request to fetch template ' + tplId + ' in language ' + language);
// Check if available in database.
var tpl = await Template.findOne({ type: tplId, language: language }, (err, tplObject) => {
if (err) {

2
main/views/TemplatesList.vue

@ -1,6 +1,6 @@
<template>
<div>
<PageTitle :title="$t('templates-title')" :button="$t('manual')" buttonhref='#/text-main-templateslist'/>
<PageTitle :title="$t('templates-title')"/>
<!-- Delete user -->
<modal-confirm v-if="showDialogDelete" v-on:accept="deleteTemplate(modifyTpl)" v-on:close="cancelDeletion();">

28
main/views/TextPage.vue

@ -28,7 +28,7 @@ import MarkdownItAttrs from 'markdown-it-attrs';
export default {
name: 'markdown-page',
components: { PageTitle, WaitIcon, ModalInfo },
props: ['module','file'],
props: ['module','file','lang', 'tpl'],
data: function() {
return {
warning:'',
@ -39,9 +39,20 @@ export default {
}
},
watch: {
"store.lang": function() { this.updateMarkdown() },
"store.lang": function() { this.lang = store.lang; this.updateMarkdown(); },
"$route": 'updateMarkdown',
},
computed: {
tplId() {
if (this.$route.meta.tplId) {
return this.$route.meta.tplId;
} else if (this.$route.params.module) {
return this.$route.params.module + '::' + this.$route.params.file;
} else {
return this.tpl;
}
}
},
mounted: function() {
this.updateMarkdown();
},
@ -51,6 +62,15 @@ export default {
this.warning='';
this.loading=true;
if (this.$route.meta.tplId) {
if (this.$route.meta.lang) {
this.lang = this.$route.meta.lang;
}
this.updateBasedTemplate();
return;
} else if (this.$route.name == 'main-text-tpl') {
if (this.$route.params.lang) {
this.lang = this.$route.params.lang;
}
this.updateBasedTemplate();
return;
}
@ -89,8 +109,8 @@ export default {
}
},
updateBasedTemplate: async function() {
var tplId = 'pages::' + this.$route.meta.tplId;
var lang = this.$route.params['lang'] || store.lang;
var tplId = 'pages::' + this.tplId;
var lang = this.lang || store.lang;
var baseUrl = 'main/templatesByTypeLang/' + tplId + '/';
var url = baseUrl + lang;
this.$axios.get(url, {params: {convert: true}}).catch((error) => {

21
server.js

@ -7,8 +7,7 @@ var fs =require('fs');
//Debugging
var debug = require('debug')('app');
//Read configuration and constants
var consts =require('./consts.js');
// Read configuration and constants
var environment = process.env.NODE_ENV;
var port= process.env.PORT;
if (!environment) environment="production";
@ -67,11 +66,10 @@ var routerFile=path.join('main','server','router.js');
if (config.CORS_SITES && config.CORS_SITES.length>0) app.use(modulePath, cors(corsOptions));
app.use(modulePath, require('./'+routerFile));
// Synchronize default templates.
var mainInit = require('./main/server/init.js');
mainInit.init();
// Parse and activate modules
const baseServerModule = require('./server/init');
const baseServer = new baseServerModule.BaseServer();
var modulePaths = [path.resolve('./main')];
var moduleDir=config.MODULE_DIR;
if (fs.existsSync(moduleDir)) {
var folderList=fs.readdirSync(moduleDir);
@ -79,16 +77,21 @@ if (fs.existsSync(moduleDir)) {
//Add to namespaces
global.__namespaces.push(folder);
//Server side routing for api
var modulePath=path.join(config.API_URL,folder);
var moduleApiPath=path.join(config.API_URL,folder);
var modulePath = path.resolve(path.join(moduleDir, folder));
modulePaths.push(modulePath);
var routerFile=path.join(moduleDir,folder,'server','router.js');
if (fs.existsSync(routerFile)) {
debug("...activating server side api for "+folder);
if (config.CORS_SITES && config.CORS_SITES.length>0) app.use(modulePath, cors(corsOptions));
app.use(modulePath, require('./'+routerFile));
if (config.CORS_SITES && config.CORS_SITES.length>0) app.use(moduleApiPath, cors(corsOptions));
app.use(moduleApiPath, require('./'+routerFile));
global.__modules.push(folder);
}
}
}
baseServer.registerModuleList(modulePaths).then(() => {
baseServer.postLoadModules();
});
//Serve client app
app.use('/app', express.static('dist/'))

59
server/init.js

@ -0,0 +1,59 @@
var fs = require('fs');
var path = require('path');
var debug = require('debug')('helpers');
var Templates = require('../main/server/templates/templates.js');
class BaseServer {
constructor() {
this.modules = [];
}
init() {
debug('Base loading,...');
}
initModule(mod) {
this.modules.push(mod);
// Register any templates.
if (mod.templates) {
mod.templates.forEach((element) => {
debug('Register ', element);
Templates.registerModuleTemplate(mod.moduleId, element);
})
}
if (mod.init) {
mod.init();
}
}
async registerModule(modulePath) {
var configFile = path.join(modulePath, 'server','server.mjs');
if (fs.existsSync(configFile)) {
debug('Register module ' + modulePath);
await import(configFile).then(mod => {
this.initModule(mod.server);
});
} else {
debug('Tried to register ' + modulePath + ' and failed (no server/config.mjs)');
}
}
async postLoadModules() {
debug('Loading all default templates.');
await Templates.loadDefault();
}
async registerModuleList(modList) {
await Promise.all(modList.map(async (modPath) => {
await this.registerModule(modPath);
}));
}
}
module.exports = {
BaseServer: BaseServer
}
Loading…
Cancel
Save