This commit is contained in:
MeowcaTheoRange 2024-09-01 16:28:26 -05:00
parent 5c7e20ecd6
commit 2d348c2118
11 changed files with 608 additions and 48 deletions

View file

@ -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 {
@ -113,3 +136,24 @@ marquee {
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);
}

343
index.js
View file

@ -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())

20
modules/fromHex.js Normal file
View file

@ -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);
}

19
modules/relativeTime.js Normal file
View file

@ -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)
}

37
package-lock.json generated
View file

@ -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",

View file

@ -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"

30
sitemap.json Normal file
View file

@ -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?"
}
]

View file

@ -0,0 +1,14 @@
<h3>
<%= mininq.name %>
<% if (mininq.url != '') { %>
@ <a href="<%= mininq.url %>" target="_blank" class="noblock"><%= new URL(mininq.url).host %></a>
<% } %>
sent:
</h3>
<p><%= mininq.msg %></p>
<% if (mininq.reply != '') { %>
<section>
<h3>sysadmin reply:</h3>
<p><%= mininq.reply %></p>
</section>
<% } %>

View file

@ -3,6 +3,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>abtmtr.link</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="abtmtr-mininq-key" content="cf28339198953cab0c635c03030fa23d63e88db92126fab5d358b7cb1aded015">
<link rel="stylesheet" href="/assets/style.css" />
<link rel="me" href="https://abtmtr.link/about">
<link rel="me" href="https://abtmtr.link/blurbs/#Hx7CuB4_zIWMAsOZlyyqsUm2upXEEYEl">

86
views/pages/mininq.ejs Normal file
View file

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<%- include("../components/page-head.ejs") %>
<body>
<main>
<h1>mininq</h1>
<p>slowchat, for sysadmins who aren't quite equine.</p>
<h2>submit a mininq</h2>
<p>
MAILBOX:
<progress value="<%= mailboxCount %>" max="<%= mailboxMaximum %>"
style="width: <%= mailboxMaximum * 2 %>ch;"></progress>
<%= mailboxCount %>/<%= mailboxMaximum %>
</p>
<p>
MAILBOX (PRIORITY):
<progress value="<%= mailboxVerifiedCount %>" max="<%= mailboxVerifiedMaximum %>"
style="width: <%= mailboxVerifiedMaximum * 2 %>ch;"></progress>
<%= mailboxVerifiedCount %>/<%= mailboxVerifiedMaximum %>
</p>
<p>mininq (<code>[min]i [inq]uiry</code>) 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.</p>
<p>either way, mininq was conceptualized, developed, and deployed without any second consideration. now, here you are.</p>
<% if (!isIpAlreadyMininq) { %>
<% if (mailboxCount < mailboxMaximum || mailboxVerifiedCount < mailboxVerifiedMaximum) { %>
<form action="/mininq/send" method="post">
<p>
<label for="username">Name:</label>
<input type="text" name="name" id="username" required>
</p>
<p>
<label for="message">Message:</label><br />
<textarea name="msg" id="message" rows="4" cols="30" required></textarea>
</p>
<% if (mailboxVerifiedCount < mailboxVerifiedMaximum) { %>
<div class="entity_box">
<% if (mailboxCount < mailboxMaximum) { %>
<h3>optional keyventure</h3>
<% } else { %>
<h3>mandatory keyventure</h3>
<p>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.<br />
or, wait until the non-priority mailbox has emptied.</p>
<% } %>
<p>this keypair has been generated for you to put on your site.</p>
<p>put this snippet in your document's head: <code>&lt;meta name="abtmtr-mininq-key" content="<%= keypair.publicKey %>"&gt;</code></p>
<p>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.<br />
you'll also get priority in the mininq inbox.</p>
<p>
<label for="secretkey">Secret Key:</label>
<input type="text" name="skey" id="secretkey" value="<%= keypair.secretKey %>"><br />
<span style="opacity: 0.5;">(keep this somewhere safe!)</span>
</p>
<p>
<label for="pubkeylocation">Website URL:</label>
<input type="url" name="pkeyurl" id="pubkeylocation">
</p>
<p></p>
</div>
<% } else { %>
<p>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.<br />
or, wait until the priority mailbox has emptied.</p>
<% } %>
<p>
<input type="submit" value="Submit Mininq">
</p>
<p style="opacity: 0.5;">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.</p>
<p style="opacity: 0.5;">rules: no slurs, no politics, no nsfw, no spam. if you break these rules outside of plausible deniability, your domain will be blacklisted.</p>
</form>
<% } else { %>
<p>uh-oh! looks like both mailboxes are full. you'll have to wait until the mailbox has emptied - then you can submit your mininq.</p>
<% } %>
<% } else { %>
<p>you've submitted a mininq!</p>
<% } %>
<h2>current mininqs</h2>
<ul>
<% data.forEach((mininq) => { %>
<li class="mininq" id="<%= mininq.id %>">
<%- include("../components/mininq.ejs", {mininq}) %>
</li>
<% }) %>
</ul>
</main>
<%- include("../components/footer.ejs") %>
<%- include("../components/post-main.ejs") %>
</body>
</html>

58
views/pages/mininqbox.ejs Normal file
View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<%- include("../components/page-head.ejs") %>
<body>
<main>
<h1>mininq mailbox</h1>
<p>slowchat, for sysadmins who aren't quite equine.</p>
<h2>mailboxes</h2>
<p>
MAILBOX:
<progress value="<%= mailboxCount %>" max="<%= mailboxMaximum %>"
style="width: <%= mailboxMaximum * 2 %>ch;"></progress>
<%= mailboxCount %>/<%= mailboxMaximum %>
</p>
<p>
MAILBOX (PRIORITY):
<progress value="<%= mailboxVerifiedCount %>" max="<%= mailboxVerifiedMaximum %>"
style="width: <%= mailboxVerifiedMaximum * 2 %>ch;"></progress>
<%= mailboxVerifiedCount %>/<%= mailboxVerifiedMaximum %>
</p>
<h2>current mininqs</h2>
<ul>
<% mail.forEach((mininq) => { %>
<li class="mininq" id="<%= mininq.id %>">
<%- include("../components/mininq.ejs", {mininq}) %>
<details>
<summary><h3>reply...</h3></summary>
<form action="/mininq/mbox/r" method="post">
<input type="hidden" name="id" value="<%= mininq.id %>">
<p>
<label for="action">action:</label>
<select name="action" id="action" required>
<option value="reply" selected>Reply & make public</option>
<option value="public" selected>Make public</option>
<option value="delete" selected>Delete mininq</option>
</select>
</p>
<p>
<label for="skey">Secret Key:</label>
<input type="text" name="skey" id="skey" required>
</p>
<p>
<label for="message">Message:</label><br />
<textarea name="msg" id="message" rows="4" cols="30"></textarea>
</p>
<p>
<input type="submit" value="Submit">
</p>
</form>
</details>
</li>
<% }) %>
</ul>
</main>
<%- include("../components/footer.ejs") %>
<%- include("../components/post-main.ejs") %>
</body>
</html>