1 Commits

Author SHA1 Message Date
3295c6b2cf Create user config sample, ignore actual config 2024-10-22 15:57:03 +02:00
37 changed files with 415 additions and 1447 deletions

View File

@@ -1,4 +1,6 @@
RELAY_URLS=wss://nostr.kosmos.org,wss://nostr.x0f.org
PORT=8000
BASE_URL=http://localhost:8000
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

View File

@@ -1 +0,0 @@
RELAY_URLS=wss://nostr.kosmos.org

View File

@@ -1,18 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
test_and_lint:
name: Test and lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: denoland/setup-deno@v2
with:
deno-version: v2.1.x
- run: "deno task test"
- run: "deno lint"

1
.gitignore vendored
View File

@@ -1,3 +1,2 @@
.env
users.yaml
build/

View File

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

View File

@@ -1,23 +1,10 @@
@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);
background-color: #f5f2eb;
color: #3b3a38;
}
h1, h2, h3, h4 {
color: var(--text-color-headings);
color: #191818;
}
a {
@@ -28,31 +15,23 @@ 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);
background-color: #333;
color: #ccc;
}
pre code {
background-color: var(--background-color-dark);
color: var(--text-color-dark-bg);
background-color: #333;
color: #ccc;
}
dl dt {
color: var(--text-color-discreet);
}
hr {
border-color: #e8e3da;
color: #888;
}
main header .draft-label {
@@ -61,62 +40,43 @@ main header .draft-label {
}
main header .meta .date {
color: var(--text-color-discreet);
color: #888;
}
main header .meta .name a {
color: var(--text-color-body);
color: #3b3a38;
}
main.profile-page header .nip05 {
color: var(--text-color-discreet);
}
main.profile-page .pubkey {
color: var(--text-color-discreet);
}
.nip05 .verified {
background-color: #e8e3da;
color: #027739;
}
.nip05 .not-verified {
background-color: #e8e3da;
color: #770202;
.profile-page .pubkey {
color: #888;
}
/* 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);
background-color: #f5f2eb;
color: #3b3a38;
border-color: #ccc;
}
.dropdown-content {
background-color: var(--background-color);
background-color: #fff;
}
.dropdown-content a {
color: var(--text-color-body) !important;
color: #3b3a38 !important;
}
.dropdown-content a:hover {
background-color: var(--background-color-highlight);
background-color: #f1f1f1;
}
.dropdown-content h4.title {
color: var(--text-color-discreet);
border-color: var(--background-color-highlight);
color: #888;
border-color: #f1f1f1;
}
.dropdown:hover .dropdown-button {
background-color: var(--background-color);
border-color: var(--background-color);
background-color: #fff;
border-color: #fff;
}

View File

@@ -1,48 +1,27 @@
import { load } from "@std/dotenv";
import { parse as parseYaml } from "jsr:@std/yaml";
import { checkFileExists } from "./utils.ts";
import { parse } from "jsr:@std/yaml";
import { log } from "./log.ts";
const denoEnv = Deno.env.get("DENO_ENV");
const dirname = Deno.cwd();
const dirname = new URL(".", import.meta.url).pathname;
await load({ envPath: `${dirname}/.env`, export: true });
function envAwarePath(path: string): string {
return denoEnv ? `${path}.${denoEnv}` : path;
}
await load({ envPath: envAwarePath(`${dirname}/.env`), export: true });
let userConfigPath: string = "";
let staticUsers: { [key: string]: string } = {};
const defaultUserConfigPaths = [
envAwarePath("/etc/substr/users.yaml"),
envAwarePath(`${dirname}/users.yaml`),
];
for (const path of defaultUserConfigPaths) {
const fileExists = await checkFileExists(path);
if (fileExists) {
userConfigPath = path;
break;
}
}
let staticUsers;
try {
const fileContent = await Deno.readTextFile(userConfigPath);
const parsedContent = parseYaml(fileContent);
if (parsedContent !== null && typeof parsedContent === "object") {
staticUsers = parsedContent as { [key: string]: string };
}
const yamlContent = await Deno.readTextFile(`${dirname}/users.yaml`);
staticUsers = parse(yamlContent);
log("Static user config:", "blue");
log(Deno.inspect(staticUsers), "blue");
} catch {
// Nothing to do
staticUsers = {};
log(`Could not find or parse a "users.yaml" config`, "yellow");
}
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,
port: Deno.env.get("PORT") || 8000,
base_url: Deno.env.get("BASE_URL") || `http://localhost:8000`,
home_relay_url: Deno.env.get("HOME_RELAY_URL") || "",
staticUsers: staticUsers,
ldapEnabled: !!Deno.env.get("LDAP_URL"),
ldap: {
url: Deno.env.get("LDAP_URL"),
@@ -50,50 +29,8 @@ const config = {
password: Deno.env.get("LDAP_PASSWORD"),
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
},
query_timeout: parseInt(Deno.env.get("RELAY_TIMEOUT_MS") || "5000"),
njump_url: Deno.env.get("NJUMP_URL") || "https://njump.me",
};
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);
}
}
log(`LDAP enabled: ${config.ldapEnabled}`, "blue");
export default config;

View File

@@ -1,24 +1,16 @@
{
"tasks": {
"dev": "deno run --allow-all --watch server.ts",
"server": "deno run --allow-all server.ts",
"compile": "deno compile --allow-all --include ./assets/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts",
"test": "DENO_ENV=test deno test --allow-read --allow-env"
"dev": "deno run --allow-net --allow-read --allow-env --watch server.ts"
},
"imports": {
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.2",
"@oak/oak": "jsr:@oak/oak@^17.1.3",
"@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/dotenv": "jsr:@std/dotenv@^0.225.2",
"@std/expect": "jsr:@std/expect@^1.0.8",
"@std/testing": "jsr:@std/testing@^1.0.11",
"@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.2"
},
"fmt": {
"exclude": [
"magick.ts"
]
"ldapts": "npm:ldapts@^7.2.1"
}
}

251
deno.lock generated
View File

@@ -1,67 +1,60 @@
{
"version": "4",
"specifiers": {
"jsr:@deno/gfm@0.10": "0.10.0",
"jsr:@deno/gfm@0.9": "0.9.0",
"jsr:@denosaurs/emoji@0.3": "0.3.1",
"jsr:@nostr/tools@^2.10.4": "2.12.0",
"jsr:@nostrify/nostrify@~0.36.2": "0.36.2",
"jsr:@nostr/tools@^2.3.1": "2.3.1",
"jsr:@nostrify/nostrify@~0.36.1": "0.36.1",
"jsr:@nostrify/types@0.35": "0.35.0",
"jsr:@oak/commons@1": "1.0.0",
"jsr:@oak/oak@^17.1.3": "17.1.3",
"jsr:@std/assert@0.224": "0.224.0",
"jsr:@std/assert@1": "1.0.8",
"jsr:@std/assert@^1.0.12": "1.0.12",
"jsr:@std/assert@^1.0.8": "1.0.8",
"jsr:@std/bytes@1": "1.0.4",
"jsr:@std/bytes@^1.0.2": "1.0.4",
"jsr:@std/crypto@0.224": "0.224.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/dotenv@~0.225.2": "0.225.3",
"jsr:@std/encoding@0.224": "0.224.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.8": "1.0.15",
"jsr:@std/http@1": "1.0.10",
"jsr:@std/internal@^1.0.5": "1.0.5",
"jsr:@std/internal@^1.0.6": "1.0.6",
"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.1.0",
"jsr:@std/path@1": "1.0.8",
"jsr:@std/path@^1.0.7": "1.0.8",
"jsr:@std/path@^1.0.8": "1.0.8",
"jsr:@std/testing@^1.0.11": "1.0.11",
"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",
"npm:@scure/base@1.1.1": "1.1.1",
"npm:@scure/base@^1.1.6": "1.2.1",
"npm:@scure/bip32@1.3.1": "1.3.1",
"npm:@scure/bip32@^1.4.0": "1.6.0",
"npm:@scure/bip39@1.2.1": "1.2.1",
"npm:@scure/bip39@^1.3.0": "1.5.0",
"npm:@scure/bip32@^1.4.0": "1.4.0",
"npm:@scure/bip39@^1.3.0": "1.3.0",
"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.2": "7.2.2",
"npm:lru-cache@^10.2.0": "10.4.3",
"npm:marked-alert@2": "2.1.2_marked@12.0.2",
"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",
"npm:marked-gfm-heading-id@^3.1.0": "3.2.0_marked@12.0.2",
"npm:marked@12": "12.0.2",
"npm:nostr-tools@^2.7.0": "2.10.4",
"npm:nostr-wasm@0.1.0": "0.1.0",
"npm:nostr-tools@^2.7.0": "2.7.0",
"npm:path-to-regexp@*": "6.2.1",
"npm:path-to-regexp@6.2.1": "6.2.1",
"npm:prismjs@^1.29.0": "1.29.0",
"npm:sanitize-html@^2.13.0": "2.13.1",
"npm:sanitize-html@^2.11.0": "2.13.1",
"npm:websocket-ts@^2.1.5": "2.1.5",
"npm:zod@^3.23.8": "3.23.8"
},
"jsr": {
"@deno/gfm@0.10.0": {
"integrity": "51708205e3559a4aeb6afb29d07c5bfafe7941f91bb360351ef6621de9a39527",
"@deno/gfm@0.9.0": {
"integrity": "9002dbdb6e382e247509edfeae3afdb9232f5ca98a8210ef186d42084e9ded30",
"dependencies": [
"jsr:@denosaurs/emoji",
"npm:github-slugger",
@@ -78,36 +71,22 @@
"@denosaurs/emoji@0.3.1": {
"integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b"
},
"@nostr/tools@2.10.4": {
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
"@nostr/tools@2.3.1": {
"integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61",
"dependencies": [
"npm:@noble/ciphers",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base@1.1.1",
"npm:@scure/bip32@1.3.1",
"npm:@scure/bip39@1.2.1",
"npm:nostr-wasm"
"npm:@scure/base"
]
},
"@nostr/tools@2.12.0": {
"integrity": "0584d5197682c6eabaded17bae10e765f215ef051ae70aa463f994abf90f295a",
"dependencies": [
"npm:@noble/ciphers",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base@1.1.1"
]
},
"@nostrify/nostrify@0.36.2": {
"integrity": "cc4787ca170b623a2e5dfed1baa4426077daa6143af728ea7dd325d58f4d04d6",
"@nostrify/nostrify@0.36.1": {
"integrity": "f76c803c0bda5df1c172f25d2313980344b0431df2a973ab3e1dd61e9e7b4b1a",
"dependencies": [
"jsr:@nostrify/types",
"jsr:@std/crypto@0.224",
"jsr:@std/encoding@~0.224.1",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0",
"npm:@scure/bip32",
"npm:@scure/bip39",
"npm:lru-cache",
"npm:nostr-tools",
"npm:websocket-ts",
@@ -122,91 +101,71 @@
"dependencies": [
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto@1",
"jsr:@std/crypto",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.1.3": {
"integrity": "d89296c22db91681dd3a2a1e1fd14e258d0d5a9654de55637aee5b661c159f33",
"@oak/oak@17.1.0": {
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto@1",
"jsr:@std/crypto",
"jsr:@std/http",
"jsr:@std/io",
"jsr:@std/media-types",
"jsr:@std/path@1",
"npm:path-to-regexp"
"npm:path-to-regexp@6.2.1"
]
},
"@std/assert@0.224.0": {
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
},
"@std/assert@1.0.8": {
"integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b",
"@std/assert@1.0.6": {
"integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207",
"dependencies": [
"jsr:@std/internal@^1.0.5"
"jsr:@std/internal"
]
},
"@std/assert@1.0.12": {
"integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a",
"dependencies": [
"jsr:@std/internal@^1.0.6"
]
},
"@std/bytes@1.0.4": {
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
},
"@std/crypto@0.224.0": {
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
"dependencies": [
"jsr:@std/assert@0.224",
"jsr:@std/encoding@0.224"
]
"@std/bytes@1.0.2": {
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
},
"@std/crypto@1.0.3": {
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
},
"@std/data-structures@1.0.4": {
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
},
"@std/dotenv@0.225.2": {
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
},
"@std/dotenv@0.225.3": {
"integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a"
},
"@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
},
"@std/encoding@1.0.5": {
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
},
"@std/expect@1.0.8": {
"integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f",
"@std/expect@1.0.5": {
"integrity": "8c7ac797e2ffe57becc6399c0f2fd06230cb9ef124d45229c6e592c563824af1",
"dependencies": [
"jsr:@std/assert@^1.0.8",
"jsr:@std/internal@^1.0.5"
"jsr:@std/assert@^1.0.6",
"jsr:@std/internal"
]
},
"@std/expect@1.0.15": {
"integrity": "eca360007b5a7f13dbfa1294224baee7fb98dcd460d8461fe64eeae302902945",
"@std/fs@1.0.4": {
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
"dependencies": [
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
"jsr:@std/path@^1.0.6"
]
},
"@std/http@1.0.10": {
"integrity": "4e32d11493ab04e3ef09f104f0cb9beb4228b1d4b47c5469573c2c294c0d3692",
"@std/http@1.0.8": {
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
"dependencies": [
"jsr:@std/encoding@^1.0.5"
]
},
"@std/internal@1.0.5": {
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
},
"@std/internal@1.0.6": {
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
"@std/internal@1.0.4": {
"integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422"
},
"@std/io@0.224.9": {
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
@@ -214,17 +173,20 @@
"jsr:@std/bytes@^1.0.2"
]
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
"@std/media-types@1.0.3": {
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
"@std/path@1.0.6": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
},
"@std/testing@1.0.11": {
"integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48",
"@std/testing@1.0.3": {
"integrity": "f98c2bee53860a5916727d7e7d3abe920dd6f9edace022e2d059f00d05c2cf42",
"dependencies": [
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
"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": {
@@ -247,10 +209,10 @@
"@noble/hashes@1.3.2"
]
},
"@noble/curves@1.7.0": {
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
"@noble/curves@1.4.0": {
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
"dependencies": [
"@noble/hashes@1.6.0"
"@noble/hashes@1.4.0"
]
},
"@noble/hashes@1.3.1": {
@@ -259,46 +221,43 @@
"@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
},
"@noble/hashes@1.6.0": {
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="
},
"@noble/hashes@1.6.1": {
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="
"@noble/hashes@1.4.0": {
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
},
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.2.1": {
"integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ=="
"@scure/base@1.1.7": {
"integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
"@scure/base@1.1.7"
]
},
"@scure/bip32@1.6.0": {
"integrity": "sha512-82q1QfklrUUdXJzjuRU7iG7D7XiFx5PHYVS0+oeNKhyDLT7WPqs6pBcM2W5ZdwOwKCwoE1Vy1se+DHjcXwCYnA==",
"@scure/bip32@1.4.0": {
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
"dependencies": [
"@noble/curves@1.7.0",
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
"@noble/curves@1.4.0",
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
"@scure/base@1.1.7"
]
},
"@scure/bip39@1.5.0": {
"integrity": "sha512-Dop+ASYhnrwm9+HA/HwXg7j2ZqM6yk2fyLWb5znexjctFY3+E+eU8cIWI0Pql0Qx4hPZCijlGq4OL71g+Uz30A==",
"@scure/bip39@1.3.0": {
"integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==",
"dependencies": [
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
]
},
"@types/asn1@0.2.4": {
@@ -386,8 +345,8 @@
"commander"
]
},
"ldapts@7.2.2": {
"integrity": "sha512-UotAq24/vJEz0m3w/jgwZm7JGNw8M6vexL/5KU5pe3aIZWBkT/HRhjsPw/buRqKSK5Y0vTu5Zv8iyPgQF7ozzg==",
"ldapts@7.2.1": {
"integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==",
"dependencies": [
"@types/asn1",
"asn1",
@@ -397,11 +356,11 @@
"whatwg-url"
]
},
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
"lru-cache@10.2.2": {
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
},
"marked-alert@2.1.2_marked@12.0.2": {
"integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==",
"marked-alert@2.1.0_marked@12.0.2": {
"integrity": "sha512-X95Z8PCDgWa0bBfM70GxZG3LD/leUrhXc3cx3w1eFExBhswd1oXn/S4S+9H8ypPdCY7okREb4dItUOc+VJq4jQ==",
"dependencies": [
"marked"
]
@@ -428,8 +387,8 @@
"nanoid@3.3.7": {
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
},
"nostr-tools@2.10.4": {
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"nostr-tools@2.7.0": {
"integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
@@ -495,8 +454,8 @@
"undici-types@6.19.8": {
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"uuid@11.0.3": {
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg=="
"uuid@10.0.0": {
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
@@ -529,15 +488,15 @@
},
"workspace": {
"dependencies": [
"jsr:@deno/gfm@0.10",
"jsr:@nostr/tools@^2.10.4",
"jsr:@nostrify/nostrify@~0.36.2",
"jsr:@oak/oak@^17.1.3",
"jsr:@deno/gfm@0.9",
"jsr:@nostr/tools@^2.3.1",
"jsr:@nostrify/nostrify@~0.36.1",
"jsr:@oak/oak@^17.1.0",
"jsr:@std/dotenv@~0.225.2",
"jsr:@std/expect@^1.0.8",
"jsr:@std/testing@^1.0.11",
"jsr:@std/expect@^1.0.5",
"jsr:@std/testing@^1.0.3",
"jsr:@std/yaml@^1.0.5",
"npm:ldapts@^7.2.2"
"npm:ldapts@^7.2.1"
]
}
}

View File

@@ -1,9 +1,8 @@
// deno-lint-ignore-file require-await
import config from "./config.ts";
import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts";
import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts";
export async function lookupUsernameByPubkey(pubkey: string): Promise<string | undefined> {
export function lookupUsernameByPubkey(pubkey: string) {
let username;
for (const [key, value] of Object.entries(config.staticUsers)) {
if (value === pubkey) {
@@ -21,7 +20,7 @@ export async function lookupUsernameByPubkey(pubkey: string): Promise<string | u
}
}
export async function lookupPubkeyByUsername(username: string): Promise<string | undefined> {
export function lookupPubkeyByUsername(username: string) {
const pubkey = config.staticUsers[username];
if (pubkey) {

View File

@@ -2,41 +2,33 @@ 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> {
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;
let articlesXml = "";
for (const article of articles) {
const contentHtml = await article.buildContentHtml();
const articlesXml = articles.map((article) => {
const articleId =
`tag:${profile.nip05},nostr-p-${profile.pubkey}-d-${article.identifier}-k-30023`;
articlesXml += `
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
return `
<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>
<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 (Articles)</title>
<title>${profile.name} on Nostr</title>
<id>${feedId}</id>
<updated>${isoDate(lastUpdate)}</updated>
<icon>${profile.avatarImageUrl}</icon>
<icon>${profile.picture}</icon>
<author>
<name>${name}</name>
</author>
@@ -44,11 +36,3 @@ export async function profileAtomFeed(
</feed>
`.trim();
}
export function cleanContentHtml(html: string) {
const cleanHtml = html.replace(
/<a class="anchor" aria-hidden="true"[^>]*>.*?<\/a>/gs,
"",
);
return cleanHtml;
}

View File

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

View File

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

10
handlers/not-found.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

136
html.ts
View File

@@ -2,13 +2,13 @@ import { localizeDate } from "./dates.ts";
import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
interface HtmlLayoutOptions {
title: string;
body: string;
metaHtml?: string;
}
function htmlLayout(title: string, body: string, profile?: Profile): string {
let feedLinksHtml = "";
if (profile) {
feedLinksHtml =
`<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
return `
<!DOCTYPE html>
<html>
@@ -17,7 +17,7 @@ function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${title}</title>
${metaHtml || ""}
${feedLinksHtml}
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
</head>
@@ -35,13 +35,10 @@ export function errorPageHtml(statusCode: number, title: string): string {
</main>
`;
return htmlLayout({ title, body });
return htmlLayout(title, body);
}
export async function articleHtml(
article: Article,
profile: Profile,
): Promise<string> {
export function articleHtml(article: Article, profile: Profile): string {
const publishedAtFormatted = localizeDate(article.publishedAt);
const pageTitle = article.isDraft ? `Draft: ${article.title}` : article.title;
let draftLabel = ``;
@@ -53,9 +50,9 @@ export async function articleHtml(
<main>
<header>
${draftLabel}
<h1>${titleHtml(article.title)}</h1>
<h1>${article.title}</h1>
<div class="meta">
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="content">
<span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
<span class="date">${publishedAtFormatted}</span>
@@ -63,7 +60,7 @@ export async function articleHtml(
</div>
</header>
<article>
${await article.buildContentHtml()}
${article.html}
<footer>
${openWithNostrAppHtml(article.naddr)}
</footer>
@@ -71,14 +68,7 @@ export async function articleHtml(
</main>
`;
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>");
return htmlLayout(pageTitle, body, profile);
}
function articleListItemHtml(article: Article): string {
@@ -86,19 +76,18 @@ function articleListItemHtml(article: Article): string {
return `
<div class="item">
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
<p class="meta">
${formattedDate}
</p>
<h3><a href="/${article.naddr}">${article.title}</a></h3>
<p>${formattedDate}</p>
</div>
`;
}
export function articleListHtml(articles: Article[]): string {
if (articles.length === 0) return "";
const sortedArticles = articles.sort((a, b) => b.publishedAt - a.publishedAt);
let html = "";
for (const article of articles) {
for (const article of sortedArticles) {
html += articleListItemHtml(article);
}
@@ -110,74 +99,37 @@ export function articleListHtml(articles: Article[]): string {
`;
}
function userAddressHtml(profile: Profile) {
let html = "";
if (profile.nip05) {
html += `<dt>Nostr address</dt><dd>${profile.nip05}</dd>\n`;
}
if (profile.lud16) {
html += `<dt>Lightning address</dt><dd>${profile.lud16}</dd>\n`;
}
return html;
}
function nip05VerifiedHtml(verified: boolean): string {
if (verified) {
return ` <span class="verified" title="Verified">✔</span>`;
} else {
return ` <span class="not-verified" title="Verification failed">✕</span>`;
}
}
export async function profilePageHtml(
profile: Profile,
articles: Article[],
): Promise<string> {
export function profilePageHtml(profile: Profile, articles: Article[]): 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.avatarImageUrl}" alt="User Avatar" />
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
<div class="bio">
<h1>${profile.name}</h1>
${nip05Html}
<p class="about">
${profile.about}
</p>
</p>
</div>
</header>
<details>
<summary>Details</summary>
<section>
<dl>
<dt>Public key</dt>
<dd>${profile.npub}</dd>
${userAddressHtml(profile)}
</dl>
</details>
</section>
<section>
${articleListHtml(articles)}
</section>
</main>
`;
let metaHtml = profileMetaHtml(profile);
metaHtml += feedLinksHtml(profile);
return htmlLayout({ title, body, metaHtml });
return htmlLayout(title, body, profile);
}
function openWithNostrAppHtml(bech32Id: string): string {
function openWithNostrAppHtml(bech32Id): string {
let appLinksHtml = "";
const appLinks = [
{ title: "Habla", href: `https://habla.news/a/${bech32Id}` },
@@ -186,7 +138,6 @@ function openWithNostrAppHtml(bech32Id: string): string {
href: `https://nostrudel.ninja/#/articles/${bech32Id}`,
},
{ title: "Coracle", href: `https://coracle.social/${bech32Id}` },
{ title: "YakiHonne", href: `https://yakihonne.com/article/${bech32Id}` },
];
for (const link of appLinks) {
@@ -204,42 +155,3 @@ function openWithNostrAppHtml(bech32Id: string): string {
</div>
`;
}
function feedLinksHtml(profile: Profile) {
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
function profileMetaHtml(profile: Profile) {
return `
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
<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 `
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
<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}">
`;
}

44
ldap.ts
View File

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

111
magick.ts
View File

@@ -1,111 +0,0 @@
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 createProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const args = [
profile.picture,
'-resize', '256x256^',
'-gravity', 'center',
'-extent', '256x256',
`${tmpImgDir}/p-${profile.event.id}.png`
];
return runCommand(magick, args);
}
async function createRoundedProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const status = await generateProfileImage(profile);
if (status && status.success) {
const args = [
`${tmpImgDir}/p-${profile.event.id}.png`,
'-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);
} else {
return false;
}
}
async function createOgImage(profile: Profile, ogImagePath: string, backgroundColor: string) {
if (!magick) return false;
const status = await createRoundedProfileImage(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 generateProfileImage(profile: Profile) {
if (!magick || !profile.picture) return false;
const imagePath = `${tmpImgDir}/p-${profile.event.id}.png`;
const fileExists = await checkFileExists(imagePath);
if (fileExists) {
return { success: true };
} else {
const status = await createProfileImage(profile);
if (status && status.success) {
log(`Created avatar image for ${profile.username}: ${imagePath}`, "blue")
return status;
} else {
log(`Could not create avatar image for ${profile.username}`, "yellow")
}
}
}
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) {
return { success: true };
} else {
const status = await createOgImage(profile, ogImagePath, backgroundColor);
if (status && status.success) {
log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue")
return status;
} else {
log(`Could not create OG image for ${profile.username}`, "yellow")
}
}
}

View File

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

View File

@@ -1,9 +1,5 @@
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();
import { nip19 } from "@nostr/tools";
import { NEvent } from "../nostr.ts";
export interface ProfileData {
name?: string;
@@ -16,8 +12,8 @@ export interface ProfileData {
}
export default class Profile {
private data: ProfileData;
event: NEvent;
private data: ProfileData;
username?: string;
constructor(event: NEvent, username?: string) {
@@ -44,7 +40,7 @@ export default class Profile {
}
get nip05(): string | undefined {
return this.data.nip05?.replace("_@", "");
return this.data.nip05;
}
get lud16(): string | undefined {
@@ -58,32 +54,4 @@ export default class Profile {
get npub(): string {
return nip19.npubEncode(this.pubkey);
}
get profileUrl(): string {
return `${config.base_url}/@${this.username}`;
}
get avatarImageUrl(): string {
if (magick) {
return `${config.base_url}/assets/g/img/p-${this.event.id}.png`;
} else {
return this.picture || "";
}
}
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);
}
}
}

View File

@@ -1,96 +1,59 @@
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
import { NRelay1 } from "@nostrify/nostrify";
import config from "./config.ts";
import Article from "./models/article.ts";
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) => [],
});
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 interface NEvent {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
sig: string;
tags: Array<[string, string, string?]>;
}
export const relay = new NRelay1(config.home_relay_url);
export async function fetchReplaceableEvent(
pubkey: string,
identifier: string,
) {
let events = await fetchWithTimeout([{
let events = await relay.query([{
authors: [pubkey],
kinds: [30023],
"#d": [identifier],
limit: 1,
}]) as NostrEvent[];
}]);
if (events.length > 0) {
return events[0];
} else {
events = await fetchWithTimeout([{
events = await relay.query([{
authors: [pubkey],
kinds: [30024],
"#d": [identifier],
limit: 1,
}]) as NostrEvent[];
}]);
return events.length > 0 ? events[0] : null;
}
}
export async function fetchArticlesByAuthor(
pubkey: string,
limit: number = 10,
) {
const events = await fetchWithTimeout([{
export async function fetchArticlesByAuthor(pubkey: string) {
const events = await relay.query([{
authors: [pubkey],
kinds: [30023],
limit: limit,
}]) as NostrEvent[];
limit: 10,
}]);
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
return events;
}
export async function fetchProfileEvent(pubkey: string) {
const events = await fetchWithTimeout([{
const events = await relay.query([{
authors: [pubkey],
kinds: [0],
limit: 1,
}]) as NostrEvent[];
}]);
return events.length > 0 ? events[0] : null;
}
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;
}
}

View File

@@ -1,129 +0,0 @@
import { lookupUsernameByPubkey } from "../directory.ts";
import { nip19 } from "@nostr/tools";
import config from "../config.ts";
const nostrUriRegex = /(nostr:|@)(nprofile|naddr|nevent|npub)[a-z0-9]+/g;
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 protectedRegex = /(`{3,}[\s\S]*?`{3,})|(`[^`]*`)|(<pre>[\s\S]*?<\/pre>)|(https?:\/\/[^\s<>"']+)/gi;
// Split text into segments: unprotected text and protected areas (code blocks, URLs)
const segments: string[] = [];
let lastIndex = 0;
markdown.replace(protectedRegex, (match, _fencedCode, _inlineCode, _preTag, _url, index) => {
segments.push(markdown.slice(lastIndex, index));
segments.push(match);
lastIndex = index + match.length;
return match;
});
segments.push(markdown.slice(lastIndex));
// Process each segment
let result = '';
for (let i = 0; i < segments.length; i++) {
if (i % 2 === 1 || protectedRegex.test(segments[i])) {
// Protected segment (code block or URL), leave unchanged
result += segments[i];
} else {
// Unprotected text, replace URIs and handle markdown links
result += await processUnprotectedText(segments[i]);
}
}
return result;
}
async function processUnprotectedText(text: string): Promise<string> {
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
const parts: string[] = [];
// Process markdown links first
let match;
while ((match = markdownLinkRegex.exec(text)) !== null) {
const [fullMatch, linkText, target] = match;
// Add text before the link
parts.push(await replaceUrisInText(text.slice(lastIndex, match.index)));
// Process the link target
if (nostrUriRegex.test(target) && target.match(nostrUriRegex)![0] === target) {
// Target is a Nostr URI, replace with resolved URL
const resolvedUrl = await nostrUriToUrl(target);
parts.push(`[${linkText}](${resolvedUrl})`);
} else {
// Not a Nostr URI, keep the original link
parts.push(fullMatch);
}
lastIndex = match.index + fullMatch.length;
}
// Add any remaining text after the last link
parts.push(await replaceUrisInText(text.slice(lastIndex)));
return parts.join('');
}
async function replaceUrisInText(text: string): Promise<string> {
let modifiedText = text;
const replacements: { start: number; end: number; replacement: string }[] = [];
// Collect all replacements for bare Nostr URIs
let match;
while ((match = nostrUriRegex.exec(modifiedText)) !== null) {
const fullUri = match[0];
const url = await nostrUriToUrl(fullUri);
const linkTitle = cleanUriForTitle(fullUri);
const markdownLink = `[${linkTitle}](${url})`;
replacements.push({
start: match.index,
end: match.index + fullUri.length,
replacement: markdownLink,
});
}
// Apply replacements from right to left to avoid index shifting
for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, replacement } = replacements[i];
modifiedText = modifiedText.slice(0, start) + replacement + modifiedText.slice(end);
}
return modifiedText;
}
function cleanUriForTitle(uri: string): string {
// Remove "nostr:" prefix, keep "@" for the title
return uri.startsWith('nostr:') ? uri.replace(/^nostr:/, '') : uri;
}

120
server.ts
View File

@@ -1,84 +1,49 @@
import { Application, Router, send } from "@oak/oak";
import { createSubtrTmpDirectories } from "./utils.ts";
import config, { ensureNecessaryConfigs } from "./config.ts";
import config 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";
import notFoundHandler from "./handlers/not-found.ts";
const router = new Router();
router.get("/:path", async (ctx) => {
const path = ctx.state.path = ctx.params.path;
router.get("/:path", async (ctx: ctx) => {
const { path } = ctx.params;
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("~")) {
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);
}
}
if (path.startsWith("naddr")) {
await naddrHandler(ctx);
} else if (path.startsWith("nprofile")) {
await nprofileHandler(ctx);
} else if (path.startsWith("npub")) {
await npubHandler(ctx);
} else if (path.startsWith("@") || path.startsWith("~")) {
await userProfileHandler(ctx);
} else {
notFoundHandler(ctx);
}
});
router.get("/:username/:identifier", async (ctx) => {
const username = ctx.state.username = ctx.params.username;
ctx.state.identifier = ctx.params.identifier;
router.get("/:user/:kind.atom", async (ctx: ctx) => {
const { user, kind } = ctx.params;
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);
}
}
if (
kind === "articles" &&
(user.startsWith("@") || user.startsWith("~"))
) {
await userAtomFeedHandler(ctx);
} else {
notFoundHandler(ctx);
}
});
router.get("/:user/:identifier", async (ctx: ctx) => {
const { user } = ctx.params;
if (user.startsWith("@") || user.startsWith("~")) {
await userEventHandler(ctx);
} else {
notFoundHandler(ctx);
}
@@ -86,31 +51,16 @@ router.get("/:username/:identifier", async (ctx) => {
router.get("/assets/:path*", async (ctx) => {
try {
let filePath = ctx.params.path || "";
let root: string;
const filePath = ctx.params.path;
if (filePath.startsWith("g/img/")) {
filePath = filePath.replace(/^g\//, "");
root = "/tmp/substr";
} else {
root = `${import.meta.dirname}/assets`;
}
await send(ctx, filePath, { root });
} catch (e) {
if (e instanceof Error && e.name === "NotFoundError") {
notFoundHandler(ctx);
} else {
console.error(e);
badGatewayHandler(ctx);
}
await send(ctx, filePath, {
root: `${Deno.cwd()}/assets`,
});
} catch (_e) {
notFoundHandler(ctx);
}
});
ensureNecessaryConfigs();
await createSubtrTmpDirectories();
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

View File

@@ -1,17 +0,0 @@
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"/);
});
});
});

View File

@@ -1,41 +0,0 @@
I think we should agree on an HTML attribute for pointing to the Nostr representation of a document/URL on the Web. We could use the existing one for link relations for example. Something like:
```html
<link rel="alternate" type="application/nostr+json"
href="nostr:naddr1qvzqqqr4..."
title="This article on Nostr" />
```
This would be useful in multiple ways:
1. Existing Web publications can retroactively create Nostr versions of their content and easily link the Nostr articles on all of their existing article pages.
2. Nostr clients, when fetching meta/preview information for a URL that is linked in a note, can detect that there's a Nostr representation of the content, and then render it in Nostr-native ways (whatever that may be depending on the client)
3. User agents, usually a browser or browser extension, when opening a URL on the Web, can offer opening the alternative representation of a page in a Nostr client. And/or they could offer to follow the author's pubkey on Nostr. And/or they could offer to zap the content.
4. When publishing a new article on Nostr, authors can share their preferred Web URL everywhere, without having to consider if the recipients' clients support Nostr IDs and content or not. This makes it easy for the reader to share the author's preferred Web URL on any medium, instead of sharing a link to whatever their own Nostr client prefers (usually its own Web UI).
### Testing Nostr IDs
Receive [special badges](https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc)
raucao scheme 1: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao at 1: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao scheme 2: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao at 2: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
raucao scheme link 1: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
raucao scheme link 2: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
Amber scheme 1: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber at 1: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber scheme 2: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber at 2: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
Amber scheme link 1: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
Amber scheme link 2: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
## More protected text
```
Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
```
Inline: `raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees`

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { NEvent } from "../../nostr.ts";
import Article from "../../models/article.ts";
describe("Article", () => {
let articleEvent: NEvent;
let article: Article;
let deletedArticle: Article;
beforeAll(() => {
article = new Article(JSON.parse(
articleEvent = JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-1.json"),
));
deletedArticle = new Article(JSON.parse(
Deno.readTextFileSync("tests/fixtures/article-deleted.json"),
));
);
article = new Article(articleEvent);
});
describe("#identifier", () => {
@@ -59,10 +58,9 @@ describe("Article", () => {
});
});
describe("#isDeleted", () => {
it("returns a boolean based on the 'deleted' tag", () => {
expect(article.isDeleted).toEqual(false);
expect(deletedArticle.isDeleted).toEqual(true);
describe("#html", () => {
it("returns a rendered HTML version of the 'content'", () => {
expect(article.html).toMatch(/<h2 id="the-solution">/);
});
});
@@ -73,11 +71,4 @@ describe("Article", () => {
);
});
});
describe("#buildContentHtml", () => {
it("returns a rendered HTML version of the 'content'", async () => {
const html = await article.buildContentHtml();
expect(html).toMatch(/<h2 id="the-solution">/);
});
});
});

View File

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

View File

@@ -1,62 +0,0 @@
import { beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { replaceNostrUris } from "../../nostr/links.ts";
describe("Nostr links", () => {
describe("#replaceNostrUris", () => {
let mdContent: string;
let result: string;
beforeAll(async () => {
mdContent = Deno.readTextFileSync("tests/fixtures/article-2.md"),
result = await replaceNostrUris(mdContent);
});
it("does not replace URIs in URLs", () => {
expect(result).toMatch(new RegExp("https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc"));
});
it("does not replace URIs in fenced code blocks", () => {
expect(result).toMatch(new RegExp("Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7"));
});
it("does not replace URIs in inline code blocks", () => {
expect(result).toMatch(new RegExp("raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees"));
});
describe("for unknown usernames", () => {
it("replaces plain nostr:id URIs with a markdown link", () => {
expect(result).toMatch(/Amber scheme 1\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
expect(result).toMatch(/Amber scheme 2\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
});
it("replaces @id URIs with a markdown link", () => {
expect(result).toMatch(/Amber at 1\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
expect(result).toMatch(/Amber at 2\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
});
it("replaces nostr links with external links", () => {
expect(result).toMatch(/Amber scheme link 1\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
expect(result).toMatch(/Amber scheme link 2\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
});
});
describe("for known usernames", () => {
it("replaces plain nostr:id URIs with a markdown link", () => {
expect(result).toMatch(/raucao scheme 1\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
expect(result).toMatch(/raucao scheme 2\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
});
it("replaces @id URIs with a markdown link", () => {
expect(result).toMatch(/raucao at 1\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
expect(result).toMatch(/raucao at 2\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
});
it("replaces scheme links with internal links", () => {
expect(result).toMatch(/raucao scheme link 1\: \[raucao\]\(\/@raucao\)/);
expect(result).toMatch(/raucao scheme link 2\: \[raucao\]\(\/@raucao\)/);
});
});
});
});

View File

@@ -1,2 +1,4 @@
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
accounts: b3e1b7c1660b7db0ecb93ec55c09e67961171a5c4e9e2602f1b47477ea61c50a
bitcoincore: 47750177bb6bb113784e4973f6b2e3dd27ef1eff227d6e38d0046d618969e41a
# jeffg: 1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef

View File

@@ -1,3 +0,0 @@
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
accounts: b3e1b7c1660b7db0ecb93ec55c09e67961171a5c4e9e2602f1b47477ea61c50a
raucao: 1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb

View File

@@ -1,54 +0,0 @@
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;
}
}