Compare commits
64 Commits
b618c6a1a1
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e081c83e5
|
|||
|
cea96e170d
|
|||
|
5906655902
|
|||
|
5f38355d5c
|
|||
|
010eb3f291
|
|||
|
0b1eca87b2
|
|||
|
f1d6ddbc84
|
|||
|
49d5aa4487
|
|||
|
4c68be19fe
|
|||
|
a6517c61a4
|
|||
|
2624f2cbf8
|
|||
|
cb4a4e06c8
|
|||
|
5f5f024ae7
|
|||
|
ec7c775e25
|
|||
|
28e9d14aae
|
|||
|
eadc40392a
|
|||
|
6ec9f51d77
|
|||
|
afcb99356c
|
|||
|
266913c369
|
|||
|
aec35d9eb3
|
|||
|
c1c9a37914
|
|||
|
30a74acf3b
|
|||
|
b7974c8610
|
|||
|
0096f3cae3
|
|||
|
fc711c2194
|
|||
|
fdd16d8236
|
|||
|
d5793d47ff
|
|||
|
32f39685a1
|
|||
|
062ded9e6d
|
|||
|
ba80792cc4
|
|||
|
a9f13310ab
|
|||
|
e921fb2d84
|
|||
|
52d56c387d
|
|||
|
46ad9813eb
|
|||
|
edaf5f5c71
|
|||
|
f15e845825
|
|||
|
453a0f14d3
|
|||
|
856b10358c
|
|||
|
fdf16227d3
|
|||
|
a0d5cba339
|
|||
|
be1b9f21ec
|
|||
|
c0a02295c1
|
|||
|
fdb13bc65d
|
|||
|
a0f0b06ad2
|
|||
|
ce469bc37f
|
|||
|
28520c59b9
|
|||
|
ba7336b4ee
|
|||
|
ea58c1f60b
|
|||
|
3914ca25a1
|
|||
|
4534147050
|
|||
|
96254b38be
|
|||
|
ffd709d2f9
|
|||
|
74f7b89c15
|
|||
|
48fcb7eac5
|
|||
|
849410bb64
|
|||
|
bab9b74090
|
|||
|
fa98e90210
|
|||
|
87792c5089
|
|||
|
0471e05ef3
|
|||
|
fe9db7509d
|
|||
|
18f3e888a6
|
|||
|
e14adffbc8
|
|||
|
0e5b4b1807
|
|||
|
9a19f7249c
|
5
.env.sample
Normal file
5
.env.sample
Normal file
@@ -0,0 +1,5 @@
|
||||
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
|
||||
LDAP_SEARCH_DN=ou=kosmos.org,cn=users,dc=kosmos,dc=org
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
users.yaml
|
||||
substr
|
||||
35
assets/css/fonts/merriweather.css
Normal file
35
assets/css/fonts/merriweather.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Regular.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Regular.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Italic.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Italic.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-Bold.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-Bold.woff") format("woff");
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src:
|
||||
url("/assets/fonts/Merriweather-BoldItalic.woff2") format("woff2"),
|
||||
url("/assets/fonts/Merriweather-BoldItalic.woff") format("woff");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
244
assets/css/layout.css
Normal file
244
assets/css/layout.css
Normal file
@@ -0,0 +1,244 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 4rem 0 1.6rem 0;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
p, pre, ul, ol, dl, blockquote, hr {
|
||||
line-height: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
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;
|
||||
}
|
||||
|
||||
dl dd {
|
||||
margin: 0 0 1.6rem 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
max-width: 44rem;
|
||||
margin: 12rem auto 24rem auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
main header {
|
||||
display: block;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
main header .draft-label {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
main header .meta {
|
||||
display: flex;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
main header .meta .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
|
||||
main header .meta .name a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
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 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
main .article-list p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
main .article-list p.meta {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
main article footer {
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
min-width: 12rem;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
padding: 0.6rem 1rem;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 100%;
|
||||
margin: 4rem 1rem 8rem 1rem !important;
|
||||
}
|
||||
|
||||
main.profile-page h1 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
padding: 0.8rem 1.2rem;
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
padding: 0.8rem 1.2rem;
|
||||
}
|
||||
}
|
||||
133
assets/css/themes/default-light.css
Normal file
133
assets/css/themes/default-light.css
Normal file
@@ -0,0 +1,133 @@
|
||||
@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 {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--background-color-body);
|
||||
color: var(--text-color-body);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
color: var(--text-color-headings);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #023b77;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #3b0277;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #e8e3da;
|
||||
color: #027739;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--background-color-dark);
|
||||
color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: var(--background-color-dark);
|
||||
color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
dl dt {
|
||||
color: var(--text-color-discreet);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: #e8e3da;
|
||||
}
|
||||
|
||||
main header .draft-label {
|
||||
color: #770202;
|
||||
border-color: #770202;
|
||||
}
|
||||
|
||||
main header .meta .date {
|
||||
color: var(--text-color-discreet);
|
||||
}
|
||||
|
||||
main header .meta .name a {
|
||||
color: var(--text-color-body);
|
||||
}
|
||||
|
||||
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: var(--background-color-body);
|
||||
color: var(--text-color-body);
|
||||
border-color: var(--text-color-dark-bg);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
color: var(--text-color-body) !important;
|
||||
}
|
||||
|
||||
.dropdown-content a:hover {
|
||||
background-color: var(--background-color-highlight);
|
||||
}
|
||||
|
||||
.dropdown-content h4.title {
|
||||
color: var(--text-color-discreet);
|
||||
border-color: var(--background-color-highlight);
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-button {
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--background-color);
|
||||
}
|
||||
BIN
assets/fonts/Merriweather-Bold.woff
Normal file
BIN
assets/fonts/Merriweather-Bold.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Bold.woff2
Normal file
BIN
assets/fonts/Merriweather-Bold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-BoldItalic.woff
Normal file
BIN
assets/fonts/Merriweather-BoldItalic.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-BoldItalic.woff2
Normal file
BIN
assets/fonts/Merriweather-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Italic.woff
Normal file
BIN
assets/fonts/Merriweather-Italic.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Italic.woff2
Normal file
BIN
assets/fonts/Merriweather-Italic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Regular.woff
Normal file
BIN
assets/fonts/Merriweather-Regular.woff
Normal file
Binary file not shown.
BIN
assets/fonts/Merriweather-Regular.woff2
Normal file
BIN
assets/fonts/Merriweather-Regular.woff2
Normal file
Binary file not shown.
94
config.ts
Normal file
94
config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { load } from "@std/dotenv";
|
||||
import { parse as parseYaml } from "jsr:@std/yaml";
|
||||
import { checkFileExists } from "./utils.ts";
|
||||
import { log } from "./log.ts";
|
||||
|
||||
const dirname = Deno.cwd();
|
||||
|
||||
await load({ envPath: `${dirname}/.env`, export: true });
|
||||
|
||||
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 fileContent = await Deno.readTextFile(userConfigPath);
|
||||
const parsedContent = parseYaml(fileContent);
|
||||
if (parsedContent !== null && typeof parsedContent === "object") {
|
||||
staticUsers = parsedContent as { [key: string]: string };
|
||||
}
|
||||
} catch {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
const config = {
|
||||
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"),
|
||||
bindDN: Deno.env.get("LDAP_BIND_DN"),
|
||||
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",
|
||||
};
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
14
deno.json
14
deno.json
@@ -1,12 +1,22 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --deny-env --watch main.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",
|
||||
"@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/dotenv": "jsr:@std/dotenv@^0.225.2",
|
||||
"@std/expect": "jsr:@std/expect@^1.0.5",
|
||||
"@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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
123
deno.lock
generated
123
deno.lock
generated
@@ -9,17 +9,26 @@
|
||||
"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",
|
||||
"jsr:@std/yaml@*": "1.0.5",
|
||||
"jsr:@std/yaml@^1.0.5": "1.0.5",
|
||||
"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 +38,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 +99,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 +111,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 +133,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 +178,19 @@
|
||||
},
|
||||
"@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"
|
||||
]
|
||||
},
|
||||
"@std/yaml@1.0.5": {
|
||||
"integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -218,9 +260,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 +345,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 +381,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 +422,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 +442,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 +492,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/dotenv@~0.225.2",
|
||||
"jsr:@std/expect@^1.0.5",
|
||||
"jsr:@std/testing@^1.0.3",
|
||||
"jsr:@std/yaml@^1.0.5",
|
||||
"npm:ldapts@^7.2.1"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
33
directory.ts
Normal file
33
directory.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import config from "./config.ts";
|
||||
import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts";
|
||||
import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts";
|
||||
|
||||
export function lookupUsernameByPubkey(pubkey: string) {
|
||||
let username;
|
||||
for (const [key, value] of Object.entries(config.staticUsers)) {
|
||||
if (value === pubkey) {
|
||||
username = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (username) {
|
||||
return username;
|
||||
} else {
|
||||
if (config.ldapEnabled) {
|
||||
return ldapLookupUsername(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function lookupPubkeyByUsername(username: string) {
|
||||
const pubkey = config.staticUsers[username];
|
||||
|
||||
if (pubkey) {
|
||||
return pubkey;
|
||||
} else {
|
||||
if (config.ldapEnabled) {
|
||||
return ldapLookupPubkey(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
feeds.ts
Normal file
54
feeds.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import Article from "./models/article.ts";
|
||||
import Profile from "./models/profile.ts";
|
||||
import { isoDate } from "./dates.ts";
|
||||
|
||||
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 = "";
|
||||
|
||||
for (const article of articles) {
|
||||
const contentHtml = await article.buildContentHtml();
|
||||
const articleId =
|
||||
`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"><![CDATA[
|
||||
${cleanContentHtml(contentHtml)}
|
||||
]]></content>
|
||||
</entry>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>${profile.name} on Nostr (Articles)</title>
|
||||
<id>${feedId}</id>
|
||||
<updated>${isoDate(lastUpdate)}</updated>
|
||||
<icon>${profile.picture}</icon>
|
||||
<author>
|
||||
<name>${name}</name>
|
||||
</author>
|
||||
${articlesXml}
|
||||
</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
20
handlers/errors.ts
Normal 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;
|
||||
};
|
||||
@@ -1,35 +1,25 @@
|
||||
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 "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const naddrHandler = async function (ctx: ctx) {
|
||||
const { request } = ctx;
|
||||
const { path } = ctx.params;
|
||||
const naddrHandler = async function (ctx: Context) {
|
||||
const naddr = ctx.state.path;
|
||||
let data: nip19.AddressPointer;
|
||||
|
||||
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;
|
||||
data = nip19.decode(naddr).data as nip19.AddressPointer;
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (articleEvent && profileEvent) {
|
||||
const profile = JSON.parse(profileEvent.content);
|
||||
const html = articleHtml(articleEvent, profile);
|
||||
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||
|
||||
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";
|
||||
if (username && data.identifier) {
|
||||
ctx.response.redirect(`/@${username}/${data.identifier}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const nprofileHandler = function (context: Context) {
|
||||
const { request, response } = context;
|
||||
const fullPath = request.url.pathname;
|
||||
const nprofileHandler = async function (ctx: Context) {
|
||||
const nprofile = ctx.state.path;
|
||||
let data: nip19.ProfilePointer;
|
||||
|
||||
response.body = `You are viewing an nprofile with address: ${fullPath}`;
|
||||
try {
|
||||
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
|
||||
console.log(data);
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
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 { lookupUsernameByPubkey } from "../directory.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const npubHandler = async function (ctx: Context) {
|
||||
const npub = ctx.state.path;
|
||||
let pubkey: string;
|
||||
|
||||
try {
|
||||
pubkey = nip19.decode(npub).data as string;
|
||||
} catch (_e) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = await lookupUsernameByPubkey(pubkey);
|
||||
|
||||
if (username) {
|
||||
ctx.response.redirect(`/@${username}`);
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
export default npubHandler;
|
||||
34
handlers/user-atom-feed.ts
Normal file
34
handlers/user-atom-feed.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||
import { profileAtomFeed } from "../feeds.ts";
|
||||
import Profile from "../models/profile.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
|
||||
const userAtomFeedHandler = async function (ctx: Context) {
|
||||
const username = ctx.state.username;
|
||||
const pubkey = await lookupPubkeyByUsername(ctx.state.username);
|
||||
|
||||
if (!pubkey) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileEvent = await fetchProfileEvent(pubkey);
|
||||
|
||||
if (profileEvent) {
|
||||
const profile = new Profile(profileEvent, username);
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
}
|
||||
notFoundHandler(ctx);
|
||||
};
|
||||
|
||||
export default userAtomFeedHandler;
|
||||
38
handlers/user-event.ts
Normal file
38
handlers/user-event.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Context } from "@oak/oak";
|
||||
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/errors.ts";
|
||||
import { generateOgProfileImage } from "../magick.ts";
|
||||
|
||||
const userEventHandler = async function (ctx: Context) {
|
||||
const username = ctx.state.username.replace(/^(@|~)/, "");
|
||||
const identifier = ctx.state.identifier;
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = await articleHtml(article, profile);
|
||||
generateOgProfileImage(profile);
|
||||
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
export default userEventHandler;
|
||||
34
handlers/user-profile.ts
Normal file
34
handlers/user-profile.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||
import Profile from "../models/profile.ts";
|
||||
import { profilePageHtml } from "../html.ts";
|
||||
import { notFoundHandler } from "../handlers/errors.ts";
|
||||
import { generateOgProfileImage } from "../magick.ts";
|
||||
|
||||
const userProfileHandler = async function (ctx: Context) {
|
||||
const username = ctx.state.path.replace(/^(@|~)/, "");
|
||||
const pubkey = await lookupPubkeyByUsername(username);
|
||||
|
||||
if (!pubkey) {
|
||||
notFoundHandler(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileEvent = await fetchProfileEvent(pubkey);
|
||||
|
||||
if (profileEvent) {
|
||||
const profile = new Profile(profileEvent, username);
|
||||
|
||||
const articles = await fetchArticlesByAuthor(pubkey, 210, ctx.state.tags);
|
||||
|
||||
const html = await profilePageHtml(profile, articles);
|
||||
generateOgProfileImage(profile);
|
||||
|
||||
ctx.response.body = html;
|
||||
} else {
|
||||
notFoundHandler(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
export default userProfileHandler;
|
||||
347
html.ts
347
html.ts
@@ -1,7 +1,14 @@
|
||||
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) {
|
||||
interface HtmlLayoutOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
metaHtml?: string;
|
||||
}
|
||||
|
||||
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -10,102 +17,9 @@ 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="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">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f5f2eb;
|
||||
color: #3b3a38;
|
||||
font-size: 18px;
|
||||
font-family: "Merriweather", serif;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 2em 0 0 0;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
color: #191818;
|
||||
}
|
||||
|
||||
p, pre, ul, ol, blockquote {
|
||||
line-height: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #023b77
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #3b0277
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1rem;
|
||||
background-color: #e8e3da;
|
||||
color: #027739;
|
||||
padding: 0.1em 0.3em;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
max-width: 728px;
|
||||
margin: 12rem auto;
|
||||
}
|
||||
|
||||
main header {
|
||||
display: block;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
main header h1 {
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
p.meta {
|
||||
display: flex;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
p.meta .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
|
||||
p.meta .date {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
${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>
|
||||
<body>
|
||||
${body}
|
||||
@@ -114,32 +28,231 @@ 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 errorPageHtml(statusCode: number, title: string): string {
|
||||
const body = `
|
||||
<main>
|
||||
<h1>${statusCode} - ${title}</h1>
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout({ title, body });
|
||||
}
|
||||
|
||||
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 = ``;
|
||||
if (article.isDraft) {
|
||||
draftLabel = `<p class="draft-label">Draft version</p>`;
|
||||
}
|
||||
|
||||
const body = `
|
||||
<main>
|
||||
<header>
|
||||
<h1>${title}</h1>
|
||||
<p class="meta">
|
||||
${draftLabel}
|
||||
<h1>${titleHtml(article.title)}</h1>
|
||||
<div 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>
|
||||
</p>
|
||||
<div class="content">
|
||||
<span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
|
||||
<span class="date">${publishedAtFormatted}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
${content}
|
||||
<article>
|
||||
${await article.buildContentHtml()}
|
||||
<footer>
|
||||
<div class="actions">
|
||||
${openWithNostrAppHtml(article.naddr)}
|
||||
</div>
|
||||
<p class="tags">
|
||||
${articleTagsHtml(article, profile)}
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
`;
|
||||
|
||||
return htmlLayout(title, body);
|
||||
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 {
|
||||
const formattedDate = localizeDate(article.publishedAt);
|
||||
|
||||
return `
|
||||
<div class="item">
|
||||
<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 "";
|
||||
let html = "";
|
||||
|
||||
for (const article of articles) {
|
||||
html += articleListItemHtml(article);
|
||||
}
|
||||
|
||||
return `
|
||||
<h2>Articles</h2>
|
||||
<div class="article-list">
|
||||
${html}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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">
|
||||
<header>
|
||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
||||
<div class="bio">
|
||||
<h1>${profile.name}</h1>
|
||||
${nip05Html}
|
||||
<p class="about">
|
||||
${profile.about}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<dl>
|
||||
<dt>Public key</dt>
|
||||
<dd>${profile.npub}</dd>
|
||||
${userAddressHtml(profile)}
|
||||
</dl>
|
||||
</details>
|
||||
<section>
|
||||
${articleListHtml(articles)}
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
let metaHtml = profileMetaHtml(profile);
|
||||
metaHtml += feedLinksHtml(profile);
|
||||
|
||||
return htmlLayout({ title, body, metaHtml });
|
||||
}
|
||||
|
||||
function openWithNostrAppHtml(bech32Id: string): string {
|
||||
let appLinksHtml = "";
|
||||
const appLinks = [
|
||||
{ title: "Habla", href: `https://habla.news/a/${bech32Id}` },
|
||||
{
|
||||
title: "noStrudel",
|
||||
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) {
|
||||
appLinksHtml += `<a href="${link.href}" target="_blank">${link.title}</a>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="open-with dropdown">
|
||||
<button class="dropdown-button">Open with Nostr app</button>
|
||||
<div class="dropdown-content">
|
||||
<a href="nostr:${bech32Id}" target="_blank">🔗 Nostr Link</a>
|
||||
<h4 class="title">Apps</h4>
|
||||
${appLinksHtml}
|
||||
</div>
|
||||
</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}">
|
||||
`;
|
||||
}
|
||||
|
||||
58
ldap.ts
Normal file
58
ldap.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Client } from "ldapts";
|
||||
import config from "./config.ts";
|
||||
|
||||
const { ldap, ldapEnabled } = config;
|
||||
|
||||
let client: Client;
|
||||
if (ldapEnabled) {
|
||||
client = new Client({ url: ldap.url as string });
|
||||
}
|
||||
|
||||
export async function lookupPubkeyByUsername(username: string) {
|
||||
let pubkey;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||
filter: `(cn=${username})`,
|
||||
attributes: ["nostrKey"],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function lookupUsernameByPubkey(pubkey: string) {
|
||||
let username;
|
||||
|
||||
try {
|
||||
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||
|
||||
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||
filter: `(nostrKey=${pubkey})`,
|
||||
attributes: ["cn"],
|
||||
});
|
||||
|
||||
if (searchEntries.length > 0) {
|
||||
username = searchEntries[0].cn;
|
||||
}
|
||||
} catch (e) {
|
||||
await client.unbind();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
69
magick.ts
Normal file
69
magick.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
30
main.ts
30
main.ts
@@ -1,30 +0,0 @@
|
||||
import { Application, Router } from "@oak/oak";
|
||||
import { log } from "./log.ts";
|
||||
import naddrHandler from "./handlers/naddr.ts";
|
||||
import nprofileHandler from "./handlers/nprofile.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/:path", async (ctx: ctx) => {
|
||||
const { path } = ctx.params;
|
||||
|
||||
if (path && path.startsWith("naddr")) {
|
||||
await naddrHandler(ctx);
|
||||
} else if (prefix && prefix.startsWith("nprofile")) {
|
||||
await nprofileHandler(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();
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
const PORT = 8000;
|
||||
app.listen({ port: 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);
|
||||
// });
|
||||
81
models/article.ts
Normal file
81
models/article.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { render as renderMarkdown } from "@deno/gfm";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import { NostrEvent as NEvent } from "@nostrify/nostrify";
|
||||
import { replaceNostrUris } from "../nostr.ts";
|
||||
import config from "../config.ts";
|
||||
|
||||
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 isDraft(): boolean {
|
||||
return this.event.kind === 30024;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return `${config.base_url}/${this.naddr}`;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
const tag = this.event.tags.find((t) => t[0] === "title");
|
||||
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;
|
||||
}
|
||||
|
||||
get updatedAt(): number {
|
||||
return this.event.created_at;
|
||||
}
|
||||
|
||||
get content(): string {
|
||||
return this.event.content;
|
||||
}
|
||||
|
||||
get isDeleted(): boolean {
|
||||
return !!this.event.tags.find((t) => t[0] === "deleted");
|
||||
}
|
||||
|
||||
get naddr(): string {
|
||||
return nip19.naddrEncode({
|
||||
identifier: this.identifier,
|
||||
pubkey: this.event.pubkey,
|
||||
kind: this.event.kind,
|
||||
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);
|
||||
}
|
||||
}
|
||||
81
models/profile.ts
Normal file
81
models/profile.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
display_name?: string;
|
||||
displayName?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
export default class Profile {
|
||||
private data: ProfileData;
|
||||
event: NEvent;
|
||||
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.display_name || this.data.displayName ||
|
||||
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?.replace("_@", "");
|
||||
}
|
||||
|
||||
get lud16(): string | undefined {
|
||||
return this.data.lud16;
|
||||
}
|
||||
|
||||
get pubkey(): string {
|
||||
return this.event.pubkey;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
145
nostr.ts
145
nostr.ts
@@ -1,24 +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 const relay = new NRelay1("wss://nostr.kosmos.org");
|
||||
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 fetchReplaceableEvent(pubkey: string, identifier: string) {
|
||||
const events = await relay.query([{
|
||||
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 async function fetchReplaceableEvent(
|
||||
pubkey: string,
|
||||
identifier: string,
|
||||
) {
|
||||
let events = await fetchWithTimeout([{
|
||||
authors: [pubkey],
|
||||
kinds: [30023],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}]) as NostrEvent[];
|
||||
|
||||
return events.length > 0 ? events[0] : null;
|
||||
if (events.length > 0) {
|
||||
return events[0];
|
||||
} else {
|
||||
events = await fetchWithTimeout([{
|
||||
authors: [pubkey],
|
||||
kinds: [30024],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
}]) as NostrEvent[];
|
||||
|
||||
return events.length > 0 ? events[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
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: limit,
|
||||
};
|
||||
if (typeof tags !== "undefined") filter["#t"] = tags;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
124
server.ts
Normal file
124
server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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 {
|
||||
badGatewayHandler,
|
||||
internalServerErrorHandler,
|
||||
notFoundHandler,
|
||||
} from "./handlers/errors.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get("/:path", async (ctx) => {
|
||||
const path = ctx.state.path = ctx.params.path;
|
||||
|
||||
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("/:username/:kind.atom", async (ctx) => {
|
||||
ctx.state.username = ctx.params.username.replace(/^(@|~)/, "");
|
||||
ctx.state.kind = ctx.params.kind;
|
||||
|
||||
if (
|
||||
ctx.state.kind === "articles" &&
|
||||
(ctx.params.username.startsWith("@") ||
|
||||
ctx.params.username.startsWith("~"))
|
||||
) {
|
||||
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("/:username/:identifier", async (ctx) => {
|
||||
const username = ctx.state.username = ctx.params.username;
|
||||
ctx.state.identifier = ctx.params.identifier;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/assets/:path*", async (ctx) => {
|
||||
try {
|
||||
let filePath = ctx.params.path || "";
|
||||
let root: string;
|
||||
|
||||
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());
|
||||
|
||||
app.listen({ port: config.port });
|
||||
|
||||
console.log(`App listening on http://localhost:${config.port}`);
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
17
tests/feeds_test.ts
Normal file
17
tests/feeds_test.ts
Normal 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"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
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/article-deleted.json
vendored
Normal file
9
tests/fixtures/article-deleted.json
vendored
Normal 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
165
tests/fixtures/gfm-content-1.html
vendored
Normal 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>
|
||||
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": []
|
||||
}
|
||||
93
tests/models/article_test.ts
Normal file
93
tests/models/article_test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||
import { expect } from "@std/expect";
|
||||
import Article from "../../models/article.ts";
|
||||
|
||||
describe("Article", () => {
|
||||
let article: Article;
|
||||
let deletedArticle: Article;
|
||||
|
||||
beforeAll(() => {
|
||||
article = new Article(JSON.parse(
|
||||
Deno.readTextFileSync("tests/fixtures/article-1.json"),
|
||||
));
|
||||
deletedArticle = new Article(JSON.parse(
|
||||
Deno.readTextFileSync("tests/fixtures/article-deleted.json"),
|
||||
));
|
||||
});
|
||||
|
||||
describe("#identifier", () => {
|
||||
it("returns the content of the 'd' tag", () => {
|
||||
expect(article.identifier).toEqual("1726396758485");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#isDraft", () => {
|
||||
it("is false when kind is 30023", () => {
|
||||
expect(article.isDraft).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when kind is 30024", () => {
|
||||
article.event.kind = 30024;
|
||||
expect(article.isDraft).toBe(true);
|
||||
article.event.kind = 30023;
|
||||
});
|
||||
});
|
||||
|
||||
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("#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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#updatedAt", () => {
|
||||
it("returns the value of the first 'published_at' tag", () => {
|
||||
expect(article.updatedAt).toEqual(1729462158);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#isDeleted", () => {
|
||||
it("returns a boolean based on the 'deleted' tag", () => {
|
||||
expect(article.isDeleted).toEqual(false);
|
||||
expect(deletedArticle.isDeleted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#naddr", () => {
|
||||
it("returns a bech32 addressable event ID", () => {
|
||||
expect(article.naddr).toMatch(
|
||||
/naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qf/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#buildContentHtml", () => {
|
||||
it("returns a rendered HTML version of the 'content'", async () => {
|
||||
const html = await article.buildContentHtml();
|
||||
expect(html).toMatch(/<h2 id="the-solution">/);
|
||||
});
|
||||
});
|
||||
});
|
||||
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 { NostrEvent as NEvent } from "@nostrify/nostrify";
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
tests/nostr_test.ts
Normal file
27
tests/nostr_test.ts
Normal 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);
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
2
users.yaml.sample
Normal file
2
users.yaml.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
|
||||
bitcoincore: 47750177bb6bb113784e4973f6b2e3dd27ef1eff227d6e38d0046d618969e41a
|
||||
54
utils.ts
Normal file
54
utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user