kempo 4.9.3 → 4.9.5

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 CHANGED
@@ -376,7 +376,7 @@ Extensions can declare `jobs` in `kempo-config.json` and kempo runs them on a ti
376
376
 
377
377
  ## Email
378
378
 
379
- Kempo does not send email itself. It renders the email, saves it in a log (**Admin > Emails** shows every one, with whether it was sent and why not), and hands it to an **email extension**: `kempo-resend` sends through Resend, `kempo-smtp` through any SMTP server (a Hostinger or Google mailbox, Amazon SES, a Postfix of your own). Install one from Admin > Extensions and enter its settings on the Settings screen; with none installed, emails are logged and fail with "No email extension is installed".
379
+ Kempo does not send email itself. It renders the email, saves it in a log (**Admin > Emails** shows every one, with whether it was sent and why not), and hands it to an **email extension**: `kempo-resend` sends through Resend, `kempo-smtp` through any SMTP server (a Hostinger or Google mailbox, Amazon SES, a Postfix of your own). Install one from Admin > Extensions and enter its settings on the Settings screen; with none installed, emails are logged and fail with "No email extension is installed". **Admin > Emails > Templates** previews and edits the emails kempo sends and any templates the site has added in `templates/emails/` (put a `<name>.sample.json` beside one to give its preview made-up values).
380
380
 
381
381
  The address emails are sent from is the **Email from** setting (`system:email_from`); until it is set, `EMAIL_FROM` from `.env` is used, and it must be on a domain your email service allows. A failed email is tried again (`email_max_retries`, 1 by default) and the log is kept for `email_retention_days` (30 by default; `email_retention_by_type` sets it per kind of email). See [docs/extensions/creating-extensions.md](docs/extensions/creating-extensions.md) to write an email extension.
382
382
 
@@ -36,6 +36,7 @@
36
36
  <location name="account-links" label="Account Links" />
37
37
  <button id="changePasswordBtn" class="btn">Change Password</button>
38
38
  <k-logout-btn redirect="{{pathToRoot}}login" button-class="btn danger">Logout</k-logout-btn>
39
+ <a href="{{pathToRoot}}delete-account/" class="btn danger">Delete my account</a>
39
40
  </div>
40
41
  </div>
41
42
  </main>
@@ -0,0 +1,95 @@
1
+ <!--
2
+ owner: system
3
+ title: Delete your account
4
+ description: Delete your account and the data this site keeps about you
5
+ -->
6
+ <page title="Delete your account">
7
+ <content>
8
+ <main class="p">
9
+ <div style="max-width: 600px; margin: 0 auto;">
10
+ <h2>Delete your account</h2>
11
+
12
+ <div id="done" class="card mb" hidden>
13
+ <h4>Your account has been deleted</h4>
14
+ <p>Your name, email address, password and sign-in details are gone, and so is everything else this site kept about you. Thank you for having been here.</p>
15
+ <a href="{{pathToRoot}}">Back to the home page</a>
16
+ </div>
17
+
18
+ <div id="form">
19
+ <p>This removes your account and everything this site keeps about you: your name, your email address, your password, your sign-in links to other providers, and your sessions. Other parts of this site that keep information about you are told, so they remove theirs too. It cannot be undone.</p>
20
+ <p class="tc-muted">We send a six-digit code to the email address on your account. Entering it proves that you control that address before anything is deleted.</p>
21
+
22
+ <label class="d-b mbq" for="email">Email address on your account</label>
23
+ <input id="email" type="email" autocomplete="email" class="full mb" />
24
+ <button id="send" class="primary" type="button">Send me a code</button>
25
+
26
+ <div id="codeStep" hidden class="mt">
27
+ <label class="d-b mbq" for="code">Code<small class="d-b tc-muted">Six digits. It works for 30 minutes.</small></label>
28
+ <input id="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="full mb" />
29
+ <div class="d-f">
30
+ <button id="delete" class="danger mr" type="button" disabled>Delete my account</button>
31
+ <button id="resend" class="link" type="button">Send a new code</button>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </main>
37
+ </content>
38
+ <content location="scripts">
39
+ <script type="module">
40
+ import Toast from '{{pathToRoot}}kempo-ui/components/Toast.js';
41
+
42
+ const email = document.getElementById('email');
43
+ const code = document.getElementById('code');
44
+ const send = document.getElementById('send');
45
+ const resend = document.getElementById('resend');
46
+ const remove = document.getElementById('delete');
47
+ const post = async (path, body) => {
48
+ const response = await fetch(`{{pathToRoot}}kempo/api/auth/account-deletion/${path}`, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify(body)
52
+ });
53
+ return { ok: response.ok, body: await response.json().catch(() => ({})) };
54
+ };
55
+
56
+ const askForCode = async () => {
57
+ const result = await post('request', { email: email.value.trim() });
58
+ if(!result.ok){
59
+ Toast.error(result.body.error || 'The code could not be sent');
60
+ return;
61
+ }
62
+ document.getElementById('codeStep').hidden = false;
63
+ Toast.success('If an account uses that address, a code is on its way');
64
+ code.focus();
65
+ };
66
+
67
+ send.addEventListener('click', askForCode);
68
+ resend.addEventListener('click', askForCode);
69
+ code.addEventListener('input', () => {
70
+ code.value = code.value.replace(/\D/g, '');
71
+ remove.disabled = code.value.length !== 6;
72
+ });
73
+
74
+ remove.addEventListener('click', async () => {
75
+ remove.disabled = true;
76
+ const result = await post('confirm', { email: email.value.trim(), code: code.value });
77
+ if(!result.ok){
78
+ Toast.error(result.body.error || 'That did not work');
79
+ remove.disabled = code.value.length !== 6;
80
+ return;
81
+ }
82
+ document.getElementById('form').hidden = true;
83
+ document.getElementById('done').hidden = false;
84
+ });
85
+
86
+ try {
87
+ const response = await fetch('{{pathToRoot}}kempo/api/auth/session');
88
+ const data = await response.json();
89
+ if(data.user?.email) email.value = data.user.email;
90
+ } catch {
91
+ /* not signed in, or no session to read: the person types the address */
92
+ }
93
+ </script>
94
+ </content>
95
+ </page>
@@ -0,0 +1,13 @@
1
+ <!--
2
+ owner: system
3
+ -->
4
+ <page template="email" title="Your code to delete your account">
5
+ <content>
6
+ <h2>Delete your account</h2>
7
+ <if condition="name"><p>Hi {{name}},</p></if>
8
+ <if condition="!name"><p>Hello,</p></if>
9
+ <p>Enter this code to delete your account. Deleting it removes your name, email address, password and everything else this site keeps about you.</p>
10
+ <h1 class="ta-center mb">{{code}}</h1>
11
+ <p class="tc-muted">The code works for {{minutes}} minutes and only once. If you did not ask for it, ignore this email: nothing will be deleted.</p>
12
+ </content>
13
+ </page>
@@ -58,7 +58,7 @@ import e from"/kempo-ui/components/ShadowComponent.js";import{html as t,nothing
58
58
  `}renderLast(e){const a=e.lastUpdate;return!a||a.ok?s:t`
59
59
  <div class="b r p mb" role="alert">
60
60
  <h2 class="h5 mb tc-danger">The last update did not work</h2>
61
- <p>The update of ${a.packages?.length?a.packages.join(", "):"Kempo"} on ${d(a.at)} did not stay running (${a.reason}), so the site went back to how it was.${a.breaking?" Your data was restored from the backup taken before it.":""}</p>
61
+ <p>The update${a.packages?.length?` of ${a.packages.join(", ")}`:""} on ${d(a.at)} did not stay running (${a.reason}), so the site went back to how it was.${a.breaking?" Your data was restored from the backup taken before it.":""}</p>
62
62
  </div>
63
63
  `}renderGoBack(e){const a=e.goBack;return a?e.switching?t`<p class="tc-muted mb">The last switch is still being checked, which takes about a minute. Going back is available after that.</p>`:a.available?t`<p class="mb"><button @click=${()=>this.openConfirm("back")}>Go back</button> <span class="tc-muted">Puts the site back on what it ran before the last update${a.packages.length?` (${a.packages.join(", ")})`:""}.${a.restoresData?" That update changed your data, so this also restores the database from before it.":""}</span></p>`:s:s}renderDatabase(e){const{applied:a=[],failed:n=[]}=e.database??{},{upgraded:i=[],failed:r=[]}=e.extensionsRun??{};return t`
64
64
  ${n.length?t`
@@ -1 +1 @@
1
- {"version":3,"file":"src\\admin\\components\\Updates.js","names":["ShadowComponent","html","nothing","Toast","getUpdateStatus","checkForUpdate","prepareUpdate","applyUpdate","rollbackUpdate","discardUpdate","AFTER_RELOAD_KEY","when","value","Date","toLocaleString","sleep","ms","Promise","resolve","setTimeout","count","n","one","many","older","a","b","split","map","Number","reduce","result","part","index","Updates","static","status","state","error","checking","phase","stalled","action","chosen","constructor","super","this","connectedCallback","load","async","msg","selectNew","reportOutcome","switching","recheck","updatable","updateAvailable","key","name","from","runningVersion","to","latestVersion","major","extensionUpdates","updates","update","installedVersion","needs","kempoRequired","seen","Set","has","add","blocked","item","Boolean","chosenItems","filter","toggle","next","delete","expected","JSON","parse","sessionStorage","getItem","removeItem","lastUpdate","ok","at","success","message","check","problem","checkError","length","staged","selection","items","version","some","extensions","prepare","discard","openConfirm","renderRoot","querySelector","open","confirmed","bootId","mode","goBack","data","restoresData","confirm","forced","plan","files","breaking","packages","join","waitForServer","setItem","stringify","now","toISOString","window","location","reload","started","wasDown","res","fetch","credentials","cache","json","oldBootId","renderBusy","title","note","preparing","installing","restarting","renderStalled","renderRestartNote","restart","renderPlan","edited","breakingUpgrades","upgrades","upgrade","breakingExtensions","extension","noChanges","labels","permissions","settings","groups","widgets","jobs","adds","Object","entries","what","description","renderStaged","renderRow","renderAvailable","selected","majors","canUpdate","reason","renderCurrent","renderLast","last","renderGoBack","back","available","renderDatabase","applied","failed","database","upgraded","extensionFailed","extensionsRun","change","renderDialog","render","checkedAt","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Dialog.js';\nimport '/kempo-ui/components/Icon.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { getUpdateStatus, checkForUpdate, prepareUpdate, applyUpdate, rollbackUpdate, discardUpdate } from '/kempo/sdk.js';\n\n/*\n The update screen, served at /admin/components/Updates.js:\n\n <k-admin-updates></k-admin-updates>\n\n Lists what is newer on npm than what the site runs: Kempo itself and each installed extension, which the server looks up\n every 12 hours (and when \"Check now\" is pressed). Each has a box; what is ticked is updated together.\n\n A site that runs from a Docker volume updates in two steps. \"Get ready\" downloads the new versions next to the running site,\n which keeps working, and shows what switching to them would change: starter files that will update, files you edited that\n stay, files that must be replaced anyway, what each extension adds, and any change to your data. \"Switch\" asks for your word\n where that is needed, then the server restarts on the new versions. A version that does not work puts the old ones back by\n itself, and \"Go back\" does it by hand. Any other site installs the update in place and restarts.\n\n While the server is gone this screen asks it, every couple of seconds, whether it is back, then reloads the page and says how\n it went. A server that does not come back in ten minutes is reported rather than waited on forever.\n*/\nconst POLL_MS = 2000;\nconst GIVE_UP_MS = 10 * 60 * 1000;\nconst AFTER_RELOAD_KEY = 'kempo-after-update';\n\nconst when = value => (value ? new Date(value).toLocaleString() : 'never');\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nconst count = (n, one, many) => `${n} ${n === 1 ? one : many}`;\n\nconst older = (a, b) => a.split('.').map(Number).reduce((result, part, index) => result || (part - Number(b.split('.')[index])), 0) < 0;\n\nexport default class Updates extends ShadowComponent {\n static properties = {\n status: { state: true },\n error: { state: true },\n checking: { state: true },\n phase: { state: true },\n stalled: { state: true },\n action: { state: true },\n chosen: { state: true },\n };\n\n constructor(){\n super();\n this.status = null;\n this.error = '';\n this.checking = false;\n this.phase = 'idle';\n this.stalled = false;\n this.action = 'update';\n this.chosen = null;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.load();\n }\n\n /*\n Loading\n */\n load = async () => {\n const [error, status] = await getUpdateStatus();\n if(error){\n this.error = error.msg || 'The update status could not be loaded';\n return;\n }\n this.error = '';\n this.status = status;\n this.selectNew(status);\n this.reportOutcome();\n /* A switch that is still being checked blocks the buttons below for about a minute: look again until it is done. */\n if(status.switching && !this.recheck){\n this.recheck = setTimeout(() => {\n this.recheck = null;\n this.load();\n }, 5000);\n }\n };\n\n /* Everything that can be updated, as { key, name, from, to, major, needs }: Kempo first, then the extensions. */\n updatable = status => [\n ...(status.updateAvailable ? [{ key: 'kempo', name: 'Kempo', from: status.runningVersion, to: status.latestVersion, major: status.major }] : []),\n ...(status.extensionUpdates?.updates ?? []).map(update => ({ key: update.name, name: update.name, from: update.installedVersion, to: update.latestVersion, major: update.major, needs: update.kempoRequired })),\n ];\n\n /* Everything that has just become available is ticked, once; what the person unticks stays unticked. */\n selectNew = status => {\n this.seen ??= new Set();\n const chosen = new Set(this.chosen ?? []);\n for(const { key } of this.updatable(status)){\n if(!this.seen.has(key)){\n this.seen.add(key);\n chosen.add(key);\n }\n }\n this.chosen = chosen;\n };\n\n /* An extension that needs a newer Kempo than the one running can only be chosen together with the Kempo update that provides it. */\n blocked = (item, status) => Boolean(item.needs) && older(status.runningVersion, item.needs) && !(this.chosen.has('kempo') && status.updateAvailable && !older(status.latestVersion, item.needs));\n\n chosenItems = () => this.updatable(this.status).filter(item => this.chosen.has(item.key) && !this.blocked(item, this.status));\n\n toggle = key => {\n const next = new Set(this.chosen);\n if(next.has(key)) next.delete(key);\n else next.add(key);\n this.chosen = next;\n };\n\n /* After the page reloads from an update: say whether the version that was asked for is the one now running. */\n reportOutcome = () => {\n try {\n const expected = JSON.parse(sessionStorage.getItem(AFTER_RELOAD_KEY) || 'null');\n if(!expected) return;\n sessionStorage.removeItem(AFTER_RELOAD_KEY);\n const failed = this.status.lastUpdate && !this.status.lastUpdate.ok && this.status.lastUpdate.at > expected.at;\n if(failed) Toast.error(`The update did not work, so the site went back to how it was`);\n else Toast.success(expected.message);\n } catch {\n /* storage can be blocked; the page has still reloaded */\n }\n };\n\n check = async () => {\n this.checking = true;\n const [error, status] = await checkForUpdate();\n this.checking = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.status = status;\n this.selectNew(status);\n const problem = status.checkError || status.extensionUpdates?.checkError;\n if(problem) Toast.error(problem);\n else if(!this.updatable(status).length && !status.staged) Toast.success('Everything is up to date');\n };\n\n /*\n Getting an update ready (Docker volume sites), or throwing it away\n */\n selection = () => {\n const items = this.chosenItems();\n return { version: items.some(item => item.key === 'kempo') ? this.status.latestVersion : null, extensions: items.filter(item => item.key !== 'kempo').map(item => item.key) };\n };\n\n prepare = async () => {\n this.phase = 'preparing';\n const [error] = await prepareUpdate(this.selection());\n this.phase = 'idle';\n if(error){\n Toast.error(error.msg);\n return;\n }\n await this.load();\n };\n\n discard = async () => {\n const [error] = await discardUpdate();\n if(error){\n Toast.error(error.msg);\n return;\n }\n await this.load();\n };\n\n /*\n Switching, updating in place and going back\n */\n openConfirm = action => {\n this.action = action;\n this.renderRoot.querySelector('#confirmDialog').open();\n };\n\n confirmed = async () => {\n const { staged, bootId, mode, goBack } = this.status;\n this.stalled = false;\n let error;\n let result;\n let message;\n\n if(this.action === 'back'){\n this.phase = 'going-back';\n [error, result] = await rollbackUpdate({ data: Boolean(goBack?.restoresData) });\n message = 'The site is back on what it ran before the last update';\n } else if(mode === 'release'){\n this.phase = 'switching';\n [error, result] = await applyUpdate({ confirm: { forced: staged.plan.files.forced.length > 0, breaking: staged.plan.breaking } });\n message = `Updated: ${staged.packages.join(', ')}`;\n } else {\n this.phase = 'installing';\n const { version, extensions } = this.selection();\n [error, result] = await applyUpdate({ version, extensions });\n message = `Updated: ${this.chosenItems().map(item => `${item.name} ${item.to}`).join(', ')}`;\n }\n\n if(error){\n this.phase = 'idle';\n Toast.error(error.msg);\n return;\n }\n this.phase = 'restarting';\n if(await this.waitForServer(bootId)){\n try {\n sessionStorage.setItem(AFTER_RELOAD_KEY, JSON.stringify({ message, at: new Date(Date.now() - 5000).toISOString(), to: result.to }));\n } catch {\n /* the reload still shows what is running */\n }\n window.location.reload();\n return;\n }\n this.stalled = true;\n };\n\n /*\n True once a server that is not the one asked about answers. The old one may still answer for a moment after\n the request that restarts it, so a reply only counts if it carries a different boot id, or if the server\n has been seen gone first (any reply short of a server error then comes from the new one, even a sign-in\n redirect that has no boot id).\n */\n waitForServer = async oldBootId => {\n const started = Date.now();\n let wasDown = false;\n while(Date.now() - started < GIVE_UP_MS){\n await sleep(POLL_MS);\n try {\n const res = await fetch('/kempo/api/updates?offline=true', { credentials: 'same-origin', cache: 'no-store' });\n if(res.ok){\n if((await res.json()).bootId !== oldBootId) return true;\n } else if(res.status >= 500){\n wasDown = true;\n } else if(wasDown){\n return true;\n }\n } catch {\n wasDown = true;\n }\n }\n return false;\n };\n\n /*\n Rendering\n */\n renderBusy(){\n const [title, note] = {\n preparing: ['Downloading the updates…', 'Your site keeps working while this happens. It can take a few minutes.'],\n installing: ['Installing the updates…', 'This can take a minute.'],\n switching: ['Switching to the updates…', 'This can take a minute.'],\n 'going-back': ['Going back…', 'This can take a minute.'],\n restarting: ['Restarting the server…', 'This page reloads by itself when the site is back, and updated extensions are brought up to date first.'],\n }[this.phase];\n return html`\n <div class=\"b r p mb\" role=\"status\">\n <p class=\"d-f mb\"><k-spinner class=\"mr\"></k-spinner><strong>${title}</strong></p>\n <p class=\"tc-muted\">${note} Please keep this page open.</p>\n </div>\n `;\n }\n\n renderStalled(){\n return html`\n <div class=\"b r p mb\" role=\"alert\">\n <p class=\"tc-danger mb\"><strong>The server did not come back within ten minutes.</strong></p>\n <p class=\"mb\">Check that the server process is running; if it is managed by pm2, systemd or Docker, check that it restarts when it exits.</p>\n <button class=\"primary\" @click=${() => window.location.reload()}>Reload this page</button>\n </div>\n `;\n }\n\n renderRestartNote(status){\n return status.restart === 'respawn'\n ? html`The site is unavailable for a few seconds while the server restarts.`\n : html`The server restarts, which takes a few seconds, and the site is unavailable until it is back.`;\n }\n\n /* What switching to the prepared versions would change. */\n renderPlan(plan){\n const { update, add, edited, forced } = plan.files;\n const breakingUpgrades = plan.upgrades.filter(upgrade => upgrade.breaking);\n const breakingExtensions = plan.extensions.filter(extension => extension.breaking);\n const noChanges = !update.length && !add.length && !edited.length && !forced.length && !plan.upgrades.length && !plan.extensions.length;\n const labels = { permissions: ['permission', 'permissions'], settings: ['setting', 'settings'], groups: ['group', 'groups'], widgets: ['dashboard widget', 'dashboard widgets'], jobs: ['scheduled job', 'scheduled jobs'] };\n const adds = extension => Object.entries(extension.adds).filter(([, n]) => n).map(([what, n]) => count(n, ...labels[what])).join(', ');\n return html`\n <ul class=\"mb\">\n ${noChanges ? html`<li>Nothing changes except the code.</li>` : nothing}\n ${plan.extensions.map(extension => html`<li>${extension.name} ${extension.from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${extension.to}${adds(extension) ? html` <span class=\"tc-muted\">adds ${adds(extension)}</span>` : nothing}</li>`)}\n ${update.length ? html`<li>${count(update.length, 'starter file', 'starter files')} you have not edited will be updated.</li>` : nothing}\n ${add.length ? html`<li>${count(add.length, 'new file', 'new files')} will be added.</li>` : nothing}\n ${edited.length ? html`<li>${count(edited.length, 'file', 'files')} you have edited will stay as you made ${edited.length === 1 ? 'it' : 'them'}, even though Kempo has a newer version: <span class=\"tc-muted\">${edited.join(', ')}</span></li>` : nothing}\n ${forced.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> ${count(forced.length, 'file', 'files')} you have edited must be replaced for security reasons. Your version is saved first: <span class=\"tc-muted\">${forced.join(', ')}</span></li>` : nothing}\n ${breakingUpgrades.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> Kempo changes your data${breakingUpgrades.some(upgrade => upgrade.description) ? html` (${breakingUpgrades.map(upgrade => upgrade.description).filter(Boolean).join('; ')})` : nothing}.</li>` : nothing}\n ${breakingExtensions.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> ${breakingExtensions.map(extension => extension.name).join(', ')} ${breakingExtensions.length === 1 ? 'is a breaking update that' : 'are breaking updates that'} may change your data.</li>` : nothing}\n ${plan.breaking ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> A backup of the database is taken first. Going back later restores it, which loses anything entered after the update.</li>` : nothing}\n </ul>\n `;\n }\n\n renderStaged(status){\n const { staged } = status;\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\">${count(staged.packages.length, 'update is', 'updates are')} ready</h2>\n <p class=\"tc-muted mb\">${staged.packages.join(', ')}. ${staged.packages.length === 1 ? 'It is' : 'They are'} downloaded next to your running site, which has not changed. Switching will:</p>\n ${this.renderPlan(staged.plan)}\n <p class=\"tc-muted mb\">${this.renderRestartNote(status)}</p>\n ${status.switching ? html`<p class=\"tc-muted mb\">The last switch is still being checked, which takes about a minute after the restart. You can switch when that is done.</p>` : nothing}\n <button class=\"primary mr\" ?disabled=${Boolean(status.switching)} @click=${() => this.openConfirm('update')}>Switch now</button>\n <button @click=${this.discard}>Not now</button>\n </div>\n `;\n }\n\n renderRow(item, status){\n const blocked = this.blocked(item, status);\n return html`\n <label class=\"d-f mb\">\n <input type=\"checkbox\" class=\"mr\" ?checked=${this.chosen.has(item.key) && !blocked} ?disabled=${blocked || this.phase !== 'idle'} @change=${() => this.toggle(item.key)}>\n <span><strong>${item.name}</strong> ${item.from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${item.to}${item.major ? html` <span class=\"tc-warning\">breaking release</span>` : nothing}\n ${blocked ? html`<small class=\"d-b tc-muted\">Needs Kempo ${item.needs} or newer: tick Kempo as well.</small>` : nothing}</span>\n </label>\n `;\n }\n\n renderAvailable(status){\n const items = this.updatable(status);\n const selected = this.chosenItems();\n const majors = selected.filter(item => item.major);\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\">${count(items.length, 'update is', 'updates are')} available</h2>\n <div class=\"mb\">${items.map(item => this.renderRow(item, status))}</div>\n ${majors.length ? html`<p class=\"tc-warning mb\" role=\"alert\"><k-icon name=\"warning\"></k-icon> ${majors.map(item => item.name).join(', ')} ${majors.length === 1 ? 'is a breaking release' : 'are breaking releases'} and may change how things work. Read ${majors.length === 1 ? 'its' : 'their'} release notes before updating.</p>` : nothing}\n ${status.canUpdate ? (status.mode === 'release' ? html`\n <p class=\"tc-muted mb\">Getting them ready downloads them without touching your site. You choose when to switch.</p>\n <button class=\"primary\" ?disabled=${!selected.length} @click=${this.prepare}>Get ${count(selected.length, 'update', 'updates')} ready</button>\n ` : html`\n <p class=\"tc-muted mb\">${this.renderRestartNote(status)}</p>\n <button class=\"primary\" ?disabled=${!selected.length} @click=${() => this.openConfirm('update')}>Update ${count(selected.length, 'item', 'items')}</button>\n `) : html`<p class=\"mb\">${status.reason} Update where it is installed, then restart the server.</p>`}\n </div>\n `;\n }\n\n renderCurrent(){\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\"><k-icon name=\"check\" class=\"tc-success\"></k-icon> Everything is up to date</h2>\n <p class=\"tc-muted\">This site runs Kempo ${this.status.runningVersion}${this.status.latestVersion ? ', the newest version' : ''}, and its extensions are on their newest versions.</p>\n </div>\n `;\n }\n\n renderLast(status){\n const last = status.lastUpdate;\n if(!last || last.ok) return nothing;\n return html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">The last update did not work</h2>\n <p>The update of ${last.packages?.length ? last.packages.join(', ') : 'Kempo'} on ${when(last.at)} did not stay running (${last.reason}), so the site went back to how it was.${last.breaking ? ' Your data was restored from the backup taken before it.' : ''}</p>\n </div>\n `;\n }\n\n renderGoBack(status){\n const back = status.goBack;\n if(!back) return nothing;\n if(status.switching) return html`<p class=\"tc-muted mb\">The last switch is still being checked, which takes about a minute. Going back is available after that.</p>`;\n if(!back.available) return nothing;\n return html`<p class=\"mb\"><button @click=${() => this.openConfirm('back')}>Go back</button> <span class=\"tc-muted\">Puts the site back on what it ran before the last update${back.packages.length ? ` (${back.packages.join(', ')})` : ''}.${back.restoresData ? ' That update changed your data, so this also restores the database from before it.' : ''}</span></p>`;\n }\n\n renderDatabase(status){\n const { applied = [], failed = [] } = status.database ?? {};\n const { upgraded = [], failed: extensionFailed = [] } = status.extensionsRun ?? {};\n return html`\n ${failed.length ? html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">The database needs attention</h2>\n <p class=\"mb\">Kempo started, but these changes to its tables were not applied. Anything that depends on them may not work until they are.</p>\n <ul class=\"mb\">\n ${failed.map(({ change, reason }) => html`<li>${change}: <span class=\"tc-danger\">${reason}</span></li>`)}\n </ul>\n <p class=\"tc-muted\">Kempo only ever adds to its tables on its own. It never drops or changes anything that holds data, so these need someone who can look at the database.</p>\n </div>\n ` : nothing}\n ${extensionFailed.length ? html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">An extension could not be brought up to date</h2>\n <ul class=\"mb\">${extensionFailed.map(({ name, from, to, reason }) => html`<li>${name} ${from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${to}: <span class=\"tc-danger\">${reason}</span></li>`)}</ul>\n <p class=\"tc-muted\">It is tried again the next time the server starts.</p>\n </div>\n ` : nothing}\n ${applied.length || upgraded.length ? html`\n <div class=\"b r p mb\">\n <h2 class=\"h5 mb\">Brought up to date when the server last started</h2>\n <ul>\n ${upgraded.map(({ name, from, to }) => html`<li>${name} ${from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${to}</li>`)}\n ${applied.map(change => html`<li>${change}</li>`)}\n </ul>\n </div>\n ` : nothing}\n `;\n }\n\n renderDialog(status){\n const back = this.action === 'back';\n const forced = status.staged?.plan.files.forced ?? [];\n const breaking = !back && (status.staged?.plan.breaking ?? false);\n const items = status.mode === 'release' ? (status.staged?.packages ?? []) : this.chosen ? this.chosenItems().map(item => `${item.name} ${item.to}`) : [];\n return html`\n <k-dialog id=\"confirmDialog\" confirm-text=${back ? 'Go back and restart' : 'Update and restart'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmed}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${back ? 'Go back' : 'Update'}</h2>\n <div class=\"p\">\n <p class=\"mb\">${back ? 'This puts the site back on what it ran before the last update.' : `This updates ${items.join(', ')}.`} ${this.renderRestartNote(status)}</p>\n ${forced.length ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> Your edits to ${forced.join(', ')} will be replaced. Your version is saved first.</p>` : nothing}\n ${breaking ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> This update changes your data. A backup is taken first, and going back later restores it, which loses anything entered after the update.</p>` : nothing}\n ${back && status.goBack?.restoresData ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> The update changed your data, so going back restores the database from before it. Anything entered since is lost.</p>` : nothing}\n <p class=\"tc-muted\">Anyone using the site meanwhile will not be able to reach it. This page reloads when the server is back.</p>\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n const status = this.status;\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n if(!status) return html`<k-spinner></k-spinner>`;\n const items = this.updatable(status);\n return html`\n ${this.phase !== 'idle' ? this.renderBusy() : nothing}\n ${this.stalled ? this.renderStalled() : nothing}\n ${this.phase === 'idle' ? html`\n ${this.renderLast(status)}\n ${this.renderDatabase(status)}\n ${status.staged ? this.renderStaged(status) : (items.length ? this.renderAvailable(status) : this.renderCurrent())}\n ${status.checkError || status.extensionUpdates?.checkError ? html`<p class=\"tc-danger mb\" role=\"alert\">The last check failed: ${status.checkError || status.extensionUpdates.checkError}</p>` : nothing}\n <p class=\"d-f mb\">\n <button ?disabled=${this.checking} @click=${this.check}>${this.checking ? 'Checking…' : 'Check now'}</button>\n <span class=\"tc-muted ph\">Last checked ${when(status.checkedAt)}</span>\n </p>\n ${this.renderGoBack(status)}\n <p class=\"tc-muted\">Automatic checks can be turned off with <strong>update_check_enabled</strong> on the System tab of <a href=\"/admin/settings\">Settings</a>. Installing or removing an extension is done from <a href=\"/admin/extensions\">Extensions</a>.</p>\n ` : nothing}\n ${this.renderDialog(status)}\n `;\n }\n}\n\ncustomElements.define('k-admin-updates', Updates);\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"src\\admin\\components\\Updates.js","names":["ShadowComponent","html","nothing","Toast","getUpdateStatus","checkForUpdate","prepareUpdate","applyUpdate","rollbackUpdate","discardUpdate","AFTER_RELOAD_KEY","when","value","Date","toLocaleString","sleep","ms","Promise","resolve","setTimeout","count","n","one","many","older","a","b","split","map","Number","reduce","result","part","index","Updates","static","status","state","error","checking","phase","stalled","action","chosen","constructor","super","this","connectedCallback","load","async","msg","selectNew","reportOutcome","switching","recheck","updatable","updateAvailable","key","name","from","runningVersion","to","latestVersion","major","extensionUpdates","updates","update","installedVersion","needs","kempoRequired","seen","Set","has","add","blocked","item","Boolean","chosenItems","filter","toggle","next","delete","expected","JSON","parse","sessionStorage","getItem","removeItem","lastUpdate","ok","at","success","message","check","problem","checkError","length","staged","selection","items","version","some","extensions","prepare","discard","openConfirm","renderRoot","querySelector","open","confirmed","bootId","mode","goBack","data","restoresData","confirm","forced","plan","files","breaking","packages","join","waitForServer","setItem","stringify","now","toISOString","window","location","reload","started","wasDown","res","fetch","credentials","cache","json","oldBootId","renderBusy","title","note","preparing","installing","restarting","renderStalled","renderRestartNote","restart","renderPlan","edited","breakingUpgrades","upgrades","upgrade","breakingExtensions","extension","noChanges","labels","permissions","settings","groups","widgets","jobs","adds","Object","entries","what","description","renderStaged","renderRow","renderAvailable","selected","majors","canUpdate","reason","renderCurrent","renderLast","last","renderGoBack","back","available","renderDatabase","applied","failed","database","upgraded","extensionFailed","extensionsRun","change","renderDialog","render","checkedAt","customElements","define"],"sources":["0"],"sourcesContent":["import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';\nimport { html, nothing } from '/kempo-ui/lit-all.min.js';\nimport '/kempo-ui/components/Spinner.js';\nimport '/kempo-ui/components/Dialog.js';\nimport '/kempo-ui/components/Icon.js';\nimport Toast from '/kempo-ui/components/Toast.js';\nimport { getUpdateStatus, checkForUpdate, prepareUpdate, applyUpdate, rollbackUpdate, discardUpdate } from '/kempo/sdk.js';\n\n/*\n The update screen, served at /admin/components/Updates.js:\n\n <k-admin-updates></k-admin-updates>\n\n Lists what is newer on npm than what the site runs: Kempo itself and each installed extension, which the server looks up\n every 12 hours (and when \"Check now\" is pressed). Each has a box; what is ticked is updated together.\n\n A site that runs from a Docker volume updates in two steps. \"Get ready\" downloads the new versions next to the running site,\n which keeps working, and shows what switching to them would change: starter files that will update, files you edited that\n stay, files that must be replaced anyway, what each extension adds, and any change to your data. \"Switch\" asks for your word\n where that is needed, then the server restarts on the new versions. A version that does not work puts the old ones back by\n itself, and \"Go back\" does it by hand. Any other site installs the update in place and restarts.\n\n While the server is gone this screen asks it, every couple of seconds, whether it is back, then reloads the page and says how\n it went. A server that does not come back in ten minutes is reported rather than waited on forever.\n*/\nconst POLL_MS = 2000;\nconst GIVE_UP_MS = 10 * 60 * 1000;\nconst AFTER_RELOAD_KEY = 'kempo-after-update';\n\nconst when = value => (value ? new Date(value).toLocaleString() : 'never');\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nconst count = (n, one, many) => `${n} ${n === 1 ? one : many}`;\n\nconst older = (a, b) => a.split('.').map(Number).reduce((result, part, index) => result || (part - Number(b.split('.')[index])), 0) < 0;\n\nexport default class Updates extends ShadowComponent {\n static properties = {\n status: { state: true },\n error: { state: true },\n checking: { state: true },\n phase: { state: true },\n stalled: { state: true },\n action: { state: true },\n chosen: { state: true },\n };\n\n constructor(){\n super();\n this.status = null;\n this.error = '';\n this.checking = false;\n this.phase = 'idle';\n this.stalled = false;\n this.action = 'update';\n this.chosen = null;\n }\n\n /*\n Lifecycle\n */\n connectedCallback(){\n super.connectedCallback();\n this.load();\n }\n\n /*\n Loading\n */\n load = async () => {\n const [error, status] = await getUpdateStatus();\n if(error){\n this.error = error.msg || 'The update status could not be loaded';\n return;\n }\n this.error = '';\n this.status = status;\n this.selectNew(status);\n this.reportOutcome();\n /* A switch that is still being checked blocks the buttons below for about a minute: look again until it is done. */\n if(status.switching && !this.recheck){\n this.recheck = setTimeout(() => {\n this.recheck = null;\n this.load();\n }, 5000);\n }\n };\n\n /* Everything that can be updated, as { key, name, from, to, major, needs }: Kempo first, then the extensions. */\n updatable = status => [\n ...(status.updateAvailable ? [{ key: 'kempo', name: 'Kempo', from: status.runningVersion, to: status.latestVersion, major: status.major }] : []),\n ...(status.extensionUpdates?.updates ?? []).map(update => ({ key: update.name, name: update.name, from: update.installedVersion, to: update.latestVersion, major: update.major, needs: update.kempoRequired })),\n ];\n\n /* Everything that has just become available is ticked, once; what the person unticks stays unticked. */\n selectNew = status => {\n this.seen ??= new Set();\n const chosen = new Set(this.chosen ?? []);\n for(const { key } of this.updatable(status)){\n if(!this.seen.has(key)){\n this.seen.add(key);\n chosen.add(key);\n }\n }\n this.chosen = chosen;\n };\n\n /* An extension that needs a newer Kempo than the one running can only be chosen together with the Kempo update that provides it. */\n blocked = (item, status) => Boolean(item.needs) && older(status.runningVersion, item.needs) && !(this.chosen.has('kempo') && status.updateAvailable && !older(status.latestVersion, item.needs));\n\n chosenItems = () => this.updatable(this.status).filter(item => this.chosen.has(item.key) && !this.blocked(item, this.status));\n\n toggle = key => {\n const next = new Set(this.chosen);\n if(next.has(key)) next.delete(key);\n else next.add(key);\n this.chosen = next;\n };\n\n /* After the page reloads from an update: say whether the version that was asked for is the one now running. */\n reportOutcome = () => {\n try {\n const expected = JSON.parse(sessionStorage.getItem(AFTER_RELOAD_KEY) || 'null');\n if(!expected) return;\n sessionStorage.removeItem(AFTER_RELOAD_KEY);\n const failed = this.status.lastUpdate && !this.status.lastUpdate.ok && this.status.lastUpdate.at > expected.at;\n if(failed) Toast.error(`The update did not work, so the site went back to how it was`);\n else Toast.success(expected.message);\n } catch {\n /* storage can be blocked; the page has still reloaded */\n }\n };\n\n check = async () => {\n this.checking = true;\n const [error, status] = await checkForUpdate();\n this.checking = false;\n if(error){\n Toast.error(error.msg);\n return;\n }\n this.status = status;\n this.selectNew(status);\n const problem = status.checkError || status.extensionUpdates?.checkError;\n if(problem) Toast.error(problem);\n else if(!this.updatable(status).length && !status.staged) Toast.success('Everything is up to date');\n };\n\n /*\n Getting an update ready (Docker volume sites), or throwing it away\n */\n selection = () => {\n const items = this.chosenItems();\n return { version: items.some(item => item.key === 'kempo') ? this.status.latestVersion : null, extensions: items.filter(item => item.key !== 'kempo').map(item => item.key) };\n };\n\n prepare = async () => {\n this.phase = 'preparing';\n const [error] = await prepareUpdate(this.selection());\n this.phase = 'idle';\n if(error){\n Toast.error(error.msg);\n return;\n }\n await this.load();\n };\n\n discard = async () => {\n const [error] = await discardUpdate();\n if(error){\n Toast.error(error.msg);\n return;\n }\n await this.load();\n };\n\n /*\n Switching, updating in place and going back\n */\n openConfirm = action => {\n this.action = action;\n this.renderRoot.querySelector('#confirmDialog').open();\n };\n\n confirmed = async () => {\n const { staged, bootId, mode, goBack } = this.status;\n this.stalled = false;\n let error;\n let result;\n let message;\n\n if(this.action === 'back'){\n this.phase = 'going-back';\n [error, result] = await rollbackUpdate({ data: Boolean(goBack?.restoresData) });\n message = 'The site is back on what it ran before the last update';\n } else if(mode === 'release'){\n this.phase = 'switching';\n [error, result] = await applyUpdate({ confirm: { forced: staged.plan.files.forced.length > 0, breaking: staged.plan.breaking } });\n message = `Updated: ${staged.packages.join(', ')}`;\n } else {\n this.phase = 'installing';\n const { version, extensions } = this.selection();\n [error, result] = await applyUpdate({ version, extensions });\n message = `Updated: ${this.chosenItems().map(item => `${item.name} ${item.to}`).join(', ')}`;\n }\n\n if(error){\n this.phase = 'idle';\n Toast.error(error.msg);\n return;\n }\n this.phase = 'restarting';\n if(await this.waitForServer(bootId)){\n try {\n sessionStorage.setItem(AFTER_RELOAD_KEY, JSON.stringify({ message, at: new Date(Date.now() - 5000).toISOString(), to: result.to }));\n } catch {\n /* the reload still shows what is running */\n }\n window.location.reload();\n return;\n }\n this.stalled = true;\n };\n\n /*\n True once a server that is not the one asked about answers. The old one may still answer for a moment after\n the request that restarts it, so a reply only counts if it carries a different boot id, or if the server\n has been seen gone first (any reply short of a server error then comes from the new one, even a sign-in\n redirect that has no boot id).\n */\n waitForServer = async oldBootId => {\n const started = Date.now();\n let wasDown = false;\n while(Date.now() - started < GIVE_UP_MS){\n await sleep(POLL_MS);\n try {\n const res = await fetch('/kempo/api/updates?offline=true', { credentials: 'same-origin', cache: 'no-store' });\n if(res.ok){\n if((await res.json()).bootId !== oldBootId) return true;\n } else if(res.status >= 500){\n wasDown = true;\n } else if(wasDown){\n return true;\n }\n } catch {\n wasDown = true;\n }\n }\n return false;\n };\n\n /*\n Rendering\n */\n renderBusy(){\n const [title, note] = {\n preparing: ['Downloading the updates…', 'Your site keeps working while this happens. It can take a few minutes.'],\n installing: ['Installing the updates…', 'This can take a minute.'],\n switching: ['Switching to the updates…', 'This can take a minute.'],\n 'going-back': ['Going back…', 'This can take a minute.'],\n restarting: ['Restarting the server…', 'This page reloads by itself when the site is back, and updated extensions are brought up to date first.'],\n }[this.phase];\n return html`\n <div class=\"b r p mb\" role=\"status\">\n <p class=\"d-f mb\"><k-spinner class=\"mr\"></k-spinner><strong>${title}</strong></p>\n <p class=\"tc-muted\">${note} Please keep this page open.</p>\n </div>\n `;\n }\n\n renderStalled(){\n return html`\n <div class=\"b r p mb\" role=\"alert\">\n <p class=\"tc-danger mb\"><strong>The server did not come back within ten minutes.</strong></p>\n <p class=\"mb\">Check that the server process is running; if it is managed by pm2, systemd or Docker, check that it restarts when it exits.</p>\n <button class=\"primary\" @click=${() => window.location.reload()}>Reload this page</button>\n </div>\n `;\n }\n\n renderRestartNote(status){\n return status.restart === 'respawn'\n ? html`The site is unavailable for a few seconds while the server restarts.`\n : html`The server restarts, which takes a few seconds, and the site is unavailable until it is back.`;\n }\n\n /* What switching to the prepared versions would change. */\n renderPlan(plan){\n const { update, add, edited, forced } = plan.files;\n const breakingUpgrades = plan.upgrades.filter(upgrade => upgrade.breaking);\n const breakingExtensions = plan.extensions.filter(extension => extension.breaking);\n const noChanges = !update.length && !add.length && !edited.length && !forced.length && !plan.upgrades.length && !plan.extensions.length;\n const labels = { permissions: ['permission', 'permissions'], settings: ['setting', 'settings'], groups: ['group', 'groups'], widgets: ['dashboard widget', 'dashboard widgets'], jobs: ['scheduled job', 'scheduled jobs'] };\n const adds = extension => Object.entries(extension.adds).filter(([, n]) => n).map(([what, n]) => count(n, ...labels[what])).join(', ');\n return html`\n <ul class=\"mb\">\n ${noChanges ? html`<li>Nothing changes except the code.</li>` : nothing}\n ${plan.extensions.map(extension => html`<li>${extension.name} ${extension.from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${extension.to}${adds(extension) ? html` <span class=\"tc-muted\">adds ${adds(extension)}</span>` : nothing}</li>`)}\n ${update.length ? html`<li>${count(update.length, 'starter file', 'starter files')} you have not edited will be updated.</li>` : nothing}\n ${add.length ? html`<li>${count(add.length, 'new file', 'new files')} will be added.</li>` : nothing}\n ${edited.length ? html`<li>${count(edited.length, 'file', 'files')} you have edited will stay as you made ${edited.length === 1 ? 'it' : 'them'}, even though Kempo has a newer version: <span class=\"tc-muted\">${edited.join(', ')}</span></li>` : nothing}\n ${forced.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> ${count(forced.length, 'file', 'files')} you have edited must be replaced for security reasons. Your version is saved first: <span class=\"tc-muted\">${forced.join(', ')}</span></li>` : nothing}\n ${breakingUpgrades.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> Kempo changes your data${breakingUpgrades.some(upgrade => upgrade.description) ? html` (${breakingUpgrades.map(upgrade => upgrade.description).filter(Boolean).join('; ')})` : nothing}.</li>` : nothing}\n ${breakingExtensions.length ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> ${breakingExtensions.map(extension => extension.name).join(', ')} ${breakingExtensions.length === 1 ? 'is a breaking update that' : 'are breaking updates that'} may change your data.</li>` : nothing}\n ${plan.breaking ? html`<li class=\"tc-warning\"><k-icon name=\"warning\"></k-icon> A backup of the database is taken first. Going back later restores it, which loses anything entered after the update.</li>` : nothing}\n </ul>\n `;\n }\n\n renderStaged(status){\n const { staged } = status;\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\">${count(staged.packages.length, 'update is', 'updates are')} ready</h2>\n <p class=\"tc-muted mb\">${staged.packages.join(', ')}. ${staged.packages.length === 1 ? 'It is' : 'They are'} downloaded next to your running site, which has not changed. Switching will:</p>\n ${this.renderPlan(staged.plan)}\n <p class=\"tc-muted mb\">${this.renderRestartNote(status)}</p>\n ${status.switching ? html`<p class=\"tc-muted mb\">The last switch is still being checked, which takes about a minute after the restart. You can switch when that is done.</p>` : nothing}\n <button class=\"primary mr\" ?disabled=${Boolean(status.switching)} @click=${() => this.openConfirm('update')}>Switch now</button>\n <button @click=${this.discard}>Not now</button>\n </div>\n `;\n }\n\n renderRow(item, status){\n const blocked = this.blocked(item, status);\n return html`\n <label class=\"d-f mb\">\n <input type=\"checkbox\" class=\"mr\" ?checked=${this.chosen.has(item.key) && !blocked} ?disabled=${blocked || this.phase !== 'idle'} @change=${() => this.toggle(item.key)}>\n <span><strong>${item.name}</strong> ${item.from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${item.to}${item.major ? html` <span class=\"tc-warning\">breaking release</span>` : nothing}\n ${blocked ? html`<small class=\"d-b tc-muted\">Needs Kempo ${item.needs} or newer: tick Kempo as well.</small>` : nothing}</span>\n </label>\n `;\n }\n\n renderAvailable(status){\n const items = this.updatable(status);\n const selected = this.chosenItems();\n const majors = selected.filter(item => item.major);\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\">${count(items.length, 'update is', 'updates are')} available</h2>\n <div class=\"mb\">${items.map(item => this.renderRow(item, status))}</div>\n ${majors.length ? html`<p class=\"tc-warning mb\" role=\"alert\"><k-icon name=\"warning\"></k-icon> ${majors.map(item => item.name).join(', ')} ${majors.length === 1 ? 'is a breaking release' : 'are breaking releases'} and may change how things work. Read ${majors.length === 1 ? 'its' : 'their'} release notes before updating.</p>` : nothing}\n ${status.canUpdate ? (status.mode === 'release' ? html`\n <p class=\"tc-muted mb\">Getting them ready downloads them without touching your site. You choose when to switch.</p>\n <button class=\"primary\" ?disabled=${!selected.length} @click=${this.prepare}>Get ${count(selected.length, 'update', 'updates')} ready</button>\n ` : html`\n <p class=\"tc-muted mb\">${this.renderRestartNote(status)}</p>\n <button class=\"primary\" ?disabled=${!selected.length} @click=${() => this.openConfirm('update')}>Update ${count(selected.length, 'item', 'items')}</button>\n `) : html`<p class=\"mb\">${status.reason} Update where it is installed, then restart the server.</p>`}\n </div>\n `;\n }\n\n renderCurrent(){\n return html`\n <div class=\"b r p mb\">\n <h2 class=\"h4 mb\"><k-icon name=\"check\" class=\"tc-success\"></k-icon> Everything is up to date</h2>\n <p class=\"tc-muted\">This site runs Kempo ${this.status.runningVersion}${this.status.latestVersion ? ', the newest version' : ''}, and its extensions are on their newest versions.</p>\n </div>\n `;\n }\n\n renderLast(status){\n const last = status.lastUpdate;\n if(!last || last.ok) return nothing;\n return html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">The last update did not work</h2>\n <p>The update${last.packages?.length ? ` of ${last.packages.join(', ')}` : ''} on ${when(last.at)} did not stay running (${last.reason}), so the site went back to how it was.${last.breaking ? ' Your data was restored from the backup taken before it.' : ''}</p>\n </div>\n `;\n }\n\n renderGoBack(status){\n const back = status.goBack;\n if(!back) return nothing;\n if(status.switching) return html`<p class=\"tc-muted mb\">The last switch is still being checked, which takes about a minute. Going back is available after that.</p>`;\n if(!back.available) return nothing;\n return html`<p class=\"mb\"><button @click=${() => this.openConfirm('back')}>Go back</button> <span class=\"tc-muted\">Puts the site back on what it ran before the last update${back.packages.length ? ` (${back.packages.join(', ')})` : ''}.${back.restoresData ? ' That update changed your data, so this also restores the database from before it.' : ''}</span></p>`;\n }\n\n renderDatabase(status){\n const { applied = [], failed = [] } = status.database ?? {};\n const { upgraded = [], failed: extensionFailed = [] } = status.extensionsRun ?? {};\n return html`\n ${failed.length ? html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">The database needs attention</h2>\n <p class=\"mb\">Kempo started, but these changes to its tables were not applied. Anything that depends on them may not work until they are.</p>\n <ul class=\"mb\">\n ${failed.map(({ change, reason }) => html`<li>${change}: <span class=\"tc-danger\">${reason}</span></li>`)}\n </ul>\n <p class=\"tc-muted\">Kempo only ever adds to its tables on its own. It never drops or changes anything that holds data, so these need someone who can look at the database.</p>\n </div>\n ` : nothing}\n ${extensionFailed.length ? html`\n <div class=\"b r p mb\" role=\"alert\">\n <h2 class=\"h5 mb tc-danger\">An extension could not be brought up to date</h2>\n <ul class=\"mb\">${extensionFailed.map(({ name, from, to, reason }) => html`<li>${name} ${from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${to}: <span class=\"tc-danger\">${reason}</span></li>`)}</ul>\n <p class=\"tc-muted\">It is tried again the next time the server starts.</p>\n </div>\n ` : nothing}\n ${applied.length || upgraded.length ? html`\n <div class=\"b r p mb\">\n <h2 class=\"h5 mb\">Brought up to date when the server last started</h2>\n <ul>\n ${upgraded.map(({ name, from, to }) => html`<li>${name} ${from} <k-icon name=\"arrow\" direction=\"right\"></k-icon> ${to}</li>`)}\n ${applied.map(change => html`<li>${change}</li>`)}\n </ul>\n </div>\n ` : nothing}\n `;\n }\n\n renderDialog(status){\n const back = this.action === 'back';\n const forced = status.staged?.plan.files.forced ?? [];\n const breaking = !back && (status.staged?.plan.breaking ?? false);\n const items = status.mode === 'release' ? (status.staged?.packages ?? []) : this.chosen ? this.chosenItems().map(item => `${item.name} ${item.to}`) : [];\n return html`\n <k-dialog id=\"confirmDialog\" confirm-text=${back ? 'Go back and restart' : 'Update and restart'} confirm-classes=\"primary ml\" cancel-text=\"Cancel\" .confirmAction=${this.confirmed}>\n <h2 slot=\"title\" class=\"h5 pyh px m0\">${back ? 'Go back' : 'Update'}</h2>\n <div class=\"p\">\n <p class=\"mb\">${back ? 'This puts the site back on what it ran before the last update.' : `This updates ${items.join(', ')}.`} ${this.renderRestartNote(status)}</p>\n ${forced.length ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> Your edits to ${forced.join(', ')} will be replaced. Your version is saved first.</p>` : nothing}\n ${breaking ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> This update changes your data. A backup is taken first, and going back later restores it, which loses anything entered after the update.</p>` : nothing}\n ${back && status.goBack?.restoresData ? html`<p class=\"tc-warning mb\"><k-icon name=\"warning\"></k-icon> The update changed your data, so going back restores the database from before it. Anything entered since is lost.</p>` : nothing}\n <p class=\"tc-muted\">Anyone using the site meanwhile will not be able to reach it. This page reloads when the server is back.</p>\n </div>\n </k-dialog>\n `;\n }\n\n render(){\n const status = this.status;\n if(this.error) return html`<p class=\"tc-danger\" role=\"alert\">${this.error}</p>`;\n if(!status) return html`<k-spinner></k-spinner>`;\n const items = this.updatable(status);\n return html`\n ${this.phase !== 'idle' ? this.renderBusy() : nothing}\n ${this.stalled ? this.renderStalled() : nothing}\n ${this.phase === 'idle' ? html`\n ${this.renderLast(status)}\n ${this.renderDatabase(status)}\n ${status.staged ? this.renderStaged(status) : (items.length ? this.renderAvailable(status) : this.renderCurrent())}\n ${status.checkError || status.extensionUpdates?.checkError ? html`<p class=\"tc-danger mb\" role=\"alert\">The last check failed: ${status.checkError || status.extensionUpdates.checkError}</p>` : nothing}\n <p class=\"d-f mb\">\n <button ?disabled=${this.checking} @click=${this.check}>${this.checking ? 'Checking…' : 'Check now'}</button>\n <span class=\"tc-muted ph\">Last checked ${when(status.checkedAt)}</span>\n </p>\n ${this.renderGoBack(status)}\n <p class=\"tc-muted\">Automatic checks can be turned off with <strong>update_check_enabled</strong> on the System tab of <a href=\"/admin/settings\">Settings</a>. Installing or removing an extension is done from <a href=\"/admin/extensions\">Extensions</a>.</p>\n ` : nothing}\n ${this.renderDialog(status)}\n `;\n }\n}\n\ncustomElements.define('k-admin-updates', Updates);\n"],"mappings":"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","ignoreList":[]}
@@ -4,11 +4,24 @@
4
4
  <page title="Emails - Admin">
5
5
  <content>
6
6
  <script type="module" src="/admin/components/Emails.js"></script>
7
+ <script type="module" src="/admin/components/EmailTemplates.js"></script>
8
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script>
7
9
 
8
10
  <h1>Emails</h1>
9
- <p class="tc-muted mb">Every email the site was asked to send, and what happened to it. Kempo does not send email itself: an email extension (such as kempo-resend or kempo-smtp) does. How long this log is kept, and how many times a failed email is tried again, are on the System tab of <a href="/admin/settings">Settings</a>.</p>
11
+ <p class="tc-muted mb">Kempo does not send email itself: an email extension (such as kempo-resend or kempo-smtp) does. How long the log is kept, and how many times a failed email is tried again, are on the System tab of <a href="/admin/settings">Settings</a>.</p>
10
12
 
11
- <k-admin-emails></k-admin-emails>
13
+ <k-tabs persistent-id="admin-emails">
14
+ <k-tab for="log">Log</k-tab>
15
+ <k-tab for="templates">Templates</k-tab>
16
+ <k-tab-content name="log">
17
+ <p class="tc-muted mb">Every email the site was asked to send, and what happened to it.</p>
18
+ <k-admin-emails></k-admin-emails>
19
+ </k-tab-content>
20
+ <k-tab-content name="templates">
21
+ <p class="tc-muted mb">The emails this site sends: kempo's own and any the site has added. Preview draws one the way it would be sent, with made-up values when the template has a <code>.sample.json</code> beside it. Edit one to reword it; your copy is used from the next email on and Reset to default brings the packaged one back.</p>
22
+ <k-email-templates></k-email-templates>
23
+ </k-tab-content>
24
+ </k-tabs>
12
25
 
13
26
  </content>
14
27
  </page>
@@ -0,0 +1,2 @@
1
+ import{confirmAccountDeletion as o}from"../../../../../../server/utils/auth/accountDeletion.js";export default async(e,t)=>{const[r,s]=await o({email:e.body?.email,code:e.body?.code});if(r)return t.status(r.code).json({error:r.msg});t.json(s)};
2
+ //# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\auth\account-deletion\confirm\POST.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src\\kempo\\api\\auth\\account-deletion\\confirm\\POST.js","names":["confirmAccountDeletion","async","request","response","error","result","email","body","code","status","json","msg"],"sources":["0"],"sourcesContent":["import { confirmAccountDeletion } from '../../../../../../server/utils/auth/accountDeletion.js';\n\n/* POST /kempo/api/auth/account-deletion/confirm { email, code } — deletes the account when the code is right. */\nexport default async (request, response) => {\n const [error, result] = await confirmAccountDeletion({ email: request.body?.email, code: request.body?.code });\n if(error) return response.status(error.code).json({ error: error.msg });\n response.json(result);\n};\n"],"mappings":"iCAASA,MAA8B,wEAGxBC,MAAOC,EAASC,KAC7B,MAAOC,EAAOC,SAAgBL,EAAuB,CAAEM,MAAOJ,EAAQK,MAAMD,MAAOE,KAAMN,EAAQK,MAAMC,OACvG,GAAGJ,EAAO,OAAOD,EAASM,OAAOL,EAAMI,MAAME,KAAK,CAAEN,MAAOA,EAAMO,MACjER,EAASO,KAAKL","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ import{requestAccountDeletion as t}from"../../../../../../server/utils/auth/accountDeletion.js";export default async(e,o)=>{const[r,s]=await t({email:e.body?.email});if(r)return o.status(r.code).json({error:r.msg});o.json(s)};
2
+ //# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\auth\account-deletion\request\POST.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src\\kempo\\api\\auth\\account-deletion\\request\\POST.js","names":["requestAccountDeletion","async","request","response","error","result","email","body","status","code","json","msg"],"sources":["0"],"sourcesContent":["import { requestAccountDeletion } from '../../../../../../server/utils/auth/accountDeletion.js';\n\n/* POST /kempo/api/auth/account-deletion/request { email } — emails a code to the account, if there is one. */\nexport default async (request, response) => {\n const [error, result] = await requestAccountDeletion({ email: request.body?.email });\n if(error) return response.status(error.code).json({ error: error.msg });\n response.json(result);\n};\n"],"mappings":"iCAASA,MAA8B,wEAGxBC,MAAOC,EAASC,KAC7B,MAAOC,EAAOC,SAAgBL,EAAuB,CAAEM,MAAOJ,EAAQK,MAAMD,QAC5E,GAAGF,EAAO,OAAOD,EAASK,OAAOJ,EAAMK,MAAMC,KAAK,CAAEN,MAAOA,EAAMO,MACjER,EAASO,KAAKL","ignoreList":[]}
@@ -1,2 +1,2 @@
1
- import s from"../../../../../server/utils/permissions/currentUserHasPermission.js";import e from"../../../../../server/utils/email/renderEmailTemplate.js";export default async(r,t)=>{const[o,i]=await s(r.cookies.session_token,"system:pages:read");if(o)return t.status(o.code).json({error:o.msg});if(!i)return t.status(403).json({error:"Insufficient permissions"});const[a,n]=await e({template:r.query.template,data:{}});if(a)return t.status(a.code).json({error:a.msg});t.type("html").send(n)};
1
+ import e from"../../../../../server/utils/permissions/currentUserHasPermission.js";import s from"../../../../../server/utils/email/renderEmailTemplate.js";import r from"../../../../../server/utils/email/readEmailSample.js";export default async(t,a)=>{const[i,o]=await e(t.cookies.session_token,"system:pages:read");if(i)return a.status(i.code).json({error:i.msg});if(!o)return a.status(403).json({error:"Insufficient permissions"});const[m,n]=await s({template:t.query.template,data:await r({template:t.query.template})});if(m)return a.status(m.code).json({error:m.msg});a.type("html").send(n)};
2
2
  //# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\api\email-templates\preview\GET.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"src\\kempo\\api\\email-templates\\preview\\GET.js","names":["currentUserHasPermission","renderEmailTemplate","async","request","response","permError","hasPermission","cookies","session_token","status","code","json","error","msg","page","template","query","data","type","send"],"sources":["0"],"sourcesContent":["import currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport renderEmailTemplate from '../../../../../server/utils/email/renderEmailTemplate.js';\n\n/*\n An email template drawn with no data, to open in a tab: what the site would send, with the parts that need data left blank. An\n extension that can do better (real or sample data) serves its own preview and gives it to <k-email-templates> as `preview-url`.\n*/\nexport default async (request, response) => {\n\tconst [permError, hasPermission] = await currentUserHasPermission(request.cookies.session_token, 'system:pages:read');\n\tif(permError) return response.status(permError.code).json({ error: permError.msg });\n\tif(!hasPermission) return response.status(403).json({ error: 'Insufficient permissions' });\n\n\tconst [error, page] = await renderEmailTemplate({ template: request.query.template, data: {} });\n\tif(error) return response.status(error.code).json({ error: error.msg });\n\n\tresponse.type('html').send(page);\n};\n"],"mappings":"OAAOA,MAA8B,6EAC9BC,MAAyB,0EAMjBC,MAAOC,EAASC,KAC9B,MAAOC,EAAWC,SAAuBN,EAAyBG,EAAQI,QAAQC,cAAe,qBACjG,GAAGH,EAAW,OAAOD,EAASK,OAAOJ,EAAUK,MAAMC,KAAK,CAAEC,MAAOP,EAAUQ,MAC7E,IAAIP,EAAe,OAAOF,EAASK,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAE7D,MAAOA,EAAOE,SAAcb,EAAoB,CAAEc,SAAUZ,EAAQa,MAAMD,SAAUE,KAAM,CAAC,IAC3F,GAAGL,EAAO,OAAOR,EAASK,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAEjET,EAASc,KAAK,QAAQC,KAAKL","ignoreList":[]}
1
+ {"version":3,"file":"src\\kempo\\api\\email-templates\\preview\\GET.js","names":["currentUserHasPermission","renderEmailTemplate","readEmailSample","async","request","response","permError","hasPermission","cookies","session_token","status","code","json","error","msg","page","template","query","data","type","send"],"sources":["0"],"sourcesContent":["import currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\r\nimport renderEmailTemplate from '../../../../../server/utils/email/renderEmailTemplate.js';\r\nimport readEmailSample from '../../../../../server/utils/email/readEmailSample.js';\r\n\r\n/*\r\n An email template drawn to open in a tab: what the site would send. It is filled in with the template's `<name>.sample.json` when\r\n it has one (made-up values, kept beside the template), and the parts that need data are left blank when it has none. An extension\r\n that can do better (its own real data) serves its own preview and gives it to <k-email-templates> as `preview-url`.\r\n*/\r\nexport default async (request, response) => {\r\n\tconst [permError, hasPermission] = await currentUserHasPermission(request.cookies.session_token, 'system:pages:read');\r\n\tif(permError) return response.status(permError.code).json({ error: permError.msg });\r\n\tif(!hasPermission) return response.status(403).json({ error: 'Insufficient permissions' });\r\n\r\n\tconst [error, page] = await renderEmailTemplate({ template: request.query.template, data: await readEmailSample({ template: request.query.template }) });\r\n\tif(error) return response.status(error.code).json({ error: error.msg });\r\n\r\n\tresponse.type('html').send(page);\r\n};\r\n"],"mappings":"OAAOA,MAA8B,6EAC9BC,MAAyB,kEACzBC,MAAqB,sEAObC,MAAOC,EAASC,KAC9B,MAAOC,EAAWC,SAAuBP,EAAyBI,EAAQI,QAAQC,cAAe,qBACjG,GAAGH,EAAW,OAAOD,EAASK,OAAOJ,EAAUK,MAAMC,KAAK,CAAEC,MAAOP,EAAUQ,MAC7E,IAAIP,EAAe,OAAOF,EAASK,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAE7D,MAAOA,EAAOE,SAAcd,EAAoB,CAAEe,SAAUZ,EAAQa,MAAMD,SAAUE,WAAYhB,EAAgB,CAAEc,SAAUZ,EAAQa,MAAMD,aAC1I,GAAGH,EAAO,OAAOR,EAASK,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAEjET,EAASc,KAAK,QAAQC,KAAKL","ignoreList":[]}
@@ -50,6 +50,7 @@ const goBack = async reason => {
50
50
  try {
51
51
  say('Restoring the database from the backup taken before the update.');
52
52
  await restoreDatabase(result.dbBackup);
53
+ say('The database was restored.');
53
54
  } catch(error) {
54
55
  say(`The database could not be restored (${error.message}). The backup is at ${result.dbBackup}.`);
55
56
  }
@@ -67,6 +68,7 @@ if(switched){
67
68
  try {
68
69
  say('Restoring the database from the backup taken before the update.');
69
70
  await restoreDatabase(switched.dbBackup);
71
+ say('The database was restored.');
70
72
  } catch(error) {
71
73
  say(`The database could not be restored (${error.message}). The backup is at ${switched.dbBackup}.`);
72
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.9.3",
3
+ "version": "4.9.5",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -33,6 +33,7 @@ export const verificationToken = pgTable('verificationToken', {
33
33
  .references(() => user.id),
34
34
  type: text('type').notNull(),
35
35
  expiresAt: timestamp('expiresAt').notNull(),
36
+ attempts: integer('attempts').notNull().default(0),
36
37
  createdAt: timestamp('createdAt').notNull(),
37
38
  });
38
39
 
@@ -0,0 +1,120 @@
1
+ import crypto from 'crypto';
2
+ import { and, count, eq, gt, sql } from 'drizzle-orm';
3
+ import db from '../../db/index.js';
4
+ import { user, userGroup, verificationToken } from '../../db/schema.js';
5
+ import sendEmailFromTemplate from '../email/sendEmailFromTemplate.js';
6
+ import deleteUser from '../users/deleteUser.js';
7
+
8
+ /*
9
+ A person deletes their own account. No login is needed: they give the email address on the account,
10
+ and a six-digit code is sent there. Typing the code back proves they control that mailbox, and only then
11
+ is the account deleted. Someone who does not hold the mailbox can request codes for it but cannot use them.
12
+
13
+ Nothing in the answer tells a stranger whether an account exists for an address: the same reply comes back
14
+ either way, and the email is sent only when there is an account to delete.
15
+ */
16
+
17
+ const CODE_MINUTES = 30;
18
+ const MAX_ATTEMPTS = 5;
19
+ const COOLDOWN_MS = 60 * 1000;
20
+ const TYPE = 'account_deletion';
21
+ const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
22
+
23
+ const tokenFor = (userId, code) => `${TYPE}:${crypto.createHash('sha256').update(`${userId}:${code}`).digest('hex')}`;
24
+
25
+ const normalised = email => String(email ?? '').trim().toLowerCase();
26
+
27
+ const accountFor = async email => {
28
+ const [found] = await db.select({ id: user.id, email: user.email, name: user.name })
29
+ .from(user)
30
+ .where(sql`lower(${user.email}) = ${email}`)
31
+ .limit(1);
32
+ return found ?? null;
33
+ };
34
+
35
+ /*
36
+ Sends a code to the account for `email`, if there is one. Always answers the same way.
37
+ */
38
+ export const requestAccountDeletion = async ({ email, now = new Date() }) => {
39
+ const address = normalised(email);
40
+ if(!EMAIL_PATTERN.test(address)){
41
+ return [{ code: 400, msg: 'Enter the email address on your account' }, null];
42
+ }
43
+
44
+ const found = await accountFor(address);
45
+ if(!found) return [null, { sent: true }];
46
+
47
+ const [recent] = await db.select({ createdAt: verificationToken.createdAt }).from(verificationToken)
48
+ .where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)))
49
+ .limit(1);
50
+ if(recent && now - recent.createdAt < COOLDOWN_MS) return [null, { sent: true }];
51
+
52
+ await db.delete(verificationToken).where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)));
53
+
54
+ const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
55
+ await db.insert(verificationToken).values({
56
+ token: tokenFor(found.id, code),
57
+ userId: found.id,
58
+ type: TYPE,
59
+ expiresAt: new Date(now.getTime() + CODE_MINUTES * 60 * 1000),
60
+ attempts: 0,
61
+ createdAt: now,
62
+ });
63
+
64
+ const [mailError] = await sendEmailFromTemplate({
65
+ to: found.email,
66
+ subject: 'Your code to delete your account',
67
+ template: 'account-deletion-code',
68
+ data: { name: found.name || '', code, minutes: CODE_MINUTES },
69
+ });
70
+ // Saved in the email log and sent when a sender is ready, so that still counts as sent.
71
+ if(mailError && !mailError.emailId){
72
+ await db.delete(verificationToken).where(eq(verificationToken.token, tokenFor(found.id, code)));
73
+ return [{ code: 502, msg: 'The email could not be sent right now. Try again in a minute' }, null];
74
+ }
75
+
76
+ return [null, { sent: true }];
77
+ };
78
+
79
+ /*
80
+ Checks the code and, if it is right, deletes the account. A wrong code counts as an attempt; after
81
+ MAX_ATTEMPTS the code is dead and a new one has to be asked for.
82
+ */
83
+ export const confirmAccountDeletion = async ({ email, code, now = new Date() }) => {
84
+ const address = normalised(email);
85
+ const given = String(code ?? '').replace(/\s/g, '');
86
+ const wrong = [{ code: 400, msg: 'That code is not right' }, null];
87
+
88
+ const found = await accountFor(address);
89
+ if(!found || !/^\d{6}$/.test(given)) return wrong;
90
+
91
+ const [row] = await db.select().from(verificationToken)
92
+ .where(and(eq(verificationToken.userId, found.id), eq(verificationToken.type, TYPE)))
93
+ .limit(1);
94
+ if(!row) return [{ code: 400, msg: 'No code has been sent for this account. Ask for a new one' }, null];
95
+ if(row.expiresAt < now) return [{ code: 400, msg: 'That code has expired. Ask for a new one' }, null];
96
+ if(row.attempts >= MAX_ATTEMPTS) return [{ code: 429, msg: 'Too many wrong tries. Ask for a new code' }, null];
97
+
98
+ if(row.token !== tokenFor(found.id, given)){
99
+ await db.update(verificationToken).set({ attempts: row.attempts + 1 }).where(eq(verificationToken.token, row.token));
100
+ return wrong;
101
+ }
102
+
103
+ /*
104
+ The last administrator cannot delete themselves: the site would be left with nobody who can run it.
105
+ */
106
+ const [admin] = await db.select({ userId: userGroup.userId }).from(userGroup)
107
+ .where(eq(userGroup.groupName, 'system:Administrators'))
108
+ .limit(1);
109
+ if(admin?.userId === found.id){
110
+ const [{ total }] = await db.select({ total: count() }).from(userGroup).where(eq(userGroup.groupName, 'system:Administrators'));
111
+ if(Number(total) <= 1){
112
+ return [{ code: 409, msg: 'You are the only administrator, so this account cannot be deleted. Make someone else an administrator first' }, null];
113
+ }
114
+ }
115
+
116
+ const [deleteError] = await deleteUser(found.id);
117
+ if(deleteError) return [deleteError, null];
118
+
119
+ return [null, { deleted: true }];
120
+ };
@@ -0,0 +1,23 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { defaultEmailsDir, locateTemplate, siteEmailsDir } from './emailTemplatePaths.js';
4
+
5
+ /*
6
+ The made-up values a template is previewed with: `<name>.sample.json` beside the template, in the site's
7
+ `templates/emails` (which wins) or the packaged one. A template with none, or one whose file is not a JSON
8
+ object, previews with no data, which is what it did before. Always resolves to an object.
9
+ */
10
+ export default async ({ template } = {}) => {
11
+ const located = locateTemplate(template);
12
+ if(!located) return {};
13
+
14
+ for(const dir of [siteEmailsDir(located.owner), defaultEmailsDir(located.owner)]){
15
+ try {
16
+ const sample = JSON.parse(await readFile(join(dir, `${located.name}.sample.json`), 'utf8'));
17
+ if(sample && typeof sample === 'object' && !Array.isArray(sample)) return sample;
18
+ } catch {
19
+ /* no file here, or not JSON: try the next place */
20
+ }
21
+ }
22
+ return {};
23
+ };
@@ -5,7 +5,7 @@ import { getCurrent, paths, readState } from './siteLayout.js';
5
5
  /*
6
6
  What "Go back" would do right now, worked out in one place so that what the Updates screen says and what the server does
7
7
  cannot differ. It is not offered while a switch is still proving itself (the site is a minute old and its record of what
8
- it came from is not final), or when there is no earlier version, or straight after a rollback (the earlier version is then
8
+ it came from is not final), or when there is no earlier version, or straight after a rollback, whether by hand or because the new version failed (the earlier version is then
9
9
  the one that was just left, and "going back" would be a confusing way to say "update again"). It restores the database
10
10
  only when the update being undone changed data and its backup is still there.
11
11
  */
@@ -23,7 +23,7 @@ export default siteRoot => {
23
23
  plan.reason = 'There is no earlier version to go back to';
24
24
  return plan;
25
25
  }
26
- if(last?.kind === 'rollback'){
26
+ if(last?.kind === 'rollback' || last?.ok === false){
27
27
  plan.reason = 'The site has just gone back, so there is nothing newer to go back from. To move forward again, update it.';
28
28
  return plan;
29
29
  }
@@ -176,7 +176,7 @@ export const rollBack = ({ siteRoot, reason }) => {
176
176
 
177
177
  swapIn({ siteRoot, sw: { id: previous.id, version: previous.version, source, from: { id: current.id, version: current.version } } });
178
178
  restoreFiles({ siteRoot, backup: sw?.filesBackup });
179
- writeState(siteRoot, 'last', { ok: false, from: current.version, to: previous.version, reason, at: new Date().toISOString(), dbBackup: sw?.dbBackup ?? null });
179
+ writeState(siteRoot, 'last', { ok: false, kind: 'rollback', from: current.version, to: previous.version, packages: sw?.packages ?? [], breaking: Boolean(sw?.breaking), reason, at: new Date().toISOString(), dbBackup: sw?.dbBackup ?? null });
180
180
  clearState(siteRoot, 'switch');
181
181
  return { ok: true, from: current.version, to: previous.version, dbBackup: sw?.dbBackup ?? null };
182
182
  };
@@ -1,19 +1,37 @@
1
1
  import db from '../../db/index.js';
2
2
  import { user, session, verificationToken, userGroup } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
6
+ /*
7
+ Deletes a user and everything kempo keeps for them: their sessions, codes, group memberships and the
8
+ account itself. Then fires `user:deleted` with `{ id, email, name }` so every extension can remove what it
9
+ keeps about that person. Those extensions own their own data; kempo only tells them.
10
+
11
+ The hook runs after the user has gone, and a handler that fails does not undo the deletion: the
12
+ notification contract keeps going and records the failure.
13
+ */
5
14
  export default async (id) => {
6
15
  if(!id){
7
16
  return [{ code: 400, msg: 'User ID is required' }, null];
8
17
  }
9
-
18
+
19
+ let removed = null;
10
20
  try {
21
+ const [existing] = await db.select({ id: user.id, email: user.email, name: user.name }).from(user).where(eq(user.id, id)).limit(1);
22
+ removed = existing ?? null;
23
+
11
24
  await db.delete(session).where(eq(session.userId, id));
12
25
  await db.delete(verificationToken).where(eq(verificationToken.userId, id));
13
26
  await db.delete(userGroup).where(eq(userGroup.userId, id));
14
27
  await db.delete(user).where(eq(user.id, id));
15
- return [null, { id }];
16
28
  } catch(error){
17
29
  return [{ code: 500, msg: 'Failed to delete user' }, null];
18
30
  }
31
+
32
+ if(removed){
33
+ await triggerHook('user:deleted', { id: removed.id, email: removed.email, name: removed.name });
34
+ }
35
+
36
+ return [null, { id }];
19
37
  };