Commit 4db5326241e0b38c9065d5b2015aa4c383154208

Authored by Adhidarma Hadiwinoto
1 parent 90c704f8a4
Exists in master

modules versions

Showing 2 changed files with 12 additions and 0 deletions Inline Diff

1 var express = require('express'); 1 var express = require('express');
2 var app = express(); 2 var app = express();
3 var nunjucks = require('nunjucks'); 3 var nunjucks = require('nunjucks');
4 var passport = require('passport'); 4 var passport = require('passport');
5 var LocalStrategy = require('passport-local').Strategy; 5 var LocalStrategy = require('passport-local').Strategy;
6 var crypto = require('crypto'); 6 var crypto = require('crypto');
7 //var sha256sum = crypto.createHash('sha256'); 7 //var sha256sum = crypto.createHash('sha256');
8 var fsextra = require('fs-extra'); 8 var fsextra = require('fs-extra');
9 var strftime = require('strftime'); 9 var strftime = require('strftime');
10 var fs = require('fs'); 10 var fs = require('fs');
11 var ini = require('ini'); 11 var ini = require('ini');
12 var os = require('os'); 12 var os = require('os');
13 var naturalSort = require('javascript-natural-sort'); 13 var naturalSort = require('javascript-natural-sort');
14 var pkginfo = require('pkginfo')(module); 14 var pkginfo = require('pkginfo')(module);
15 15
16 var config; 16 var config;
17 var aaa; 17 var aaa;
18 var matrix; 18 var matrix;
19 var view_path = __dirname + '/views'; 19 var view_path = __dirname + '/views';
20 20
21 function loggedIn(req, res, next) { 21 function loggedIn(req, res, next) {
22 if (req.user) { 22 if (req.user) {
23 next(); 23 next();
24 } else { 24 } else {
25 res.redirect('/login'); 25 res.redirect('/login');
26 } 26 }
27 } 27 }
28 28
29 function matchedPasswordAndHash(password, hash) { 29 function matchedPasswordAndHash(password, hash) {
30 var sha256sum = crypto.createHash('sha256'); 30 var sha256sum = crypto.createHash('sha256');
31 sha256sum.update(password); 31 sha256sum.update(password);
32 return (hash == sha256sum.digest('hex')) 32 return (hash == sha256sum.digest('hex'))
33 } 33 }
34 34
35 function renderDashboard(req, res) { 35 function renderDashboard(req, res) {
36 res.render('dashboard.html', { 36 res.render('dashboard.html', {
37 gateway_name: config.globals.gateway_name, 37 gateway_name: config.globals.gateway_name,
38 title: 'Dashboard', 38 title: 'Dashboard',
39 config: config, 39 config: config,
40 matrix: matrix, 40 matrix: matrix,
41 }); 41 });
42 } 42 }
43 43
44 function renderConfigIndex(req, res) { 44 function renderConfigIndex(req, res) {
45 45
46 masked = ['config.expresso.password']; 46 masked = ['config.expresso.password'];
47 47
48 res.render('config.index.html', { 48 res.render('config.index.html', {
49 gateway_name: config.globals.gateway_name, 49 gateway_name: config.globals.gateway_name,
50 title: 'CONFIG', configs: config, 50 title: 'CONFIG', configs: config,
51 config_dirty: matrix.config_dirty, 51 config_dirty: matrix.config_dirty,
52 isMask: function(scope, keyword) { 52 isMask: function(scope, keyword) {
53 return masked.indexOf('config.' + scope + '.' + keyword) >= 0; 53 return masked.indexOf('config.' + scope + '.' + keyword) >= 0;
54 }, 54 },
55 }); 55 });
56 } 56 }
57 57
58 function renderConfigEdit(req, res) { 58 function renderConfigEdit(req, res) {
59 var template = "config.edit.html"; 59 var template = "config.edit.html";
60 60
61 if (req.scope == 'expresso' && req.keyword == 'password') { 61 if (req.scope == 'expresso' && req.keyword == 'password') {
62 template = "config.edit.expresso.password.html"; 62 template = "config.edit.expresso.password.html";
63 } 63 }
64 64
65 res.render(template, { 65 res.render(template, {
66 gateway_name: config.globals.gateway_name, 66 gateway_name: config.globals.gateway_name,
67 title: 'Edit Konfigurasi', 67 title: 'Edit Konfigurasi',
68 scope: req.scope, 68 scope: req.scope,
69 keyword: req.keyword, 69 keyword: req.keyword,
70 old_value: config[req.scope][req.keyword] 70 old_value: config[req.scope][req.keyword]
71 }); 71 });
72 } 72 }
73 73
74 function renderConfigAdd(req, res){ 74 function renderConfigAdd(req, res){
75 75
76 var template = 'config.add.html'; 76 var template = 'config.add.html';
77 if (req.scope == 'products') { 77 if (req.scope == 'products') {
78 template = 'config.add.products.html'; 78 template = 'config.add.products.html';
79 } 79 }
80 80
81 res.render(template, { 81 res.render(template, {
82 gateway_name: config.globals.gateway_name, 82 gateway_name: config.globals.gateway_name,
83 title: 'Tambah Item Konfigurasi', 83 title: 'Tambah Item Konfigurasi',
84 scope: req.scope 84 scope: req.scope
85 }); 85 });
86 } 86 }
87 87
88 function submitPause(req, res) { 88 function submitPause(req, res) {
89 matrix.pause = 1; 89 matrix.pause = 1;
90 res.redirect('/'); 90 res.redirect('/');
91 return; 91 return;
92 } 92 }
93 93
94 function submitResume(req, res) { 94 function submitResume(req, res) {
95 matrix.pause = 0; 95 matrix.pause = 0;
96 res.redirect('/'); 96 res.redirect('/');
97 return; 97 return;
98 } 98 }
99 99
100 function submitTerminate(req, res) { 100 function submitTerminate(req, res) {
101 res.end('Terminating....', function() { 101 res.end('Terminating....', function() {
102 setTimeout(process.exit, 2 * 1000); 102 setTimeout(process.exit, 2 * 1000);
103 }); 103 });
104 } 104 }
105 105
106 function submitConfigEdit(req, res) { 106 function submitConfigEdit(req, res) {
107 107
108 if (req.scope == 'expresso' && req.keyword == 'password') { 108 if (req.scope == 'expresso' && req.keyword == 'password') {
109 if (req.body.newValue != req.body.newValue2) { 109 if (req.body.newValue != req.body.newValue2) {
110 res.redirect('/config/edit/' + req.scope + '/' + req.keyword); 110 res.redirect('/config/edit/' + req.scope + '/' + req.keyword);
111 return; 111 return;
112 } 112 }
113 113
114 var sha256sum = crypto.createHash('sha256'); 114 var sha256sum = crypto.createHash('sha256');
115 sha256sum.update(req.body.newValue); 115 sha256sum.update(req.body.newValue);
116 req.body.newValue = sha256sum.digest('hex'); 116 req.body.newValue = sha256sum.digest('hex');
117 117
118 } else if (req.scope == 'globals' && req.keyword == 'products' && req.body.newValue.trim()) { 118 } else if (req.scope == 'globals' && req.keyword == 'products' && req.body.newValue.trim()) {
119 119
120 try { 120 try {
121 /* 121 /*
122 var unsortedString = req.body.newValue.toUpperCase().replace(/ /g, '').trim(); 122 var unsortedString = req.body.newValue.toUpperCase().replace(/ /g, '').trim();
123 var unsortedProducts = unsortedString.split(','); 123 var unsortedProducts = unsortedString.split(',');
124 var sortedProducts = aaa.sortProductsArray(unsortedProducts); 124 var sortedProducts = aaa.sortProductsArray(unsortedProducts);
125 req.body.newValue = sortedProducts.join(','); 125 req.body.newValue = sortedProducts.join(',');
126 */ 126 */
127 127
128 req.body.newValue = req.body.newValue.trim().toUpperCase().split(/\W+/).sort(naturalSort).join(','); 128 req.body.newValue = req.body.newValue.trim().toUpperCase().split(/\W+/).sort(naturalSort).join(',');
129 } 129 }
130 catch(e) { 130 catch(e) {
131 console.log('Error sorting products'); 131 console.log('Error sorting products');
132 } 132 }
133 } 133 }
134 134
135 if (config[req.body.scope][req.body.keyword] != req.body.newValue.trim()) { 135 if (config[req.body.scope][req.body.keyword] != req.body.newValue.trim()) {
136 config[req.body.scope][req.body.keyword] = req.body.newValue.trim(); 136 config[req.body.scope][req.body.keyword] = req.body.newValue.trim();
137 matrix.config_dirty = 1; 137 matrix.config_dirty = 1;
138 } 138 }
139 139
140 res.redirect('/config'); 140 res.redirect('/config');
141 } 141 }
142 142
143 function submitConfigAdd(req, res) { 143 function submitConfigAdd(req, res) {
144 144
145 if (!req.body.newKeyword.trim()) { 145 if (!req.body.newKeyword.trim()) {
146 res.redirect('/config'); 146 res.redirect('/config');
147 return; 147 return;
148 } 148 }
149 149
150 if (config[req.body.scope] === undefined) { 150 if (config[req.body.scope] === undefined) {
151 config[req.body.scope] = {}; 151 config[req.body.scope] = {};
152 } 152 }
153 153
154 config[req.body.scope][req.body.newKeyword.trim()] = req.body.newValue.trim(); 154 config[req.body.scope][req.body.newKeyword.trim()] = req.body.newValue.trim();
155 matrix.config_dirty = 1; 155 matrix.config_dirty = 1;
156 156
157 res.redirect('/config#config.' + req.body.scope + '.' + req.body.newKeyword); 157 res.redirect('/config#config.' + req.body.scope + '.' + req.body.newKeyword);
158 } 158 }
159 159
160 function submitConfigDelete(req, res) { 160 function submitConfigDelete(req, res) {
161 161
162 matrix.config_dirty = 1; 162 matrix.config_dirty = 1;
163 delete config[req.scope][req.keyword]; 163 delete config[req.scope][req.keyword];
164 164
165 res.redirect('/config'); 165 res.redirect('/config');
166 } 166 }
167 167
168 function submitConfigSave(req, res) { 168 function submitConfigSave(req, res) {
169 fsextra.copy('config.ini', 'config.ini.backup_' + strftime('%F_%H%M%S', new Date()), function(err) { 169 fsextra.copy('config.ini', 'config.ini.backup_' + strftime('%F_%H%M%S', new Date()), function(err) {
170 fs.writeFileSync('./config.ini', ini.stringify(config)); 170 fs.writeFileSync('./config.ini', ini.stringify(config));
171 matrix.config_dirty = 0; 171 matrix.config_dirty = 0;
172 res.redirect('/config'); 172 res.redirect('/config');
173 }); 173 });
174 } 174 }
175 175
176 function renderLoginPage(req, res) { 176 function renderLoginPage(req, res) {
177 res.render('signin.html', {title: config.globals.gateway_name}); 177 res.render('signin.html', {title: config.globals.gateway_name});
178 } 178 }
179 179
180 function renderConfigAskDelete(req, res) { 180 function renderConfigAskDelete(req, res) {
181 res.render('config.ask.delete.html', { 181 res.render('config.ask.delete.html', {
182 gateway_name: config.globals.gateway_name, 182 gateway_name: config.globals.gateway_name,
183 title: 'Konfirmasi Penghapusan', 183 title: 'Konfirmasi Penghapusan',
184 scope: req.scope, 184 scope: req.scope,
185 keyword: req.keyword, 185 keyword: req.keyword,
186 value: config[req.scope][req.keyword], 186 value: config[req.scope][req.keyword],
187 }); 187 });
188 } 188 }
189 189
190 function renderAskTerminate(req, res) { 190 function renderAskTerminate(req, res) {
191 res.render('ask.terminate.html', { 191 res.render('ask.terminate.html', {
192 gateway_name: config.globals.gateway_name, 192 gateway_name: config.globals.gateway_name,
193 title: 'Konfirmasi Terminasi' 193 title: 'Konfirmasi Terminasi'
194 }); 194 });
195 } 195 }
196 196
197 197
198 function renderRuntimeInfo(req, res) { 198 function renderRuntimeInfo(req, res) {
199 var template = "runtime-info.html"; 199 var template = "runtime-info.html";
200 200
201 var os_info = { 201 var os_info = {
202 uptime: os.uptime(), 202 uptime: os.uptime(),
203 loadavg: os.loadavg(), 203 loadavg: os.loadavg(),
204 hostname: os.hostname(), 204 hostname: os.hostname(),
205 type: os.type(), 205 type: os.type(),
206 platform: os.platform(), 206 platform: os.platform(),
207 arch: os.arch(), 207 arch: os.arch(),
208 release: os.release(), 208 release: os.release(),
209 totalmem: os.totalmem(), 209 totalmem: os.totalmem(),
210 } 210 }
211 211
212 var modules_versions = {
213 'expresso': module.exports.version
214 }
215
216 try {
217 modules_versions.sate24 = aaa.version;
218 } catch (e) {}
219
212 var pendings; 220 var pendings;
213 try { 221 try {
214 pendings = JSON.stringify(aaa.getPendingList(), null, 2); 222 pendings = JSON.stringify(aaa.getPendingList(), null, 2);
215 } 223 }
216 catch(err) { 224 catch(err) {
217 pendings = "{}"; 225 pendings = "{}";
218 } 226 }
219 227
220 res.render(template, { 228 res.render(template, {
221 gateway_name: config.globals.gateway_name, 229 gateway_name: config.globals.gateway_name,
222 title: 'Runtime Info', 230 title: 'Runtime Info',
223 matrix: JSON.stringify(matrix, null, 2), 231 matrix: JSON.stringify(matrix, null, 2),
224 config: JSON.stringify(config, null, 2), 232 config: JSON.stringify(config, null, 2),
225 nodejs_versions: JSON.stringify(process.versions, null, 2), 233 nodejs_versions: JSON.stringify(process.versions, null, 2),
226 pendings: pendings, 234 pendings: pendings,
227 memory_usage: JSON.stringify(process.memoryUsage(), null, 2), 235 memory_usage: JSON.stringify(process.memoryUsage(), null, 2),
228 uptime: process.uptime(), 236 uptime: process.uptime(),
229 os_info: JSON.stringify(os_info, null, 2), 237 os_info: JSON.stringify(os_info, null, 2),
230 net_ifaces: JSON.stringify(os.networkInterfaces(), null, 2), 238 net_ifaces: JSON.stringify(os.networkInterfaces(), null, 2),
239 modules_versions: JSON.stringify(modules_versions, null, 2)
231 }); 240 });
232 } 241 }
233 242
234 function isNoTemplateCache() { 243 function isNoTemplateCache() {
235 var retval = false; 244 var retval = false;
236 try { 245 try {
237 retval = config.expresso.no_template_cache.toUpperCase() == 'YES'; 246 retval = config.expresso.no_template_cache.toUpperCase() == 'YES';
238 } 247 }
239 catch(err) { 248 catch(err) {
240 return retval; 249 return retval;
241 } 250 }
242 251
243 return retval; 252 return retval;
244 } 253 }
245 254
246 function createServer() { 255 function createServer() {
247 if (!config.expresso || !config.expresso.listen_port) { 256 if (!config.expresso || !config.expresso.listen_port) {
248 console.log('Not starting expresso admin UI'); 257 console.log('Not starting expresso admin UI');
249 return; 258 return;
250 } 259 }
251 260
252 nunjucks.configure(view_path, { 261 nunjucks.configure(view_path, {
253 autoescape: true, 262 autoescape: true,
254 noCache: isNoTemplateCache(), 263 noCache: isNoTemplateCache(),
255 express: app 264 express: app
256 }); 265 });
257 266
258 app.use(express.static(__dirname + '/public')); 267 app.use(express.static(__dirname + '/public'));
259 app.use(require('cookie-parser')()); 268 app.use(require('cookie-parser')());
260 app.use(require('body-parser').urlencoded({ extended: true })); 269 app.use(require('body-parser').urlencoded({ extended: true }));
261 270
262 var express_session_opts = { 271 var express_session_opts = {
263 secret: 'keyboard cat', 272 secret: 'keyboard cat',
264 resave: false, 273 resave: false,
265 saveUninitialized: false 274 saveUninitialized: false
266 }; 275 };
267 276
268 if (config.expresso && config.expresso.session_name) { 277 if (config.expresso && config.expresso.session_name) {
269 express_session_opts.name = config.expresso.session_name; 278 express_session_opts.name = config.expresso.session_name;
270 } 279 }
271 280
272 app.use(require('express-session')(express_session_opts)); 281 app.use(require('express-session')(express_session_opts));
273 app.use(passport.initialize()); 282 app.use(passport.initialize());
274 app.use(passport.session()); 283 app.use(passport.session());
275 284
276 passport.use(new LocalStrategy( 285 passport.use(new LocalStrategy(
277 function(username, password, done) { 286 function(username, password, done) {
278 287
279 if (username == 'admin' && matchedPasswordAndHash(password, config.expresso.password)) { 288 if (username == 'admin' && matchedPasswordAndHash(password, config.expresso.password)) {
280 var user = { 289 var user = {
281 username: username, 290 username: username,
282 last_login: Date.now() / 1000 | 0 291 last_login: Date.now() / 1000 | 0
283 } 292 }
284 293
285 return done(null, user); 294 return done(null, user);
286 } 295 }
287 296
288 return done(null, false, { message: 'Incorrect password.' }); 297 return done(null, false, { message: 'Incorrect password.' });
289 } 298 }
290 )); 299 ));
291 300
292 passport.serializeUser(function(user, done) { 301 passport.serializeUser(function(user, done) {
293 done(null, user.username); 302 done(null, user.username);
294 }); 303 });
295 304
296 passport.deserializeUser(function(username, done) { 305 passport.deserializeUser(function(username, done) {
297 var user = { 306 var user = {
298 username: username 307 username: username
299 }; 308 };
300 done(null, user); 309 done(null, user);
301 }); 310 });
302 311
303 app.get('/', loggedIn, function(req, res) { res.redirect('/runtime-info'); }); 312 app.get('/', loggedIn, function(req, res) { res.redirect('/runtime-info'); });
304 app.get('/dashboard', loggedIn, renderDashboard); 313 app.get('/dashboard', loggedIn, renderDashboard);
305 app.get('/config', loggedIn, renderConfigIndex); 314 app.get('/config', loggedIn, renderConfigIndex);
306 app.get('/login', renderLoginPage); 315 app.get('/login', renderLoginPage);
307 app.post('/login', passport.authenticate('local', { successRedirect: '/', failureRedirect: '/login' })); 316 app.post('/login', passport.authenticate('local', { successRedirect: '/', failureRedirect: '/login' }));
308 app.get('/logout', function(req, res){ 317 app.get('/logout', function(req, res){
309 req.logout(); 318 req.logout();
310 res.redirect('/login'); 319 res.redirect('/login');
311 }); 320 });
312 321
313 app.param('scope', function(req, res, next, value) { 322 app.param('scope', function(req, res, next, value) {
314 req.scope = value; 323 req.scope = value;
315 next(); 324 next();
316 }); 325 });
317 326
318 app.param('keyword', function(req, res, next, value) { 327 app.param('keyword', function(req, res, next, value) {
319 req.keyword = value; 328 req.keyword = value;
320 next(); 329 next();
321 }); 330 });
322 331
323 app.get('/config/edit/:scope/:keyword', loggedIn, renderConfigEdit); 332 app.get('/config/edit/:scope/:keyword', loggedIn, renderConfigEdit);
324 app.post('/config/edit/:scope/:keyword', loggedIn, submitConfigEdit); 333 app.post('/config/edit/:scope/:keyword', loggedIn, submitConfigEdit);
325 334
326 app.get('/config/ask-delete/:scope/:keyword', loggedIn, renderConfigAskDelete); 335 app.get('/config/ask-delete/:scope/:keyword', loggedIn, renderConfigAskDelete);
327 app.get('/config/delete/:scope/:keyword', loggedIn, submitConfigDelete); 336 app.get('/config/delete/:scope/:keyword', loggedIn, submitConfigDelete);
328 337
329 app.get('/config/add/:scope', loggedIn, renderConfigAdd); 338 app.get('/config/add/:scope', loggedIn, renderConfigAdd);
330 app.post('/config/add/:scope', loggedIn, submitConfigAdd); 339 app.post('/config/add/:scope', loggedIn, submitConfigAdd);
331 340
332 app.get('/config/save', loggedIn, submitConfigSave); 341 app.get('/config/save', loggedIn, submitConfigSave);
333 342
334 app.get('/pause', loggedIn, submitPause); 343 app.get('/pause', loggedIn, submitPause);
335 app.get('/resume', loggedIn, submitResume); 344 app.get('/resume', loggedIn, submitResume);
336 app.get('/terminate', loggedIn, submitTerminate); 345 app.get('/terminate', loggedIn, submitTerminate);
337 app.get('/ask-terminate', loggedIn, renderAskTerminate); 346 app.get('/ask-terminate', loggedIn, renderAskTerminate);
338 347
339 app.get('/runtime-info', loggedIn, renderRuntimeInfo); 348 app.get('/runtime-info', loggedIn, renderRuntimeInfo);
340 349
341 var server = app.listen(config.expresso.listen_port, function () { 350 var server = app.listen(config.expresso.listen_port, function () {
342 var host = server.address().address; 351 var host = server.address().address;
343 var port = server.address().port; 352 var port = server.address().port;
344 353
345 console.log('Expresso admin UI listening at http://%s:%s', host, port); 354 console.log('Expresso admin UI listening at http://%s:%s', host, port);
346 }); 355 });
347 } 356 }
348 357
349 function sanitizeMatrix(matrix) { 358 function sanitizeMatrix(matrix) {
350 if (matrix === undefined) { 359 if (matrix === undefined) {
351 matrix = {}; 360 matrix = {};
352 } 361 }
353 if (matrix.config_dirty === undefined) { 362 if (matrix.config_dirty === undefined) {
354 matrix.config_dirty = 0; 363 matrix.config_dirty = 0;
355 } 364 }
356 } 365 }
357 366
358 function start(options) { 367 function start(options) {
359 if (options['config']) { 368 if (options['config']) {
360 config = options['config']; 369 config = options['config'];
361 } 370 }
362 371
363 if (options['matrix']) { 372 if (options['matrix']) {
364 matrix = options['matrix']; 373 matrix = options['matrix'];
365 } 374 }
366 sanitizeMatrix(matrix); 375 sanitizeMatrix(matrix);
367 376
368 if (options['aaa']) { 377 if (options['aaa']) {
369 aaa = options['aaa']; 378 aaa = options['aaa'];
370 } 379 }
371 380
372 if (options['expresso_views']) { 381 if (options['expresso_views']) {
373 view_path = options['expresso_views']; 382 view_path = options['expresso_views'];
374 } 383 }
375 384
376 createServer(); 385 createServer();
377 } 386 }
378 387
379 exports.start = start; 388 exports.start = start;
380 389
views/runtime-info.html
1 {% extends "layout-with-topbar.html" %} 1 {% extends "layout-with-topbar.html" %}
2 2
3 {% block content %} 3 {% block content %}
4 4
5 <p> 5 <p>
6 Process uptime: {{ uptime }} seconds 6 Process uptime: {{ uptime }} seconds
7 </p> 7 </p>
8 8
9 <h2 id="matrix">The Matrix</h2> 9 <h2 id="matrix">The Matrix</h2>
10 <pre>{{ matrix }}</pre> 10 <pre>{{ matrix }}</pre>
11 11
12 <h2 id="pendings">Pending Transactions</h2> 12 <h2 id="pendings">Pending Transactions</h2>
13 <pre>{{ pendings }}</pre> 13 <pre>{{ pendings }}</pre>
14 14
15 15
16 <h2 id="config">Config</h2> 16 <h2 id="config">Config</h2>
17 <pre>{{ config }}</pre> 17 <pre>{{ config }}</pre>
18 18
19 <h2 id="memory">Memory Usage</h2> 19 <h2 id="memory">Memory Usage</h2>
20 <pre>{{ memory_usage }}</pre> 20 <pre>{{ memory_usage }}</pre>
21 21
22 <h2 id="nodejs_versions">Node.js Versions</h2> 22 <h2 id="nodejs_versions">Node.js Versions</h2>
23 <pre>{{ nodejs_versions }}</pre> 23 <pre>{{ nodejs_versions }}</pre>
24 24
25 <h2 id="modules_versions">Modules Versions</h2>
26 <pre>{{ modules_versions }}</pre>
27
25 <h2 id="os_info">OS</h2> 28 <h2 id="os_info">OS</h2>
26 <pre>{{ os_info }}</pre> 29 <pre>{{ os_info }}</pre>
27 30
28 <h2 id="net_iface">Network Interfaces</h2> 31 <h2 id="net_iface">Network Interfaces</h2>
29 <pre>{{ net_ifaces }}</pre> 32 <pre>{{ net_ifaces }}</pre>
30 33
31 {% endblock %} 34 {% endblock %}
32 35