kempo 4.6.2 → 4.8.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.
- package/README.md +8 -1
- package/app-public/account/index.page.html +2 -0
- package/app-public/nav.fragment.html +2 -1
- package/app-templates/emails/email.template.html +14 -0
- package/app-templates/emails/reset-password.page.html +19 -0
- package/app-templates/emails/test.page.html +21 -0
- package/app-templates/emails/verify-email.page.html +18 -0
- package/bin/cli.js +18 -33
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/EmailTemplates.js +25 -0
- package/dist/admin/components/EmailTemplates.js.map +1 -0
- package/dist/admin/components/Emails.js +49 -0
- package/dist/admin/components/Emails.js.map +1 -0
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/components/settings/AdminSettings.js +7 -0
- package/dist/admin/components/settings/AdminSettings.js.map +1 -0
- package/dist/admin/components/settings/BooleanSetting.js +2 -0
- package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
- package/dist/admin/components/settings/JsonSetting.js +2 -0
- package/dist/admin/components/settings/JsonSetting.js.map +1 -0
- package/dist/admin/components/settings/NumberSetting.js +2 -0
- package/dist/admin/components/settings/NumberSetting.js.map +1 -0
- package/dist/admin/components/settings/SecretSetting.js +2 -0
- package/dist/admin/components/settings/SecretSetting.js.map +1 -0
- package/dist/admin/components/settings/Setting.js +2 -0
- package/dist/admin/components/settings/Setting.js.map +1 -0
- package/dist/admin/components/settings/SettingEditor.js +2 -0
- package/dist/admin/components/settings/SettingEditor.js.map +1 -0
- package/dist/admin/components/settings/SettingsPanel.js +24 -0
- package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
- package/dist/admin/components/settings/StringSetting.js +2 -0
- package/dist/admin/components/settings/StringSetting.js.map +1 -0
- package/dist/admin/components/settings/humanize.js +2 -0
- package/dist/admin/components/settings/humanize.js.map +1 -0
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/emails/index.page.html +14 -0
- package/dist/admin/icons/mail.svg +1 -0
- package/dist/admin/nav.fragment.html +12 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
- package/dist/admin/settings/index.page.html +6 -289
- package/dist/kempo/api/email-templates/GET.js +2 -0
- package/dist/kempo/api/email-templates/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
- package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
- package/dist/kempo/api/email-templates/file/GET.js +2 -0
- package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/PUT.js +2 -0
- package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
- package/dist/kempo/api/email-templates/preview/GET.js +2 -0
- package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
- package/dist/kempo/api/emails/GET.js +2 -0
- package/dist/kempo/api/emails/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/GET.js +2 -0
- package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
- package/dist/kempo/api/emails/test/POST.js +2 -0
- package/dist/kempo/api/emails/test/POST.js.map +1 -0
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js.map +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/GET.js.map +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/POST.js.map +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js.map +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/DELETE.js.map +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/GET.js.map +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/POST.js.map +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js.map +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
- package/dist/kempo/api/menus/DELETE.js +2 -0
- package/dist/kempo/api/menus/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/GET.js +2 -0
- package/dist/kempo/api/menus/GET.js.map +1 -0
- package/dist/kempo/api/menus/POST.js +2 -0
- package/dist/kempo/api/menus/POST.js.map +1 -0
- package/dist/kempo/api/menus/adopt/PUT.js +2 -0
- package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/DELETE.js +2 -0
- package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/items/POST.js +2 -0
- package/dist/kempo/api/menus/items/POST.js.map +1 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/order/PUT.js +2 -0
- package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
- package/dist/kempo/api/menus/location/PUT.js +2 -0
- package/dist/kempo/api/menus/location/PUT.js.map +1 -0
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/DELETE.js.map +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/GET.js.map +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/POST.js.map +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js.map +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/GET.js.map +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js.map +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/search/GET.js.map +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js.map +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/DELETE.js.map +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/POST.js.map +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/GET.js.map +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js.map +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
- package/dist/kempo/components/DashboardWidget.js +4 -4
- package/dist/kempo/components/DashboardWidget.js.map +1 -1
- package/dist/kempo/icons/campaign.svg +1 -0
- package/dist/kempo/icons/fields.svg +1 -0
- package/dist/kempo/icons/open_in_new.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +69 -12
- package/package.json +3 -4
- package/server/db/schema.js +56 -0
- package/server/sdk.js +26 -0
- package/server/utils/auth/requestPasswordReset.js +9 -3
- package/server/utils/auth/verifyEmail.js +10 -1
- package/server/utils/dashboard/coreWidgets.js +1 -0
- package/server/utils/dashboard/helpers.js +6 -0
- package/server/utils/email/defaultSender.js +2 -0
- package/server/utils/email/dispatchEmail.js +56 -0
- package/server/utils/email/emailSettings.js +21 -0
- package/server/utils/email/emailTemplatePaths.js +36 -0
- package/server/utils/email/getEmail.js +14 -0
- package/server/utils/email/getSenderEmail.js +11 -0
- package/server/utils/email/getSenderFrom.js +18 -0
- package/server/utils/email/isValidSender.js +4 -0
- package/server/utils/email/listEmailTemplates.js +23 -0
- package/server/utils/email/listEmails.js +35 -0
- package/server/utils/email/maintainEmails.js +46 -0
- package/server/utils/email/pruneEmails.js +29 -0
- package/server/utils/email/readEmailTemplate.js +28 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/resetEmailTemplate.js +15 -0
- package/server/utils/email/retryEmail.js +21 -0
- package/server/utils/email/saveEmailTemplate.js +38 -0
- package/server/utils/email/sendEmail.js +22 -32
- package/server/utils/email/sendEmailFromTemplate.js +9 -27
- package/server/utils/extensions/aliases.js +71 -0
- package/server/utils/extensions/checkClaims.js +32 -0
- package/server/utils/extensions/claims.js +54 -0
- package/server/utils/extensions/createTablesFromSchema.js +13 -20
- package/server/utils/extensions/enableExtensionTree.js +8 -0
- package/server/utils/extensions/installExtension.js +32 -0
- package/server/utils/extensions/readKempoConfig.js +2 -2
- package/server/utils/extensions/scopeCache.js +14 -1
- package/server/utils/extensions/scopes.js +72 -0
- package/server/utils/extensions/uninstallExtensionTree.js +2 -1
- package/server/utils/extensions/updateExtension.js +35 -0
- package/server/utils/global-content/createGlobalContent.js +8 -18
- package/server/utils/global-content/deleteGlobalContent.js +1 -16
- package/server/utils/global-content/disableGlobalContent.js +1 -16
- package/server/utils/global-content/enableGlobalContent.js +1 -16
- package/server/utils/global-content/helpers.js +40 -6
- package/server/utils/global-content/listGlobalContent.js +2 -2
- package/server/utils/global-content/setGlobalContentLocked.js +1 -16
- package/server/utils/global-content/updateGlobalContent.js +1 -16
- package/server/utils/hooks/triggerHook.js +17 -4
- package/server/utils/menus/adoptMenuItem.js +58 -0
- package/server/utils/menus/assignMenuLocation.js +47 -0
- package/server/utils/menus/deleteMenu.js +27 -0
- package/server/utils/menus/deleteMenuItems.js +27 -0
- package/server/utils/menus/findMenuLocations.js +41 -0
- package/server/utils/menus/findNavItems.js +74 -0
- package/server/utils/menus/findPages.js +73 -0
- package/server/utils/menus/listMenus.js +103 -0
- package/server/utils/menus/markup.js +55 -0
- package/server/utils/menus/reorderMenuItems.js +36 -0
- package/server/utils/menus/saveMenu.js +54 -0
- package/server/utils/menus/saveMenuItem.js +122 -0
- package/server/utils/menus/setMenuItemEnabled.js +31 -0
- package/server/utils/menus/store.js +90 -0
- package/server/utils/scheduler/helpers.js +36 -0
- package/server/utils/scheduler/listJobs.js +11 -0
- package/server/utils/scheduler/registerJobs.js +37 -0
- package/server/utils/scheduler/runJobs.js +90 -0
- package/server/utils/scheduler/scheduler.js +37 -0
- package/server/utils/scheduler/unregisterJobs.js +16 -0
- package/server/utils/settings/ensureEncryptionKey.js +132 -0
- package/server/utils/settings/listSettings.js +10 -5
- package/server/utils/settings/secretCrypto.js +5 -1
- package/server/utils/settings/setSetting.js +4 -0
- package/server/utils/settings/settingRules.js +21 -0
- package/server/utils/settings/systemSettings.js +40 -0
- package/server/utils/setup/seedDatabase.js +5 -60
- package/server/utils/setup/systemPermissions.js +75 -0
- package/dist/kempo/icons/create_new_folder.svg +0 -1
- package/dist/kempo/icons/credit_card.svg +0 -1
- package/dist/kempo/icons/login.svg +0 -1
- package/dist/kempo/icons/payments.svg +0 -1
- package/dist/kempo/icons/toll.svg +0 -1
- package/dist/kempo/icons/wallet.svg +0 -1
|
@@ -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
|
+
};
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
2
|
import { setting } from '../../db/schema.js';
|
|
3
|
-
import { like, not, and, sql } from 'drizzle-orm';
|
|
3
|
+
import { like, not, and, asc, sql } from 'drizzle-orm';
|
|
4
4
|
import { SECRET_MASK } from './secretCrypto.js';
|
|
5
5
|
|
|
6
|
+
/*
|
|
7
|
+
The settings of one owner, by name. `owner` is 'system', 'custom', 'extension' (every setting that is neither, whoever owns it),
|
|
8
|
+
or the name of one extension (`kempo-commerce`), which lists just its own. The owner is matched as a plain prefix, never as a
|
|
9
|
+
pattern, so a name with `_` or `%` in it cannot reach another owner's settings.
|
|
10
|
+
*/
|
|
6
11
|
export default async ({ owner, limit = 50, offset = 0 } = {}) => {
|
|
7
12
|
try {
|
|
8
13
|
let where;
|
|
9
|
-
if(owner === '
|
|
10
|
-
where = like(setting.name, `${owner}:%`);
|
|
11
|
-
} else if(owner === 'extension'){
|
|
14
|
+
if(owner === 'extension'){
|
|
12
15
|
where = and(
|
|
13
16
|
not(like(setting.name, 'system:%')),
|
|
14
17
|
not(like(setting.name, 'custom:%'))
|
|
15
18
|
);
|
|
19
|
+
} else if(owner){
|
|
20
|
+
where = sql`left(${setting.name}, ${owner.length + 1}) = ${`${owner}:`}`;
|
|
16
21
|
}
|
|
17
22
|
const q = db.select().from(setting);
|
|
18
23
|
const cq = db.select({ count: sql`count(*)` }).from(setting);
|
|
19
24
|
if(where){ q.where(where); cq.where(where); }
|
|
20
|
-
const results = await q.limit(limit).offset(offset);
|
|
25
|
+
const results = await q.orderBy(asc(setting.name)).limit(limit).offset(offset);
|
|
21
26
|
const [{ count }] = await cq;
|
|
22
27
|
|
|
23
28
|
// Never send secret ciphertext to the admin UI, not even encrypted.
|
|
@@ -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
|
-
|
|
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
|
};
|
|
@@ -3,12 +3,16 @@ import { setting } from '../../db/schema.js';
|
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
4
|
import { serializeValue } from './helpers.js';
|
|
5
5
|
import { SECRET_MASK } from './secretCrypto.js';
|
|
6
|
+
import settingRule from './settingRules.js';
|
|
6
7
|
|
|
7
8
|
export default async (owner, name, value, type = null, isPublic = false, description = null) => {
|
|
8
9
|
if(!owner || !name){
|
|
9
10
|
return [{ code: 400, msg: 'Both owner and name are required' }, null];
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
const ruleError = settingRule(owner, name, value);
|
|
14
|
+
if(ruleError) return [ruleError, null];
|
|
15
|
+
|
|
12
16
|
try {
|
|
13
17
|
const fullName = `${owner}:${name}`;
|
|
14
18
|
const detectedType = type || (typeof value === 'object' ? 'json' : typeof value);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import isValidSender from '../email/isValidSender.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Rules for particular settings, checked whenever one is saved (the admin screen, the API and the SDK alike), because a bad value of
|
|
5
|
+
these breaks something site-wide. Resolves to an `{ code, msg }` error, or null when the value is fine.
|
|
6
|
+
*/
|
|
7
|
+
const whole = (value, min, max) => Number.isInteger(Number(value)) && value !== '' && value !== null && Number(value) >= min && Number(value) <= max;
|
|
8
|
+
|
|
9
|
+
const RULES = {
|
|
10
|
+
'system:email_max_retries': value => (whole(value, 0, 10) ? null : { code: 400, msg: 'Retries must be a whole number from 0 to 10' }),
|
|
11
|
+
'system:email_retention_days': value => (whole(value, 0, 3650) ? null : { code: 400, msg: 'Retention must be a whole number of days from 0 to 3650' }),
|
|
12
|
+
'system:email_retention_by_type': value => (value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every(days => whole(days, 0, 3650))
|
|
13
|
+
? null
|
|
14
|
+
: { code: 400, msg: 'Give each kind of email a whole number of days from 0 to 3650, like { "verify-email": 7 }' }),
|
|
15
|
+
'system:email_from': value => (value === '' || isValidSender(value) ? null : { code: 400, msg: 'The sender must look like name@example.com or Shop Name <name@example.com>' }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default (owner, name, value) => {
|
|
19
|
+
const rule = Object.hasOwn(RULES, `${owner}:${name}`) ? RULES[`${owner}:${name}`] : null;
|
|
20
|
+
return rule ? rule(value) : null;
|
|
21
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { setting } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import setSetting from './setSetting.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The settings kempo itself owns, with their defaults. A new site gets them when it is set up (seedDatabase), and `ensureSystemSettings`
|
|
8
|
+
adds any that an older site is missing when the server starts, so a setting added in a later version shows on the settings screen
|
|
9
|
+
and can be edited like the rest. It only ever adds: a setting that exists is never touched, so nothing an admin chose is overwritten.
|
|
10
|
+
*/
|
|
11
|
+
export const SYSTEM_SETTINGS = [
|
|
12
|
+
{ owner: 'system', name: 'site_name', value: 'Kempo Site', type: 'string', isPublic: true, description: 'The name of the website' },
|
|
13
|
+
{ owner: 'system', name: 'session_duration_days', value: 7, type: 'number', isPublic: false, description: 'Session duration in days' },
|
|
14
|
+
{ owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
|
|
15
|
+
{ owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
|
|
16
|
+
{ owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
|
|
17
|
+
{ owner: 'system', name: 'notification_retention_days', value: 90, type: 'number', isPublic: false, description: 'Days to keep notifications before they are deleted (0 keeps them forever)' },
|
|
18
|
+
{ owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
|
|
19
|
+
{ owner: 'system', name: 'email_max_retries', value: 1, type: 'number', isPublic: false, description: 'How many times kempo tries again to send an email that could not be sent, after the first try (0 never retries)' },
|
|
20
|
+
{ owner: 'system', name: 'email_retention_days', value: 30, type: 'number', isPublic: false, description: 'Days to keep the log of sent emails before it is deleted (0 keeps it forever)' },
|
|
21
|
+
{ owner: 'system', name: 'email_retention_by_type', value: {}, type: 'json', isPublic: false, description: 'Days to keep a particular kind of email, instead of the number above, as { "verify-email": 7, "kempo-commerce/order-processing": 365 } (0 keeps that kind forever)' },
|
|
22
|
+
{ owner: 'system', name: 'email_from', value: '', type: 'string', isPublic: false, description: 'The address emails are sent from, as name@example.com or Shop Name <name@example.com>. It must be on a domain verified with the email provider. Empty uses EMAIL_FROM from the server environment' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/* Resolves to [error, { added: [names] }]. */
|
|
26
|
+
export const ensureSystemSettings = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const added = [];
|
|
29
|
+
for(const entry of SYSTEM_SETTINGS){
|
|
30
|
+
const [existing] = await db.select({ name: setting.name }).from(setting).where(eq(setting.name, `${entry.owner}:${entry.name}`)).limit(1);
|
|
31
|
+
if(existing) continue;
|
|
32
|
+
const [error] = await setSetting(entry.owner, entry.name, entry.value, entry.type, entry.isPublic, entry.description);
|
|
33
|
+
if(error) return [error, null];
|
|
34
|
+
added.push(entry.name);
|
|
35
|
+
}
|
|
36
|
+
return [null, { added }];
|
|
37
|
+
} catch {
|
|
38
|
+
return [{ code: 500, msg: 'The system settings could not be checked' }, null];
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -2,54 +2,7 @@ import crypto from 'crypto';
|
|
|
2
2
|
import db from '../../db/index.js';
|
|
3
3
|
import { permission, group, groupPermission, setting, createSettingProtectionTrigger, createGroupProtectionTrigger, createPermissionProtectionTrigger } from '../../db/schema.js';
|
|
4
4
|
import { eq, and, sql } from 'drizzle-orm';
|
|
5
|
-
|
|
6
|
-
const DEFAULT_PERMISSIONS = [
|
|
7
|
-
{ name: 'system:user:create', description: 'Create new users' },
|
|
8
|
-
{ name: 'system:user:read', description: 'View users' },
|
|
9
|
-
{ name: 'system:user:update', description: 'Edit users' },
|
|
10
|
-
{ name: 'system:user:delete', description: 'Delete users' },
|
|
11
|
-
|
|
12
|
-
{ name: 'system:group:create', description: 'Create new groups' },
|
|
13
|
-
{ name: 'system:group:read', description: 'View groups' },
|
|
14
|
-
{ name: 'system:group:update', description: 'Edit groups and manage membership' },
|
|
15
|
-
{ name: 'system:group:delete', description: 'Delete groups' },
|
|
16
|
-
|
|
17
|
-
{ name: 'system:permissions:read', description: 'View permissions for all users' },
|
|
18
|
-
{ name: 'system:permissions:manage', description: 'Add or remove permissions' },
|
|
19
|
-
|
|
20
|
-
{ name: 'system:settings:read', description: 'View private settings' },
|
|
21
|
-
{ name: 'system:settings:update', description: 'Update settings' },
|
|
22
|
-
{ name: 'system:custom-settings:manage', description: 'Create and delete custom settings' },
|
|
23
|
-
|
|
24
|
-
{ name: 'system:pages:read', description: 'View pages in admin' },
|
|
25
|
-
{ name: 'system:pages:create', description: 'Create new pages' },
|
|
26
|
-
{ name: 'system:pages:update', description: 'Edit pages' },
|
|
27
|
-
{ name: 'system:pages:delete', description: 'Delete pages' },
|
|
28
|
-
|
|
29
|
-
{ name: 'system:menus:create', description: 'Create new menus' },
|
|
30
|
-
{ name: 'system:menus:read', description: 'View menus in admin' },
|
|
31
|
-
{ name: 'system:menus:update', description: 'Edit menus and menu items' },
|
|
32
|
-
{ name: 'system:menus:delete', description: 'Delete menus' },
|
|
33
|
-
|
|
34
|
-
{ name: 'system:fragments:read', description: 'View fragments in admin' },
|
|
35
|
-
{ name: 'system:fragments:create', description: 'Create new fragments' },
|
|
36
|
-
{ name: 'system:fragments:update', description: 'Edit fragments' },
|
|
37
|
-
{ name: 'system:fragments:delete', description: 'Delete fragments' },
|
|
38
|
-
|
|
39
|
-
{ name: 'system:globals:read', description: 'View global content in admin' },
|
|
40
|
-
{ name: 'system:globals:create', description: 'Create new global content' },
|
|
41
|
-
{ name: 'system:globals:update', description: 'Edit global content' },
|
|
42
|
-
{ name: 'system:globals:delete', description: 'Delete global content' },
|
|
43
|
-
|
|
44
|
-
{ name: 'system:extensions:read', description: 'View installed extensions' },
|
|
45
|
-
{ name: 'system:extensions:install', description: 'Install extensions' },
|
|
46
|
-
{ name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
|
|
47
|
-
{ name: 'system:extensions:manage', description: 'Enable and disable extensions' },
|
|
48
|
-
|
|
49
|
-
{ name: 'system:realtime:read', description: 'View realtime connections and channels' },
|
|
50
|
-
|
|
51
|
-
{ name: 'system:admin:access', description: 'Access admin panel' },
|
|
52
|
-
];
|
|
5
|
+
import { SYSTEM_PERMISSIONS } from './systemPermissions.js';
|
|
53
6
|
|
|
54
7
|
const DEFAULT_GROUPS = {
|
|
55
8
|
'system:Users': {
|
|
@@ -69,7 +22,7 @@ const DEFAULT_GROUPS = {
|
|
|
69
22
|
'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
|
|
70
23
|
'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
|
|
71
24
|
'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
|
|
72
|
-
'system:realtime:read'
|
|
25
|
+
'system:realtime:read', 'system:emails:read', 'system:emails:manage'
|
|
73
26
|
]
|
|
74
27
|
}
|
|
75
28
|
};
|
|
@@ -88,7 +41,7 @@ const runScript = text => db.$client?.exec ? db.$client.exec(text) : db.execute(
|
|
|
88
41
|
export default async ({ log = console.log } = {}) => {
|
|
89
42
|
log('=== Seeding Permissions ===\n');
|
|
90
43
|
|
|
91
|
-
for(const perm of
|
|
44
|
+
for(const perm of SYSTEM_PERMISSIONS) {
|
|
92
45
|
const existing = await db.select()
|
|
93
46
|
.from(permission)
|
|
94
47
|
.where(eq(permission.name, perm.name))
|
|
@@ -167,18 +120,10 @@ export default async ({ log = console.log } = {}) => {
|
|
|
167
120
|
|
|
168
121
|
log('\n=== Creating Default Settings ===\n');
|
|
169
122
|
|
|
170
|
-
const
|
|
171
|
-
{ owner: 'system', name: 'site_name', value: 'Kempo Site', type: 'string', isPublic: true, description: 'The name of the website' },
|
|
172
|
-
{ owner: 'system', name: 'session_duration_days', value: 7, type: 'number', isPublic: false, description: 'Session duration in days' },
|
|
173
|
-
{ owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
|
|
174
|
-
{ owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
|
|
175
|
-
{ owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
|
|
176
|
-
{ owner: 'system', name: 'notification_retention_days', value: 90, type: 'number', isPublic: false, description: 'Days to keep notifications before they are deleted (0 keeps them forever)' },
|
|
177
|
-
{ owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
|
|
178
|
-
];
|
|
123
|
+
const { SYSTEM_SETTINGS } = await import('../settings/systemSettings.js');
|
|
179
124
|
|
|
180
125
|
const { default: setSetting } = await import('../settings/setSetting.js');
|
|
181
|
-
for(const s of
|
|
126
|
+
for(const s of SYSTEM_SETTINGS){
|
|
182
127
|
await setSetting(s.owner, s.name, s.value, s.type, s.isPublic, s.description);
|
|
183
128
|
log(`✓ Created setting: ${s.owner}:${s.name} = ${s.value}`);
|
|
184
129
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import db from '../../db/index.js';
|
|
2
|
+
import { permission } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The permissions kempo itself defines. A new site gets them when it is set up (seedDatabase), and `ensureSystemPermissions` adds any
|
|
7
|
+
that an older site is missing when the server starts, so a permission added in a later version exists there too. It only adds: one
|
|
8
|
+
that exists is never touched. The Administrators group has every permission without being given each one.
|
|
9
|
+
*/
|
|
10
|
+
export const SYSTEM_PERMISSIONS = [
|
|
11
|
+
{ name: 'system:user:create', description: 'Create new users' },
|
|
12
|
+
{ name: 'system:user:read', description: 'View users' },
|
|
13
|
+
{ name: 'system:user:update', description: 'Edit users' },
|
|
14
|
+
{ name: 'system:user:delete', description: 'Delete users' },
|
|
15
|
+
|
|
16
|
+
{ name: 'system:group:create', description: 'Create new groups' },
|
|
17
|
+
{ name: 'system:group:read', description: 'View groups' },
|
|
18
|
+
{ name: 'system:group:update', description: 'Edit groups and manage membership' },
|
|
19
|
+
{ name: 'system:group:delete', description: 'Delete groups' },
|
|
20
|
+
|
|
21
|
+
{ name: 'system:permissions:read', description: 'View permissions for all users' },
|
|
22
|
+
{ name: 'system:permissions:manage', description: 'Add or remove permissions' },
|
|
23
|
+
|
|
24
|
+
{ name: 'system:settings:read', description: 'View private settings' },
|
|
25
|
+
{ name: 'system:settings:update', description: 'Update settings' },
|
|
26
|
+
{ name: 'system:custom-settings:manage', description: 'Create and delete custom settings' },
|
|
27
|
+
|
|
28
|
+
{ name: 'system:pages:read', description: 'View pages in admin' },
|
|
29
|
+
{ name: 'system:pages:create', description: 'Create new pages' },
|
|
30
|
+
{ name: 'system:pages:update', description: 'Edit pages' },
|
|
31
|
+
{ name: 'system:pages:delete', description: 'Delete pages' },
|
|
32
|
+
|
|
33
|
+
{ name: 'system:menus:create', description: 'Create new menus' },
|
|
34
|
+
{ name: 'system:menus:read', description: 'View menus in admin' },
|
|
35
|
+
{ name: 'system:menus:update', description: 'Edit menus and menu items' },
|
|
36
|
+
{ name: 'system:menus:delete', description: 'Delete menus' },
|
|
37
|
+
|
|
38
|
+
{ name: 'system:fragments:read', description: 'View fragments in admin' },
|
|
39
|
+
{ name: 'system:fragments:create', description: 'Create new fragments' },
|
|
40
|
+
{ name: 'system:fragments:update', description: 'Edit fragments' },
|
|
41
|
+
{ name: 'system:fragments:delete', description: 'Delete fragments' },
|
|
42
|
+
|
|
43
|
+
{ name: 'system:globals:read', description: 'View global content in admin' },
|
|
44
|
+
{ name: 'system:globals:create', description: 'Create new global content' },
|
|
45
|
+
{ name: 'system:globals:update', description: 'Edit global content' },
|
|
46
|
+
{ name: 'system:globals:delete', description: 'Delete global content' },
|
|
47
|
+
|
|
48
|
+
{ name: 'system:extensions:read', description: 'View installed extensions' },
|
|
49
|
+
{ name: 'system:extensions:install', description: 'Install extensions' },
|
|
50
|
+
{ name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
|
|
51
|
+
{ name: 'system:extensions:manage', description: 'Enable and disable extensions' },
|
|
52
|
+
|
|
53
|
+
{ name: 'system:realtime:read', description: 'View realtime connections and channels' },
|
|
54
|
+
|
|
55
|
+
{ name: 'system:emails:read', description: 'View the log of emails, including what they said' },
|
|
56
|
+
{ name: 'system:emails:manage', description: 'Send a test email and try failed emails again' },
|
|
57
|
+
|
|
58
|
+
{ name: 'system:admin:access', description: 'Access admin panel' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/* Resolves to [error, { added: [names] }]. */
|
|
62
|
+
export const ensureSystemPermissions = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const added = [];
|
|
65
|
+
for(const perm of SYSTEM_PERMISSIONS){
|
|
66
|
+
const [existing] = await db.select({ name: permission.name }).from(permission).where(eq(permission.name, perm.name)).limit(1);
|
|
67
|
+
if(existing) continue;
|
|
68
|
+
await db.insert(permission).values({ name: perm.name, description: perm.description, owner: 'system', createdAt: new Date() });
|
|
69
|
+
added.push(perm.name);
|
|
70
|
+
}
|
|
71
|
+
return [null, { added }];
|
|
72
|
+
} catch {
|
|
73
|
+
return [{ code: 500, msg: 'The system permissions could not be checked' }, null];
|
|
74
|
+
}
|
|
75
|
+
};
|
|
@@ -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>
|