kempo 4.6.2 → 4.7.0

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.
Files changed (174) hide show
  1. package/README.md +5 -0
  2. package/app-public/account/index.page.html +2 -0
  3. package/app-public/nav.fragment.html +2 -1
  4. package/app-templates/emails/email.template.html +14 -0
  5. package/app-templates/emails/reset-password.page.html +19 -0
  6. package/app-templates/emails/test.page.html +21 -0
  7. package/app-templates/emails/verify-email.page.html +18 -0
  8. package/bin/cli.js +3 -0
  9. package/dist/admin/components/Dashboard.js +2 -2
  10. package/dist/admin/components/Dashboard.js.map +1 -1
  11. package/dist/admin/components/Menus.js +103 -0
  12. package/dist/admin/components/Menus.js.map +1 -0
  13. package/dist/admin/content/menus/index.page.html +11 -0
  14. package/dist/admin/nav.fragment.html +6 -0
  15. package/dist/kempo/api/fragments/DELETE.js +1 -1
  16. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  17. package/dist/kempo/api/fragments/GET.js +1 -1
  18. package/dist/kempo/api/fragments/GET.js.map +1 -1
  19. package/dist/kempo/api/fragments/POST.js +1 -1
  20. package/dist/kempo/api/fragments/POST.js.map +1 -1
  21. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  22. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  23. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  24. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  25. package/dist/kempo/api/fragments/file/GET.js +1 -1
  26. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  27. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  28. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  29. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  30. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  31. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  32. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  33. package/dist/kempo/api/globals/DELETE.js +1 -1
  34. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  35. package/dist/kempo/api/globals/GET.js +1 -1
  36. package/dist/kempo/api/globals/GET.js.map +1 -1
  37. package/dist/kempo/api/globals/POST.js +1 -1
  38. package/dist/kempo/api/globals/POST.js.map +1 -1
  39. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  40. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  41. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  42. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  43. package/dist/kempo/api/globals/entry/GET.js +1 -1
  44. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  45. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  46. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  47. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  48. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  49. package/dist/kempo/api/menus/DELETE.js +2 -0
  50. package/dist/kempo/api/menus/DELETE.js.map +1 -0
  51. package/dist/kempo/api/menus/GET.js +2 -0
  52. package/dist/kempo/api/menus/GET.js.map +1 -0
  53. package/dist/kempo/api/menus/POST.js +2 -0
  54. package/dist/kempo/api/menus/POST.js.map +1 -0
  55. package/dist/kempo/api/menus/adopt/PUT.js +2 -0
  56. package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
  57. package/dist/kempo/api/menus/items/DELETE.js +2 -0
  58. package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
  59. package/dist/kempo/api/menus/items/POST.js +2 -0
  60. package/dist/kempo/api/menus/items/POST.js.map +1 -0
  61. package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
  62. package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
  63. package/dist/kempo/api/menus/items/order/PUT.js +2 -0
  64. package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
  65. package/dist/kempo/api/menus/location/PUT.js +2 -0
  66. package/dist/kempo/api/menus/location/PUT.js.map +1 -0
  67. package/dist/kempo/api/pages/DELETE.js +1 -1
  68. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  69. package/dist/kempo/api/pages/GET.js +1 -1
  70. package/dist/kempo/api/pages/GET.js.map +1 -1
  71. package/dist/kempo/api/pages/POST.js +1 -1
  72. package/dist/kempo/api/pages/POST.js.map +1 -1
  73. package/dist/kempo/api/pages/directories/GET.js +1 -1
  74. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  75. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  76. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  77. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  78. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  79. package/dist/kempo/api/pages/file/GET.js +1 -1
  80. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  81. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  82. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  83. package/dist/kempo/api/pages/file/PUT.js +1 -1
  84. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  85. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  86. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  87. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  88. package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
  89. package/dist/kempo/api/pages/search/GET.js +1 -1
  90. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  91. package/dist/kempo/api/pages/templates/GET.js +1 -1
  92. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  93. package/dist/kempo/api/templates/DELETE.js +1 -1
  94. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  95. package/dist/kempo/api/templates/GET.js +1 -1
  96. package/dist/kempo/api/templates/GET.js.map +1 -1
  97. package/dist/kempo/api/templates/POST.js +1 -1
  98. package/dist/kempo/api/templates/POST.js.map +1 -1
  99. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  100. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  101. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  102. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  103. package/dist/kempo/api/templates/file/GET.js +1 -1
  104. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  105. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  106. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  107. package/dist/kempo/api/templates/file/PUT.js +1 -1
  108. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  109. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  110. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  111. package/dist/kempo/components/DashboardWidget.js +4 -4
  112. package/dist/kempo/components/DashboardWidget.js.map +1 -1
  113. package/dist/kempo/icons/campaign.svg +1 -0
  114. package/dist/kempo/icons/fields.svg +1 -0
  115. package/dist/kempo/icons/open_in_new.svg +1 -0
  116. package/dist/kempo/sdk.js +1 -1
  117. package/dist/kempo/sdk.js.map +1 -1
  118. package/middleware/kempo.js +48 -12
  119. package/package.json +2 -2
  120. package/server/db/schema.js +24 -0
  121. package/server/sdk.js +21 -0
  122. package/server/utils/auth/requestPasswordReset.js +9 -3
  123. package/server/utils/auth/verifyEmail.js +10 -1
  124. package/server/utils/dashboard/coreWidgets.js +1 -0
  125. package/server/utils/dashboard/helpers.js +6 -0
  126. package/server/utils/email/renderEmailTemplate.js +63 -0
  127. package/server/utils/email/sendEmailFromTemplate.js +9 -27
  128. package/server/utils/extensions/aliases.js +71 -0
  129. package/server/utils/extensions/checkClaims.js +32 -0
  130. package/server/utils/extensions/claims.js +54 -0
  131. package/server/utils/extensions/createTablesFromSchema.js +13 -20
  132. package/server/utils/extensions/enableExtensionTree.js +8 -0
  133. package/server/utils/extensions/installExtension.js +32 -0
  134. package/server/utils/extensions/readKempoConfig.js +2 -2
  135. package/server/utils/extensions/scopeCache.js +14 -1
  136. package/server/utils/extensions/scopes.js +72 -0
  137. package/server/utils/extensions/uninstallExtensionTree.js +2 -1
  138. package/server/utils/extensions/updateExtension.js +35 -0
  139. package/server/utils/global-content/createGlobalContent.js +8 -18
  140. package/server/utils/global-content/deleteGlobalContent.js +1 -16
  141. package/server/utils/global-content/disableGlobalContent.js +1 -16
  142. package/server/utils/global-content/enableGlobalContent.js +1 -16
  143. package/server/utils/global-content/helpers.js +40 -6
  144. package/server/utils/global-content/listGlobalContent.js +2 -2
  145. package/server/utils/global-content/setGlobalContentLocked.js +1 -16
  146. package/server/utils/global-content/updateGlobalContent.js +1 -16
  147. package/server/utils/menus/adoptMenuItem.js +58 -0
  148. package/server/utils/menus/assignMenuLocation.js +47 -0
  149. package/server/utils/menus/deleteMenu.js +27 -0
  150. package/server/utils/menus/deleteMenuItems.js +27 -0
  151. package/server/utils/menus/findMenuLocations.js +41 -0
  152. package/server/utils/menus/findNavItems.js +74 -0
  153. package/server/utils/menus/findPages.js +73 -0
  154. package/server/utils/menus/listMenus.js +103 -0
  155. package/server/utils/menus/markup.js +55 -0
  156. package/server/utils/menus/reorderMenuItems.js +36 -0
  157. package/server/utils/menus/saveMenu.js +54 -0
  158. package/server/utils/menus/saveMenuItem.js +122 -0
  159. package/server/utils/menus/setMenuItemEnabled.js +31 -0
  160. package/server/utils/menus/store.js +90 -0
  161. package/server/utils/scheduler/helpers.js +36 -0
  162. package/server/utils/scheduler/listJobs.js +11 -0
  163. package/server/utils/scheduler/registerJobs.js +37 -0
  164. package/server/utils/scheduler/runJobs.js +90 -0
  165. package/server/utils/scheduler/scheduler.js +37 -0
  166. package/server/utils/scheduler/unregisterJobs.js +16 -0
  167. package/server/utils/settings/ensureEncryptionKey.js +132 -0
  168. package/server/utils/settings/secretCrypto.js +5 -1
  169. package/dist/kempo/icons/create_new_folder.svg +0 -1
  170. package/dist/kempo/icons/credit_card.svg +0 -1
  171. package/dist/kempo/icons/login.svg +0 -1
  172. package/dist/kempo/icons/payments.svg +0 -1
  173. package/dist/kempo/icons/toll.svg +0 -1
  174. package/dist/kempo/icons/wallet.svg +0 -1
@@ -0,0 +1,37 @@
1
+ import { runDueJobs } from './runJobs.js';
2
+
3
+ /*
4
+ One ticker per process, held on a Symbol.for global so that the middleware and an extension reach the
5
+ same one even if kempo was resolved twice. Each tick only asks the database what is due; the lease on
6
+ each job row is what stops several processes running the same job. Set KEMPO_SCHEDULER=off to keep a
7
+ process from running jobs.
8
+ */
9
+ const SCHEDULER = Symbol.for('kempo.scheduler');
10
+
11
+ export const TICK_MS = 15000;
12
+
13
+ export const startScheduler = ({ tickMs = TICK_MS } = {}) => {
14
+ if(globalThis[SCHEDULER] || process.env.KEMPO_SCHEDULER === 'off') return false;
15
+
16
+ let running = false;
17
+ const timer = setInterval(async () => {
18
+ if(running) return;
19
+ running = true;
20
+ try {
21
+ const [error] = await runDueJobs();
22
+ if(error) console.error(`[kempo] Scheduler: ${error.msg}`);
23
+ } finally {
24
+ running = false;
25
+ }
26
+ }, tickMs);
27
+ timer.unref?.();
28
+ globalThis[SCHEDULER] = timer;
29
+ return true;
30
+ };
31
+
32
+ export const stopScheduler = () => {
33
+ if(!globalThis[SCHEDULER]) return false;
34
+ clearInterval(globalThis[SCHEDULER]);
35
+ globalThis[SCHEDULER] = null;
36
+ return true;
37
+ };
@@ -0,0 +1,16 @@
1
+ import db from '../../db/index.js';
2
+ import { scheduledJob } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+
5
+ export default async ({ owner }) => {
6
+ if(!owner){
7
+ return [{ code: 400, msg: 'Owner is required' }, null];
8
+ }
9
+
10
+ try {
11
+ await db.delete(scheduledJob).where(eq(scheduledJob.owner, owner));
12
+ return [null, { success: true }];
13
+ } catch(error) {
14
+ return [{ code: 500, msg: 'Failed to remove jobs' }, null];
15
+ }
16
+ };
@@ -0,0 +1,132 @@
1
+ import { readFile, writeFile, chmod } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import crypto from 'crypto';
4
+ import { and, eq, isNotNull, ne, sql } from 'drizzle-orm';
5
+ import db from '../../db/index.js';
6
+ import { setting } from '../../db/schema.js';
7
+
8
+ /*
9
+ Makes sure SETTINGS_ENCRYPTION_KEY exists before anything needs it, so a site never has to set one up by
10
+ hand. In order:
11
+
12
+ 1. the environment, when it has one (a secret manager, docker compose, the host). It always wins
13
+ 2. the site's `.env` file, for a key written after the process started
14
+ 3. a new random key, but only when nothing is encrypted yet. Written to `.env` (owner-only, and a
15
+ .gitignore that names it is kept honest), then set for this process, so there is no restart
16
+
17
+ A key is never generated while the database already holds encrypted settings: a new one would make every
18
+ one of them unreadable. That case is an error that says so. A key is also never kept only in memory:
19
+ when `.env` cannot be written the answer is an error, because a key that vanishes on the next restart
20
+ is worse than none.
21
+
22
+ Anyone with shell access to the server can read the key in `.env`. Back it up with the database: losing
23
+ it makes the stored credentials unrecoverable (they can be typed in again, nothing else is lost).
24
+ */
25
+
26
+ const NAME = 'SETTINGS_ENCRYPTION_KEY';
27
+ const VALID = /^[0-9a-fA-F]{64}$/;
28
+ const LINE = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${NAME}[ \\t]*=[ \\t]*(.*?)[ \\t]*$`, 'm');
29
+ const COMMENT = '# Encrypts the API credentials saved in the admin. Back this up with your database: if it is lost they cannot be recovered.';
30
+
31
+ const unquote = value => value.replace(/^(['"])(.*)\1$/, '$2');
32
+
33
+ const readText = async path => {
34
+ try {
35
+ return await readFile(path, 'utf8');
36
+ } catch(error) {
37
+ if(error.code === 'ENOENT') return null;
38
+ throw error;
39
+ }
40
+ };
41
+
42
+ /* How many settings are stored encrypted. A site that has not created its tables yet has none. */
43
+ const storedSecrets = async () => {
44
+ try {
45
+ const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(setting)
46
+ .where(and(eq(setting.type, 'secret'), isNotNull(setting.value), ne(setting.value, '')));
47
+ return [null, total];
48
+ } catch(error) {
49
+ if((error?.code || error?.cause?.code) === '42P01') return [null, 0];
50
+ return [{ code: 500, msg: 'Could not check whether this site already has encrypted settings, so no encryption key was created' }, null];
51
+ }
52
+ };
53
+
54
+ /* Puts the key in `.env`: replaces an empty `SETTINGS_ENCRYPTION_KEY=` line, or adds one. */
55
+ const saveKey = async (path, key, text) => {
56
+ const line = `${NAME}=${key}`;
57
+ let next;
58
+ if(text === null) next = `${COMMENT}\n${line}\n`;
59
+ else if(LINE.test(text)) next = text.replace(LINE, line);
60
+ else next = `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}\n${COMMENT}\n${line}\n`;
61
+ await writeFile(path, next, { mode: 0o600 });
62
+ await chmod(path, 0o600).catch(() => {});
63
+ };
64
+
65
+ /* A .gitignore that exists but does not name `.env` would let the key be committed. */
66
+ const keepOutOfGit = async dir => {
67
+ const path = join(dir, '.gitignore');
68
+ try {
69
+ const text = await readText(path);
70
+ if(text === null || /^\/?\.env\s*$/m.test(text)) return;
71
+ await writeFile(path, `${text}${text.endsWith('\n') ? '' : '\n'}.env\n`);
72
+ } catch {
73
+ /* Not being able to tidy .gitignore is not worth refusing a key over. */
74
+ }
75
+ };
76
+
77
+ const resolve = async ({ cwd }) => {
78
+ const fromEnvironment = process.env[NAME];
79
+ if(fromEnvironment){
80
+ return VALID.test(fromEnvironment)
81
+ ? [null, { source: 'environment' }]
82
+ : [{ code: 500, msg: `${NAME} must be a 64-character hex string (32 bytes). Remove it to have one generated, or replace it with the key this site was created with` }, null];
83
+ }
84
+
85
+ const path = join(cwd, '.env');
86
+ let text;
87
+ try {
88
+ text = await readText(path);
89
+ } catch(error) {
90
+ return [{ code: 500, msg: `Could not read ${path} (${error.code ?? error.message}), so no encryption key was created` }, null];
91
+ }
92
+
93
+ const inFile = text === null ? '' : unquote(LINE.exec(text)?.[1] ?? '');
94
+ if(inFile){
95
+ if(!VALID.test(inFile)) return [{ code: 500, msg: `${NAME} in ${path} must be a 64-character hex string (32 bytes)` }, null];
96
+ process.env[NAME] = inFile;
97
+ return [null, { source: 'file', path }];
98
+ }
99
+
100
+ const [countError, count] = await storedSecrets();
101
+ if(countError) return [countError, null];
102
+ if(count > 0){
103
+ return [{ code: 500, msg: `This site has ${count} encrypted setting${count === 1 ? '' : 's'} but ${NAME} is not set. Restore the key it was created with (your .env backup, or the host's environment). A new key would make them unreadable` }, null];
104
+ }
105
+
106
+ const key = crypto.randomBytes(32).toString('hex');
107
+ try {
108
+ await saveKey(path, key, text);
109
+ } catch(error) {
110
+ return [{ code: 500, msg: `Could not save a new ${NAME} to ${path} (${error.code ?? error.message}). Set ${NAME} in the server's environment instead` }, null];
111
+ }
112
+ await keepOutOfGit(cwd);
113
+ process.env[NAME] = key;
114
+ console.warn(`[kempo] ${NAME} was not set, so one was generated and saved to ${path}. Back it up with your database: if it is lost, stored credentials (Stripe keys and the like) cannot be recovered and must be entered again.`);
115
+ return [null, { source: 'generated', path }];
116
+ };
117
+
118
+ /* One answer per process: concurrent callers share the work, and a failure can be tried again. */
119
+ let pending = null;
120
+
121
+ export const ensureEncryptionKey = ({ cwd = process.cwd() } = {}) => {
122
+ pending ??= resolve({ cwd }).then(result => {
123
+ if(result[0]) pending = null;
124
+ return result;
125
+ });
126
+ return pending;
127
+ };
128
+
129
+ /* For tests: forget the answer so the next call decides again. */
130
+ export const resetEncryptionKey = () => {
131
+ pending = null;
132
+ };
@@ -37,5 +37,9 @@ export const decryptSecret = encoded => {
37
37
  const ciphertext = data.subarray(28);
38
38
  const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
39
39
  decipher.setAuthTag(authTag);
40
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
40
+ try {
41
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
42
+ } catch {
43
+ throw new Error('A stored secret could not be decrypted: SETTINGS_ENCRYPTION_KEY is not the key it was saved with. Restore the original key, or enter the credential again');
44
+ }
41
45
  };
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M560-320h80v-80h80v-80h-80v-80h-80v80h-80v80h80v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M880-720v480q0 33-23.5 56.5T800-160H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720ZM160-640h640v-80H160v80Zm0 160v240h640v-240H160Zm0 240v-480 480Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M480-120v-80h280v-560H480v-80h280q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H480Zm-80-160-55-58 102-102H120v-80h327L345-622l55-58 200 200-200 200Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M560-440q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35ZM280-320q-33 0-56.5-23.5T200-400v-320q0-33 23.5-56.5T280-800h560q33 0 56.5 23.5T920-720v320q0 33-23.5 56.5T840-320H280Zm80-80h400q0-33 23.5-56.5T840-480v-160q-33 0-56.5-23.5T760-720H360q0 33-23.5 56.5T280-640v160q33 0 56.5 23.5T360-400Zm440 240H120q-33 0-56.5-23.5T40-240v-440h80v440h680v80ZM280-400v-320 320Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M373-253q-93-93-93-227t93-227q93-93 227-93t227 93q93 93 93 227t-93 227q-93 93-227 93t-227-93Zm-93 83q-106-28-173-114T40-480q0-110 67-196t173-114v84q-72 25-116 87t-44 139q0 77 44 139t116 87v84Zm320-310Zm170 170q70-70 70-170t-70-170q-70-70-170-70t-170 70q-70 70-70 170t70 170q70 70 170 70t170-70Z"/></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v80h-80v-80H200v560h560v-80h80v80q0 33-23.5 56.5T760-120H200Zm320-160q-33 0-56.5-23.5T440-360v-240q0-33 23.5-56.5T520-680h280q33 0 56.5 23.5T880-600v240q0 33-23.5 56.5T800-280H520Zm280-80v-240H520v240h280Zm-160-60q25 0 42.5-17.5T700-480q0-25-17.5-42.5T640-540q-25 0-42.5 17.5T580-480q0 25 17.5 42.5T640-420Z"/></svg>