aegisnode 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegisnode",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "A view-first Node.js framework for modular web apps and JSON APIs with CLI scaffolding, runtime injection, auth, uploads, i18n, mail, and WebSocket support.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -16,7 +16,6 @@
16
16
  "test": "node ./scripts/smoke-test.js"
17
17
  },
18
18
  "files": [
19
- "assets",
20
19
  "bin",
21
20
  "src",
22
21
  "scripts",
@@ -38,7 +37,11 @@
38
37
  "uploads",
39
38
  "websocket"
40
39
  ],
41
- "author": "",
40
+ "author": "jason90 <sidiki90@gmail.com>",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/jason09/aegisnode.git"
44
+ },
42
45
  "license": "MIT",
43
46
  "dependencies": {
44
47
  "ejs": "^3.1.10",
@@ -10,6 +10,7 @@ import { createApp } from '../src/cli/commands/createapp.js';
10
10
  import { generateArtifact } from '../src/cli/commands/generate.js';
11
11
  import { createKernel } from '../src/runtime/kernel.js';
12
12
  import { runServer } from '../src/cli/commands/runserver.js';
13
+ import { runGenerateLoader } from '../src/cli/commands/generateloader.js';
13
14
  import { runProject } from '../src/index.js';
14
15
  import { createAuthManager, normalizeAuthConfig } from '../src/runtime/auth.js';
15
16
  import { loadProjectConfig } from '../src/runtime/config.js';
@@ -137,8 +138,10 @@ async function main() {
137
138
  await startProject({ projectName, cwd: sandboxRoot });
138
139
  const generatedProjectEnv = await fs.readFile(path.join(projectRoot, '.env'), 'utf8');
139
140
  assert.match(generatedProjectEnv, /^APP_SECRET=.{16,}$/m);
141
+ const generatedAppSecret = generatedProjectEnv.match(/^APP_SECRET=(.+)$/m)?.[1]?.trim();
142
+ assert.ok(generatedAppSecret);
140
143
  const generatedSettings = await fs.readFile(path.join(projectRoot, 'settings.js'), 'utf8');
141
- assert.match(generatedSettings, /appSecret:\s*process\.env\.APP_SECRET\s*\|\|\s*''/);
144
+ assert.ok(generatedSettings.includes(`appSecret: process.env.APP_SECRET || ${JSON.stringify(generatedAppSecret)}`));
142
145
  await assert.rejects(
143
146
  () => runProject({
144
147
  rootDir: projectRoot,
@@ -404,6 +407,74 @@ dkcqnJD4SGWVeG+KhA==
404
407
  await fs.access(filePath);
405
408
  }
406
409
 
410
+ await fs.unlink(path.join(projectRoot, 'app.js'));
411
+ await fs.unlink(path.join(projectRoot, 'loader.cjs'));
412
+ const restoredStartupEntries = await runGenerateLoader({
413
+ projectRoot,
414
+ output: {
415
+ log() {},
416
+ },
417
+ });
418
+ assert.equal(restoredStartupEntries.createdApp, true);
419
+ assert.equal(restoredStartupEntries.createdLoader, true);
420
+ await fs.access(path.join(projectRoot, 'app.js'));
421
+ await fs.access(path.join(projectRoot, 'loader.cjs'));
422
+
423
+ await fs.writeFile(
424
+ path.join(envProjectRoot, 'settings.js'),
425
+ `export default {
426
+ appName: 'envdemo',
427
+ env: 'production',
428
+ logging: {
429
+ level: 'info',
430
+ },
431
+ security: {
432
+ appSecret: process.env.APP_SECRET || '',
433
+ ddos: {
434
+ maxRequests: 120,
435
+ },
436
+ },
437
+ environments: {
438
+ default: {
439
+ security: {
440
+ ddos: {
441
+ windowMs: 45000,
442
+ },
443
+ },
444
+ },
445
+ production: {
446
+ logging: { level: 'warn' },
447
+ security: { ddos: { maxRequests: 80 } },
448
+ },
449
+ },
450
+ };
451
+ `,
452
+ 'utf8',
453
+ );
454
+ await fs.unlink(path.join(envProjectRoot, 'loader.cjs'));
455
+ const missingLoaderDoctorReport = await runDoctor({
456
+ projectRoot: envProjectRoot,
457
+ failOnError: false,
458
+ output: {
459
+ log() {},
460
+ },
461
+ });
462
+ assert.equal(missingLoaderDoctorReport.summary.errors, 1);
463
+ assert.ok(
464
+ missingLoaderDoctorReport.entries.some((entry) => (
465
+ entry.level === 'ERROR'
466
+ && /loader\.cjs is missing for production startup/.test(entry.message)
467
+ )),
468
+ );
469
+ const restoredProductionLoader = await runGenerateLoader({
470
+ projectRoot: envProjectRoot,
471
+ output: {
472
+ log() {},
473
+ },
474
+ });
475
+ assert.equal(restoredProductionLoader.createdLoader, true);
476
+ await fs.access(path.join(envProjectRoot, 'loader.cjs'));
477
+
407
478
  const registryPackages = new Map([
408
479
  ['alpha', '2.0.0'],
409
480
  ['@scope/bravo', '3.4.0'],
@@ -1288,7 +1359,7 @@ export default {
1288
1359
  );
1289
1360
  await fs.writeFile(
1290
1361
  path.join(projectRoot, 'routes.js'),
1291
- `export default {\n register(route) {\n route.get('/csrf-token', (req, res) => {\n res.json({ token: req.csrfToken() });\n });\n\n route.get('/csrf-form', (req, res) => {\n return res.render('csrf-form', { layout: false });\n });\n\n route.post('/submit', (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n },\n};\n`,
1362
+ `export default {\n register(route) {\n route.get('/csrf-token', (req, res) => {\n res.json({ token: req.csrfToken() });\n });\n\n route.get('/csrf-form', (req, res) => {\n return res.render('csrf-form', { layout: false });\n });\n\n route.post('/submit', (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n\n route.post('/submit-upload', route.upload.none(), (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n },\n};\n`,
1292
1363
  'utf8',
1293
1364
  );
1294
1365
 
@@ -1361,6 +1432,32 @@ export default {
1361
1432
  assert.equal(validJsonTokenJson.ok, true);
1362
1433
  assert.equal(validJsonTokenJson.body.name, 'json-with-token');
1363
1434
 
1435
+ const missingMultipartTokenBody = new FormData();
1436
+ missingMultipartTokenBody.set('name', 'multipart-without-token');
1437
+ const missingMultipartTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit-upload`, {
1438
+ method: 'POST',
1439
+ headers: {
1440
+ cookie: csrfCookie,
1441
+ },
1442
+ body: missingMultipartTokenBody,
1443
+ });
1444
+ assert.equal(missingMultipartTokenResponse.status, 403);
1445
+
1446
+ const validMultipartTokenBody = new FormData();
1447
+ validMultipartTokenBody.set('_csrf', csrfTokenJson.token);
1448
+ validMultipartTokenBody.set('name', 'multipart-with-token');
1449
+ const validMultipartTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit-upload`, {
1450
+ method: 'POST',
1451
+ headers: {
1452
+ cookie: csrfCookie,
1453
+ },
1454
+ body: validMultipartTokenBody,
1455
+ });
1456
+ assert.equal(validMultipartTokenResponse.status, 200);
1457
+ const validMultipartTokenJson = await validMultipartTokenResponse.json();
1458
+ assert.equal(validMultipartTokenJson.ok, true);
1459
+ assert.equal(validMultipartTokenJson.body.name, 'multipart-with-token');
1460
+
1364
1461
  const csrfFormResponse = await fetch(`http://127.0.0.1:${csrfPort}/csrf-form`, {
1365
1462
  headers: {
1366
1463
  cookie: csrfCookie,
@@ -70,6 +70,30 @@ async function runAppChecks(rootDir, config, collector) {
70
70
  }
71
71
  }
72
72
 
73
+ async function runStartupEntryChecks(rootDir, config, collector) {
74
+ const env = String(config.env || process.env.NODE_ENV || 'development').trim().toLowerCase();
75
+ const appEntryPath = path.join(rootDir, 'app.js');
76
+ const loaderEntryPath = path.join(rootDir, 'loader.cjs');
77
+ const appEntryExists = await fileExists(appEntryPath);
78
+ const loaderEntryExists = await fileExists(loaderEntryPath);
79
+
80
+ if (appEntryExists) {
81
+ collector.ok('app.js exists.');
82
+ } else if (env === 'production') {
83
+ collector.error('app.js is missing for production startup. Run "aegisnode generateloader" to restore startup entry files.');
84
+ } else {
85
+ collector.warn('app.js is missing. Run "aegisnode generateloader" to restore startup entry files.');
86
+ }
87
+
88
+ if (loaderEntryExists) {
89
+ collector.ok('loader.cjs exists.');
90
+ } else if (env === 'production') {
91
+ collector.error('loader.cjs is missing for production startup. Run "aegisnode generateloader" to restore it.');
92
+ } else {
93
+ collector.warn('loader.cjs is missing. Generate it with "aegisnode generateloader" before non-development startup.');
94
+ }
95
+ }
96
+
73
97
  function runSecurityChecks(config, collector) {
74
98
  const env = String(config.env || process.env.NODE_ENV || 'development');
75
99
  const security = config.security || {};
@@ -179,6 +203,7 @@ export async function runDoctor({
179
203
  collector.ok(`Environment: ${config.env || 'development'}`);
180
204
 
181
205
  await runAppChecks(resolvedRoot, config, collector);
206
+ await runStartupEntryChecks(resolvedRoot, config, collector);
182
207
  runSecurityChecks(config, collector);
183
208
  runAuthChecks(config, collector);
184
209
  runApiChecks(config, collector);
@@ -0,0 +1,37 @@
1
+ import path from 'path';
2
+ import { exists, writeFile } from '../utils/fs.js';
3
+ import { resolveProjectRoot } from '../utils/project.js';
4
+ import { renderProjectAppJs, renderProjectLoaderCjs } from '../utils/scaffolds.js';
5
+
6
+ async function ensureStartupFile(rootDir, fileName, content, output) {
7
+ const target = path.join(rootDir, fileName);
8
+ if (await exists(target)) {
9
+ output.log(`${fileName} already exists.`);
10
+ return false;
11
+ }
12
+
13
+ await writeFile(target, content);
14
+ output.log(`Generated ${fileName}.`);
15
+ return true;
16
+ }
17
+
18
+ export async function runGenerateLoader({
19
+ projectRoot,
20
+ output = console,
21
+ } = {}) {
22
+ const resolvedRoot = await resolveProjectRoot(projectRoot || process.cwd());
23
+ const createdApp = await ensureStartupFile(resolvedRoot, 'app.js', renderProjectAppJs(), output);
24
+ const createdLoader = await ensureStartupFile(resolvedRoot, 'loader.cjs', renderProjectLoaderCjs(), output);
25
+
26
+ if (!createdApp && !createdLoader) {
27
+ output.log(`Startup entry files already exist in ${resolvedRoot}`);
28
+ } else {
29
+ output.log(`Startup entry files are ready in ${resolvedRoot}`);
30
+ }
31
+
32
+ return {
33
+ rootDir: resolvedRoot,
34
+ createdApp,
35
+ createdLoader,
36
+ };
37
+ }
@@ -53,7 +53,7 @@ async function createBaseProjectFiles(projectRoot, projectName) {
53
53
  await writeFile(path.join(projectRoot, '.env'), renderProjectEnv(appSecret));
54
54
  await writeFile(path.join(projectRoot, '.env.example'), renderEnvExample());
55
55
 
56
- await writeFile(path.join(projectRoot, 'settings.js'), renderProjectSettings(projectName, apps));
56
+ await writeFile(path.join(projectRoot, 'settings.js'), renderProjectSettings(projectName, apps, appSecret));
57
57
  await writeFile(path.join(projectRoot, 'routes.js'), renderProjectRoutes());
58
58
  }
59
59
 
package/src/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { runServer } from './commands/runserver.js';
4
4
  import { generateArtifact } from './commands/generate.js';
5
5
  import { runDoctor } from './commands/doctor.js';
6
6
  import { runUpdateDependencies } from './commands/updatedeps.js';
7
+ import { runGenerateLoader } from './commands/generateloader.js';
7
8
 
8
9
  function printHelp() {
9
10
  console.log(`AegisNode CLI
@@ -13,6 +14,7 @@ Usage:
13
14
  aegisnode createapp <app-name> [--project <path>] [--mount </path>]
14
15
  aegisnode generate <type> <name> --app <app-name> [--project <path>]
15
16
  aegisnode runserver [--project <path>] [--port <number>]
17
+ aegisnode generateloader [--project <path>]
16
18
  aegisnode doctor [--project <path>]
17
19
  aegisnode updatedeps [--project <path>]
18
20
 
@@ -24,6 +26,7 @@ Examples:
24
26
  aegisnode createapp users
25
27
  aegisnode generate view user --app users
26
28
  aegisnode generate validator user --app users
29
+ aegisnode generateloader --project blog
27
30
  aegisnode updatedeps --project blog
28
31
  `);
29
32
  }
@@ -126,6 +129,14 @@ export async function runCli(argv) {
126
129
  return;
127
130
  }
128
131
 
132
+ case 'generateloader':
133
+ case 'loader': {
134
+ await runGenerateLoader({
135
+ projectRoot: flags.project ? String(flags.project) : process.cwd(),
136
+ });
137
+ return;
138
+ }
139
+
129
140
  case 'updatedeps': {
130
141
  await runUpdateDependencies({
131
142
  projectRoot: flags.project ? String(flags.project) : process.cwd(),
@@ -53,7 +53,7 @@ export function renderProjectLoaderCjs() {
53
53
  `;
54
54
  }
55
55
 
56
- export function renderProjectSettings(projectName, apps) {
56
+ export function renderProjectSettings(projectName, apps, appSecret = '') {
57
57
  return `export default {
58
58
  appName: '${projectName}',
59
59
  env: process.env.NODE_ENV || 'development',
@@ -61,8 +61,9 @@ export function renderProjectSettings(projectName, apps) {
61
61
  port: process.env.PORT ? Number(process.env.PORT) : 3000,
62
62
  trustProxy: false,
63
63
  security: {
64
- // Loaded from .env by default. Replace or rotate APP_SECRET in production.
65
- appSecret: process.env.APP_SECRET || '',
64
+ // Loaded from .env by default. Scaffold also embeds the generated secret as a fallback.
65
+ // Replace or rotate APP_SECRET in production.
66
+ appSecret: process.env.APP_SECRET || ${JSON.stringify(appSecret)},
66
67
  },
67
68
  logging: {
68
69
  level: process.env.LOG_LEVEL || 'info',
@@ -3148,6 +3148,16 @@ function attachCsrfProtection(expressApp, config, logger, auth = null) {
3148
3148
  }
3149
3149
 
3150
3150
  const provided = extractCsrfToken(req, csrfConfig);
3151
+ if (!provided && isMultipartRequestContentType(req.headers?.['content-type'])) {
3152
+ req.aegis = req.aegis || {};
3153
+ req.aegis.csrf = {
3154
+ deferredMultipart: true,
3155
+ fieldName: csrfConfig.fieldName,
3156
+ token,
3157
+ };
3158
+ return next();
3159
+ }
3160
+
3151
3161
  if (!provided || !constantTimeEqual(provided, token)) {
3152
3162
  return res.status(403).json({ error: 'CSRF token missing or invalid' });
3153
3163
  }
@@ -120,6 +120,47 @@ function createUploadError(code, message, statusCode) {
120
120
  return error;
121
121
  }
122
122
 
123
+ function constantTimeEqual(left, right) {
124
+ if (typeof left !== 'string' || typeof right !== 'string') {
125
+ return false;
126
+ }
127
+
128
+ const a = Buffer.from(left);
129
+ const b = Buffer.from(right);
130
+ if (a.length !== b.length) {
131
+ return false;
132
+ }
133
+
134
+ try {
135
+ return crypto.timingSafeEqual(a, b);
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function validateDeferredMultipartCsrf(req) {
142
+ const csrfState = req?.aegis?.csrf;
143
+ if (!csrfState || csrfState.deferredMultipart !== true) {
144
+ return null;
145
+ }
146
+
147
+ const expected = typeof csrfState.token === 'string' ? csrfState.token : '';
148
+ const fieldName = typeof csrfState.fieldName === 'string' && csrfState.fieldName.length > 0
149
+ ? csrfState.fieldName
150
+ : '_csrf';
151
+ const provided = req?.body && typeof req.body === 'object'
152
+ ? req.body[fieldName]
153
+ : '';
154
+
155
+ delete req.aegis.csrf;
156
+
157
+ if (!expected || typeof provided !== 'string' || !constantTimeEqual(provided, expected)) {
158
+ return createUploadError('AEGIS_CSRF_INVALID', 'CSRF token missing or invalid', 403);
159
+ }
160
+
161
+ return null;
162
+ }
163
+
123
164
  function resolveUploadError(error) {
124
165
  if (!error) {
125
166
  return null;
@@ -207,6 +248,13 @@ function wrapUploadMiddleware(middleware) {
207
248
  return (req, res, next) => {
208
249
  middleware(req, res, (error) => {
209
250
  if (!error) {
251
+ const csrfError = validateDeferredMultipartCsrf(req);
252
+ if (csrfError) {
253
+ res.status(csrfError.statusCode || 403).json({
254
+ error: csrfError.message || 'CSRF token missing or invalid',
255
+ });
256
+ return;
257
+ }
210
258
  next();
211
259
  return;
212
260
  }
Binary file
@@ -1,66 +0,0 @@
1
- <svg width="1600" height="520" viewBox="0 0 1600 520" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <defs>
3
- <linearGradient id="banner-bg" x1="0" y1="0" x2="1600" y2="520" gradientUnits="userSpaceOnUse">
4
- <stop stop-color="#0E1737"/>
5
- <stop offset="0.5" stop-color="#103C70"/>
6
- <stop offset="1" stop-color="#14A49F"/>
7
- </linearGradient>
8
- <linearGradient id="glow" x1="224" y1="80" x2="496" y2="352" gradientUnits="userSpaceOnUse">
9
- <stop stop-color="#FFFFFF" stop-opacity="0.9"/>
10
- <stop offset="1" stop-color="#D5EAFF" stop-opacity="0.75"/>
11
- </linearGradient>
12
- <linearGradient id="line" x1="184" y1="315" x2="392" y2="162" gradientUnits="userSpaceOnUse">
13
- <stop stop-color="#0E4E8A"/>
14
- <stop offset="1" stop-color="#1AA7A1"/>
15
- </linearGradient>
16
- <filter id="soft" x="0" y="0" width="1600" height="520" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
17
- <feGaussianBlur stdDeviation="0"/>
18
- </filter>
19
- </defs>
20
-
21
- <rect width="1600" height="520" rx="40" fill="url(#banner-bg)"/>
22
-
23
- <g opacity="0.22" stroke="#A5D7FF" stroke-width="1.4">
24
- <path d="M0 90H1600"/>
25
- <path d="M0 150H1600"/>
26
- <path d="M0 210H1600"/>
27
- <path d="M0 270H1600"/>
28
- <path d="M0 330H1600"/>
29
- <path d="M0 390H1600"/>
30
- <path d="M0 450H1600"/>
31
- </g>
32
-
33
- <circle cx="1280" cy="160" r="170" fill="#7EC8FF" fill-opacity="0.12"/>
34
- <circle cx="1430" cy="390" r="210" fill="#44E0D0" fill-opacity="0.1"/>
35
-
36
- <g filter="url(#soft)">
37
- <path d="M288 78L431 135V257C431 341 370 411 288 460C206 411 145 341 145 257V135L288 78Z" fill="url(#glow)"/>
38
- <path d="M288 100L409 149V251C409 324 358 386 288 425C218 386 167 324 167 251V149L288 100Z" fill="#E9F4FF"/>
39
-
40
- <g stroke="url(#line)" stroke-width="18" stroke-linecap="round" stroke-linejoin="round">
41
- <path d="M200 349L288 190L376 349"/>
42
- <path d="M239 285H337"/>
43
- <path d="M201 349H376"/>
44
- </g>
45
-
46
- <g fill="#0B2A50">
47
- <circle cx="200" cy="349" r="16"/>
48
- <circle cx="288" cy="190" r="16"/>
49
- <circle cx="376" cy="349" r="16"/>
50
- </g>
51
- </g>
52
-
53
- <g fill="#EAF5FF">
54
- <text x="520" y="226" font-family="Segoe UI, Trebuchet MS, Arial, sans-serif" font-size="118" font-weight="700" letter-spacing="0.6">AegisNode</text>
55
- <text x="525" y="296" font-family="Segoe UI, Trebuchet MS, Arial, sans-serif" font-size="38" font-weight="500" fill="#CDE8FF">
56
- View-first Node.js framework starter
57
- </text>
58
- </g>
59
-
60
- <g>
61
- <rect x="523" y="333" width="510" height="58" rx="14" fill="#0E2D57" fill-opacity="0.78"/>
62
- <text x="552" y="372" font-family="Segoe UI, Trebuchet MS, Arial, sans-serif" font-size="28" font-weight="600" fill="#8FE6DA">
63
- CLI | DI | Events | SQL/NoSQL | WebSocket
64
- </text>
65
- </g>
66
- </svg>
Binary file
@@ -1,43 +0,0 @@
1
- <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <defs>
3
- <linearGradient id="bg" x1="44" y1="36" x2="474" y2="496" gradientUnits="userSpaceOnUse">
4
- <stop stop-color="#0F1B3D"/>
5
- <stop offset="0.55" stop-color="#0F4C81"/>
6
- <stop offset="1" stop-color="#14A4A0"/>
7
- </linearGradient>
8
- <linearGradient id="shield" x1="256" y1="98" x2="256" y2="394" gradientUnits="userSpaceOnUse">
9
- <stop stop-color="#F8FAFF" stop-opacity="0.98"/>
10
- <stop offset="1" stop-color="#D5E9FF" stop-opacity="0.94"/>
11
- </linearGradient>
12
- <linearGradient id="accent" x1="175" y1="309" x2="340" y2="189" gradientUnits="userSpaceOnUse">
13
- <stop stop-color="#0E4E8A"/>
14
- <stop offset="1" stop-color="#1AA7A1"/>
15
- </linearGradient>
16
- </defs>
17
-
18
- <rect x="20" y="20" width="472" height="472" rx="116" fill="url(#bg)"/>
19
-
20
- <g opacity="0.2" stroke="#9FD4FF" stroke-width="1.2">
21
- <path d="M80 138H432"/>
22
- <path d="M80 188H432"/>
23
- <path d="M80 238H432"/>
24
- <path d="M80 288H432"/>
25
- <path d="M80 338H432"/>
26
- <path d="M80 388H432"/>
27
- </g>
28
-
29
- <path d="M256 94L366 138V232C366 296 319 350 256 388C193 350 146 296 146 232V138L256 94Z" fill="url(#shield)"/>
30
- <path d="M256 111L351 149V228C351 285 311 334 256 366C201 334 161 285 161 228V149L256 111Z" fill="#E9F4FF"/>
31
-
32
- <g stroke="url(#accent)" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">
33
- <path d="M189 304L256 182L323 304"/>
34
- <path d="M218 254H294"/>
35
- <path d="M190 304H323"/>
36
- </g>
37
-
38
- <g fill="#0C2F58">
39
- <circle cx="189" cy="304" r="13"/>
40
- <circle cx="256" cy="182" r="13"/>
41
- <circle cx="323" cy="304" r="13"/>
42
- </g>
43
- </svg>