669 lines
No EOL
19 KiB
JavaScript
669 lines
No EOL
19 KiB
JavaScript
import express from "express";
|
|
import { config as dotenvConfig } from "dotenv";
|
|
import path from "path";
|
|
import SQLite from 'better-sqlite3';
|
|
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();
|
|
|
|
const __dirname = import.meta.dirname;
|
|
|
|
const rawDB = new SQLite('abtmtr.db');
|
|
|
|
const db = new Kysely({
|
|
dialect: new SqliteDialect({
|
|
database: rawDB,
|
|
})
|
|
});
|
|
|
|
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();
|
|
|
|
|
|
app.set('trust proxy', true);
|
|
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 = sitemap;
|
|
app.locals.curCommit = "0000000000";
|
|
|
|
async function getCurCommit() {
|
|
const curCommit = await fetch("https://git.abtmtr.link/api/v1/repos/MeowcaTheoRange/abtmtr-v13/branches/main")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
|
|
app.locals.curCommit = curCommit.commit.id.substr(0, 10);
|
|
}
|
|
|
|
getCurCommit();
|
|
|
|
app.get('/', async (req, res) => {
|
|
const statusesJson = await fetch("https://cdn.abtmtr.link/site_content/v13/statuses.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
|
|
res.render('index', {
|
|
statuses: statusesJson
|
|
});
|
|
})
|
|
|
|
app.get('/servers', async (req, res) => {
|
|
const servicesJson = await fetch("https://cdn.abtmtr.link/site_content/v13/services.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
const computersJson = await fetch("https://cdn.abtmtr.link/site_content/v13/computers.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
|
|
res.render('servers', {
|
|
services: servicesJson,
|
|
computers: computersJson,
|
|
visibility: [
|
|
"for domain performance",
|
|
"for personal use",
|
|
"for use by friends of abtmtr.link's webmaster(s)",
|
|
"for restricted public use",
|
|
"for public use"
|
|
]
|
|
});
|
|
});
|
|
|
|
app.get('/about', async (req, res) => {
|
|
const linksJson = await fetch("https://cdn.abtmtr.link/site_content/v13/links.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
const currentlyListeningJson = await fetch(`https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=MeowcaTheoRange&api_key=${process.env.LFM_API_KEY}&format=json&limit=2&extended=1`)
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
|
|
res.render('about', {
|
|
links: linksJson,
|
|
cl: currentlyListeningJson.recenttracks.track[0],
|
|
getRelativeTime
|
|
});
|
|
});
|
|
|
|
app.get('/characters', async (req, res) => {
|
|
const charactersJson = await fetch("https://cdn.abtmtr.link/site_content/v13/characters.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json());
|
|
|
|
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: process.env.VERIF_URL
|
|
});
|
|
|
|
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: process.env.VERIF_URL
|
|
});
|
|
|
|
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":
|
|
if (process.env.MININQ_WEBHOOK != null) {
|
|
const mininq_pub = await db
|
|
.selectFrom('mininq')
|
|
.selectAll()
|
|
.where('id', '=', body.id)
|
|
.executeTakeFirst();
|
|
|
|
await fetch(process.env.MININQ_WEBHOOK, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
"content": "New mininq:",
|
|
"embeds": [
|
|
{
|
|
"description": mininq_pub.msg,
|
|
"color": 12298956,
|
|
// TODO: make this smaller
|
|
"author": mininq_pub.url != '' ? {
|
|
"name": `${mininq_pub.name} @ ${new URL(mininq_pub.url).host} sent:`,
|
|
"url": `https://abtmtr.link/mininq/#${mininq.id}`
|
|
} : {
|
|
"name": `${mininq_pub.name} sent:`,
|
|
"url": `https://abtmtr.link/mininq/#${mininq.id}`
|
|
},
|
|
"timestamp": new Date(mininq_pub.time).toISOString()
|
|
}
|
|
],
|
|
"attachments": []
|
|
})
|
|
});
|
|
}
|
|
|
|
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");
|
|
|
|
if (process.env.MININQ_WEBHOOK != null) {
|
|
const mininq = await db
|
|
.selectFrom('mininq')
|
|
.selectAll()
|
|
.where('id', '=', body.id)
|
|
.executeTakeFirst();
|
|
|
|
await fetch(process.env.MININQ_WEBHOOK, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
"content": "New mininq:",
|
|
"embeds": [
|
|
{
|
|
"description": mininq.msg,
|
|
"color": 12298956,
|
|
"author": mininq.url != '' ? {
|
|
"name": `${mininq.name} @ ${new URL(mininq.url).host} sent:`,
|
|
"url": `https://abtmtr.link/mininq/#${mininq.id}`
|
|
} : {
|
|
"name": `${mininq.name} sent:`
|
|
},
|
|
"timestamp": new Date(mininq.time).toISOString()
|
|
},
|
|
{
|
|
"description": body.msg,
|
|
"color": 13421772,
|
|
"author": {
|
|
"name": "sysadmin reply:"
|
|
}
|
|
}
|
|
],
|
|
"attachments": []
|
|
})
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
const isUrlAlreadyMininq = await db
|
|
.selectFrom('mininq')
|
|
.select('url')
|
|
.where('url', '=', newObject.url)
|
|
.where('public', '=', 0)
|
|
.executeTakeFirst();
|
|
|
|
if (isUrlAlreadyMininq != null) return res.status(403).send("Pubkey is already in mailbox");
|
|
}
|
|
} 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())
|
|
.then((res) => res.text());
|
|
|
|
const rssParsed = await rssParser.parseString(rssXML);
|
|
|
|
res.render('updates', {
|
|
rss: rssParsed
|
|
});
|
|
});
|
|
|
|
app.get('/sites', async (req, res) => {
|
|
const buttonsJson = await fetch("https://cdn.abtmtr.link/site_content/buttons.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json())
|
|
.catch(() => res.status(500).send());
|
|
const followingJson = await fetch("https://cdn.abtmtr.link/site_content/following.json")
|
|
.catch(() => res.status(500).send())
|
|
.then((res) => res.json())
|
|
.catch(() => res.status(500).send());
|
|
|
|
res.render('sites', {
|
|
b: buttonsJson, f: followingJson
|
|
});
|
|
});
|
|
|
|
app.get('/blurbs', async (req, res) => {
|
|
const data = await db
|
|
.selectFrom('blurbs')
|
|
.selectAll()
|
|
.where('verified', '=', 1)
|
|
.orderBy('time', "desc")
|
|
.execute();
|
|
res.render('blurbs', { data });
|
|
});
|
|
|
|
// brbrleibbghbelsbbsbuuebbuubsubss
|
|
async function cleanupBlurbles() {
|
|
const timeNow = Date.now() - 86400000;
|
|
await db
|
|
.deleteFrom('blurbs')
|
|
.where('time', '<', timeNow)
|
|
.where('verified', '=', 0)
|
|
.execute()
|
|
}
|
|
|
|
app.get('/blurbs/testsend', async (req, res) => {
|
|
|
|
res.render('blurbsent', { id: "THISISATESTLOL" });
|
|
});
|
|
|
|
app.get('/blurbs/send', async (req, res) => {
|
|
const body = req.query;
|
|
const errors = [];
|
|
|
|
if (body.site == null) errors.push("Site domain required");
|
|
else {
|
|
const status = await fetch(body.site).then(x => x.status).catch(_ => _);
|
|
if (status != 200) errors.push("Site must be online");
|
|
}
|
|
if (body.text == null) errors.push("Blurb text required");
|
|
else {
|
|
if (body.text.length < 1) errors.push("Blurb text must not be blank");
|
|
if (body.text.length > 140) errors.push("Blurb text must not exceed 140 characters");
|
|
}
|
|
|
|
if (errors.length > 0) return res.status(400).json({
|
|
error: "Bad Request",
|
|
message: errors.join(", ")
|
|
});
|
|
|
|
try {
|
|
const domain = new URL(body.site).hostname;
|
|
const isBadSite = await db
|
|
.selectFrom('blacklist')
|
|
.selectAll()
|
|
.where('domain', '=', domain)
|
|
.executeTakeFirst();
|
|
|
|
if (isBadSite != null)
|
|
return res.status(403).json({
|
|
error: "Forbidden",
|
|
message: "\u{1f595}"
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({
|
|
error: "Internal Server Error",
|
|
message: "URL CONSTRUCTOR FAILED???? i think this might be your fault...."
|
|
});
|
|
}
|
|
|
|
const postId = nanoid(32);
|
|
const timestamp = Date.now();
|
|
|
|
try {
|
|
await db.insertInto('blurbs')
|
|
.values({
|
|
id: postId,
|
|
site: body.site,
|
|
blurb: body.text,
|
|
verified: 0,
|
|
time: timestamp
|
|
})
|
|
.executeTakeFirstOrThrow();
|
|
} catch (err) {
|
|
console.log(err)
|
|
return res.status(500).send('Internal Server Error');
|
|
}
|
|
|
|
cleanupBlurbles();
|
|
|
|
res.render('blurbsent', { id: postId });
|
|
});
|
|
|
|
app.get('/blurbs/check', async (req, res) => {
|
|
const body = req.query;
|
|
const blurbFromId = await db
|
|
.selectFrom('blurbs')
|
|
.selectAll()
|
|
.where('id', '=', body.id)
|
|
.executeTakeFirst();
|
|
|
|
if (blurbFromId == null) return res.redirect("/blurbs/");
|
|
|
|
const site = await fetch(blurbFromId.site).then(x => x.text()).catch(_ => _);
|
|
const dom = new JSDOM(site);
|
|
const relationLink = dom.window.document.querySelector(
|
|
`[rel=me][href="https://abtmtr.link/blurbs/#${blurbFromId.id}"]`
|
|
);
|
|
if (relationLink != null && blurbFromId.verified != 1) {
|
|
await db
|
|
.updateTable('blurbs')
|
|
.set({
|
|
verified: 1
|
|
})
|
|
.where('id', '=', body.id)
|
|
.executeTakeFirst();
|
|
|
|
if (process.env.BLURBS_WEBHOOK != null) {
|
|
await fetch(process.env.BLURBS_WEBHOOK, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
"content": "New blurb:",
|
|
"embeds": [
|
|
{
|
|
"description": blurbFromId.blurb,
|
|
"color": 12298956,
|
|
"author": {
|
|
"name": `${new URL(blurbFromId.site).host}`,
|
|
"url": `https://abtmtr.link/blurbs/#${blurbFromId.id}`
|
|
},
|
|
"timestamp": new Date(blurbFromId.time).toISOString()
|
|
}
|
|
],
|
|
"attachments": []
|
|
})
|
|
});
|
|
}
|
|
} else if (relationLink == null) {
|
|
await db
|
|
.updateTable('blurbs')
|
|
.set({
|
|
verified: 0
|
|
})
|
|
.where('id', '=', body.id)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
cleanupBlurbles();
|
|
|
|
res.redirect("/blurbs/");
|
|
});
|
|
|
|
app.get("/favicon.ico", (req, res) => {
|
|
res.redirect("https://cdn.abtmtr.link/site_content/favicon.ico")
|
|
})
|
|
|
|
app.get(["/rss", "/rss.xml"], (req, res) => {
|
|
res.redirect("https://cdn.abtmtr.link/site_content/rss.xml")
|
|
})
|
|
|
|
app.all('*', (req, res) => {
|
|
res.status(404).render('404');
|
|
})
|
|
|
|
app.listen(process.env.PORT, () => {
|
|
const url = new URL("http://localhost/");
|
|
url.port = process.env.PORT;
|
|
console.log(`Example app listening on ${url.toString()}`);
|
|
}); |