Compare commits
10 Commits
b618c6a1a1
...
9ec8cec302
| Author | SHA1 | Date | |
|---|---|---|---|
|
9ec8cec302
|
|||
|
7c311987f0
|
|||
|
79c77c9d3c
|
|||
|
87792c5089
|
|||
|
0471e05ef3
|
|||
|
fe9db7509d
|
|||
|
18f3e888a6
|
|||
|
e14adffbc8
|
|||
|
0e5b4b1807
|
|||
|
9a19f7249c
|
5
.env.sample
Normal file
5
.env.sample
Normal file
@@ -0,0 +1,5 @@
|
||||
HOME_RELAY_URL=wss://nostr.kosmos.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
|
||||
LDAP_SEARCH_DN=ou=kosmos.org,cn=users,dc=kosmos,dc=org
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
16
config.ts
Normal file
16
config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { load } from "@std/dotenv";
|
||||
|
||||
const dirname = new URL(".", import.meta.url).pathname;
|
||||
await load({ envPath: `${dirname}/.env`, export: true });
|
||||
|
||||
const config = {
|
||||
home_relay_url: Deno.env.get("HOME_RELAY_URL"),
|
||||
ldap: {
|
||||
url: Deno.env.get("LDAP_URL"),
|
||||
bindDN: Deno.env.get("LDAP_BIND_DN"),
|
||||
password: Deno.env.get("LDAP_PASSWORD"),
|
||||
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
dates.ts
Normal file
13
dates.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function localizeDate(timestamp: number) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function isoDate(timestamp: number) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toISOString();
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --deny-env --watch main.ts"
|
||||
"dev": "deno run --allow-net --allow-read --allow-env --deny-env --watch main.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",
|
||||
"@nostr/tools": "jsr:@nostr/tools@^2.3.1",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1",
|
||||
"@oak/oak": "jsr:@oak/oak@^17.1.0",
|
||||
"@std/assert": "jsr:@std/assert@1"
|
||||
"@std/assert": "jsr:@std/assert@1",
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
|
||||
"@std/expect": "jsr:@std/expect@^1.0.5",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.3",
|
||||
"ldapts": "npm:ldapts@^7.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
118
deno.lock
generated
118
deno.lock
generated
@@ -9,17 +9,24 @@
|
||||
"jsr:@oak/commons@1": "1.0.0",
|
||||
"jsr:@oak/oak@^17.1.0": "17.1.0",
|
||||
"jsr:@std/assert@1": "1.0.6",
|
||||
"jsr:@std/assert@^1.0.6": "1.0.6",
|
||||
"jsr:@std/bytes@1": "1.0.2",
|
||||
"jsr:@std/bytes@^1.0.2": "1.0.2",
|
||||
"jsr:@std/crypto@1": "1.0.3",
|
||||
"jsr:@std/data-structures@^1.0.4": "1.0.4",
|
||||
"jsr:@std/dotenv@~0.225.2": "0.225.2",
|
||||
"jsr:@std/encoding@1": "1.0.5",
|
||||
"jsr:@std/encoding@^1.0.5": "1.0.5",
|
||||
"jsr:@std/encoding@~0.224.1": "0.224.3",
|
||||
"jsr:@std/expect@^1.0.5": "1.0.5",
|
||||
"jsr:@std/fs@^1.0.4": "1.0.4",
|
||||
"jsr:@std/http@1": "1.0.8",
|
||||
"jsr:@std/internal@^1.0.4": "1.0.4",
|
||||
"jsr:@std/io@0.224": "0.224.9",
|
||||
"jsr:@std/media-types@1": "1.0.3",
|
||||
"jsr:@std/path@1": "1.0.6",
|
||||
"jsr:@std/path@^1.0.6": "1.0.6",
|
||||
"jsr:@std/testing@^1.0.3": "1.0.3",
|
||||
"npm:@noble/ciphers@~0.5.1": "0.5.3",
|
||||
"npm:@noble/curves@1.2.0": "1.2.0",
|
||||
"npm:@noble/hashes@1.3.1": "1.3.1",
|
||||
@@ -29,6 +36,7 @@
|
||||
"npm:github-slugger@2": "2.0.0",
|
||||
"npm:he@^1.2.0": "1.2.0",
|
||||
"npm:katex@0.16": "0.16.11",
|
||||
"npm:ldapts@^7.2.1": "7.2.1",
|
||||
"npm:lru-cache@^10.2.0": "10.2.2",
|
||||
"npm:marked-alert@2": "2.1.0_marked@12.0.2",
|
||||
"npm:marked-footnote@^1.2.0": "1.2.4_marked@12.0.2",
|
||||
@@ -89,7 +97,7 @@
|
||||
"@oak/commons@1.0.0": {
|
||||
"integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/bytes@1",
|
||||
"jsr:@std/crypto",
|
||||
"jsr:@std/encoding@1",
|
||||
@@ -101,13 +109,13 @@
|
||||
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
|
||||
"dependencies": [
|
||||
"jsr:@oak/commons",
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/bytes@1",
|
||||
"jsr:@std/crypto",
|
||||
"jsr:@std/http",
|
||||
"jsr:@std/io",
|
||||
"jsr:@std/media-types",
|
||||
"jsr:@std/path",
|
||||
"jsr:@std/path@1",
|
||||
"npm:path-to-regexp@6.2.1"
|
||||
]
|
||||
},
|
||||
@@ -123,12 +131,31 @@
|
||||
"@std/crypto@1.0.3": {
|
||||
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
||||
},
|
||||
"@std/data-structures@1.0.4": {
|
||||
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
|
||||
},
|
||||
"@std/dotenv@0.225.2": {
|
||||
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
|
||||
},
|
||||
"@std/encoding@0.224.3": {
|
||||
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
||||
},
|
||||
"@std/encoding@1.0.5": {
|
||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||
},
|
||||
"@std/expect@1.0.5": {
|
||||
"integrity": "8c7ac797e2ffe57becc6399c0f2fd06230cb9ef124d45229c6e592c563824af1",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.6",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/fs@1.0.4": {
|
||||
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
|
||||
"dependencies": [
|
||||
"jsr:@std/path@^1.0.6"
|
||||
]
|
||||
},
|
||||
"@std/http@1.0.8": {
|
||||
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
|
||||
"dependencies": [
|
||||
@@ -149,6 +176,16 @@
|
||||
},
|
||||
"@std/path@1.0.6": {
|
||||
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
|
||||
},
|
||||
"@std/testing@1.0.3": {
|
||||
"integrity": "f98c2bee53860a5916727d7e7d3abe920dd6f9edace022e2d059f00d05c2cf42",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.6",
|
||||
"jsr:@std/data-structures",
|
||||
"jsr:@std/fs",
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.0.6"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -218,9 +255,33 @@
|
||||
"@scure/base@1.1.7"
|
||||
]
|
||||
},
|
||||
"@types/asn1@0.2.4": {
|
||||
"integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"@types/node@22.5.4": {
|
||||
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
|
||||
"dependencies": [
|
||||
"undici-types"
|
||||
]
|
||||
},
|
||||
"asn1@0.2.6": {
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": [
|
||||
"safer-buffer"
|
||||
]
|
||||
},
|
||||
"commander@8.3.0": {
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
|
||||
},
|
||||
"debug@4.3.7": {
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"dependencies": [
|
||||
"ms"
|
||||
]
|
||||
},
|
||||
"deepmerge@4.3.1": {
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||
},
|
||||
@@ -279,6 +340,17 @@
|
||||
"commander"
|
||||
]
|
||||
},
|
||||
"ldapts@7.2.1": {
|
||||
"integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==",
|
||||
"dependencies": [
|
||||
"@types/asn1",
|
||||
"asn1",
|
||||
"debug",
|
||||
"strict-event-emitter-types",
|
||||
"uuid",
|
||||
"whatwg-url"
|
||||
]
|
||||
},
|
||||
"lru-cache@10.2.2": {
|
||||
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
|
||||
},
|
||||
@@ -304,6 +376,9 @@
|
||||
"marked@12.0.2": {
|
||||
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="
|
||||
},
|
||||
"ms@2.1.3": {
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"nanoid@3.3.7": {
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
||||
},
|
||||
@@ -342,6 +417,12 @@
|
||||
"prismjs@1.29.0": {
|
||||
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="
|
||||
},
|
||||
"punycode@2.3.1": {
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
|
||||
},
|
||||
"safer-buffer@2.1.2": {
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"sanitize-html@2.13.1": {
|
||||
"integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==",
|
||||
"dependencies": [
|
||||
@@ -356,9 +437,34 @@
|
||||
"source-map-js@1.2.1": {
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||
},
|
||||
"strict-event-emitter-types@2.0.0": {
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
|
||||
},
|
||||
"tr46@5.0.0": {
|
||||
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
|
||||
"dependencies": [
|
||||
"punycode"
|
||||
]
|
||||
},
|
||||
"undici-types@6.19.8": {
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||
},
|
||||
"uuid@10.0.0": {
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
|
||||
},
|
||||
"webidl-conversions@7.0.0": {
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
|
||||
},
|
||||
"websocket-ts@2.1.5": {
|
||||
"integrity": "sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA=="
|
||||
},
|
||||
"whatwg-url@14.0.0": {
|
||||
"integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==",
|
||||
"dependencies": [
|
||||
"tr46",
|
||||
"webidl-conversions"
|
||||
]
|
||||
},
|
||||
"zod@3.23.8": {
|
||||
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="
|
||||
}
|
||||
@@ -381,7 +487,11 @@
|
||||
"jsr:@nostr/tools@^2.3.1",
|
||||
"jsr:@nostrify/nostrify@~0.36.1",
|
||||
"jsr:@oak/oak@^17.1.0",
|
||||
"jsr:@std/assert@1"
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/dotenv@~0.225.2",
|
||||
"jsr:@std/expect@^1.0.5",
|
||||
"jsr:@std/testing@^1.0.3",
|
||||
"npm:ldapts@^7.2.1"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
38
feeds.ts
Normal file
38
feeds.ts
Normal file
@@ -0,0 +1,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[]) {
|
||||
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
|
||||
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
||||
?.updatedAt;
|
||||
|
||||
const articlesXml = articles.map((article) => {
|
||||
const articleId =
|
||||
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
|
||||
return `
|
||||
<entry>
|
||||
<id>${articleId}</id>
|
||||
<title>${article.title}</title>
|
||||
<link href="/${article.naddr}" />
|
||||
<updated>${isoDate(article.updatedAt)}</updated>
|
||||
<summary>${article.summary}</summary>
|
||||
<content type="html">${article.html}</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>
|
||||
<id>${feedId}</id>
|
||||
<updated>${isoDate(lastUpdate)}</updated>
|
||||
<icon>${profile.picture}</icon>
|
||||
<author>
|
||||
<name>${name}</name>
|
||||
</author>
|
||||
${articlesXml}
|
||||
</feed>
|
||||
`.trim();
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { ctx } from "@oak/oak";
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { articleHtml } from "../html.ts"
|
||||
import {
|
||||
fetchReplaceableEvent,
|
||||
fetchProfileEvent
|
||||
} from "../nostr.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
|
||||
const naddrHandler = async function (ctx: ctx) {
|
||||
const { request } = ctx;
|
||||
const { path } = ctx.params;
|
||||
const naddrHandler = async function (ctx: Context) {
|
||||
const naddr = ctx.params.path;
|
||||
|
||||
try {
|
||||
const r = nip19.decode(path);
|
||||
const articleEvent = await fetchReplaceableEvent(r.data.pubkey, r.data.identifier);
|
||||
const profileEvent = await fetchProfileEvent(r.data.pubkey);
|
||||
let profile;
|
||||
const r = nip19.decode(naddr);
|
||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
||||
|
||||
if (articleEvent && profileEvent) {
|
||||
const profile = JSON.parse(profileEvent.content);
|
||||
const html = articleHtml(articleEvent, profile);
|
||||
|
||||
ctx.response.body = html;
|
||||
if (username && r.data.identifier) {
|
||||
ctx.response.redirect(`/@${username}/${r.data.identifier}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
|
||||
const nprofileHandler = function (context: Context) {
|
||||
const { request, response } = context;
|
||||
const fullPath = request.url.pathname;
|
||||
const nprofileHandler = async function (ctx: Context) {
|
||||
const nprofile = ctx.params.path;
|
||||
|
||||
response.body = `You are viewing an nprofile with address: ${fullPath}`;
|
||||
try {
|
||||
const r = nip19.decode(nprofile);
|
||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
export default nprofileHandler;
|
||||
|
||||
26
handlers/npub.ts
Normal file
26
handlers/npub.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
||||
|
||||
const npubHandler = async function (ctx: Context) {
|
||||
const npub = ctx.params.path;
|
||||
|
||||
try {
|
||||
const r = nip19.decode(npub);
|
||||
const username = await lookupUsernameByPubkey(r.data);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
export default npubHandler;
|
||||
41
handlers/user-atom-feed.ts
Normal file
41
handlers/user-atom-feed.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||
import { profileAtomFeed } from "../feeds.ts";
|
||||
import Article from "../models/article.ts";
|
||||
import Profile from "../models/profile.ts";
|
||||
|
||||
const userAtomFeedHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profileEvent = await fetchProfileEvent(pubkey);
|
||||
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);
|
||||
|
||||
ctx.response.headers.set("Content-Type", "application/atom+xml");
|
||||
ctx.response.body = atom;
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
export default userAtomFeedHandler;
|
||||
44
handlers/user-event.ts
Normal file
44
handlers/user-event.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
|
||||
import Article from "../models/article.ts";
|
||||
import Profile from "../models/profile.ts";
|
||||
import { articleHtml } from "../html.ts";
|
||||
|
||||
const userEventHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
||||
const identifier = ctx.params.identifier;
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
export default userEventHandler;
|
||||
40
handlers/user-profile.ts
Normal file
40
handlers/user-profile.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { log } from "../log.ts";
|
||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||
import Article from "../models/article.ts";
|
||||
import Profile from "../models/profile.ts";
|
||||
import { profilePageHtml } from "../html.ts";
|
||||
|
||||
const userProfileHandler = async function (ctx: Context) {
|
||||
const username = ctx.params.path.replace(/^(@|~)/, "");
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
} catch (e) {
|
||||
log(e, "yellow");
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
export default userProfileHandler;
|
||||
97
html.ts
97
html.ts
@@ -1,7 +1,8 @@
|
||||
import { render as renderMarkdown } from "@deno/gfm";
|
||||
import { log } from "./log.ts";
|
||||
import { localizeDate } from "./dates.ts";
|
||||
import Article from "./models/article.ts";
|
||||
import Profile from "./models/profile.ts";
|
||||
|
||||
export function htmlLayout(title: string, body: string) {
|
||||
export function htmlLayout(title: string, body: string, profile: Profile) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -10,6 +11,7 @@ export function htmlLayout(title: string, body: string) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>${title}</title>
|
||||
<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;0,900;1,400;1,700&display=swap" rel="stylesheet">
|
||||
@@ -31,6 +33,11 @@ export function htmlLayout(title: string, body: string) {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.profile-page img.avatar {
|
||||
height: 128px;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
display: none;
|
||||
}
|
||||
@@ -42,6 +49,7 @@ export function htmlLayout(title: string, body: string) {
|
||||
h2, h3, h4 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
@@ -105,6 +113,19 @@ export function htmlLayout(title: string, body: string) {
|
||||
p.meta .date {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
p.meta .name a {
|
||||
color: #3b3a38;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-list .item {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.article-list .item h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -114,32 +135,72 @@ export function htmlLayout(title: string, body: string) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function articleHtml(articleEvent: object, profile: object) {
|
||||
const titleTag = articleEvent.tags.find(t => t[0] === "title");
|
||||
const title = titleTag ? titleTag[1] : "Untitled";
|
||||
const content = renderMarkdown(articleEvent.content);
|
||||
const date = new Date(articleEvent.created_at * 1000);
|
||||
const formattedDate = date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
export function articleHtml(article: Article, profile: Profile) {
|
||||
const publishedAtFormatted = localizeDate(article.publishedAt);
|
||||
|
||||
const body = `
|
||||
<main>
|
||||
<header>
|
||||
<h1>${title}</h1>
|
||||
<h1>${article.title}</h1>
|
||||
<p class="meta">
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<span class="content">
|
||||
<span class="name">${profile.name}</span>
|
||||
<span class="date">${formattedDate}</span>
|
||||
<span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
|
||||
<span class="date">${publishedAtFormatted}</span>
|
||||
</span>
|
||||
</p>
|
||||
</header>
|
||||
${content}
|
||||
${article.html}
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout(title, body);
|
||||
return htmlLayout(article.title, body, profile);
|
||||
}
|
||||
|
||||
function articleListItemHtml(article: Article) {
|
||||
const formattedDate = localizeDate(article.publishedAt);
|
||||
|
||||
return `
|
||||
<div class="item">
|
||||
<h3><a href="/${article.naddr}">${article.title}</a></h3>
|
||||
<p>${formattedDate}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function articleListHtml(articles: Article[]) {
|
||||
if (articles.length === 0) return "";
|
||||
let html = "";
|
||||
|
||||
for (const article of articles) {
|
||||
html += articleListItemHtml(article);
|
||||
}
|
||||
|
||||
return `
|
||||
<h2>Articles</h2>
|
||||
<div class="article-list">
|
||||
${html}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function profilePageHtml(profile: Profile, articles: Article[]) {
|
||||
const title = `${profile.name} on Nostr`;
|
||||
|
||||
const body = `
|
||||
<main class="profile-page">
|
||||
<header>
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<div class="bio">
|
||||
<h1>${profile.name}</h1>
|
||||
<p class="about">
|
||||
${profile.about}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
${articleListHtml(articles)}
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout(title, body, profile);
|
||||
}
|
||||
|
||||
48
ldap.ts
Normal file
48
ldap.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Client } from "ldapts";
|
||||
import { log } from "./log.ts";
|
||||
import config from "./config.ts";
|
||||
|
||||
const { ldap } = config;
|
||||
const client = new Client({ url: ldap.url });
|
||||
|
||||
export async function lookupPubkeyByUsername(username: string) {
|
||||
let pubkey;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN, ldap.password);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
||||
filter: `(cn=${username})`,
|
||||
attributes: ["nostrKey"],
|
||||
});
|
||||
|
||||
pubkey = searchEntries[0]?.nostrKey;
|
||||
} catch (ex) {
|
||||
log(ex, "red");
|
||||
} finally {
|
||||
await client.unbind();
|
||||
}
|
||||
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
export async function lookupUsernameByPubkey(pubkey: string) {
|
||||
let username;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN, ldap.password);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
||||
filter: `(nostrKey=${pubkey})`,
|
||||
attributes: ["cn"],
|
||||
});
|
||||
|
||||
username = searchEntries[0]?.cn;
|
||||
} catch (ex) {
|
||||
log(ex, "red");
|
||||
} finally {
|
||||
await client.unbind();
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
51
main.ts
51
main.ts
@@ -2,22 +2,65 @@ import { Application, Router } from "@oak/oak";
|
||||
import { log } from "./log.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";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/:path", async (ctx: ctx) => {
|
||||
const { path } = ctx.params;
|
||||
|
||||
if (path && path.startsWith("naddr")) {
|
||||
if (path.startsWith("naddr")) {
|
||||
await naddrHandler(ctx);
|
||||
} else if (prefix && prefix.startsWith("nprofile")) {
|
||||
} 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 {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`, "gray");
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:kind.atom", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~") || kind === "articles") {
|
||||
await userAtomFeedHandler(ctx);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:user/:identifier", async (ctx: ctx) => {
|
||||
const { user } = ctx.params;
|
||||
|
||||
if (user.startsWith("@") || user.startsWith("~")) {
|
||||
await userEventHandler(ctx);
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = "Not Found";
|
||||
}
|
||||
|
||||
log(
|
||||
`${ctx.request.method} ${ctx.request.url} - ${ctx.response.status}`,
|
||||
"gray",
|
||||
);
|
||||
});
|
||||
|
||||
const app = new Application();
|
||||
@@ -27,4 +70,4 @@ app.use(router.allowedMethods());
|
||||
const PORT = 8000;
|
||||
app.listen({ port: PORT });
|
||||
|
||||
console.log(`App listening on http://localhost:${PORT}`)
|
||||
console.log(`App listening on http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
// import { add } from "./main.ts";
|
||||
|
||||
// Deno.test(function addTest() {
|
||||
// assertEquals(add(2, 3), 5);
|
||||
// });
|
||||
47
models/article.ts
Normal file
47
models/article.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { NEvent } from "../nostr.ts";
|
||||
import { render as renderMarkdown } from "@deno/gfm";
|
||||
|
||||
export default class Article {
|
||||
event: NEvent;
|
||||
|
||||
constructor(event: NEvent) {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
get identifier(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "d");
|
||||
return tag ? tag[1] : "";
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "title");
|
||||
return tag ? tag[1] : "Untitled";
|
||||
}
|
||||
|
||||
get summary(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "summary");
|
||||
return tag ? tag[1] : "";
|
||||
}
|
||||
|
||||
get publishedAt(): number {
|
||||
const tag = this.event.tags.find((t) => t[0] === "published_at");
|
||||
return tag ? parseInt(tag[1]) : this.event.created_at;
|
||||
}
|
||||
|
||||
get updatedAt(): number {
|
||||
return this.event.created_at;
|
||||
}
|
||||
|
||||
get html(): string {
|
||||
return renderMarkdown(this.event.content);
|
||||
}
|
||||
|
||||
get naddr(): string {
|
||||
return nip19.naddrEncode({
|
||||
identifier: this.identifier,
|
||||
pubkey: this.event.pubkey,
|
||||
kind: this.event.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
54
models/profile.ts
Normal file
54
models/profile.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { NEvent } from "../nostr.ts";
|
||||
|
||||
export interface ProfileData {
|
||||
name: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
export default class Profile {
|
||||
event: NEvent;
|
||||
private data: ProfileData;
|
||||
username?: string;
|
||||
|
||||
constructor(event: NEvent, username?: string) {
|
||||
this.event = event;
|
||||
this.data = JSON.parse(event.content);
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
get updatedAt(): number {
|
||||
return this.event.created_at;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.data.name || "Anonymous";
|
||||
}
|
||||
|
||||
get about(): string {
|
||||
return this.data.about || "";
|
||||
}
|
||||
|
||||
get picture(): string | undefined {
|
||||
return this.data.picture;
|
||||
}
|
||||
|
||||
get nip05(): string | undefined {
|
||||
return this.data.nip05;
|
||||
}
|
||||
|
||||
get lud16(): string | undefined {
|
||||
return this.data.lud16;
|
||||
}
|
||||
|
||||
get pubkey(): string {
|
||||
return this.event.pubkey;
|
||||
}
|
||||
|
||||
get npub(): string {
|
||||
return nip19.npubEncode(this.pubkey);
|
||||
}
|
||||
}
|
||||
28
nostr.ts
28
nostr.ts
@@ -1,8 +1,22 @@
|
||||
import { NRelay1 } from "@nostrify/nostrify";
|
||||
import config from "./config.ts";
|
||||
|
||||
export const relay = new NRelay1("wss://nostr.kosmos.org");
|
||||
export interface NEvent {
|
||||
content: string;
|
||||
created_at: number;
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
tags: Array<[string, string, string?]>;
|
||||
}
|
||||
|
||||
export async function fetchReplaceableEvent(pubkey: string, identifier: string) {
|
||||
export const relay = new NRelay1(config.home_relay_url);
|
||||
|
||||
export async function fetchReplaceableEvent(
|
||||
pubkey: string,
|
||||
identifier: string,
|
||||
) {
|
||||
const events = await relay.query([{
|
||||
authors: [pubkey],
|
||||
kinds: [30023],
|
||||
@@ -13,6 +27,16 @@ export async function fetchReplaceableEvent(pubkey: string, identifier: string)
|
||||
return events.length > 0 ? events[0] : null;
|
||||
}
|
||||
|
||||
export async function fetchArticlesByAuthor(pubkey: string) {
|
||||
const events = await relay.query([{
|
||||
authors: [pubkey],
|
||||
kinds: [30023],
|
||||
limit: 10,
|
||||
}]);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function fetchProfileEvent(pubkey: string) {
|
||||
const events = await relay.query([{
|
||||
authors: [pubkey],
|
||||
|
||||
19
tests/dates_test.ts
Normal file
19
tests/dates_test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import { localizeDate } from "../dates.ts";
|
||||
|
||||
describe("Dates", () => {
|
||||
describe("#localizeDate", () => {
|
||||
it("returns a human-readable date for timestamp", () => {
|
||||
const date = localizeDate(1726402055);
|
||||
expect(date).toEqual("September 15, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#isoDate", () => {
|
||||
it("returns an ISO 8601 date for the timestamp", () => {
|
||||
const date = localizeDate(1726402055);
|
||||
expect(date).toEqual("September 15, 2024");
|
||||
});
|
||||
});
|
||||
});
|
||||
29
tests/fixtures/article-1.json
vendored
Normal file
29
tests/fixtures/article-1.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"content": "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.\n\nWith LND, if the channel is created as an [anchor channel](https://lightning.engineering/posts/2021-01-28-lnd-v0.12/) (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: `lncli wallet bumpclosefee`\n\nHowever, 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?\n\n## The solution\n\nEnter the [mempool.space Accelerator](https://mempool.space/accelerator). 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.\n\nNow, 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?\n\n### 1. Get the transaction to be broadcast\n\nIn your `bitcoin.conf`, add the following line:\n\n minrelaytxfee=0\n\nThis sets the minimum fee to 0, meaning it will accept and broadcast your transactions, no matter how low the fee is. Restart `bitcoind` 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.\n\n### 2. Use the Accelerator to confirm it\n\nOnce you can see the transaction on [mempool.space](https://mempool.space), 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.\n\nIf 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 **not** cheap! Bumping my fee from 1 sat/vB to ~9 sats/vB cost a whopping 51,500 sats (~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.\n\nUnfortunately, 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.\n\nNext, you will see something like this:\n\n\n\nTime 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.\n\n### 3. Wait\n\nNow that our transaction is confirmed, our channel is not closed immediately, of course. The [time lock of the HTLC](https://docs.lightning.engineering/the-lightning-network/multihop-payments/hash-time-lock-contract-htlc) protects our channel partner from us broadcasting an old channel state in which our balance might be higher than in the latest state.\n\nIn 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. 🥳",
|
||||
"created_at": 1729462158,
|
||||
"id": "b45714c3965549c11dde7228071313bfc53a4df1896d939fb767ed0b6fcdab3a",
|
||||
"kind": 30023,
|
||||
"pubkey": "1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb",
|
||||
"sig": "23d929aa05921f5b10cc4ca1d05cd99b9d595888a94c394ffa1850b97c39b1b3d3d7b7472df959f2675b9f936b3334a55073c8931c52cdcfea18c19cdb9cdcb6",
|
||||
"tags": [
|
||||
["d", "1726396758485"],
|
||||
[
|
||||
"title",
|
||||
"How to confirm a stuck Lightning channel closing transaction with the mempool.space Accelerator"
|
||||
],
|
||||
["summary", ""],
|
||||
["published_at", "1726402055"],
|
||||
["published_at", "1726402055"],
|
||||
[
|
||||
"alt",
|
||||
"This is a long form article, you can read it in https://habla.news/a/naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qfx2ekk56aqc2ht4ak03z3em4r4cdcwtqqxnzdejxcenjd3hx5urgwp4676hkz"
|
||||
],
|
||||
["published_at", "1726402055"],
|
||||
["published_at", "1726402055"],
|
||||
["published_at", "1726402055"],
|
||||
["t", "lightning"],
|
||||
["t", "lightning network"],
|
||||
["t", "howto"],
|
||||
["published_at", "1726402055"]
|
||||
]
|
||||
}
|
||||
9
tests/fixtures/profile-1.json
vendored
Normal file
9
tests/fixtures/profile-1.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"content": "{\"name\":\"Râu Cao ⚡\",\"nip05\":\"raucao@kosmos.org\",\"about\":\"Traveling full-time since 2010. Working on open-source software daily. Currently integrating Nostr features into Kosmos accounts.\",\"picture\":\"https://storage.kosmos.org/raucao/public/shares/240604-1441-fuerte-256.png\",\"lud16\":\"raucao@kosmos.org\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\"}",
|
||||
"created_at": 1728814592,
|
||||
"id": "d437964cdd87f4b5bd595f47c2c64f4ba02c849ca215ed56fcb5fd3335ae2720",
|
||||
"kind": 0,
|
||||
"pubkey": "1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb",
|
||||
"sig": "ee067f88344fa8380a16154b7d988087c41d6c87ae720dd52947a38e63232ab6998de37f28e8c7115a0604fc184035af543ad354ed7b616b8ba29974653042cc",
|
||||
"tags": []
|
||||
}
|
||||
62
tests/models/article_test.ts
Normal file
62
tests/models/article_test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
|
||||
beforeAll(() => {
|
||||
articleEvent = JSON.parse(
|
||||
Deno.readTextFileSync("tests/fixtures/article-1.json"),
|
||||
);
|
||||
article = new Article(articleEvent);
|
||||
});
|
||||
|
||||
describe("#identifier", () => {
|
||||
it("returns the content of the 'd' tag", () => {
|
||||
expect(article.identifier).toEqual("1726396758485");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#title", () => {
|
||||
it("returns the content of the 'title' tag", () => {
|
||||
expect(article.title).toMatch(
|
||||
/How to confirm a stuck Lightning channel closing transaction/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#summary", () => {
|
||||
it("returns the content of the 'summary' tag", () => {
|
||||
expect(article.summary).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#publishedAt", () => {
|
||||
it("returns the value of the first 'published_at' tag", () => {
|
||||
expect(article.publishedAt).toEqual(1726402055);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#updatedAt", () => {
|
||||
it("returns the value of the first 'published_at' tag", () => {
|
||||
expect(article.updatedAt).toEqual(1729462158);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#html", () => {
|
||||
it("returns a rendered HTML version of the 'content'", () => {
|
||||
expect(article.html).toMatch(/<h2 id="the-solution">/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#naddr", () => {
|
||||
it("returns bech32 addressable event ID", () => {
|
||||
expect(article.naddr).toEqual(
|
||||
"naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qfx2ekk56aqc2ht4ak03z3em4r4cdcwtqqxnzdejxcenjd3hx5urgwp4676hkz",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
tests/models/profile_test.ts
Normal file
43
tests/models/profile_test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import { NEvent } from "../../nostr.ts";
|
||||
import Profile from "../../models/profile.ts";
|
||||
|
||||
describe("Profile", () => {
|
||||
let profileEvent: NEvent;
|
||||
let profile: Profile;
|
||||
|
||||
beforeAll(() => {
|
||||
profileEvent = JSON.parse(
|
||||
Deno.readTextFileSync("tests/fixtures/profile-1.json"),
|
||||
);
|
||||
profile = new Profile(profileEvent);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("instantiates the username when given", () => {
|
||||
profile = new Profile(profileEvent, "raucao");
|
||||
expect(profile.username).toEqual("raucao");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#updatedAt", () => {
|
||||
it("returns the value of the profile event's 'created_at'", () => {
|
||||
expect(profile.updatedAt).toEqual(1728814592);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#name", () => {
|
||||
it("returns the profile's name when present", () => {
|
||||
expect(profile.name).toEqual("Râu Cao ⚡");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#npub", () => {
|
||||
it("returns the bech32-encoded version of the pubkey", () => {
|
||||
expect(profile.npub).toEqual(
|
||||
"npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user