@raketa-cloud/admin 0.0.92 → 0.0.94
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/jest.config.js +2 -2
- package/package.json +2 -2
- package/src/handlers/RedirectsHandler.js +1 -1
- package/src/handlers/TagsHandler.js +1 -1
- package/src/handlers/categories/index.js +1 -1
- package/src/helpers/FormBuilder.js +6 -1
- package/src/helpers/HTMLBuilder.js +1 -1
- package/src/setupAuthentication.js +5 -2
- package/src/simpleCipher.js +23 -13
- package/src/tests/Logger.test.js +33 -0
- package/src/tests/handlers/RedirectsHandler.test/destroy.test.js +1 -1
- package/src/tests/handlers/TagsHandler.test/destroy.test.js +1 -1
- package/src/tests/handlers/categories/destroy.test.js +16 -12
- package/src/tests/helpers/HTMLBuilder.test.js +44 -0
- package/src/views/article_revisions/publishes/_form.ejs +1 -1
- package/src/views/articles/show.ejs +0 -8
- package/src/views/event_revisions/publishes/_form.ejs +1 -1
- package/src/views/events/show.ejs +0 -8
- package/src/views/page_revisions/publishes/_form.ejs +1 -1
- package/src/views/pages/show.ejs +0 -8
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raketa-cloud/admin",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.94",
|
|
4
4
|
"main": "src/index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "npx tailwindcss -i ./src/styles/application.css -o ../../apps/admin/static/application.css --watch",
|
|
7
|
-
"test": "jest --coverage --coverageReporters=
|
|
7
|
+
"test": "jest --coverage --coverageReporters=html --coverageReporters=\"text-summary\""
|
|
8
8
|
},
|
|
9
9
|
"devDependencies": {
|
|
10
10
|
"@babel/core": "7.24.7",
|
|
@@ -385,7 +385,12 @@ export default class FormBuilder extends HTMLBuilder {
|
|
|
385
385
|
});
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
|
|
388
|
+
let defaultLabel = this.__getDefaultLabel(name);
|
|
389
|
+
|
|
390
|
+
if (Object.hasOwn(opts, "label")) {
|
|
391
|
+
defaultLabel = opts?.label;
|
|
392
|
+
}
|
|
393
|
+
|
|
389
394
|
const htmlID = this.__getID(name);
|
|
390
395
|
const error = this.__getError(name);
|
|
391
396
|
|
|
@@ -31,7 +31,7 @@ export default class HTMLBuilder {
|
|
|
31
31
|
}, "");
|
|
32
32
|
|
|
33
33
|
if (children || nonVoid)
|
|
34
|
-
return `<${tag} ${htmlAttributes}>${children}</${tag}>`;
|
|
34
|
+
return `<${tag} ${htmlAttributes}>${children ? children : ""}</${tag}>`;
|
|
35
35
|
|
|
36
36
|
return `<${tag} ${htmlAttributes} />`;
|
|
37
37
|
}
|
|
@@ -5,13 +5,16 @@ import currentPath from "./currentPath.cjs";
|
|
|
5
5
|
import AuthHelper from "./helpers/AuthHelper";
|
|
6
6
|
// TODO: Maybe change the encryption mechanism?
|
|
7
7
|
import simpleCipher from "./simpleCipher";
|
|
8
|
+
import getConfig from "../../../apps/admin/src/getConfig";
|
|
8
9
|
|
|
9
10
|
// Generate a random signing secret on every server restart
|
|
10
|
-
const SIGNING_SECRET = randomBytes(64).toString("hex");
|
|
11
|
+
// const SIGNING_SECRET = randomBytes(64).toString("hex");
|
|
11
12
|
const DELIMITER = ",";
|
|
12
13
|
|
|
13
14
|
function calculateHash(text) {
|
|
14
|
-
return createHmac("sha256",
|
|
15
|
+
return createHmac("sha256", getConfig().secrets.signing_secret)
|
|
16
|
+
.update(text)
|
|
17
|
+
.digest("hex");
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function obfuscateToken(token) {
|
package/src/simpleCipher.js
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
+
import getConfig from "../../../apps/admin/src/getConfig";
|
|
2
|
+
|
|
1
3
|
const ALPHABET =
|
|
2
4
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789,.";
|
|
3
5
|
|
|
4
|
-
function generateKey() {
|
|
5
|
-
const shuffled = ALPHABET.split("")
|
|
6
|
-
.sort(() => Math.random() - 0.5)
|
|
7
|
-
.join("");
|
|
8
|
-
|
|
9
|
-
return shuffled;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
function generateEncryptionLookup(key) {
|
|
13
7
|
const lookup = {};
|
|
14
8
|
|
|
@@ -29,22 +23,38 @@ function generateDecryptionLookup(key) {
|
|
|
29
23
|
return lookup;
|
|
30
24
|
}
|
|
31
25
|
|
|
26
|
+
// function generateKey() {
|
|
27
|
+
// const shuffled = ALPHABET.split("")
|
|
28
|
+
// .sort(() => Math.random() - 0.5)
|
|
29
|
+
// .join("");
|
|
30
|
+
|
|
31
|
+
// return shuffled;
|
|
32
|
+
// }
|
|
33
|
+
|
|
32
34
|
// Scramble lookup maps on every server restart
|
|
33
|
-
const CIPHER_KEY = generateKey();
|
|
34
|
-
const ENCRYPT_LOOKUP = generateEncryptionLookup(CIPHER_KEY);
|
|
35
|
-
const DECRYPT_LOOKUP = generateDecryptionLookup(CIPHER_KEY);
|
|
35
|
+
// const CIPHER_KEY = generateKey();
|
|
36
|
+
// const ENCRYPT_LOOKUP = generateEncryptionLookup(CIPHER_KEY);
|
|
37
|
+
// const DECRYPT_LOOKUP = generateDecryptionLookup(CIPHER_KEY);
|
|
36
38
|
|
|
37
39
|
function encrypt(plaintext) {
|
|
40
|
+
const ecryptLookup = generateEncryptionLookup(
|
|
41
|
+
getConfig().secrets.crypto_secret
|
|
42
|
+
);
|
|
43
|
+
|
|
38
44
|
return plaintext
|
|
39
45
|
.split("")
|
|
40
|
-
.map((char) =>
|
|
46
|
+
.map((char) => ecryptLookup[char] || char)
|
|
41
47
|
.join("");
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
function decrypt(ciphertext) {
|
|
51
|
+
const decryptLookup = generateDecryptionLookup(
|
|
52
|
+
getConfig().secrets.crypto_secret
|
|
53
|
+
);
|
|
54
|
+
|
|
45
55
|
return ciphertext
|
|
46
56
|
.split("")
|
|
47
|
-
.map((char) =>
|
|
57
|
+
.map((char) => decryptLookup[char] || char)
|
|
48
58
|
.join("");
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Logger from "../Logger";
|
|
2
|
+
|
|
3
|
+
describe("Logger", () => {
|
|
4
|
+
describe("error", () => {
|
|
5
|
+
test("Logs an error to the console", () => {
|
|
6
|
+
const logSpy = jest.spyOn(global.console, "error");
|
|
7
|
+
|
|
8
|
+
Logger.error({ foo: "BAR" });
|
|
9
|
+
|
|
10
|
+
expect(logSpy).toHaveBeenCalledWith("[ERROR]", "{ foo: 'BAR' }");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("info", () => {
|
|
15
|
+
test("Logs info to the console", () => {
|
|
16
|
+
const logSpy = jest.spyOn(global.console, "info");
|
|
17
|
+
|
|
18
|
+
Logger.info({ foo: "BAR" });
|
|
19
|
+
|
|
20
|
+
expect(logSpy).toHaveBeenCalledWith("[INFO]", "{ foo: 'BAR' }");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("debug", () => {
|
|
25
|
+
test("Logs debug information to the console", () => {
|
|
26
|
+
const logSpy = jest.spyOn(global.console, "debug");
|
|
27
|
+
|
|
28
|
+
Logger.debug({ foo: "BAR" });
|
|
29
|
+
|
|
30
|
+
expect(logSpy).toHaveBeenCalledWith("[DEBUG]", "{ foo: 'BAR' }");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,34 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import CategoriesHandler from "../../../handlers/categories";
|
|
2
|
+
import Categories from "../../../cloud/Categories";
|
|
3
3
|
|
|
4
4
|
import Factory from "../../support/factory";
|
|
5
5
|
|
|
6
6
|
function buildCtx() {
|
|
7
7
|
return {
|
|
8
8
|
client: {
|
|
9
|
-
|
|
9
|
+
categories: new Categories(() => ({}), "token"),
|
|
10
10
|
},
|
|
11
11
|
params: { id: 1 },
|
|
12
12
|
locale: "en",
|
|
13
|
+
resourceConfig: {
|
|
14
|
+
categoryType: "cat",
|
|
15
|
+
categoryTypePlural: "cats",
|
|
16
|
+
},
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
describe("
|
|
20
|
+
describe("Categories", () => {
|
|
17
21
|
test("destroy successful", async () => {
|
|
18
22
|
const redirect = Factory.build("redirect");
|
|
19
23
|
const ctx = buildCtx();
|
|
20
24
|
|
|
21
25
|
const mockDelete = jest
|
|
22
|
-
.spyOn(ctx.client.
|
|
26
|
+
.spyOn(ctx.client.categories, "delete")
|
|
23
27
|
.mockImplementation(() => ({ redirect: redirect, errors: null }));
|
|
24
28
|
|
|
25
|
-
const result = await
|
|
29
|
+
const result = await CategoriesHandler.destroy(ctx);
|
|
26
30
|
|
|
27
|
-
expect(mockDelete).toHaveBeenCalledWith(1);
|
|
31
|
+
expect(mockDelete).toHaveBeenCalledWith(1, "en");
|
|
28
32
|
|
|
29
33
|
expect(result).toEqual({
|
|
30
34
|
headers: {
|
|
31
|
-
"HX-Redirect": "/en/
|
|
35
|
+
"HX-Redirect": "/en/cats",
|
|
32
36
|
},
|
|
33
37
|
json: {},
|
|
34
38
|
});
|
|
@@ -38,16 +42,16 @@ describe("Redirects", () => {
|
|
|
38
42
|
const ctx = buildCtx();
|
|
39
43
|
|
|
40
44
|
const mockDelete = jest
|
|
41
|
-
.spyOn(ctx.client.
|
|
45
|
+
.spyOn(ctx.client.categories, "delete")
|
|
42
46
|
.mockImplementation(() => ({
|
|
43
47
|
redirect: null,
|
|
44
48
|
errors: [{ key: "generalError", message: "Not found" }],
|
|
45
49
|
}));
|
|
46
50
|
|
|
47
|
-
await expect(() =>
|
|
48
|
-
"Failed to delete
|
|
51
|
+
await expect(() => CategoriesHandler.destroy(ctx)).rejects.toEqual(
|
|
52
|
+
"Failed to delete category #1."
|
|
49
53
|
);
|
|
50
54
|
|
|
51
|
-
expect(mockDelete).toHaveBeenCalledWith(1);
|
|
55
|
+
expect(mockDelete).toHaveBeenCalledWith(1, "en");
|
|
52
56
|
});
|
|
53
57
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import HTMLBuilder from "../../helpers/HTMLBuilder";
|
|
2
|
+
|
|
3
|
+
describe("HTMLBuilder", () => {
|
|
4
|
+
function normalizeHTML(html) {
|
|
5
|
+
return html.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("tag", () => {
|
|
9
|
+
test("renders a self closing tag", () => {
|
|
10
|
+
const builder = new HTMLBuilder();
|
|
11
|
+
|
|
12
|
+
const result = builder.tag("img", null, { src: "/some-image.jpg" });
|
|
13
|
+
|
|
14
|
+
expect(normalizeHTML(result)).toEqual(
|
|
15
|
+
normalizeHTML('<img src="/some-image.jpg" />')
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("renders a label tag", () => {
|
|
20
|
+
const builder = new HTMLBuilder();
|
|
21
|
+
|
|
22
|
+
const result = builder.tag("label", "Heelo", { for: "some-input-id" });
|
|
23
|
+
|
|
24
|
+
expect(normalizeHTML(result)).toEqual(
|
|
25
|
+
normalizeHTML('<label for="some-input-id">Heelo</label>')
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("renders a textarea tag", () => {
|
|
30
|
+
const builder = new HTMLBuilder();
|
|
31
|
+
|
|
32
|
+
const result = builder.tag(
|
|
33
|
+
"textarea",
|
|
34
|
+
undefined,
|
|
35
|
+
{ id: "some-input-id", value: "Text?" },
|
|
36
|
+
{ nonVoid: true }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(normalizeHTML(result)).toEqual(
|
|
40
|
+
normalizeHTML('<textarea id="some-input-id" value="Text?"></textarea>')
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<div class="mb-4 text-red-500">A page with this URL already exists</div>
|
|
5
5
|
<% } %>
|
|
6
6
|
|
|
7
|
-
<%- f.field('publishing.type', { as: 'radio_group', options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
7
|
+
<%- f.field('publishing.type', { as: 'radio_group', label: "", options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
8
8
|
<%- f.field('publishing.mergeAt', { type: 'datetime-local', label: 'Publish revision at (UTC)' }) %>
|
|
9
9
|
|
|
10
10
|
<%- f.field('publishing.title'), { required: true, hint: "Appears in the history of changes" } %>
|
|
@@ -191,14 +191,6 @@
|
|
|
191
191
|
<%- f.submit('Remove schedule', { class: 'btn-text' }) %>
|
|
192
192
|
</form>
|
|
193
193
|
|
|
194
|
-
<button hx-delete="<%= `/${_context.locale}/${_context.resourceConfig.articleType}_revisions/${revision.id}` %>" hx-confirm="Are you sure?" type="button" class="btn-danger has-icon <%= revision.meta && revision.meta.locked ? 'cursor-not-allowed text-gray-400 pointer-events-none' : '' %>">
|
|
195
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
196
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
197
|
-
</svg>
|
|
198
|
-
|
|
199
|
-
Delete
|
|
200
|
-
</button>
|
|
201
|
-
|
|
202
194
|
<a href="/remote/login?redirect_to=/<%= _context.locale %>/admin/<%= _context.resourceConfig.articleType %>-revisions/<%= revision.id %>/preview" target="_blank" class="btn-text has-icon">
|
|
203
195
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
204
196
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<div class="mb-4 text-red-500">A page with this URL already exists</div>
|
|
5
5
|
<% } %>
|
|
6
6
|
|
|
7
|
-
<%- f.field('publishing.type', { as: 'radio_group', options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
7
|
+
<%- f.field('publishing.type', { as: 'radio_group', label: "", options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
8
8
|
<%- f.field('publishing.mergeAt', { type: 'datetime-local', label: 'Publish revision at (UTC)' }) %>
|
|
9
9
|
|
|
10
10
|
<%- f.field('publishing.title'), { required: true, hint: "Appears in the history of changes" } %>
|
|
@@ -191,14 +191,6 @@
|
|
|
191
191
|
<%- f.submit('Remove schedule', { class: 'btn-text' }) %>
|
|
192
192
|
</form>
|
|
193
193
|
|
|
194
|
-
<button hx-delete="<%= `/${_context.locale}/${_context.resourceConfig.eventType}_revisions/${revision.id}` %>" hx-confirm="Are you sure?" type="button" class="btn-danger has-icon <%= revision.meta && revision.meta.locked ? 'cursor-not-allowed text-gray-400 pointer-events-none' : '' %>">
|
|
195
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
196
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
197
|
-
</svg>
|
|
198
|
-
|
|
199
|
-
Delete
|
|
200
|
-
</button>
|
|
201
|
-
|
|
202
194
|
<a href="/remote/login?redirect_to=/<%= _context.locale %>/admin/<%= _context.resourceConfig.eventType %>-revisions/<%= revision.id %>/preview" target="_blank" class="btn-text has-icon">
|
|
203
195
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
204
196
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<div class="mb-4 text-red-500">A page with this URL already exists</div>
|
|
5
5
|
<% } %>
|
|
6
6
|
|
|
7
|
-
<%- f.field('publishing.type', { as: 'radio_group', options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
7
|
+
<%- f.field('publishing.type', { as: 'radio_group', label: "", options: [["now", "Publish now"], ["later", "Schedule for a date"]] }) %>
|
|
8
8
|
<%- f.field('publishing.mergeAt', { type: 'datetime-local', label: 'Publish revision at (UTC)' }) %>
|
|
9
9
|
|
|
10
10
|
<%- f.field('publishing.title'), { required: true, hint: "Appears in the history of changes" } %>
|
package/src/views/pages/show.ejs
CHANGED
|
@@ -192,14 +192,6 @@
|
|
|
192
192
|
<%- f.submit('Remove schedule', { class: 'btn-text' }) %>
|
|
193
193
|
</form>
|
|
194
194
|
|
|
195
|
-
<button hx-delete="<%= `/${_context.locale}/${_context.resourceConfig.pageType}_revisions/${revision.id}` %>" hx-confirm="Are you sure?" type="button" class="btn-danger has-icon <%= revision.meta && revision.meta.locked ? 'cursor-not-allowed text-gray-400 pointer-events-none' : '' %>">
|
|
196
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
197
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
198
|
-
</svg>
|
|
199
|
-
|
|
200
|
-
Delete
|
|
201
|
-
</button>
|
|
202
|
-
|
|
203
195
|
<a href="/remote/login?redirect_to=/<%= _context.locale %>/admin/<%= _context.resourceConfig.pageType %>-revisions/<%= revision.id %>/preview" target="_blank" class="btn-text has-icon">
|
|
204
196
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
205
197
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|