38 Commits

Author SHA1 Message Date
1e081c83e5 WIP Tags 2024-10-28 15:01:17 +01:00
cea96e170d Don't include deleted or empty articles in list 2024-10-28 13:56:36 +01:00
5906655902 Format inline code blocks in article titles 2024-10-28 12:36:15 +01:00
5f38355d5c Link plain Nostr URIs
Not just explicit Markdown links
2024-10-28 12:28:00 +01:00
010eb3f291 Refactor article fetching
* Apply different limits to profile and feed
* Ensure the limit is the returned amount
* Pre-sort articles
2024-10-26 12:04:06 +02:00
0b1eca87b2 Increase limit for article list
Needs pagination
2024-10-26 11:32:31 +02:00
f1d6ddbc84 Crop profile images to square format for preview 2024-10-25 18:40:22 +02:00
49d5aa4487 Add author pubkey to feed entry IDs 2024-10-25 18:01:03 +02:00
4c68be19fe Verify nip05 address, display status 2024-10-25 17:55:19 +02:00
a6517c61a4 Add user addresses to profile page, hide details by default 2024-10-25 16:25:25 +02:00
2624f2cbf8 Small CSS fix 2024-10-25 15:19:35 +02:00
cb4a4e06c8 Fix feeds 2024-10-25 14:59:20 +02:00
5f5f024ae7 Refactor/fix error handling, add query timeouts 2024-10-25 14:25:41 +02:00
ec7c775e25 Add YakiHonne app link 2024-10-25 12:47:17 +02:00
28e9d14aae Log relays on startup 2024-10-25 12:47:17 +02:00
eadc40392a WIP Nostr links 2024-10-25 12:47:13 +02:00
6ec9f51d77 Fix code element font size for headlines 2024-10-25 01:09:12 +02:00
afcb99356c Refactor CSS a bit, use variables for shared colors 2024-10-24 23:00:23 +02:00
266913c369 Style hr elements 2024-10-24 22:59:57 +02:00
aec35d9eb3 Add published date to feed items 2024-10-24 16:13:14 +02:00
c1c9a37914 Fall back to plain profile picture if imagemagick is not installed 2024-10-24 16:09:36 +02:00
30a74acf3b Support older imagemagick command 2024-10-24 15:40:08 +02:00
b7974c8610 Improve config processing 2024-10-24 15:17:11 +02:00
0096f3cae3 Fix linter/transpiler errors, formatting 2024-10-24 15:00:46 +02:00
fc711c2194 Rename some things 2024-10-24 14:28:49 +02:00
fdd16d8236 Add OG and Twitter Card markup, generate OG profile images
closes #2
2024-10-24 14:19:37 +02:00
d5793d47ff Choose a fun default port number 2024-10-23 23:47:18 +02:00
32f39685a1 Fix search timing out on non-existing nostrKey
fixes #1
2024-10-23 22:38:30 +02:00
062ded9e6d Remove generated anchor links from feed content 2024-10-23 14:54:18 +02:00
ba80792cc4 Formatting 2024-10-23 14:02:38 +02:00
a9f13310ab Wrap atom content in CDATA tag 2024-10-23 14:01:54 +02:00
e921fb2d84 Add alternative default path for users config 2024-10-23 01:11:00 +02:00
52d56c387d Fix current path in compiled program 2024-10-23 00:31:13 +02:00
46ad9813eb Typing all the types 2024-10-23 00:27:20 +02:00
edaf5f5c71 Improve deno tasks 2024-10-22 19:42:02 +02:00
f15e845825 Config UX 2024-10-22 19:41:49 +02:00
453a0f14d3 Support multiple relays for fetching content 2024-10-22 19:41:38 +02:00
856b10358c Improve layout 2024-10-22 19:16:58 +02:00
29 changed files with 1105 additions and 285 deletions

View File

@@ -1,6 +1,4 @@
PORT=8000
BASE_URL=http://localhost:8000
HOME_RELAY_URL=wss://nostr.kosmos.org
RELAY_URLS=wss://nostr.kosmos.org,wss://nostr.x0f.org
LDAP_URL=ldap://10.1.1.116:389
LDAP_BIND_DN=uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org
LDAP_PASSWORD=123456abcdef

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env
users.yaml
substr

View File

@@ -1,31 +1,13 @@
@import url("/assets/css/fonts/merriweather.css");
html {
font-size: 16px;
}
body {
font-size: 1.125rem;
font-family: "Merriweather", serif;
}
img {
max-width: 100%;
}
img.avatar {
height: 3rem;
width: 3rem;
border-radius: 50%;
}
.profile-page img.avatar {
height: 8rem;
width: 8rem;
}
h1 {
margin: 4rem 0 0 0;
margin: 4rem 0 1.6rem 0;
}
h2, h3, h4 {
@@ -34,7 +16,7 @@ h2, h3, h4 {
line-height: 1.6em;
}
p, pre, ul, ol, dl, blockquote {
p, pre, ul, ol, dl, blockquote, hr {
line-height: 1.6em;
margin-bottom: 1.6em;
}
@@ -44,7 +26,7 @@ a.anchor {
}
code {
font-size: 1rem;
font-size: 0.9em;
padding: 0.1em 0.3em;
}
@@ -59,6 +41,16 @@ pre code {
padding: 0.6rem 1rem;
}
img {
max-width: 100%;
}
img.avatar {
height: 3rem;
width: 3rem;
border-radius: 50%;
}
dl dt {
font-weight: bold;
margin: 0 0 0.5rem 0;
@@ -68,6 +60,10 @@ dl dd {
margin: 0 0 1.6rem 0;
}
hr {
border: 1px solid;
}
main {
display: block;
max-width: 44rem;
@@ -88,10 +84,6 @@ main header .draft-label {
font-weight: bold;
}
main header h1 {
margin-bottom: 1.6rem;
}
main header .meta {
display: flex;
column-gap: 1rem;
@@ -108,8 +100,30 @@ main header .meta .name a {
text-decoration: none;
}
main .article-list .item {
margin-bottom: 3rem;
main.profile-page {
margin-top: 6rem;
}
main.profile-page header {
margin-bottom: 2rem;
}
main.profile-page header h1 {
margin-bottom: 0;
}
main.profile-page header .nip05 {
font-size: 1rem;
}
main.profile-page img.avatar {
height: 8rem;
width: 8rem;
}
main.profile-page details summary {
cursor: pointer;
margin-bottom: 1.6rem;
}
main .article-list .item h3 {
@@ -120,14 +134,30 @@ main .article-list p {
margin-top: 0.5rem;
}
main .article-list p.meta {
font-size: 1rem;
}
main article footer {
margin-top: 5rem;
}
.profile-page .about {
main article footer .actions {
margin-bottom: 3rem;
}
main article footer p {
font-size: 1rem;
}
.nip05 .verified,
.nip05 .not-verified {
margin-left: 0.3rem;
padding: 0.1em 0.3em;
border-radius: 5px;
cursor: default;
}
/* Dropdown menu */
.dropdown {
@@ -136,7 +166,6 @@ main article footer {
}
.dropdown-button {
font-family: "Merriweather", serif;
font-size: 0.875rem;
padding: 0.6rem 1rem;
border: 1px solid;
@@ -194,10 +223,10 @@ main article footer {
main {
max-width: 100%;
margin: 4rem 1rem 8rem 1rem;
margin: 4rem 1rem 8rem 1rem !important;
}
.profile-page h1 {
main.profile-page h1 {
margin-top: 2rem;
}

View File

@@ -1,10 +1,23 @@
@import url("/assets/css/fonts/merriweather.css");
:root {
--font-family: "Merriweather", serif;
--background-color-body: #f5f2eb;
--background-color-dark: #333;
--text-color-body: #3b3a38;
--text-color-headings: #191818;
--text-color-discreet: #888;
--text-color-dark-bg: #ccc;
}
body {
background-color: #f5f2eb;
color: #3b3a38;
font-family: var(--font-family);
background-color: var(--background-color-body);
color: var(--text-color-body);
}
h1, h2, h3, h4 {
color: #191818;
color: var(--text-color-headings);
}
a {
@@ -15,23 +28,31 @@ a:visited {
color: #3b0277;
}
button {
font-family: var(--font-family);
}
code {
background-color: #e8e3da;
color: #027739;
}
pre {
background-color: #333;
color: #ccc;
background-color: var(--background-color-dark);
color: var(--text-color-dark-bg);
}
pre code {
background-color: #333;
color: #ccc;
background-color: var(--background-color-dark);
color: var(--text-color-dark-bg);
}
dl dt {
color: #888;
color: var(--text-color-discreet);
}
hr {
border-color: #e8e3da;
}
main header .draft-label {
@@ -40,43 +61,73 @@ main header .draft-label {
}
main header .meta .date {
color: #888;
color: var(--text-color-discreet);
}
main header .meta .name a {
color: #3b3a38;
color: var(--text-color-body);
}
.profile-page .pubkey {
color: #888;
main.profile-page header .nip05 {
color: var(--text-color-discreet);
}
main.profile-page .pubkey {
color: var(--text-color-discreet);
}
main article footer a,
main article footer a:visited {
text-decoration: none;
color: var(--text-color-discreet);
}
main article footer a:hover,
main article footer a:active {
text-decoration: underline;
}
.nip05 .verified {
background-color: #e8e3da;
color: #027739;
}
.nip05 .not-verified {
background-color: #e8e3da;
color: #770202;
}
/* Dropdown menu */
.dropdown {
--background-color: #fff;
--background-color-highlight: #f1f1f1;
}
.dropdown-button {
background-color: #f5f2eb;
color: #3b3a38;
border-color: #ccc;
background-color: var(--background-color-body);
color: var(--text-color-body);
border-color: var(--text-color-dark-bg);
}
.dropdown-content {
background-color: #fff;
background-color: var(--background-color);
}
.dropdown-content a {
color: #3b3a38 !important;
color: var(--text-color-body) !important;
}
.dropdown-content a:hover {
background-color: #f1f1f1;
background-color: var(--background-color-highlight);
}
.dropdown-content h4.title {
color: #888;
border-color: #f1f1f1;
color: var(--text-color-discreet);
border-color: var(--background-color-highlight);
}
.dropdown:hover .dropdown-button {
background-color: #fff;
border-color: #fff;
background-color: var(--background-color);
border-color: var(--background-color);
}

View File

@@ -1,27 +1,43 @@
import { load } from "@std/dotenv";
import { parse } from "jsr:@std/yaml";
import { parse as parseYaml } from "jsr:@std/yaml";
import { checkFileExists } from "./utils.ts";
import { log } from "./log.ts";
const dirname = new URL(".", import.meta.url).pathname;
const dirname = Deno.cwd();
await load({ envPath: `${dirname}/.env`, export: true });
let staticUsers;
let userConfigPath: string = "";
let staticUsers: { [key: string]: string } = {};
const defaultUserConfigPaths = [
"/etc/substr/users.yaml",
`${dirname}/users.yaml`,
];
for (const path of defaultUserConfigPaths) {
const fileExists = await checkFileExists(path);
if (fileExists) {
userConfigPath = path;
break;
}
}
try {
const yamlContent = await Deno.readTextFile(`${dirname}/users.yaml`);
staticUsers = parse(yamlContent);
log("Static user config:", "blue");
log(Deno.inspect(staticUsers), "blue");
const fileContent = await Deno.readTextFile(userConfigPath);
const parsedContent = parseYaml(fileContent);
if (parsedContent !== null && typeof parsedContent === "object") {
staticUsers = parsedContent as { [key: string]: string };
}
} catch {
staticUsers = {};
log(`Could not find or parse a "users.yaml" config`, "yellow");
// Nothing to do
}
const config = {
port: Deno.env.get("PORT") || 8000,
base_url: Deno.env.get("BASE_URL") || `http://localhost:8000`,
home_relay_url: Deno.env.get("HOME_RELAY_URL") || "",
staticUsers: staticUsers,
port: parseInt(Deno.env.get("PORT") || "30023"),
base_url: Deno.env.get("BASE_URL") || `http://localhost:30023`,
relay_urls: Deno.env.get("RELAY_URLS")?.split(",") || [],
staticUsers,
ldapEnabled: !!Deno.env.get("LDAP_URL"),
ldap: {
url: Deno.env.get("LDAP_URL"),
@@ -29,8 +45,50 @@ const config = {
password: Deno.env.get("LDAP_PASSWORD"),
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
},
query_timeout: parseInt(Deno.env.get("RELAY_TIMEOUT_MS") || "5000"),
njump_url: Deno.env.get("NJUMP_URL") || "https://njump.me",
};
log(`LDAP enabled: ${config.ldapEnabled}`, "blue");
const staticUsersConfigured = Object.keys(staticUsers).length > 0;
export function ensureNecessaryConfigs() {
if (config.relay_urls.length === 0) {
log(
`No relays configured. Please add at least one relay to RELAY_URLS.`,
"yellow",
);
Deno.exit(1);
} else {
log(`Relays: ${config.relay_urls.join(", ")}`, "green");
}
if (staticUsersConfigured) {
log(`Serving content for pubkeys in users.yaml`, "blue");
} else {
log(`Could not find or parse a users.yaml config`, "gray");
}
if (config.ldapEnabled) {
if (
config.ldap.url && config.ldap.bindDN && config.ldap.password &&
config.ldap.searchDN
) {
log(`Serving content for pubkeys from ${config.ldap.url}`, "blue");
} else {
log(`The LDAP config is incomplete`);
Deno.exit(1);
}
} else {
log(`LDAP not enabled`, "blue");
}
if (!staticUsersConfigured && !config.ldapEnabled) {
log(
`Neither static users nor LDAP configured. Nothing to serve.`,
"yellow",
);
Deno.exit(1);
}
}
export default config;

View File

@@ -1,7 +1,7 @@
{
"tasks": {
"dev": "deno run server",
"server": "deno run --allow-net --allow-read --allow-env --watch server.ts"
"dev": "deno run --allow-all --watch server.ts",
"server": "deno run --allow-all server.ts"
},
"imports": {
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",
@@ -13,5 +13,10 @@
"@std/testing": "jsr:@std/testing@^1.0.3",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"ldapts": "npm:ldapts@^7.2.1"
},
"fmt": {
"exclude": [
"magick.ts"
]
}
}

View File

@@ -2,30 +2,38 @@ import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
import { isoDate } from "./dates.ts";
export function profileAtomFeed(profile: Profile, articles: Article[]) {
export async function profileAtomFeed(
profile: Profile,
articles: Article[],
): Promise<string> {
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
?.updatedAt;
let articlesXml = "";
const articlesXml = articles.map((article) => {
for (const article of articles) {
const contentHtml = await article.buildContentHtml();
const articleId =
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
return `
`tag:${profile.nip05},nostr-p-${profile.pubkey}-d-${article.identifier}-k-30023`;
articlesXml += `
<entry>
<id>${articleId}</id>
<title>${article.title}</title>
<link href="${article.url}" />
<updated>${isoDate(article.updatedAt)}</updated>
<published>${isoDate(article.publishedAt)}</published>
<summary>${article.summary}</summary>
<content type="html">${article.html}</content>
<content type="html"><![CDATA[
${cleanContentHtml(contentHtml)}
]]></content>
</entry>
`;
}).join("\n");
}
return `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${profile.name} on Nostr</title>
<title>${profile.name} on Nostr (Articles)</title>
<id>${feedId}</id>
<updated>${isoDate(lastUpdate)}</updated>
<icon>${profile.picture}</icon>
@@ -36,3 +44,11 @@ export function profileAtomFeed(profile: Profile, articles: Article[]) {
</feed>
`.trim();
}
export function cleanContentHtml(html: string) {
const cleanHtml = html.replace(
/<a class="anchor" aria-hidden="true"[^>]*>.*?<\/a>/gs,
"",
);
return cleanHtml;
}

20
handlers/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Context } from "@oak/oak";
import { errorPageHtml } from "../html.ts";
export const notFoundHandler = function (ctx: Context) {
const html = errorPageHtml(404, "Not found");
ctx.response.body = html;
ctx.response.status = 404;
};
export const badGatewayHandler = function (ctx: Context) {
const html = errorPageHtml(502, "Bad gateway");
ctx.response.body = html;
ctx.response.status = 502;
};
export const internalServerErrorHandler = function (ctx: Context) {
const html = errorPageHtml(500, "Internal server error");
ctx.response.body = html;
ctx.response.status = 500;
};

View File

@@ -1,23 +1,24 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { log } from "../log.ts";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const naddrHandler = async function (ctx: Context) {
const naddr = ctx.params.path;
const naddr = ctx.state.path;
let data: nip19.AddressPointer;
try {
const r = nip19.decode(naddr);
const username = await lookupUsernameByPubkey(r.data.pubkey);
data = nip19.decode(naddr).data as nip19.AddressPointer;
} catch (_e) {
notFoundHandler(ctx);
return;
}
if (username && r.data.identifier) {
ctx.response.redirect(`/@${username}/${r.data.identifier}`);
} else {
notFoundHandler(ctx);
}
} catch (e) {
log(e, "yellow");
const username = await lookupUsernameByPubkey(data.pubkey);
if (username && data.identifier) {
ctx.response.redirect(`/@${username}/${data.identifier}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,10 +0,0 @@
import { Context } from "@oak/oak";
import { errorPageHtml } from "../html.ts";
const notFoundHandler = function (ctx: Context) {
const html = errorPageHtml(404, "Not found");
ctx.response.body = html;
ctx.response.status = 404;
};
export default notFoundHandler;

View File

@@ -1,23 +1,25 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { log } from "../log.ts";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const nprofileHandler = async function (ctx: Context) {
const nprofile = ctx.params.path;
const nprofile = ctx.state.path;
let data: nip19.ProfilePointer;
try {
const r = nip19.decode(nprofile);
const username = await lookupUsernameByPubkey(r.data.pubkey);
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
console.log(data);
} catch (_e) {
notFoundHandler(ctx);
return;
}
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
} catch (e) {
log(e, "yellow");
const username = await lookupUsernameByPubkey(data.pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,23 +1,24 @@
import { Context } from "@oak/oak";
import { nip19 } from "@nostr/tools";
import { log } from "../log.ts";
import { lookupUsernameByPubkey } from "../directory.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const npubHandler = async function (ctx: Context) {
const npub = ctx.params.path;
const npub = ctx.state.path;
let pubkey: string;
try {
const r = nip19.decode(npub);
const username = await lookupUsernameByPubkey(r.data);
pubkey = nip19.decode(npub).data as string;
} catch (_e) {
notFoundHandler(ctx);
return;
}
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
} catch (e) {
log(e, "yellow");
const username = await lookupUsernameByPubkey(pubkey);
if (username) {
ctx.response.redirect(`/@${username}`);
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,40 +1,34 @@
import { Context } from "@oak/oak";
import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../directory.ts";
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import { profileAtomFeed } from "../feeds.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
const userAtomFeedHandler = async function (ctx: Context) {
const username = ctx.params.user.replace(/^(@|~)/, "");
const pubkey = await lookupPubkeyByUsername(username);
const username = ctx.state.username;
const pubkey = await lookupPubkeyByUsername(ctx.state.username);
if (!pubkey) {
notFoundHandler(ctx);
return;
}
try {
const profileEvent = await fetchProfileEvent(pubkey);
const profileEvent = await fetchProfileEvent(pubkey);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
if (profileEvent && profile.nip05) {
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const atom = profileAtomFeed(profile, articles);
if (profile.nip05) {
const articles = await fetchArticlesByAuthor(pubkey, 10);
const atom = await profileAtomFeed(profile, articles);
ctx.response.headers.set("Content-Type", "application/atom+xml");
ctx.response.body = atom;
} else {
ctx.response.status = 404;
ctx.response.body = "Not Found";
return;
}
} catch (e) {
log(e, "yellow");
notFoundHandler(ctx);
}
notFoundHandler(ctx);
};
export default userAtomFeedHandler;

View File

@@ -1,15 +1,15 @@
import { Context } from "@oak/oak";
import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../directory.ts";
import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { articleHtml } from "../html.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
import { generateOgProfileImage } from "../magick.ts";
const userEventHandler = async function (ctx: Context) {
const username = ctx.params.user.replace(/^(@|~)/, "");
const identifier = ctx.params.identifier;
const username = ctx.state.username.replace(/^(@|~)/, "");
const identifier = ctx.state.identifier;
const pubkey = await lookupPubkeyByUsername(username);
if (!pubkey) {
@@ -17,24 +17,20 @@ const userEventHandler = async function (ctx: Context) {
return;
}
try {
const articleEvent = await fetchReplaceableEvent(
pubkey,
identifier,
);
const profileEvent = await fetchProfileEvent(pubkey);
const articleEvent = await fetchReplaceableEvent(
pubkey,
identifier,
);
const profileEvent = await fetchProfileEvent(pubkey);
if (articleEvent && profileEvent) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = articleHtml(article, profile);
if (articleEvent && profileEvent) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = await articleHtml(article, profile);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
} catch (e) {
log(e, "yellow");
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};

View File

@@ -1,14 +1,13 @@
import { Context } from "@oak/oak";
import { log } from "../log.ts";
import { lookupPubkeyByUsername } from "../directory.ts";
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { profilePageHtml } from "../html.ts";
import notFoundHandler from "../handlers/not-found.ts";
import { notFoundHandler } from "../handlers/errors.ts";
import { generateOgProfileImage } from "../magick.ts";
const userProfileHandler = async function (ctx: Context) {
const username = ctx.params.path.replace(/^(@|~)/, "");
const username = ctx.state.path.replace(/^(@|~)/, "");
const pubkey = await lookupPubkeyByUsername(username);
if (!pubkey) {
@@ -16,22 +15,18 @@ const userProfileHandler = async function (ctx: Context) {
return;
}
try {
const profileEvent = await fetchProfileEvent(pubkey);
const profileEvent = await fetchProfileEvent(pubkey);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const html = profilePageHtml(profile, articles);
if (profileEvent) {
const profile = new Profile(profileEvent, username);
ctx.response.body = html;
} else {
log(`No profile event found for @${username}`, "yellow");
notFoundHandler(ctx);
}
} catch (e) {
log(e, "yellow");
const articles = await fetchArticlesByAuthor(pubkey, 210, ctx.state.tags);
const html = await profilePageHtml(profile, articles);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};

145
html.ts
View File

@@ -2,13 +2,13 @@ import { localizeDate } from "./dates.ts";
import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
function htmlLayout(title: string, body: string, profile?: Profile): string {
let feedLinksHtml = "";
if (profile) {
feedLinksHtml =
`<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
interface HtmlLayoutOptions {
title: string;
body: string;
metaHtml?: string;
}
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
return `
<!DOCTYPE html>
<html>
@@ -17,7 +17,7 @@ function htmlLayout(title: string, body: string, profile?: Profile): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${title}</title>
${feedLinksHtml}
${metaHtml || ""}
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
</head>
@@ -35,10 +35,23 @@ export function errorPageHtml(statusCode: number, title: string): string {
</main>
`;
return htmlLayout(title, body);
return htmlLayout({ title, body });
}
export function articleHtml(article: Article, profile: Profile): string {
function articleTagsHtml(article: Article, profile: Profile): string {
if (article.tags.length === 0) return "";
const tags = article.tags.map((tag) => {
return `<a href="/@${profile.username}?tags=${tag}">${tag}</a>`;
});
return `Tags: ${tags.join(", ")}</p>\n`;
}
export async function articleHtml(
article: Article,
profile: Profile,
): Promise<string> {
const publishedAtFormatted = localizeDate(article.publishedAt);
const pageTitle = article.isDraft ? `Draft: ${article.title}` : article.title;
let draftLabel = ``;
@@ -50,7 +63,7 @@ export function articleHtml(article: Article, profile: Profile): string {
<main>
<header>
${draftLabel}
<h1>${article.title}</h1>
<h1>${titleHtml(article.title)}</h1>
<div class="meta">
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="content">
@@ -60,15 +73,27 @@ export function articleHtml(article: Article, profile: Profile): string {
</div>
</header>
<article>
${article.html}
${await article.buildContentHtml()}
<footer>
${openWithNostrAppHtml(article.naddr)}
<div class="actions">
${openWithNostrAppHtml(article.naddr)}
</div>
<p class="tags">
${articleTagsHtml(article, profile)}
</p>
</footer>
</article>
</main>
`;
return htmlLayout(pageTitle, body, profile);
let metaHtml = articleMetaHtml(article, profile);
metaHtml += feedLinksHtml(profile);
return htmlLayout({ title: pageTitle, body, metaHtml });
}
function titleHtml(title: string) {
return title.replace(/`([^`]+)`/g, "<code>$1</code>");
}
function articleListItemHtml(article: Article): string {
@@ -76,18 +101,19 @@ function articleListItemHtml(article: Article): string {
return `
<div class="item">
<h3><a href="/${article.naddr}">${article.title}</a></h3>
<p>${formattedDate}</p>
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
<p class="meta">
${formattedDate}
</p>
</div>
`;
}
export function articleListHtml(articles: Article[]): string {
if (articles.length === 0) return "";
const sortedArticles = articles.sort((a, b) => b.publishedAt - a.publishedAt);
let html = "";
for (const article of sortedArticles) {
for (const article of articles) {
html += articleListItemHtml(article);
}
@@ -99,8 +125,40 @@ export function articleListHtml(articles: Article[]): string {
`;
}
export function profilePageHtml(profile: Profile, articles: Article[]): string {
function userAddressHtml(profile: Profile) {
let html = "";
if (profile.nip05) {
html += `<dt>Nostr address</dt><dd>${profile.nip05}</dd>\n`;
}
if (profile.lud16) {
html += `<dt>Lightning address</dt><dd>${profile.lud16}</dd>\n`;
}
return html;
}
function nip05VerifiedHtml(verified: boolean): string {
if (verified) {
return ` <span class="verified" title="Verified">✔</span>`;
} else {
return ` <span class="not-verified" title="Verification failed">✕</span>`;
}
}
export async function profilePageHtml(
profile: Profile,
articles: Article[],
): Promise<string> {
const title = `${profile.name} on Nostr`;
let nip05Html = "";
if (profile.nip05) {
const nip05Verified = await profile.verifyNip05();
nip05Html += `<p class="nip05">${profile.nip05}${
nip05VerifiedHtml(nip05Verified)
}</p>\n`;
}
const body = `
<main class="profile-page">
@@ -108,25 +166,30 @@ export function profilePageHtml(profile: Profile, articles: Article[]): string {
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="bio">
<h1>${profile.name}</h1>
${nip05Html}
<p class="about">
${profile.about}
</p>
</p>
</div>
</header>
<section>
<details>
<summary>Details</summary>
<dl>
<dt>Public key</dt>
<dd>${profile.npub}</dd>
${userAddressHtml(profile)}
</dl>
</section>
</details>
<section>
${articleListHtml(articles)}
</section>
</main>
`;
return htmlLayout(title, body, profile);
let metaHtml = profileMetaHtml(profile);
metaHtml += feedLinksHtml(profile);
return htmlLayout({ title, body, metaHtml });
}
function openWithNostrAppHtml(bech32Id: string): string {
@@ -138,6 +201,7 @@ function openWithNostrAppHtml(bech32Id: string): string {
href: `https://nostrudel.ninja/#/articles/${bech32Id}`,
},
{ title: "Coracle", href: `https://coracle.social/${bech32Id}` },
{ title: "YakiHonne", href: `https://yakihonne.com/article/${bech32Id}` },
];
for (const link of appLinks) {
@@ -155,3 +219,40 @@ function openWithNostrAppHtml(bech32Id: string): string {
</div>
`;
}
function feedLinksHtml(profile: Profile) {
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
function profileMetaHtml(profile: Profile) {
return `
<meta property="og:url" content="${profile.profileUrl}">
<meta property="og:type" content="website">
<meta property="og:title" content="${profile.name} on Nostr">
<meta property="og:description" content="">
<meta property="og:image" content="${profile.ogImageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="nostr.kosmos.org">
<meta property="twitter:url" content="${profile.profileUrl}">
<meta name="twitter:title" content="${profile.name} on Nostr">
<meta name="twitter:description" content="">
<meta name="twitter:image" content="${profile.ogImageUrl}">
`;
}
function articleMetaHtml(article: Article, profile: Profile) {
const imageUrl = article.image || profile.ogImageUrl;
return `
<meta property="og:url" content="${article.url}">
<meta property="og:type" content="website">
<meta property="og:title" content="${article.title}">
<meta property="og:description" content="${article.summary}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="${article.url}">
<meta name="twitter:title" content="${article.title}">
<meta name="twitter:description" content="${article.summary}">
<meta name="twitter:image" content="${imageUrl}">
`;
}

34
ldap.ts
View File

@@ -1,30 +1,35 @@
import { Client } from "ldapts";
import { log } from "./log.ts";
import config from "./config.ts";
const { ldap, ldapEnabled } = config;
let client: Client;
if (ldapEnabled) {
client = new Client({ url: ldap.url });
client = new Client({ url: ldap.url as string });
}
export async function lookupPubkeyByUsername(username: string) {
let pubkey;
try {
await client.bind(ldap.bindDN, ldap.password);
await client.bind(ldap.bindDN as string, ldap.password as string);
const { searchEntries } = await client.search(ldap.searchDN, {
const { searchEntries } = await client.search(ldap.searchDN as string, {
filter: `(cn=${username})`,
attributes: ["nostrKey"],
});
pubkey = searchEntries[0]?.nostrKey;
} catch (ex) {
log(ex, "red");
} finally {
if (
searchEntries.length > 0 &&
typeof searchEntries[0].nostrKey === "string"
) {
pubkey = searchEntries[0].nostrKey;
}
await client.unbind();
} catch (e) {
await client.unbind();
throw e;
}
return pubkey;
@@ -34,18 +39,19 @@ export async function lookupUsernameByPubkey(pubkey: string) {
let username;
try {
await client.bind(ldap.bindDN, ldap.password);
await client.bind(ldap.bindDN as string, ldap.password as string);
const { searchEntries } = await client.search(ldap.searchDN, {
const { searchEntries } = await client.search(ldap.searchDN as string, {
filter: `(nostrKey=${pubkey})`,
attributes: ["cn"],
});
username = searchEntries[0]?.cn;
} catch (ex) {
log(ex, "red");
} finally {
if (searchEntries.length > 0) {
username = searchEntries[0].cn;
}
} catch (e) {
await client.unbind();
throw e;
}
return username;

69
magick.ts Normal file
View File

@@ -0,0 +1,69 @@
import Profile from "./models/profile.ts";
import { checkFileExists, getImageMagickCommand, runCommand } from "./utils.ts";
import { log } from "./log.ts";
const tmpImgDir = "/tmp/substr/img";
const magick = await getImageMagickCommand();
if (!magick) {
log("ImageMagick is not installed. Cannot generate preview images", "yellow")
}
function createRoundedImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const args = [
profile.picture,
'-resize', '256x256^',
'-gravity', 'center',
'-extent', '256x256',
'(', '+clone', '-alpha', 'extract',
'-draw', "fill black polygon 0,0 0,128 128,0 fill white circle 128,128 128,0",
'(', '+clone', '-flip', ')', '-compose', 'Multiply', '-composite',
'(', '+clone', '-flop', ')', '-compose', 'Multiply', '-composite',
')',
'-alpha', 'off',
'-compose', 'CopyOpacity',
'-composite',
`${tmpImgDir}/p-${profile.event.id}-rounded.png`
];
return runCommand(magick, args);
}
async function createOgImage(profile: Profile, ogImagePath: string, backgroundColor: string) {
if (!magick) return false;
const status = await createRoundedImage(profile);
if (status && status.success) {
const args = [
`${tmpImgDir}/p-${profile.event.id}-rounded.png`,
'-resize', '256x256',
'-background', backgroundColor,
'-gravity', 'center',
'-extent', '1200x630',
'-size', '1200x630',
"-format", "png",
ogImagePath
];
return runCommand(magick, args);
}
};
export async function generateOgProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const ogImagePath = `${tmpImgDir}/og-p-${profile.event.id}.png`;
const backgroundColor = "#333333";
const fileExists = await checkFileExists(ogImagePath);
if (!fileExists) {
const status = await createOgImage(profile, ogImagePath, backgroundColor);
if (status && status.success) {
log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue")
} else {
log(`Could not create OG image for ${profile.username}`, "yellow")
}
}
}

View File

@@ -1,6 +1,7 @@
import { render as renderMarkdown } from "@deno/gfm";
import { nip19 } from "@nostr/tools";
import { NEvent } from "../nostr.ts";
import { NostrEvent as NEvent } from "@nostrify/nostrify";
import { replaceNostrUris } from "../nostr.ts";
import config from "../config.ts";
export default class Article {
@@ -28,11 +29,23 @@ export default class Article {
return tag ? tag[1] : "Untitled";
}
get image(): string | undefined {
const tag = this.event.tags.find((t) => t[0] === "image");
return tag ? tag[1] : undefined;
}
get summary(): string {
const tag = this.event.tags.find((t) => t[0] === "summary");
return tag ? tag[1] : "";
}
get tags(): string[] {
return this.event.tags
.filter((t) => t[0] === "t")
.filter((t) => t[1] !== "")
.map((t) => t[1]);
}
get publishedAt(): number {
const tag = this.event.tags.find((t) => t[0] === "published_at");
return tag ? parseInt(tag[1]) : this.event.created_at;
@@ -42,8 +55,12 @@ export default class Article {
return this.event.created_at;
}
get html(): string {
return renderMarkdown(this.event.content);
get content(): string {
return this.event.content;
}
get isDeleted(): boolean {
return !!this.event.tags.find((t) => t[0] === "deleted");
}
get naddr(): string {
@@ -51,7 +68,14 @@ export default class Article {
identifier: this.identifier,
pubkey: this.event.pubkey,
kind: this.event.kind,
relays: [config.home_relay_url],
relays: [config.relay_urls[0]],
});
}
async buildContentHtml(): Promise<string> {
let md = this.event.content.trim();
md = md.replace(`# ${this.title}\n`, "");
md = await replaceNostrUris(md);
return renderMarkdown(md);
}
}

View File

@@ -1,5 +1,9 @@
import { nip19 } from "@nostr/tools";
import { NEvent } from "../nostr.ts";
import { nip19, NostrEvent as NEvent } from "@nostr/tools";
import { verifyNip05Address } from "../nostr.ts";
import { getImageMagickCommand } from "../utils.ts";
import config from "../config.ts";
const magick = await getImageMagickCommand();
export interface ProfileData {
name?: string;
@@ -12,8 +16,8 @@ export interface ProfileData {
}
export default class Profile {
event: NEvent;
private data: ProfileData;
event: NEvent;
username?: string;
constructor(event: NEvent, username?: string) {
@@ -40,7 +44,7 @@ export default class Profile {
}
get nip05(): string | undefined {
return this.data.nip05;
return this.data.nip05?.replace("_@", "");
}
get lud16(): string | undefined {
@@ -54,4 +58,24 @@ export default class Profile {
get npub(): string {
return nip19.npubEncode(this.pubkey);
}
get profileUrl(): string {
return `${config.base_url}/@${this.username}`;
}
get ogImageUrl(): string {
if (magick) {
return `${config.base_url}/assets/g/img/og-p-${this.event.id}.png`;
} else {
return this.picture || "";
}
}
verifyNip05(): Promise<boolean> {
if (typeof this.data.nip05 !== "undefined") {
return verifyNip05Address(this.data.nip05, this.pubkey);
} else {
return Promise.resolve(false);
}
}
}

138
nostr.ts
View File

@@ -1,59 +1,153 @@
import { NRelay1 } from "@nostrify/nostrify";
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
import { nip19 } from "@nostr/tools";
import { lookupUsernameByPubkey } from "./directory.ts";
import config from "./config.ts";
import Article from "./models/article.ts";
export interface NEvent {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
sig: string;
tags: Array<[string, string, string?]>;
const relayPool = new NPool({
open: (url) => new NRelay1(url),
// deno-lint-ignore require-await
reqRouter: async (filters) =>
new Map(
config.relay_urls.map((url) => [url, filters]),
),
// deno-lint-ignore require-await
eventRouter: async (_event) => [],
});
export async function fetchWithTimeout(filters: NostrFilter[]) {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("relay timeout")), config.query_timeout)
);
const eventsPromise = relayPool.query(filters);
const events = await Promise.race([eventsPromise, timeoutPromise]);
return events;
}
export const relay = new NRelay1(config.home_relay_url);
export async function fetchReplaceableEvent(
pubkey: string,
identifier: string,
) {
let events = await relay.query([{
let events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30023],
"#d": [identifier],
limit: 1,
}]);
}]) as NostrEvent[];
if (events.length > 0) {
return events[0];
} else {
events = await relay.query([{
events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30024],
"#d": [identifier],
limit: 1,
}]);
}]) as NostrEvent[];
return events.length > 0 ? events[0] : null;
}
}
export async function fetchArticlesByAuthor(pubkey: string) {
const events = await relay.query([{
export function createTagList(
articles: Article[],
): Record<string, number> {
return articles.flatMap((a) => a.tags).reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
}
export async function fetchArticlesByAuthor(
pubkey: string,
limit: number = 10,
tags?: string[],
) {
const filter = {
authors: [pubkey],
kinds: [30023],
limit: 10,
}]);
limit: limit,
};
if (typeof tags !== "undefined") filter["#t"] = tags;
return events;
const events = await fetchWithTimeout([filter]) as NostrEvent[];
const articles = events.map((a) => new Article(a));
return articles
.filter((a) => !a.isDeleted)
.filter((a) => a.content.trim() !== "")
.sort((a, b) => b.publishedAt - a.publishedAt)
.slice(0, limit); // The limit seems to apply per relay, not per pool query
}
export async function fetchProfileEvent(pubkey: string) {
const events = await relay.query([{
const events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [0],
limit: 1,
}]);
}]) as NostrEvent[];
return events.length > 0 ? events[0] : null;
}
export async function nostrUriToUrl(uri: string): Promise<string> {
const bech32 = uri.replace(/^nostr:/, "");
if (bech32.match(/^(naddr|nprofile|npub)/)) {
try {
const r = nip19.decode(bech32);
let username;
switch (r.type) {
case "naddr":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/${bech32}`;
break;
case "nprofile":
username = await lookupUsernameByPubkey(r.data.pubkey);
if (username) return `/@${username}`;
break;
case "npub":
username = await lookupUsernameByPubkey(r.data);
if (username) return `/@${username}`;
break;
}
} catch (e) {
console.error(e);
}
}
return `${config.njump_url}/${bech32}`;
}
export async function replaceNostrUris(markdown: string): Promise<string> {
const nostrUriRegex = /(nostr:|nprofile|naddr|nevent|nrelay|npub)[a-z0-9]+/g;
const matches = markdown.match(nostrUriRegex);
if (!matches) return markdown;
for (const uri of matches) {
const url = await nostrUriToUrl(uri);
markdown = markdown.replace(uri, url);
}
return markdown;
}
export async function verifyNip05Address(
address: string,
pubkey: string,
): Promise<boolean> {
const [username, host] = address.split("@");
const url = `https://${host}/.well-known/nostr.json?name=${username}`;
try {
const res = await fetch(url);
if (res.status === 404 || !res.ok) return false;
const data = await res.json();
return data.names && data.names[username] === pubkey;
} catch (_e) {
return false;
}
}

114
server.ts
View File

@@ -1,49 +1,88 @@
import { Application, Context, Router, send } from "@oak/oak";
import config from "./config.ts";
import { Application, Router, send } from "@oak/oak";
import { createSubtrTmpDirectories } from "./utils.ts";
import config, { ensureNecessaryConfigs } from "./config.ts";
import naddrHandler from "./handlers/naddr.ts";
import nprofileHandler from "./handlers/nprofile.ts";
import npubHandler from "./handlers/npub.ts";
import userProfileHandler from "./handlers/user-profile.ts";
import userEventHandler from "./handlers/user-event.ts";
import userAtomFeedHandler from "./handlers/user-atom-feed.ts";
import notFoundHandler from "./handlers/not-found.ts";
import {
badGatewayHandler,
internalServerErrorHandler,
notFoundHandler,
} from "./handlers/errors.ts";
const router = new Router();
router.get("/:path", async (ctx: Context) => {
const { path } = ctx.params;
router.get("/:path", async (ctx) => {
const path = ctx.state.path = ctx.params.path;
if (path.startsWith("naddr")) {
await naddrHandler(ctx);
} else if (path.startsWith("nprofile")) {
await nprofileHandler(ctx);
} else if (path.startsWith("npub")) {
await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) {
await userProfileHandler(ctx);
} else {
notFoundHandler(ctx);
try {
if (path.startsWith("naddr")) {
await naddrHandler(ctx);
} else if (path.startsWith("nprofile")) {
await nprofileHandler(ctx);
} else if (path.startsWith("npub")) {
await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) {
const tags = ctx.request.url.searchParams.get("tags");
if (typeof tags === "string" && tags !== "") {
ctx.state.tags = tags.split(",");
}
await userProfileHandler(ctx);
} else {
notFoundHandler(ctx);
}
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
});
router.get("/:user/:kind.atom", async (ctx: Context) => {
const { user, kind } = ctx.params;
router.get("/:username/:kind.atom", async (ctx) => {
ctx.state.username = ctx.params.username.replace(/^(@|~)/, "");
ctx.state.kind = ctx.params.kind;
if (
kind === "articles" &&
(user.startsWith("@") || user.startsWith("~"))
ctx.state.kind === "articles" &&
(ctx.params.username.startsWith("@") ||
ctx.params.username.startsWith("~"))
) {
await userAtomFeedHandler(ctx);
try {
await userAtomFeedHandler(ctx);
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
} else {
notFoundHandler(ctx);
}
});
router.get("/:user/:identifier", async (ctx: Context) => {
const { user } = ctx.params;
router.get("/:username/:identifier", async (ctx) => {
const username = ctx.state.username = ctx.params.username;
ctx.state.identifier = ctx.params.identifier;
if (user.startsWith("@") || user.startsWith("~")) {
await userEventHandler(ctx);
if (username.startsWith("@") || username.startsWith("~")) {
try {
await userEventHandler(ctx);
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
badGatewayHandler(ctx);
} else {
internalServerErrorHandler(ctx);
}
}
} else {
notFoundHandler(ctx);
}
@@ -51,16 +90,31 @@ router.get("/:user/:identifier", async (ctx: Context) => {
router.get("/assets/:path*", async (ctx) => {
try {
const filePath = ctx.params.path || '';
let filePath = ctx.params.path || "";
let root: string;
await send(ctx, filePath, {
root: `${Deno.cwd()}/assets`,
});
} catch (_e) {
notFoundHandler(ctx);
if (filePath.startsWith("g/img/")) {
filePath = filePath.replace(/^g\//, "");
root = "/tmp/substr";
} else {
root = `${Deno.cwd()}/assets`;
}
await send(ctx, filePath, { root });
} catch (e) {
if (e instanceof Error && e.name === "NotFoundError") {
notFoundHandler(ctx);
} else {
console.error(e);
badGatewayHandler(ctx);
}
}
});
ensureNecessaryConfigs();
await createSubtrTmpDirectories();
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

17
tests/feeds_test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { cleanContentHtml } from "../feeds.ts";
describe("Feeds", () => {
describe("#cleanContentHtml", () => {
const articleHtml = Deno.readTextFileSync(
"tests/fixtures/gfm-content-1.html",
);
it("removes the anchor links for headlines", () => {
const cleanHtml = cleanContentHtml(articleHtml);
expect(cleanHtml).not.toMatch(/<a class="anchor" aria-hidden="true"/);
expect(cleanHtml).not.toMatch(/<svg class="octicon octicon-link"/);
});
});
});

9
tests/fixtures/article-deleted.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"content": "",
"created_at": 1716761766,
"id": "ae83c3e23e11db5fd0e6dacaece38847451e81d1429e4182a0cadd409bdce30f",
"kind": 30023,
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"sig": "6c21eba5324af302dfbfb9dadfc2d067646a3594ffed02d2528cca8ee0f7c16b9ffb3dc640420304882be94e789d93191d830108ac52d57b2e72445025e433b2",
"tags": [["d", "66674915"], ["deleted"]]
}

165
tests/fixtures/gfm-content-1.html vendored Normal file
View File

@@ -0,0 +1,165 @@
<p>
This week, it finally happened: I still had a Lightning channel open with a
node that hadn't been online for the better part of a year now, so I decided
to close the channel unilaterally. But force-closing a channel means you have
to broadcast the latest commitment transaction, the pre-set fee of which was
only ~1 sat/vB for this one.
</p>
<p>
With LND, if the channel is created as an <a
href="https://lightning.engineering/posts/2021-01-28-lnd-v0.12/"
rel="noopener noreferrer"
>anchor channel</a> (by default only since version 0.12), then the commitment
transaction contains small extra outputs (currently 330 sats), which let
either channel partner spend one of them into a child transaction that can be
created with higher fees to pay for the parent transaction (CPFP). LND even
has a built-in command for that: <code>lncli wallet bumpclosefee</code>
</p>
<p>
However, this channel was created in the old-school way, and was thus stuck
with its low fee. In fact, even the local bitcoin node refused to accept the
transaction into its own mempool, so the bitcoin p2p network didn't even know
it existed. So how do we get out of this pickle?
</p>
<h2 id="the-solution">
<a class="anchor" aria-hidden="true" tabindex="-1" href="#the-solution"><svg
class="octicon octicon-link"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
>
</path>
</svg></a>The solution
</h2>
<p>
Enter the <a
href="https://mempool.space/accelerator"
rel="noopener noreferrer"
>mempool.space Accelerator</a>. It is essentially an automated way to create
agreements with various mining pools to mine your low-fee transaction in
exchange for an out-of-band payment. Mempool.space coordinates these
agreements and out-of-band payments with miners and gets a share from the
overall fee for that.
</p>
<p>
Now, if you're in the same situation as I was, you might search for the ID of
your closing transaction and find that mempool.space cannot find it. Remember
how the local bitcoin node (with mostly default settings) didn't accept it in
the first place?
</p>
<h3 id="1-get-the-transaction-to-be-broadcast">
<a
class="anchor"
aria-hidden="true"
tabindex="-1"
href="#1-get-the-transaction-to-be-broadcast"
><svg
class="octicon octicon-link"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
>
</path>
</svg></a>1. Get the transaction to be broadcast
</h3>
<p>In your <code>bitcoin.conf</code>, add the following line:</p>
<pre><code>minrelaytxfee=0</code></pre><p>
This sets the minimum fee to 0, meaning it will accept and broadcast your
transactions, no matter how low the fee is. Restart <code>bitcoind</code> and
wait a little bit. LND will retry broadcasting the closing transaction every
minute or so until it succeeds. At some point you should be able to find it on
mempool.space.
</p>
<h3 id="2-use-the-accelerator-to-confirm-it">
<a
class="anchor"
aria-hidden="true"
tabindex="-1"
href="#2-use-the-accelerator-to-confirm-it"
><svg
class="octicon octicon-link"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
>
</path>
</svg></a>2. Use the Accelerator to confirm it
</h3>
<p>
Once you can see the transaction on <a
href="https://mempool.space"
rel="noopener noreferrer"
>mempool.space</a>, you can just click the "Accelerate" button next to the
ETA. This will bring you to a page that shows you the estimated share of
miners that will include your transaction in their blocks, as well as some
acceleration fee options for various transaction fee levels, which you can pay
for via the Lightning Network, of course.
</p>
<p>
If you haven't looked into this service before (which I had), then the fees
might be a bit of a surprise to you. This thing is <strong>not</strong> cheap!
Bumping my fee from 1 sat/vB to <del>9 sats/vB cost a whopping 51,500 sats
(</del>31 USD that day). Bumping it higher only seemed to add the difference
in the transaction fee itself, so the service seems to have cost a flat 50K
sats at the time.
</p>
<p>
Unfortunately, this channel wasn't particularly large, so the acceleration fee
amounted to ~9% of my remaining channel balance. But 91% of something is
better than 100% of nothing, so I actually felt pretty good about it.
</p>
<p>Next, you will see something like this:</p>
<p>
<img
src="https://image.nostr.build/76151cc2ae06a93a8fcd97102bf4fa63541f8f3bd19800b96ff1070c9450945c.png"
alt="Screenshot of an accelerated transaction on mempool.space"
/>
</p>
<p>
Time to lean back and let the miners work for you. In my case, the ETA was
eerily precise. It told me that it would take ~56 minutes to confirm the
transaction, and almost exactly an hour later it was mined.
</p>
<h3 id="3-wait">
<a class="anchor" aria-hidden="true" tabindex="-1" href="#3-wait"><svg
class="octicon octicon-link"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
>
</path>
</svg></a>3. Wait
</h3>
<p>
Now that our transaction is confirmed, our channel is not closed immediately,
of course. The <a
href="https://docs.lightning.engineering/the-lightning-network/multihop-payments/hash-time-lock-contract-htlc"
rel="noopener noreferrer"
>time lock of the HTLC</a> protects our channel partner from us broadcasting
an old channel state in which our balance might be higher than in the latest
state.
</p>
<p>
In my case, it was set to 144 blocks, i.e. ~24 hours. So I checked back the
next day, et voilá: channel closed and balance restored. 🥳
</p>

View File

@@ -1,17 +1,18 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { NEvent } from "../../nostr.ts";
import Article from "../../models/article.ts";
describe("Article", () => {
let articleEvent: NEvent;
let article: Article;
let deletedArticle: Article;
beforeAll(() => {
articleEvent = JSON.parse(
article = new Article(JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-1.json"),
);
article = new Article(articleEvent);
));
deletedArticle = new Article(JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-deleted.json"),
));
});
describe("#identifier", () => {
@@ -46,6 +47,16 @@ describe("Article", () => {
});
});
describe("#tags", () => {
it("returns a flattened tag list", () => {
expect(article.tags).toEqual([
"lightning",
"lightning network",
"howto",
]);
});
});
describe("#publishedAt", () => {
it("returns the value of the first 'published_at' tag", () => {
expect(article.publishedAt).toEqual(1726402055);
@@ -58,9 +69,10 @@ describe("Article", () => {
});
});
describe("#html", () => {
it("returns a rendered HTML version of the 'content'", () => {
expect(article.html).toMatch(/<h2 id="the-solution">/);
describe("#isDeleted", () => {
it("returns a boolean based on the 'deleted' tag", () => {
expect(article.isDeleted).toEqual(false);
expect(deletedArticle.isDeleted).toEqual(true);
});
});
@@ -71,4 +83,11 @@ describe("Article", () => {
);
});
});
describe("#buildContentHtml", () => {
it("returns a rendered HTML version of the 'content'", async () => {
const html = await article.buildContentHtml();
expect(html).toMatch(/<h2 id="the-solution">/);
});
});
});

View File

@@ -1,6 +1,6 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { NEvent } from "../../nostr.ts";
import { NostrEvent as NEvent } from "@nostrify/nostrify";
import Profile from "../../models/profile.ts";
describe("Profile", () => {

27
tests/nostr_test.ts Normal file
View File

@@ -0,0 +1,27 @@
// import { describe, it } from "@std/testing/bdd";
// import { stub } from "@std/testing/mock";
// import { expect } from "@std/expect";
// import { NostrEvent, NostrFilter } from "@nostrify/nostrify";
// import * as nostr from "../nostr.ts";
//
// async function fetchWithTimeout(filters: NostrFilter[]) {
// console.log("================")
// const events = [];
// const fixtures = [ "article-1.json", "article-deleted.json" ]
// for (const filename of fixtures) {
// const event = JSON.parse(Deno.readTextFileSync(`tests/fixtures/${filename}`));
// events.push(event);
// }
// return Promise.resolve(events);
// }
//
// describe("Nostr", () => {
// describe("#fetchArticlesByAuthor", () => {
// it("removes the anchor links for headlines", async () => {
// stub(nostr, "fetchArticlesByAuthor");
//
// const articles = await nostr.fetchArticlesByAuthor("123456abcdef");
// expect(articles.length).toEqual(2);
// });
// });
// });

54
utils.ts Normal file
View File

@@ -0,0 +1,54 @@
export async function checkFileExists(filePath: string): Promise<boolean> {
try {
await Deno.lstat(filePath);
return true;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return false;
} else {
throw error;
}
}
}
async function checkExecutableExists(name: string): Promise<boolean> {
try {
const command = new Deno.Command("which", { args: [name] });
const { success } = await command.output();
return success;
} catch {
return false;
}
}
export async function createSubtrTmpDirectories(): Promise<void> {
const dirs = [
"/tmp/substr/img/",
];
for (const path of dirs) {
await Deno.mkdir(path, { recursive: true });
}
}
export async function runCommand(cmd: string, args: string[]) {
const command = new Deno.Command(cmd, { args });
const { code, success, stdout, stderr } = await command.output();
if (code === 1) {
console.log(new TextDecoder().decode(stdout));
console.log(new TextDecoder().decode(stderr));
}
return { success, stdout, stderr };
}
export async function getImageMagickCommand(): Promise<string | undefined> {
if (await checkExecutableExists("magick")) {
return "magick";
} else if (await checkExecutableExists("convert")) {
return "convert";
} else {
return undefined;
}
}