From 2d348c2118611a264ee0ab239f78870c912e1f95 Mon Sep 17 00:00:00 2001 From: MeowcaTheoRange Date: Sun, 1 Sep 2024 16:28:26 -0500 Subject: [PATCH] MININQ --- assets/elements.css | 44 +++++ index.js | 343 ++++++++++++++++++++++++++++----- modules/fromHex.js | 20 ++ modules/relativeTime.js | 19 ++ package-lock.json | 37 +++- package.json | 4 +- sitemap.json | 30 +++ views/components/mininq.ejs | 14 ++ views/components/page-head.ejs | 1 + views/pages/mininq.ejs | 86 +++++++++ views/pages/mininqbox.ejs | 58 ++++++ 11 files changed, 608 insertions(+), 48 deletions(-) create mode 100644 modules/fromHex.js create mode 100644 modules/relativeTime.js create mode 100644 sitemap.json create mode 100644 views/components/mininq.ejs create mode 100644 views/pages/mininq.ejs create mode 100644 views/pages/mininqbox.ejs diff --git a/assets/elements.css b/assets/elements.css index 3296192..89b9f12 100644 --- a/assets/elements.css +++ b/assets/elements.css @@ -25,6 +25,12 @@ h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { color: var(--color-bg); } +a.noblock { + display: inline; + margin-inline: 0; + padding-block: 0.25rem; +} + p { margin-inline: 2ch; margin-block: 1.5rem; @@ -44,6 +50,18 @@ ul li { margin-block: 1.5rem; } +details > summary { + list-style-type: '► '; +} + +details[open] > summary { + list-style-type: '▼ '; +} + +summary > h1, summary > h2, summary > h3, summary > h4, summary > h5, summary > h6 { + display: inline-block; +} + section { margin-block: 1.5em; margin-inline-start: 2ch; @@ -61,11 +79,16 @@ a { code { display: inline-block; + max-width: 100%; + box-sizing: border-box; font: inherit; background-color: var(--color-code); padding-inline: 1ch; color: var(--color-code-text); user-select: all; + word-break: break-word; + overflow: auto; + vertical-align: top; } button, input, textarea, select { @@ -112,4 +135,25 @@ marquee { content: ""; display: table; clear: both; +} + +progress { + border: none; + border-radius: 0; + border-inline: 1ch solid var(--color-text); + background-color: var(--color-bg-scrim); + height: 1.5rem; + vertical-align: top; +} +progress::-webkit-progress-bar { + border: none; + border-radius: 0; + border-inline: 1ch solid var(--color-text); + background-color: var(--color-bg-scrim); +} +progress::-webkit-progress-value { + background-color: var(--color-accent); +} +progress::-moz-progress-bar { + background-color: var(--color-accent); } \ No newline at end of file diff --git a/index.js b/index.js index 82ed663..3ae2291 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,12 @@ import { Kysely, SqliteDialect } from 'kysely'; import { nanoid } from "nanoid"; import { JSDOM } from "jsdom"; import Parser from "rss-parser"; +import nacl from "tweetnacl"; +import expressBasicAuth from "express-basic-auth"; +import { getRelativeTime } from "./modules/relativeTime.js"; +import { fromHex } from "./modules/fromHex.js"; + +import sitemap from "./sitemap.json" assert { type: "json" }; dotenvConfig(); @@ -22,6 +28,7 @@ const db = new Kysely({ const rssParser = new Parser(); rawDB.exec(`CREATE TABLE IF NOT EXISTS blurbs( 'id' TEXT, 'site' TEXT, 'blurb' TEXT, 'verified' INTEGER, 'time' INTEGER );`); +rawDB.exec(`CREATE TABLE IF NOT EXISTS mininq( 'id' TEXT, 'name' TEXT, 'ip' TEXT, 'msg' TEXT, 'url' TEXT, 'public' INTEGER, 'reply' TEXT, 'time' INTEGER );`); rawDB.exec(`CREATE TABLE IF NOT EXISTS blacklist( 'domain' TEXT );`); const app = express(); @@ -29,33 +36,11 @@ const app = express(); app.set('view engine', 'ejs'); app.use('/assets', express.static('assets')); app.set('views', path.join(__dirname, "views", "pages")); +app.use(express.urlencoded({ + extended: true +})) -app.locals.siteMap = [ - { - link: "blurbs", - description: "what are other sites saying about abtmtr.link?" - }, - { - link: "characters", - description: "about abtmtr.link's mascots and other such characters" - }, - { - link: "updates", - description: "what's going on with abtmtr.link?" - }, - { - link: "servers", - description: "about the services on abtmtr.link and the machines that serve them." - }, - { - link: "sites", - description: "a collection of other sites in the form of 88x31s." - }, - { - link: "about", - description: "who runs abtmtr.link?" - } -]; +app.locals.siteMap = sitemap; app.locals.curCommit = "0000000000"; async function getCurCommit() { @@ -99,26 +84,6 @@ app.get('/servers', async (req, res) => { }); }); -var units = { - year: 24 * 60 * 60 * 1000 * 365, - month: 24 * 60 * 60 * 1000 * 365 / 12, - day: 24 * 60 * 60 * 1000, - hour: 60 * 60 * 1000, - minute: 60 * 1000, - second: 1000 -} - -var rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) - -var getRelativeTime = (d1, d2 = new Date()) => { - var elapsed = d1 - d2 - - // "Math.abs" accounts for both "past" & "future" scenarios - for (var u in units) - if (Math.abs(elapsed) > units[u] || u == 'second') - return rtf.format(Math.round(elapsed / units[u]), u) -} - app.get('/about', async (req, res) => { const linksJson = await fetch("https://cdn.abtmtr.link/site_content/v13/links.json") .catch(() => res.status(500).send()) @@ -142,6 +107,292 @@ app.get('/characters', async (req, res) => { res.render('characters', { characters: charactersJson }); }); +app.get('/mininq', async (req, res) => { + const mailbox = await db + .selectFrom('mininq') + .select('id') + .where('public', '=', 0) + .where('url', '=', '') + .orderBy('time', "desc") + .limit(process.env.MININQ_MAILBOX_MAX) + .execute(); + + const verifiedMailbox = await db + .selectFrom('mininq') + .select('id') + .where('public', '=', 0) + .where('url', '!=', '') + .orderBy('time', "desc") + .limit(process.env.MININQ_VERIFIED_MAILBOX_MAX) + .execute(); + + const isIpAlreadyMininq = await db + .selectFrom('mininq') + .select('ip') + .where('ip', '=', req.ip) + .where('public', '=', 0) + .executeTakeFirst(); + + const { publicKey, secretKey } = nacl.box.keyPair(); + + const data = await db + .selectFrom('mininq') + .selectAll() + .where('public', '=', 1) + .orderBy('time', "desc") + .execute(); + + res.render('mininq', { + keypair: { + publicKey: Buffer.from(publicKey).toString('hex'), + secretKey: Buffer.from(secretKey).toString('hex'), + }, + data, + mailboxCount: mailbox.length, + isIpAlreadyMininq, + mailboxVerifiedCount: verifiedMailbox.length, + mailboxMaximum: process.env.MININQ_MAILBOX_MAX, + mailboxVerifiedMaximum: process.env.MININQ_VERIFIED_MAILBOX_MAX + }); +}); + +async function checkIsMtr(user, pass, cb) { + try { + let publicKeyWorks = await checkPublicKey({ + skey: pass, + pkeyurl: `http://localhost:${process.env.PORT}/` + }); + + return cb(null, publicKeyWorks); + } catch (err) { + return cb(null, false); + } +} + +app.get('/mininq/mbox', expressBasicAuth({ + authorizer: checkIsMtr, + authorizeAsync: true, + challenge: true +}), async (req, res) => { + const mailbox = await db + .selectFrom('mininq') + .selectAll() + .where('public', '=', 0) + .where('url', '=', '') + .orderBy('time', "desc") + .execute(); + + const verifiedMailbox = await db + .selectFrom('mininq') + .selectAll() + .where('public', '=', 0) + .where('url', '!=', '') + .orderBy('time', "desc") + .execute(); + + res.render('mininqbox', { + mail: verifiedMailbox.concat(mailbox), + mailboxCount: mailbox.length, + mailboxVerifiedCount: verifiedMailbox.length, + mailboxMaximum: process.env.MININQ_MAILBOX_MAX, + mailboxVerifiedMaximum: process.env.MININQ_VERIFIED_MAILBOX_MAX + }); +}); + +app.post('/mininq/mbox/r', async (req, res) => { + const body = req.body; + + if (typeof body.skey != "string") return res.status(400).send("skey is not a string"); + if (body.skey.length != 64) return res.status(400).send("skey must be a valid secret key"); + + try { + let publicKeyWorks = await checkPublicKey({ + skey: body.skey, + pkeyurl: `http://localhost:${process.env.PORT}/` + }); + + if (!publicKeyWorks) return res.status(401).send("No?"); + } catch (err) { + return res.status(400).send(err.message); + } + + if (typeof body.id != "string") return res.status(400).send("id is not a string"); + if (body.id.length != 32) return res.status(400).send("id must be a mininq ID"); + + if (typeof body.action != "string") return res.status(400).send("action is not a string"); + if (body.action.length > 10) return res.status(400).send("action must be valid"); + + switch (body.action) { + case "public": + await db + .updateTable('mininq') + .set({ + public: 1 + }) + .where('id', '=', body.id) + .where('public', '=', 0) + .executeTakeFirst(); + break; + case "delete": + await db + .deleteFrom('mininq') + .where('id', '=', body.id) + .executeTakeFirst(); + break; + default: + if (typeof body.msg != "string") return res.status(400).send("msg is not a string"); + if (body.msg.length < 1) return res.status(400).send("msg is required"); + if (body.msg.length > 4000) return res.status(400).send("msg must not be longer than 4000 characters"); + + await db + .updateTable('mininq') + .set({ + public: 1, + reply: body.msg + }) + .where('id', '=', body.id) + .where('public', '=', 0) + .executeTakeFirst(); + break; + } + + res.redirect('/mininq/mbox'); +}); + +async function checkPublicKey(body) { + if (typeof body.skey != "string") throw ("skey is not a string"); + if (body.skey.length != 64) throw ("skey must be a valid secret key"); + + if (body.pkeyurl.length < 1) throw ("pkeyurl is required"); + if (body.pkeyurl.length > 512) throw ("pkeyurl must not be longer than 512 characters"); + const site = await fetch(body.pkeyurl).then(async x => ({ s: x.status, t: await x.text() })).catch(_ => _); + if (site.s != 200) throw ("pkeyurl's site is not online"); + try { + const domain = new URL(body.pkeyurl).hostname; + const isBadSite = await db + .selectFrom('blacklist') + .selectAll() + .where('domain', '=', domain) + .executeTakeFirst(); + + if (isBadSite != null) + throw ({ + error: "Forbidden", + message: "\u{1f595} (or don't authenticate, i guess)" + }); + } catch (err) { + throw ({ + error: "Internal Server Error", + message: "URL CONSTRUCTOR FAILED???? i think this might be your fault...." + }); + } + let relationMeta; + try { + let webDom = new JSDOM(site.t); + relationMeta = webDom.window.document.querySelector( + `meta[name="abtmtr-mininq-key"]` + ); + } catch (err) { + throw ("Something went wrong parsing your site's DOM"); + } + let publicKey; + if (relationMeta != null) publicKey = relationMeta.content; + else throw ("Can't find public key"); + + let pkMatches; + try { + const generatedPublicKey = nacl.box.keyPair.fromSecretKey(fromHex(body.skey)); + pkMatches = Buffer.from(generatedPublicKey.publicKey).toString('hex') == publicKey; + } catch (err) { + throw ("Something went wrong verifying your keypair"); + } + + return pkMatches; +} + +app.post('/mininq/send', async (req, res) => { + + const isIpAlreadyMininq = await db + .selectFrom('mininq') + .select('ip') + .where('ip', '=', req.ip) + .where('public', '=', 0) + .executeTakeFirst(); + + if (isIpAlreadyMininq != null) return res.status(403).send("IP is already in mailbox"); + + const body = req.body; + + let newObject = { + url: '', + id: nanoid(32), + public: 0, + reply: '', + ip: req.ip + }; + + if (typeof body.name != "string") return res.status(400).send("name is not a string"); + if (body.name.length < 1) return res.status(400).send("name is required"); + if (body.name.length > 40) return res.status(400).send("name must not be longer than 40 characters"); + + newObject.name = body.name; + + if (typeof body.msg != "string") return res.status(400).send("msg is not a string"); + if (body.msg.length < 1) return res.status(400).send("msg is required"); + if (body.msg.length > 2000) return res.status(400).send("msg must not be longer than 2000 characters"); + + newObject.msg = body.msg; + + if (typeof body.pkeyurl == "string") { + try { + let publicKeyWorks = await checkPublicKey(body); + + if (publicKeyWorks) newObject.url = body.pkeyurl; + } catch (err) { + // do nothing lol + } + } + + const mailbox = await db + .selectFrom('mininq') + .select('id') + .where('public', '=', 0) + .where('url', '=', '') + .orderBy('time', "desc") + .limit(process.env.MININQ_MAILBOX_MAX) + .execute(); + + const verifiedMailbox = await db + .selectFrom('mininq') + .select('id') + .where('public', '=', 0) + .where('url', '!=', '') + .orderBy('time', "desc") + .limit(process.env.MININQ_VERIFIED_MAILBOX_MAX) + .execute(); + + if (newObject.url) { + if (verifiedMailbox.length >= process.env.MININQ_VERIFIED_MAILBOX_MAX) + return res.status(403).send("Mailbox is full"); + } else { + if (mailbox.length >= process.env.MININQ_MAILBOX_MAX) + return res.status(403).send("Mailbox is full"); + } + + newObject.time = Date.now(); + + try { + await db.insertInto('mininq') + .values(newObject) + .executeTakeFirstOrThrow(); + } catch (err) { + console.log(err) + return res.status(500).send('Internal Server Error'); + } + + res.redirect('/mininq/'); +}); + app.get('/updates', async (req, res) => { const rssXML = await fetch("https://cdn.abtmtr.link/site_content/rss.xml") .catch(() => res.status(500).send()) diff --git a/modules/fromHex.js b/modules/fromHex.js new file mode 100644 index 0000000..97d686f --- /dev/null +++ b/modules/fromHex.js @@ -0,0 +1,20 @@ +const MAP_HEX = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, + 7: 7, 8: 8, 9: 9, a: 10, b: 11, c: 12, d: 13, + e: 14, f: 15, A: 10, B: 11, C: 12, D: 13, + E: 14, F: 15 +}; + +export function fromHex(hexString) { + const bytes = new Uint8Array(Math.floor((hexString || "").length / 2)); + let i; + for (i = 0; i < bytes.length; i++) { + const a = MAP_HEX[hexString[i * 2]]; + const b = MAP_HEX[hexString[i * 2 + 1]]; + if (a === undefined || b === undefined) { + break; + } + bytes[i] = (a << 4) | b; + } + return i === bytes.length ? bytes : bytes.slice(0, i); +} \ No newline at end of file diff --git a/modules/relativeTime.js b/modules/relativeTime.js new file mode 100644 index 0000000..0cd7253 --- /dev/null +++ b/modules/relativeTime.js @@ -0,0 +1,19 @@ +var units = { + year: 24 * 60 * 60 * 1000 * 365, + month: 24 * 60 * 60 * 1000 * 365 / 12, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 +} + +var rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +export function getRelativeTime(d1, d2 = new Date()) { + var elapsed = d1 - d2 + + // "Math.abs" accounts for both "past" & "future" scenarios + for (var u in units) + if (Math.abs(elapsed) > units[u] || u == 'second') + return rtf.format(Math.round(elapsed / units[u]), u) +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8fb8f9e..191533f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "dotenv": "^16.4.5", "ejs": "^3.1.10", "express": "^4.19.2", + "express-basic-auth": "^1.2.1", "jsdom": "^25.0.0", "kysely": "^0.27.4", "nanoid": "^5.0.7", "pg": "^8.12.0", - "rss-parser": "^3.13.0" + "rss-parser": "^3.13.0", + "tweetnacl": "^1.0.3" }, "devDependencies": { "nodemon": "^3.1.4" @@ -144,6 +146,24 @@ ], "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/better-sqlite3": { "version": "11.2.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.2.1.tgz", @@ -680,6 +700,15 @@ "node": ">= 0.10.0" } }, + "node_modules/express-basic-auth": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz", + "integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==", + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2178,6 +2207,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index 93456a8..ed858c5 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "dotenv": "^16.4.5", "ejs": "^3.1.10", "express": "^4.19.2", + "express-basic-auth": "^1.2.1", "jsdom": "^25.0.0", "kysely": "^0.27.4", "nanoid": "^5.0.7", "pg": "^8.12.0", - "rss-parser": "^3.13.0" + "rss-parser": "^3.13.0", + "tweetnacl": "^1.0.3" }, "devDependencies": { "nodemon": "^3.1.4" diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..517af61 --- /dev/null +++ b/sitemap.json @@ -0,0 +1,30 @@ +[ + { + "link": "blurbs", + "description": "what are other sites saying about abtmtr.link?" + }, + { + "link": "characters", + "description": "about abtmtr.link's mascots and other such characters" + }, + { + "link": "updates", + "description": "what's going on with abtmtr.link?" + }, + { + "link": "mininq", + "description": "slowchat, for sysadmins who aren't quite equine." + }, + { + "link": "servers", + "description": "about the services on abtmtr.link and the machines that serve them." + }, + { + "link": "sites", + "description": "a collection of other sites in the form of 88x31s." + }, + { + "link": "about", + "description": "who runs abtmtr.link?" + } +] diff --git a/views/components/mininq.ejs b/views/components/mininq.ejs new file mode 100644 index 0000000..641d928 --- /dev/null +++ b/views/components/mininq.ejs @@ -0,0 +1,14 @@ +

+ <%= mininq.name %> + <% if (mininq.url != '') { %> + @ <%= new URL(mininq.url).host %> + <% } %> + sent: +

+

<%= mininq.msg %>

+<% if (mininq.reply != '') { %> +
+

sysadmin reply:

+

<%= mininq.reply %>

+
+<% } %> \ No newline at end of file diff --git a/views/components/page-head.ejs b/views/components/page-head.ejs index c5edbe4..238480f 100644 --- a/views/components/page-head.ejs +++ b/views/components/page-head.ejs @@ -3,6 +3,7 @@ abtmtr.link + diff --git a/views/pages/mininq.ejs b/views/pages/mininq.ejs new file mode 100644 index 0000000..18b4999 --- /dev/null +++ b/views/pages/mininq.ejs @@ -0,0 +1,86 @@ + + + <%- include("../components/page-head.ejs") %> + +
+

mininq

+

slowchat, for sysadmins who aren't quite equine.

+

submit a mininq

+

+ MAILBOX: + + <%= mailboxCount %>/<%= mailboxMaximum %> +

+

+ MAILBOX (PRIORITY): + + <%= mailboxVerifiedCount %>/<%= mailboxVerifiedMaximum %> +

+

mininq ([min]i [inq]uiry) is a ping-ponging non-realtime chat interface made in the early 2000s to satisfy the needs of the single abtmtr.link sysadmin writing it.

+

either way, mininq was conceptualized, developed, and deployed without any second consideration. now, here you are.

+ <% if (!isIpAlreadyMininq) { %> + <% if (mailboxCount < mailboxMaximum || mailboxVerifiedCount < mailboxVerifiedMaximum) { %> +
+

+ + +

+

+
+ +

+ <% if (mailboxVerifiedCount < mailboxVerifiedMaximum) { %> +
+ <% if (mailboxCount < mailboxMaximum) { %> +

optional keyventure

+ <% } else { %> +

mandatory keyventure

+

uh-oh! looks like the non-priority mailbox is full. if you still want to submit a mininq, you'll have to follow the instructions below.
+ or, wait until the non-priority mailbox has emptied.

+ <% } %> +

this keypair has been generated for you to put on your site.

+

put this snippet in your document's head: <meta name="abtmtr-mininq-key" content="<%= keypair.publicKey %>">

+

then, if you want to make a mininq, you can enter your secret key and your site's URL here, and your domain will appear beside your name.
+ you'll also get priority in the mininq inbox.

+

+ +
+ (keep this somewhere safe!) +

+

+ + +

+

+
+ <% } else { %> +

uh-oh! there was a keyventure here, but it looks like the priority mailbox is full. if you still want to submit a mininq, you'll have to appear anonymously.
+ or, wait until the priority mailbox has emptied.

+ <% } %> +

+ +

+

by clicking this button and successfully submitting a mininq, you agree that your input may be displayed on this site and stored on abtmtr.link servers.

+

rules: no slurs, no politics, no nsfw, no spam. if you break these rules outside of plausible deniability, your domain will be blacklisted.

+
+ <% } else { %> +

uh-oh! looks like both mailboxes are full. you'll have to wait until the mailbox has emptied - then you can submit your mininq.

+ <% } %> + <% } else { %> +

you've submitted a mininq!

+ <% } %> +

current mininqs

+ +
+ <%- include("../components/footer.ejs") %> + <%- include("../components/post-main.ejs") %> + + \ No newline at end of file diff --git a/views/pages/mininqbox.ejs b/views/pages/mininqbox.ejs new file mode 100644 index 0000000..c8b6979 --- /dev/null +++ b/views/pages/mininqbox.ejs @@ -0,0 +1,58 @@ + + + <%- include("../components/page-head.ejs") %> + +
+

mininq mailbox

+

slowchat, for sysadmins who aren't quite equine.

+

mailboxes

+

+ MAILBOX: + + <%= mailboxCount %>/<%= mailboxMaximum %> +

+

+ MAILBOX (PRIORITY): + + <%= mailboxVerifiedCount %>/<%= mailboxVerifiedMaximum %> +

+

current mininqs

+ +
+ <%- include("../components/footer.ejs") %> + <%- include("../components/post-main.ejs") %> + + \ No newline at end of file