91 Commits

Author SHA1 Message Date
91eff4ad07 Add missing unbind
All checks were successful
CI / Test and lint (push) Successful in 24s
2025-05-21 16:34:12 +04:00
994053e080 Ignore test coverage results dir
All checks were successful
CI / Test and lint (push) Successful in 16s
2025-05-02 13:22:41 +04:00
bc37756097 Add exclude option to compile command
All checks were successful
CI / Test and lint (push) Successful in 18s
2025-05-02 13:09:11 +04:00
3a5733eeee Use nprofile for article author link 2025-05-02 13:04:15 +04:00
7c2549cbfe Add more link rel elements, use nprofile
All checks were successful
CI / Test and lint (push) Successful in 17s
2025-04-30 12:28:10 +04:00
e3bd385c96 Add rel=author Nostr links to article pages 2025-04-30 12:22:39 +04:00
038ce15908 Fix and complete author element in Atom feed 2025-04-30 12:22:16 +04:00
7aebcfc43f Add nprofile property to profile model 2025-04-30 12:21:51 +04:00
b7eccde9d0 Add alternate HTML links to feeds
Some checks failed
CI / Test and lint (push) Failing after 10m38s
See https://github.com/feedbin/feedbin/issues/747
2025-04-27 11:57:44 +04:00
9305e9f718 Add code highlighting
All checks were successful
CI / Test and lint (push) Successful in 19s
2025-04-26 18:28:40 +04:00
b907cc2f65 Remove redundant nostr address from details
All checks were successful
CI / Test and lint (push) Successful in 20s
It's already shown under the name, including the verification status
2025-04-26 17:44:56 +04:00
29e2fca2a5 Strict syntax in XML
All checks were successful
CI / Test and lint (push) Successful in 15s
2025-04-26 00:04:44 +04:00
d3e908b2b0 Add alternate links to Atom feeds
All checks were successful
CI / Test and lint (push) Successful in 15s
2025-04-25 11:58:26 +04:00
5608176a20 Add some margin to list items
All checks were successful
CI / Test and lint (push) Successful in 16s
2025-04-23 15:30:07 +04:00
fa21e72b3f Add alternative nostr links to HTML meta tags
All checks were successful
CI / Test and lint (push) Successful in 19s
2025-04-22 13:09:03 +04:00
5b0397268b Improve Nostr link handling
Some checks failed
CI / Test and lint (push) Failing after 10m22s
Fixes a bunch of problems with how Nostr links are created and replaced
in Markdown content
2025-04-21 16:53:22 +04:00
fb37db8583 Typing 2025-04-21 16:44:13 +04:00
07f881d543 Load test configs when running tests 2025-04-21 16:44:13 +04:00
3f9dad8f9a Don't link nrelay in content
All checks were successful
CI / Test and lint (push) Successful in 19s
It's deprecated
2024-12-17 14:40:44 +04:00
275fb73896 Move executables to build/ dir
All checks were successful
CI / Test and lint (push) Successful in 17s
2024-12-09 18:58:17 +04:00
204b4f44c7 Merge pull request 'Store and serve local profile icons for user pages' (#10) from feature/icons into master
All checks were successful
CI / Test and lint (push) Successful in 18s
Reviewed-on: #10
2024-12-03 17:51:56 +00:00
8e802f314a Remove debug statements
All checks were successful
CI / Test and lint (push) Successful in 16s
CI / Test and lint (pull_request) Successful in 16s
2024-12-03 18:50:40 +01:00
f4d1ba897b Store and serve local profile icons for user pages
Shown e.g. in browser tabs and as RSS icon
2024-12-03 18:50:10 +01:00
7f975f2dbb Merge pull request 'Set up CI' (#9) from dev/3-ci into master
All checks were successful
CI / Test and lint (push) Successful in 15s
Reviewed-on: #9
2024-12-03 16:59:51 +00:00
5e1e249052 Rename workflow
All checks were successful
CI / Test and lint (push) Successful in 16s
CI / Test and lint (pull_request) Successful in 14s
2024-12-03 17:58:22 +01:00
b376bbd2aa Set up CI
All checks were successful
Test and lint / Test and lint (push) Successful in 17s
Test and lint / Test and lint (pull_request) Successful in 14s
closes #3
2024-12-03 17:56:22 +01:00
51ae16e57f Update dependencies 2024-12-03 17:30:23 +01:00
2ac3180c0f Support compiling static assets, add compile task 2024-12-03 17:25:19 +01:00
cea96e170d Don't include deleted or empty articles in list 2024-10-28 13:56:36 +01:00
5906655902 Format inline code blocks in article titles 2024-10-28 12:36:15 +01:00
5f38355d5c Link plain Nostr URIs
Not just explicit Markdown links
2024-10-28 12:28:00 +01:00
010eb3f291 Refactor article fetching
* Apply different limits to profile and feed
* Ensure the limit is the returned amount
* Pre-sort articles
2024-10-26 12:04:06 +02:00
0b1eca87b2 Increase limit for article list
Needs pagination
2024-10-26 11:32:31 +02:00
f1d6ddbc84 Crop profile images to square format for preview 2024-10-25 18:40:22 +02:00
49d5aa4487 Add author pubkey to feed entry IDs 2024-10-25 18:01:03 +02:00
4c68be19fe Verify nip05 address, display status 2024-10-25 17:55:19 +02:00
a6517c61a4 Add user addresses to profile page, hide details by default 2024-10-25 16:25:25 +02:00
2624f2cbf8 Small CSS fix 2024-10-25 15:19:35 +02:00
cb4a4e06c8 Fix feeds 2024-10-25 14:59:20 +02:00
5f5f024ae7 Refactor/fix error handling, add query timeouts 2024-10-25 14:25:41 +02:00
ec7c775e25 Add YakiHonne app link 2024-10-25 12:47:17 +02:00
28e9d14aae Log relays on startup 2024-10-25 12:47:17 +02:00
eadc40392a WIP Nostr links 2024-10-25 12:47:13 +02:00
6ec9f51d77 Fix code element font size for headlines 2024-10-25 01:09:12 +02:00
afcb99356c Refactor CSS a bit, use variables for shared colors 2024-10-24 23:00:23 +02:00
266913c369 Style hr elements 2024-10-24 22:59:57 +02:00
aec35d9eb3 Add published date to feed items 2024-10-24 16:13:14 +02:00
c1c9a37914 Fall back to plain profile picture if imagemagick is not installed 2024-10-24 16:09:36 +02:00
30a74acf3b Support older imagemagick command 2024-10-24 15:40:08 +02:00
b7974c8610 Improve config processing 2024-10-24 15:17:11 +02:00
0096f3cae3 Fix linter/transpiler errors, formatting 2024-10-24 15:00:46 +02:00
fc711c2194 Rename some things 2024-10-24 14:28:49 +02:00
fdd16d8236 Add OG and Twitter Card markup, generate OG profile images
closes #2
2024-10-24 14:19:37 +02:00
d5793d47ff Choose a fun default port number 2024-10-23 23:47:18 +02:00
32f39685a1 Fix search timing out on non-existing nostrKey
fixes #1
2024-10-23 22:38:30 +02:00
062ded9e6d Remove generated anchor links from feed content 2024-10-23 14:54:18 +02:00
ba80792cc4 Formatting 2024-10-23 14:02:38 +02:00
a9f13310ab Wrap atom content in CDATA tag 2024-10-23 14:01:54 +02:00
e921fb2d84 Add alternative default path for users config 2024-10-23 01:11:00 +02:00
52d56c387d Fix current path in compiled program 2024-10-23 00:31:13 +02:00
46ad9813eb Typing all the types 2024-10-23 00:27:20 +02:00
edaf5f5c71 Improve deno tasks 2024-10-22 19:42:02 +02:00
f15e845825 Config UX 2024-10-22 19:41:49 +02:00
453a0f14d3 Support multiple relays for fetching content 2024-10-22 19:41:38 +02:00
856b10358c Improve layout 2024-10-22 19:16:58 +02:00
fdf16227d3 Rename server task 2024-10-22 18:26:22 +02:00
a0d5cba339 WIP Typing fixes for compilation 2024-10-22 16:15:19 +02:00
be1b9f21ec Create user config sample, ignore actual config 2024-10-22 15:58:01 +02:00
c0a02295c1 Render article drafts when opened directly 2024-10-22 15:55:41 +02:00
fdb13bc65d Show display name for profile 2024-10-22 15:54:50 +02:00
a0f0b06ad2 Include home relay in generated naddr 2024-10-22 15:54:22 +02:00
ce469bc37f Improve code block styling 2024-10-22 15:53:54 +02:00
28520c59b9 Add lookups for static user list, make LDAP optional 2024-10-22 13:59:01 +02:00
ba7336b4ee Render error pages as HTML 2024-10-22 12:49:14 +02:00
ea58c1f60b Fix Atom feed route issue 2024-10-22 12:48:57 +02:00
3914ca25a1 Improve mobile layout, add dl lists, add npub to profile 2024-10-22 12:18:52 +02:00
4534147050 Improve open-with dropdown 2024-10-22 11:27:58 +02:00
96254b38be Add dropdown menu for opening article in other apps 2024-10-21 23:12:58 +02:00
ffd709d2f9 Use absolute URLs in Atom feed 2024-10-21 21:18:16 +02:00
74f7b89c15 Move CSS and fonts to separate static asset dir 2024-10-21 21:03:29 +02:00
48fcb7eac5 Remove obsolete log statements 2024-10-21 20:29:17 +02:00
849410bb64 Make listen port configurable 2024-10-21 20:22:51 +02:00
bab9b74090 Rename main.ts 2024-10-21 15:45:08 +02:00
fa98e90210 MVP 2024-10-21 15:43:58 +02:00
87792c5089 WIP 2024-10-21 13:27:33 +02:00
0471e05ef3 Fix lint errors 2024-10-21 00:30:34 +02:00
fe9db7509d Support pretty URLs for articles, redirect from naddr 2024-10-21 00:30:30 +02:00
18f3e888a6 Show list of linked articles on profile page 2024-10-21 00:06:15 +02:00
e14adffbc8 Allow old-school username routes with ~ 2024-10-21 00:05:50 +02:00
0e5b4b1807 Redirect npub and nprofile to username if exists 2024-10-20 23:05:45 +02:00
9a19f7249c WIP Resolve username via npub, LDAP 2024-10-20 22:52:47 +02:00
53 changed files with 2811 additions and 277 deletions

5
.env.sample Normal file
View 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

1
.env.test Normal file
View File

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

18
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,18 @@
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"

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
users.yaml
build/
coverage/

View 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;
}

245
assets/css/layout.css Normal file
View File

@@ -0,0 +1,245 @@
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;
}
li {
margin-bottom: 0.8em;
}
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;
}
.highlight pre {
font-size: 0.9em;
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;
}
.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;
}
}

140
assets/css/prism.css Normal file
View File

@@ -0,0 +1,140 @@
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
code[class*="language-"]::selection, code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
/* This background color was intended by the author of this theme. */
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View File

@@ -0,0 +1,129 @@
@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);
}
.highlight pre,
.highlight code {
background-color: var(--background-color-body);
border: 2px solid #e8e3da;
color: var(--text-color-body);
}
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);
}
.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);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

108
config.ts Normal file
View File

@@ -0,0 +1,108 @@
import { load } from "@std/dotenv";
import { parse as parseYaml } from "jsr:@std/yaml";
import { checkFileExists } from "./utils.ts";
import { log } from "./log.ts";
const denoEnv = Deno.env.get("DENO_ENV");
const dirname = Deno.cwd();
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;
}
}
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",
prism: {
// TODO make configurable via ENV
// Supported languages: https://app.unpkg.com/prismjs@1.29.0/files/components
extraLanguages: [
"bash",
"markdown",
"typescript"
]
}
};
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
View 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();
}

View File

@@ -1,12 +1,24 @@
{
"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",
"compile": "deno compile --allow-all --include ./assets/ --exclude ./tests/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts",
"test": "DENO_ENV=test deno test --allow-read --allow-env"
},
"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"
"@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",
"@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/yaml": "jsr:@std/yaml@^1.0.5",
"ldapts": "npm:ldapts@^7.2.2"
},
"fmt": {
"exclude": [
"magick.ts"
]
}
}

313
deno.lock generated
View File

@@ -1,50 +1,68 @@
{
"version": "4",
"specifiers": {
"jsr:@deno/gfm@0.9": "0.9.0",
"jsr:@deno/gfm@0.10": "0.10.0",
"jsr:@denosaurs/emoji@0.3": "0.3.1",
"jsr:@nostr/tools@^2.3.1": "2.3.1",
"jsr:@nostrify/nostrify@~0.36.1": "0.36.1",
"jsr:@nostr/tools@^2.10.4": "2.12.0",
"jsr:@nostrify/nostrify@~0.36.2": "0.36.2",
"jsr:@nostrify/types@0.35": "0.35.0",
"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/bytes@1": "1.0.2",
"jsr:@std/bytes@^1.0.2": "1.0.2",
"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:@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/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/http@1": "1.0.8",
"jsr:@std/internal@^1.0.4": "1.0.4",
"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/io@0.224": "0.224.9",
"jsr:@std/media-types@1": "1.0.3",
"jsr:@std/path@1": "1.0.6",
"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/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/bip32@^1.4.0": "1.4.0",
"npm:@scure/bip39@^1.3.0": "1.3.0",
"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:github-slugger@2": "2.0.0",
"npm:he@^1.2.0": "1.2.0",
"npm:katex@0.16": "0.16.11",
"npm:lru-cache@^10.2.0": "10.2.2",
"npm:marked-alert@2": "2.1.0_marked@12.0.2",
"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: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.7.0",
"npm:path-to-regexp@*": "6.2.1",
"npm:nostr-tools@^2.7.0": "2.10.4",
"npm:nostr-wasm@0.1.0": "0.1.0",
"npm:path-to-regexp@6.2.1": "6.2.1",
"npm:prismjs@1.29.0": "1.29.0",
"npm:prismjs@^1.29.0": "1.29.0",
"npm:sanitize-html@^2.11.0": "2.13.1",
"npm:sanitize-html@^2.13.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.9.0": {
"integrity": "9002dbdb6e382e247509edfeae3afdb9232f5ca98a8210ef186d42084e9ded30",
"@deno/gfm@0.10.0": {
"integrity": "51708205e3559a4aeb6afb29d07c5bfafe7941f91bb360351ef6621de9a39527",
"dependencies": [
"jsr:@denosaurs/emoji",
"npm:github-slugger",
@@ -54,29 +72,43 @@
"npm:marked-alert",
"npm:marked-footnote",
"npm:marked-gfm-heading-id",
"npm:prismjs",
"npm:prismjs@^1.29.0",
"npm:sanitize-html"
]
},
"@denosaurs/emoji@0.3.1": {
"integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b"
},
"@nostr/tools@2.3.1": {
"integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61",
"@nostr/tools@2.10.4": {
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
"dependencies": [
"npm:@noble/ciphers",
"npm:@noble/curves",
"npm:@noble/hashes",
"npm:@scure/base"
"npm:@scure/base@1.1.1",
"npm:@scure/bip32@1.3.1",
"npm:@scure/bip39@1.2.1",
"npm:nostr-wasm"
]
},
"@nostrify/nostrify@0.36.1": {
"integrity": "f76c803c0bda5df1c172f25d2313980344b0431df2a973ab3e1dd61e9e7b4b1a",
"@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",
"dependencies": [
"jsr:@nostrify/types",
"jsr:@std/crypto@0.224",
"jsr:@std/encoding@~0.224.1",
"npm:@scure/bip32",
"npm:@scure/bip39",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0",
"npm:lru-cache",
"npm:nostr-tools",
"npm:websocket-ts",
@@ -89,54 +121,93 @@
"@oak/commons@1.0.0": {
"integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto",
"jsr:@std/crypto@1",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.1.0": {
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
"@oak/oak@17.1.3": {
"integrity": "d89296c22db91681dd3a2a1e1fd14e258d0d5a9654de55637aee5b661c159f33",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert",
"jsr:@std/assert@1",
"jsr:@std/bytes@1",
"jsr:@std/crypto",
"jsr:@std/crypto@1",
"jsr:@std/http",
"jsr:@std/io",
"jsr:@std/media-types",
"jsr:@std/path",
"npm:path-to-regexp@6.2.1"
"jsr:@std/path@1",
"npm:path-to-regexp"
]
},
"@std/assert@1.0.6": {
"integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207",
"@std/assert@0.224.0": {
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
},
"@std/assert@1.0.8": {
"integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b",
"dependencies": [
"jsr:@std/internal"
"jsr:@std/internal@^1.0.5"
]
},
"@std/bytes@1.0.2": {
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
"@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/crypto@1.0.3": {
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
},
"@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/http@1.0.8": {
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
"@std/expect@1.0.8": {
"integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f",
"dependencies": [
"jsr:@std/assert@^1.0.8",
"jsr:@std/internal@^1.0.5"
]
},
"@std/expect@1.0.15": {
"integrity": "eca360007b5a7f13dbfa1294224baee7fb98dcd460d8461fe64eeae302902945",
"dependencies": [
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
]
},
"@std/http@1.0.10": {
"integrity": "4e32d11493ab04e3ef09f104f0cb9beb4228b1d4b47c5469573c2c294c0d3692",
"dependencies": [
"jsr:@std/encoding@^1.0.5"
]
},
"@std/internal@1.0.4": {
"integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422"
"@std/internal@1.0.5": {
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
},
"@std/internal@1.0.6": {
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
},
"@std/io@0.224.9": {
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
@@ -144,11 +215,21 @@
"jsr:@std/bytes@^1.0.2"
]
},
"@std/media-types@1.0.3": {
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.0.6": {
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
},
"@std/testing@1.0.11": {
"integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48",
"dependencies": [
"jsr:@std/assert@^1.0.12",
"jsr:@std/internal@^1.0.6"
]
},
"@std/yaml@1.0.5": {
"integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742"
}
},
"npm": {
@@ -167,10 +248,10 @@
"@noble/hashes@1.3.2"
]
},
"@noble/curves@1.4.0": {
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
"@noble/curves@1.7.0": {
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
"dependencies": [
"@noble/hashes@1.4.0"
"@noble/hashes@1.6.0"
]
},
"@noble/hashes@1.3.1": {
@@ -179,48 +260,75 @@
"@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
},
"@noble/hashes@1.4.0": {
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
"@noble/hashes@1.6.0": {
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="
},
"@noble/hashes@1.6.1": {
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="
},
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.1.7": {
"integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g=="
"@scure/base@1.2.1": {
"integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.7"
"@scure/base@1.1.1"
]
},
"@scure/bip32@1.4.0": {
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
"@scure/bip32@1.6.0": {
"integrity": "sha512-82q1QfklrUUdXJzjuRU7iG7D7XiFx5PHYVS0+oeNKhyDLT7WPqs6pBcM2W5ZdwOwKCwoE1Vy1se+DHjcXwCYnA==",
"dependencies": [
"@noble/curves@1.4.0",
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
"@noble/curves@1.7.0",
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.7"
"@scure/base@1.1.1"
]
},
"@scure/bip39@1.3.0": {
"integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==",
"@scure/bip39@1.5.0": {
"integrity": "sha512-Dop+ASYhnrwm9+HA/HwXg7j2ZqM6yk2fyLWb5znexjctFY3+E+eU8cIWI0Pql0Qx4hPZCijlGq4OL71g+Uz30A==",
"dependencies": [
"@noble/hashes@1.4.0",
"@scure/base@1.1.7"
"@noble/hashes@1.6.1",
"@scure/base@1.2.1"
]
},
"@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,11 +387,22 @@
"commander"
]
},
"lru-cache@10.2.2": {
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
"ldapts@7.2.2": {
"integrity": "sha512-UotAq24/vJEz0m3w/jgwZm7JGNw8M6vexL/5KU5pe3aIZWBkT/HRhjsPw/buRqKSK5Y0vTu5Zv8iyPgQF7ozzg==",
"dependencies": [
"@types/asn1",
"asn1",
"debug",
"strict-event-emitter-types",
"uuid",
"whatwg-url"
]
},
"marked-alert@2.1.0_marked@12.0.2": {
"integrity": "sha512-X95Z8PCDgWa0bBfM70GxZG3LD/leUrhXc3cx3w1eFExBhswd1oXn/S4S+9H8ypPdCY7okREb4dItUOc+VJq4jQ==",
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"marked-alert@2.1.2_marked@12.0.2": {
"integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==",
"dependencies": [
"marked"
]
@@ -304,11 +423,14 @@
"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=="
},
"nostr-tools@2.7.0": {
"integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==",
"nostr-tools@2.10.4": {
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
@@ -342,6 +464,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 +484,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@11.0.3": {
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg=="
},
"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=="
}
@@ -377,11 +530,15 @@
},
"workspace": {
"dependencies": [
"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/assert@1"
"jsr:@deno/gfm@0.10",
"jsr:@nostr/tools@^2.10.4",
"jsr:@nostrify/nostrify@~0.36.2",
"jsr:@oak/oak@^17.1.3",
"jsr:@std/dotenv@~0.225.2",
"jsr:@std/expect@^1.0.8",
"jsr:@std/testing@^1.0.11",
"jsr:@std/yaml@^1.0.5",
"npm:ldapts@^7.2.2"
]
}
}

34
directory.ts Normal file
View File

@@ -0,0 +1,34 @@
// 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> {
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 async function lookupPubkeyByUsername(username: string): Promise<string | undefined> {
const pubkey = config.staticUsers[username];
if (pubkey) {
return pubkey;
} else {
if (config.ldapEnabled) {
return ldapLookupPubkey(username);
}
}
}

60
feeds.ts Normal file
View File

@@ -0,0 +1,60 @@
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}" />
<link rel="alternate" type="text/html" href="${article.url}" />
<link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" />
<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>
<link rel="alternate" type="text/html" href="${profile.profileUrl}" />
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.npub}" />
<id>${feedId}</id>
<updated>${isoDate(lastUpdate)}</updated>
<icon>${profile.avatarImageUrl}</icon>
<author>
<name>${profile.name}</name>
<uri>${profile.profileUrl}</uri>
<nostr:uri>nostr:${profile.nprofile}</nostr>
</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
View File

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

View File

@@ -1,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);
}
};

View File

@@ -1,10 +1,26 @@
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;
} 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
View 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;

View 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
View 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);
await generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};
export default userEventHandler;

32
handlers/user-profile.ts Normal file
View File

@@ -0,0 +1,32 @@
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);
const html = await profilePageHtml(profile, articles);
await generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
};
export default userProfileHandler;

341
html.ts
View File

@@ -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,10 @@ 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/prism.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
</head>
<body>
${body}
@@ -114,32 +29,220 @@ 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 });
}
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">
<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>
${draftLabel}
<h1>${titleHtml(article.title)}</h1>
<div class="meta">
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
<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>
${openWithNostrAppHtml(article.naddr)}
</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.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.avatarImageUrl}" 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">🔗&nbsp; 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 `
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
<link rel="me" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name}">
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name} on Nostr">
<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">
<link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" title="This article on Nostr">
<link rel="author" type="text/html" href="${profile.profileUrl}" title="${profile.name}">
<link rel="author" type="application/nostr+json" href="nostr:${profile.nprofile}" title="${profile.name}">
<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}">
`;
}

60
ldap.ts Normal file
View File

@@ -0,0 +1,60 @@
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): Promise<string | undefined> {
let pubkey: string | undefined;
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.toString();
}
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;
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.toString();
}
await client.unbind();
} catch (e) {
await client.unbind();
throw e;
}
return username;
}

111
magick.ts Normal file
View File

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

30
main.ts
View File

@@ -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}`)

View File

@@ -1,6 +0,0 @@
import { assertEquals } from "@std/assert";
// import { add } from "./main.ts";
// Deno.test(function addTest() {
// assertEquals(add(2, 3), 5);
// });

78
models/article.ts Normal file
View File

@@ -0,0 +1,78 @@
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 config from "../config.ts";
for (const language of config.prism.extraLanguages) {
await import(`npm:prismjs@1.29.0/components/prism-${language}.js`);
}
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 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);
}
}

96
models/profile.ts Normal file
View File

@@ -0,0 +1,96 @@
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 nprofile(): string {
return nip19.nprofileEncode({
pubkey: this.pubkey,
relays: [config.relay_urls[0]],
});
}
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,24 +1,96 @@
import { NRelay1 } from "@nostrify/nostrify";
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
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([{
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 async function fetchArticlesByAuthor(
pubkey: string,
limit: number = 10,
) {
const events = await fetchWithTimeout([{
authors: [pubkey],
kinds: [30023],
limit: limit,
}]) 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 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;
}
}

129
nostr/links.ts Normal file
View File

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

140
prism.css Normal file
View File

@@ -0,0 +1,140 @@
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
code[class*="language-"]::selection, code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
/* This background color was intended by the author of this theme. */
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

120
server.ts Normal file
View File

@@ -0,0 +1,120 @@
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("~")) {
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 = `${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);
}
}
});
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
View 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
View File

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

29
tests/fixtures/article-1.json vendored Normal file
View 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![Screenshot of an accelerated transaction on mempool.space](https://image.nostr.build/76151cc2ae06a93a8fcd97102bf4fa63541f8f3bd19800b96ff1070c9450945c.png)\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"]
]
}

41
tests/fixtures/article-2.md vendored Normal file
View File

@@ -0,0 +1,41 @@
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`

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

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

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

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

9
tests/fixtures/profile-1.json vendored Normal file
View 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": []
}

View File

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

View File

@@ -0,0 +1,51 @@
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",
);
});
});
describe("#nprofile", () => {
it("returns a bech32 profile ID", () => {
expect(profile.nprofile).toMatch(
/^nprofile1qyt8wumn8ghj7mn0wd68ytntdaek6mmn9ehhyecqyq0hjpvvw73zfed7yf/,
);
});
});
});

62
tests/nostr/links_test.ts Normal file
View File

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

2
users.yaml.sample Normal file
View File

@@ -0,0 +1,2 @@
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
bitcoincore: 47750177bb6bb113784e4973f6b2e3dd27ef1eff227d6e38d0046d618969e41a

3
users.yaml.test Normal file
View File

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

54
utils.ts Normal file
View File

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