Compare commits
77 Commits
74f7b89c15
...
v1.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
bc37756097
|
|||
|
3a5733eeee
|
|||
|
7c2549cbfe
|
|||
|
e3bd385c96
|
|||
|
038ce15908
|
|||
|
7aebcfc43f
|
|||
|
b7eccde9d0
|
|||
|
9305e9f718
|
|||
|
b907cc2f65
|
|||
|
29e2fca2a5
|
|||
|
d3e908b2b0
|
|||
|
5608176a20
|
|||
|
fa21e72b3f
|
|||
|
5b0397268b
|
|||
|
fb37db8583
|
|||
|
07f881d543
|
|||
|
3f9dad8f9a
|
|||
|
275fb73896
|
|||
| 204b4f44c7 | |||
|
8e802f314a
|
|||
|
f4d1ba897b
|
|||
| 7f975f2dbb | |||
|
5e1e249052
|
|||
|
b376bbd2aa
|
|||
|
51ae16e57f
|
|||
|
2ac3180c0f
|
|||
|
cea96e170d
|
|||
|
5906655902
|
|||
|
5f38355d5c
|
|||
|
010eb3f291
|
|||
|
0b1eca87b2
|
|||
|
f1d6ddbc84
|
|||
|
49d5aa4487
|
|||
|
4c68be19fe
|
|||
|
a6517c61a4
|
|||
|
2624f2cbf8
|
|||
|
cb4a4e06c8
|
|||
|
5f5f024ae7
|
|||
|
ec7c775e25
|
|||
|
28e9d14aae
|
|||
|
eadc40392a
|
|||
|
6ec9f51d77
|
|||
|
afcb99356c
|
|||
|
266913c369
|
|||
|
aec35d9eb3
|
|||
|
c1c9a37914
|
|||
|
30a74acf3b
|
|||
|
b7974c8610
|
|||
|
0096f3cae3
|
|||
|
fc711c2194
|
|||
|
fdd16d8236
|
|||
|
d5793d47ff
|
|||
|
32f39685a1
|
|||
|
062ded9e6d
|
|||
|
ba80792cc4
|
|||
|
a9f13310ab
|
|||
|
e921fb2d84
|
|||
|
52d56c387d
|
|||
|
46ad9813eb
|
|||
|
edaf5f5c71
|
|||
|
f15e845825
|
|||
|
453a0f14d3
|
|||
|
856b10358c
|
|||
|
fdf16227d3
|
|||
|
a0d5cba339
|
|||
|
be1b9f21ec
|
|||
|
c0a02295c1
|
|||
|
fdb13bc65d
|
|||
|
a0f0b06ad2
|
|||
|
ce469bc37f
|
|||
|
28520c59b9
|
|||
|
ba7336b4ee
|
|||
|
ea58c1f60b
|
|||
|
3914ca25a1
|
|||
|
4534147050
|
|||
|
96254b38be
|
|||
|
ffd709d2f9
|
@@ -1,4 +1,4 @@
|
|||||||
HOME_RELAY_URL=wss://nostr.kosmos.org
|
RELAY_URLS=wss://nostr.kosmos.org,wss://nostr.x0f.org
|
||||||
LDAP_URL=ldap://10.1.1.116:389
|
LDAP_URL=ldap://10.1.1.116:389
|
||||||
LDAP_BIND_DN=uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org
|
LDAP_BIND_DN=uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org
|
||||||
LDAP_PASSWORD=123456abcdef
|
LDAP_PASSWORD=123456abcdef
|
||||||
|
|||||||
18
.gitea/workflows/ci.yml
Normal file
18
.gitea/workflows/ci.yml
Normal 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"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
users.yaml
|
||||||
|
build/
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
@import url("/assets/css/fonts/merriweather.css");
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-size: 18px;
|
font-size: 1.125rem;
|
||||||
font-family: "Merriweather", serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.avatar {
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-page img.avatar {
|
|
||||||
height: 128px;
|
|
||||||
width: 128px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 2em 0 0 0;
|
margin: 4rem 0 1.6rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h3, h4 {
|
h2, h3, h4 {
|
||||||
@@ -30,29 +16,68 @@ h2, h3, h4 {
|
|||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
p, pre, ul, ol, blockquote {
|
p, pre, ul, ol, dl, blockquote, hr {
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
margin-bottom: 1.6em;
|
margin-bottom: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
a.anchor {
|
a.anchor {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-size: 1rem;
|
font-size: 0.9em;
|
||||||
padding: 0.1em 0.3em;
|
padding: 0.1em 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.6rem 1rem;
|
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 {
|
main {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 728px;
|
max-width: 44rem;
|
||||||
margin: 12rem auto;
|
margin: 12rem auto 24rem auto;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
main header {
|
main header {
|
||||||
@@ -60,30 +85,161 @@ main header {
|
|||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main header h1 {
|
main header .draft-label {
|
||||||
margin-bottom: 1.6rem;
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.meta {
|
main header .meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.meta .content {
|
main header .meta .content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.6rem;
|
line-height: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.meta .name a {
|
main header .meta .name a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-list .item {
|
main.profile-page {
|
||||||
margin-bottom: 3rem;
|
margin-top: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-list .item h3 {
|
main.profile-page header {
|
||||||
margin-bottom: 1rem;
|
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
140
assets/css/prism.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
|
@import url("/assets/css/fonts/merriweather.css");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-family: "Merriweather", serif;
|
||||||
|
--background-color-body: #f5f2eb;
|
||||||
|
--background-color-dark: #333;
|
||||||
|
--text-color-body: #3b3a38;
|
||||||
|
--text-color-headings: #191818;
|
||||||
|
--text-color-discreet: #888;
|
||||||
|
--text-color-dark-bg: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #f5f2eb;
|
font-family: var(--font-family);
|
||||||
color: #3b3a38;
|
background-color: var(--background-color-body);
|
||||||
|
color: var(--text-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
color: #191818;
|
color: var(--text-color-headings);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -15,20 +28,102 @@ a:visited {
|
|||||||
color: #3b0277;
|
color: #3b0277;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background-color: #e8e3da;
|
background-color: #e8e3da;
|
||||||
color: #027739;
|
color: #027739;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: var(--background-color-dark);
|
||||||
|
color: var(--text-color-dark-bg);
|
||||||
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
background-color: #333;
|
background-color: var(--background-color-dark);
|
||||||
color: #ccc;
|
color: var(--text-color-dark-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.meta .date {
|
.highlight pre,
|
||||||
color: #888;
|
.highlight code {
|
||||||
|
background-color: var(--background-color-body);
|
||||||
|
border: 2px solid #e8e3da;
|
||||||
|
color: var(--text-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.meta .name a {
|
dl dt {
|
||||||
color: #3b3a38;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
99
config.ts
99
config.ts
@@ -1,17 +1,108 @@
|
|||||||
import { load } from "@std/dotenv";
|
import { load } from "@std/dotenv";
|
||||||
|
import { parse as parseYaml } from "jsr:@std/yaml";
|
||||||
|
import { checkFileExists } from "./utils.ts";
|
||||||
|
import { log } from "./log.ts";
|
||||||
|
|
||||||
const dirname = new URL(".", import.meta.url).pathname;
|
const denoEnv = Deno.env.get("DENO_ENV");
|
||||||
await load({ envPath: `${dirname}/.env`, export: true });
|
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 = {
|
const config = {
|
||||||
port: Deno.env.get("PORT") || 8000,
|
port: parseInt(Deno.env.get("PORT") || "30023"),
|
||||||
home_relay_url: Deno.env.get("HOME_RELAY_URL") || "",
|
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: {
|
ldap: {
|
||||||
url: Deno.env.get("LDAP_URL"),
|
url: Deno.env.get("LDAP_URL"),
|
||||||
bindDN: Deno.env.get("LDAP_BIND_DN"),
|
bindDN: Deno.env.get("LDAP_BIND_DN"),
|
||||||
password: Deno.env.get("LDAP_PASSWORD"),
|
password: Deno.env.get("LDAP_PASSWORD"),
|
||||||
searchDN: Deno.env.get("LDAP_SEARCH_DN"),
|
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;
|
export default config;
|
||||||
|
|||||||
25
deno.json
25
deno.json
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --allow-net --allow-read --allow-env --watch server.ts"
|
"dev": "deno run --allow-all --watch server.ts",
|
||||||
|
"server": "deno run --allow-all server.ts",
|
||||||
|
"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": {
|
"imports": {
|
||||||
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",
|
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
|
||||||
"@nostr/tools": "jsr:@nostr/tools@^2.3.1",
|
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
|
||||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.1",
|
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.2",
|
||||||
"@oak/oak": "jsr:@oak/oak@^17.1.0",
|
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
|
"@std/dotenv": "jsr:@std/dotenv@^0.225.2",
|
||||||
"@std/expect": "jsr:@std/expect@^1.0.5",
|
"@std/expect": "jsr:@std/expect@^1.0.8",
|
||||||
"@std/testing": "jsr:@std/testing@^1.0.3",
|
"@std/testing": "jsr:@std/testing@^1.0.11",
|
||||||
"ldapts": "npm:ldapts@^7.2.1"
|
"@std/yaml": "jsr:@std/yaml@^1.0.5",
|
||||||
|
"ldapts": "npm:ldapts@^7.2.2"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"exclude": [
|
||||||
|
"magick.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
260
deno.lock
generated
260
deno.lock
generated
@@ -1,58 +1,68 @@
|
|||||||
{
|
{
|
||||||
"version": "4",
|
"version": "4",
|
||||||
"specifiers": {
|
"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:@denosaurs/emoji@0.3": "0.3.1",
|
||||||
"jsr:@nostr/tools@^2.3.1": "2.3.1",
|
"jsr:@nostr/tools@^2.10.4": "2.12.0",
|
||||||
"jsr:@nostrify/nostrify@~0.36.1": "0.36.1",
|
"jsr:@nostrify/nostrify@~0.36.2": "0.36.2",
|
||||||
"jsr:@nostrify/types@0.35": "0.35.0",
|
"jsr:@nostrify/types@0.35": "0.35.0",
|
||||||
"jsr:@oak/commons@1": "1.0.0",
|
"jsr:@oak/commons@1": "1.0.0",
|
||||||
"jsr:@oak/oak@^17.1.0": "17.1.0",
|
"jsr:@oak/oak@^17.1.3": "17.1.3",
|
||||||
"jsr:@std/assert@1": "1.0.6",
|
"jsr:@std/assert@0.224": "0.224.0",
|
||||||
"jsr:@std/assert@^1.0.6": "1.0.6",
|
"jsr:@std/assert@1": "1.0.8",
|
||||||
"jsr:@std/bytes@1": "1.0.2",
|
"jsr:@std/assert@^1.0.12": "1.0.12",
|
||||||
"jsr:@std/bytes@^1.0.2": "1.0.2",
|
"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/crypto@1": "1.0.3",
|
||||||
"jsr:@std/data-structures@^1.0.4": "1.0.4",
|
"jsr:@std/dotenv@~0.225.2": "0.225.3",
|
||||||
"jsr:@std/dotenv@~0.225.2": "0.225.2",
|
"jsr:@std/encoding@0.224": "0.224.3",
|
||||||
"jsr:@std/encoding@1": "1.0.5",
|
"jsr:@std/encoding@1": "1.0.5",
|
||||||
"jsr:@std/encoding@^1.0.5": "1.0.5",
|
"jsr:@std/encoding@^1.0.5": "1.0.5",
|
||||||
"jsr:@std/encoding@~0.224.1": "0.224.3",
|
"jsr:@std/encoding@~0.224.1": "0.224.3",
|
||||||
"jsr:@std/expect@^1.0.5": "1.0.5",
|
"jsr:@std/expect@^1.0.8": "1.0.15",
|
||||||
"jsr:@std/fs@^1.0.4": "1.0.4",
|
"jsr:@std/http@1": "1.0.10",
|
||||||
"jsr:@std/http@1": "1.0.8",
|
"jsr:@std/internal@^1.0.5": "1.0.5",
|
||||||
"jsr:@std/internal@^1.0.4": "1.0.4",
|
"jsr:@std/internal@^1.0.6": "1.0.6",
|
||||||
"jsr:@std/io@0.224": "0.224.9",
|
"jsr:@std/io@0.224": "0.224.9",
|
||||||
"jsr:@std/media-types@1": "1.0.3",
|
"jsr:@std/media-types@1": "1.1.0",
|
||||||
"jsr:@std/path@1": "1.0.6",
|
"jsr:@std/path@1": "1.0.8",
|
||||||
"jsr:@std/path@^1.0.6": "1.0.6",
|
"jsr:@std/path@^1.0.7": "1.0.8",
|
||||||
"jsr:@std/testing@^1.0.3": "1.0.3",
|
"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/ciphers@~0.5.1": "0.5.3",
|
||||||
"npm:@noble/curves@1.2.0": "1.2.0",
|
"npm:@noble/curves@1.2.0": "1.2.0",
|
||||||
"npm:@noble/hashes@1.3.1": "1.3.1",
|
"npm:@noble/hashes@1.3.1": "1.3.1",
|
||||||
"npm:@scure/base@1.1.1": "1.1.1",
|
"npm:@scure/base@1.1.1": "1.1.1",
|
||||||
"npm:@scure/bip32@^1.4.0": "1.4.0",
|
"npm:@scure/base@^1.1.6": "1.2.1",
|
||||||
"npm:@scure/bip39@^1.3.0": "1.3.0",
|
"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:github-slugger@2": "2.0.0",
|
||||||
"npm:he@^1.2.0": "1.2.0",
|
"npm:he@^1.2.0": "1.2.0",
|
||||||
"npm:katex@0.16": "0.16.11",
|
"npm:katex@0.16": "0.16.11",
|
||||||
"npm:ldapts@^7.2.1": "7.2.1",
|
"npm:ldapts@^7.2.2": "7.2.2",
|
||||||
"npm:lru-cache@^10.2.0": "10.2.2",
|
"npm:lru-cache@^10.2.0": "10.4.3",
|
||||||
"npm:marked-alert@2": "2.1.0_marked@12.0.2",
|
"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-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-gfm-heading-id@^3.1.0": "3.2.0_marked@12.0.2",
|
||||||
"npm:marked@12": "12.0.2",
|
"npm:marked@12": "12.0.2",
|
||||||
"npm:nostr-tools@^2.7.0": "2.7.0",
|
"npm:nostr-tools@^2.7.0": "2.10.4",
|
||||||
"npm:path-to-regexp@*": "6.2.1",
|
"npm:nostr-wasm@0.1.0": "0.1.0",
|
||||||
"npm:path-to-regexp@6.2.1": "6.2.1",
|
"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: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:websocket-ts@^2.1.5": "2.1.5",
|
||||||
"npm:zod@^3.23.8": "3.23.8"
|
"npm:zod@^3.23.8": "3.23.8"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
"@deno/gfm@0.9.0": {
|
"@deno/gfm@0.10.0": {
|
||||||
"integrity": "9002dbdb6e382e247509edfeae3afdb9232f5ca98a8210ef186d42084e9ded30",
|
"integrity": "51708205e3559a4aeb6afb29d07c5bfafe7941f91bb360351ef6621de9a39527",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@denosaurs/emoji",
|
"jsr:@denosaurs/emoji",
|
||||||
"npm:github-slugger",
|
"npm:github-slugger",
|
||||||
@@ -62,29 +72,43 @@
|
|||||||
"npm:marked-alert",
|
"npm:marked-alert",
|
||||||
"npm:marked-footnote",
|
"npm:marked-footnote",
|
||||||
"npm:marked-gfm-heading-id",
|
"npm:marked-gfm-heading-id",
|
||||||
"npm:prismjs",
|
"npm:prismjs@^1.29.0",
|
||||||
"npm:sanitize-html"
|
"npm:sanitize-html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@denosaurs/emoji@0.3.1": {
|
"@denosaurs/emoji@0.3.1": {
|
||||||
"integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b"
|
"integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b"
|
||||||
},
|
},
|
||||||
"@nostr/tools@2.3.1": {
|
"@nostr/tools@2.10.4": {
|
||||||
"integrity": "af01dc45cb28784c584d7a0699707196f397bcc53946efa582a01b11ddde4d61",
|
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@noble/ciphers",
|
"npm:@noble/ciphers",
|
||||||
"npm:@noble/curves",
|
"npm:@noble/curves",
|
||||||
"npm:@noble/hashes",
|
"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": {
|
"@nostr/tools@2.12.0": {
|
||||||
"integrity": "f76c803c0bda5df1c172f25d2313980344b0431df2a973ab3e1dd61e9e7b4b1a",
|
"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": [
|
"dependencies": [
|
||||||
"jsr:@nostrify/types",
|
"jsr:@nostrify/types",
|
||||||
|
"jsr:@std/crypto@0.224",
|
||||||
"jsr:@std/encoding@~0.224.1",
|
"jsr:@std/encoding@~0.224.1",
|
||||||
"npm:@scure/bip32",
|
"npm:@scure/base@^1.1.6",
|
||||||
"npm:@scure/bip39",
|
"npm:@scure/bip32@^1.4.0",
|
||||||
|
"npm:@scure/bip39@^1.3.0",
|
||||||
"npm:lru-cache",
|
"npm:lru-cache",
|
||||||
"npm:nostr-tools",
|
"npm:nostr-tools",
|
||||||
"npm:websocket-ts",
|
"npm:websocket-ts",
|
||||||
@@ -99,71 +123,91 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/assert@1",
|
"jsr:@std/assert@1",
|
||||||
"jsr:@std/bytes@1",
|
"jsr:@std/bytes@1",
|
||||||
"jsr:@std/crypto",
|
"jsr:@std/crypto@1",
|
||||||
"jsr:@std/encoding@1",
|
"jsr:@std/encoding@1",
|
||||||
"jsr:@std/http",
|
"jsr:@std/http",
|
||||||
"jsr:@std/media-types"
|
"jsr:@std/media-types"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@oak/oak@17.1.0": {
|
"@oak/oak@17.1.3": {
|
||||||
"integrity": "14ffb400c3c268bdc7b3a838664fab782b4ed35bb0dfe7669013c95bb12a9503",
|
"integrity": "d89296c22db91681dd3a2a1e1fd14e258d0d5a9654de55637aee5b661c159f33",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@oak/commons",
|
"jsr:@oak/commons",
|
||||||
"jsr:@std/assert@1",
|
"jsr:@std/assert@1",
|
||||||
"jsr:@std/bytes@1",
|
"jsr:@std/bytes@1",
|
||||||
"jsr:@std/crypto",
|
"jsr:@std/crypto@1",
|
||||||
"jsr:@std/http",
|
"jsr:@std/http",
|
||||||
"jsr:@std/io",
|
"jsr:@std/io",
|
||||||
"jsr:@std/media-types",
|
"jsr:@std/media-types",
|
||||||
"jsr:@std/path@1",
|
"jsr:@std/path@1",
|
||||||
"npm:path-to-regexp@6.2.1"
|
"npm:path-to-regexp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/assert@1.0.6": {
|
"@std/assert@0.224.0": {
|
||||||
"integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207",
|
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
|
||||||
|
},
|
||||||
|
"@std/assert@1.0.8": {
|
||||||
|
"integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/internal"
|
"jsr:@std/internal@^1.0.5"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/bytes@1.0.2": {
|
"@std/assert@1.0.12": {
|
||||||
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
|
"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": {
|
"@std/crypto@1.0.3": {
|
||||||
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
||||||
},
|
},
|
||||||
"@std/data-structures@1.0.4": {
|
|
||||||
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
|
|
||||||
},
|
|
||||||
"@std/dotenv@0.225.2": {
|
"@std/dotenv@0.225.2": {
|
||||||
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
|
"integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23"
|
||||||
},
|
},
|
||||||
|
"@std/dotenv@0.225.3": {
|
||||||
|
"integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a"
|
||||||
|
},
|
||||||
"@std/encoding@0.224.3": {
|
"@std/encoding@0.224.3": {
|
||||||
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
||||||
},
|
},
|
||||||
"@std/encoding@1.0.5": {
|
"@std/encoding@1.0.5": {
|
||||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||||
},
|
},
|
||||||
"@std/expect@1.0.5": {
|
"@std/expect@1.0.8": {
|
||||||
"integrity": "8c7ac797e2ffe57becc6399c0f2fd06230cb9ef124d45229c6e592c563824af1",
|
"integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/assert@^1.0.6",
|
"jsr:@std/assert@^1.0.8",
|
||||||
"jsr:@std/internal"
|
"jsr:@std/internal@^1.0.5"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/fs@1.0.4": {
|
"@std/expect@1.0.15": {
|
||||||
"integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c",
|
"integrity": "eca360007b5a7f13dbfa1294224baee7fb98dcd460d8461fe64eeae302902945",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/path@^1.0.6"
|
"jsr:@std/assert@^1.0.12",
|
||||||
|
"jsr:@std/internal@^1.0.6"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/http@1.0.8": {
|
"@std/http@1.0.10": {
|
||||||
"integrity": "6ea1b2e8d33929967754a3b6d6c6f399ad6647d7bbb5a466c1eaf9b294a6ebcd",
|
"integrity": "4e32d11493ab04e3ef09f104f0cb9beb4228b1d4b47c5469573c2c294c0d3692",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/encoding@^1.0.5"
|
"jsr:@std/encoding@^1.0.5"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/internal@1.0.4": {
|
"@std/internal@1.0.5": {
|
||||||
"integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422"
|
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
|
||||||
|
},
|
||||||
|
"@std/internal@1.0.6": {
|
||||||
|
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
|
||||||
},
|
},
|
||||||
"@std/io@0.224.9": {
|
"@std/io@0.224.9": {
|
||||||
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
|
"integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3",
|
||||||
@@ -171,21 +215,21 @@
|
|||||||
"jsr:@std/bytes@^1.0.2"
|
"jsr:@std/bytes@^1.0.2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/media-types@1.0.3": {
|
"@std/media-types@1.1.0": {
|
||||||
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
|
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||||
},
|
},
|
||||||
"@std/path@1.0.6": {
|
"@std/path@1.0.8": {
|
||||||
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
|
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||||
},
|
},
|
||||||
"@std/testing@1.0.3": {
|
"@std/testing@1.0.11": {
|
||||||
"integrity": "f98c2bee53860a5916727d7e7d3abe920dd6f9edace022e2d059f00d05c2cf42",
|
"integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/assert@^1.0.6",
|
"jsr:@std/assert@^1.0.12",
|
||||||
"jsr:@std/data-structures",
|
"jsr:@std/internal@^1.0.6"
|
||||||
"jsr:@std/fs",
|
|
||||||
"jsr:@std/internal",
|
|
||||||
"jsr:@std/path@^1.0.6"
|
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"@std/yaml@1.0.5": {
|
||||||
|
"integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
@@ -204,10 +248,10 @@
|
|||||||
"@noble/hashes@1.3.2"
|
"@noble/hashes@1.3.2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@noble/curves@1.4.0": {
|
"@noble/curves@1.7.0": {
|
||||||
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
|
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/hashes@1.4.0"
|
"@noble/hashes@1.6.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@noble/hashes@1.3.1": {
|
"@noble/hashes@1.3.1": {
|
||||||
@@ -216,43 +260,46 @@
|
|||||||
"@noble/hashes@1.3.2": {
|
"@noble/hashes@1.3.2": {
|
||||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
|
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
|
||||||
},
|
},
|
||||||
"@noble/hashes@1.4.0": {
|
"@noble/hashes@1.6.0": {
|
||||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="
|
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="
|
||||||
|
},
|
||||||
|
"@noble/hashes@1.6.1": {
|
||||||
|
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="
|
||||||
},
|
},
|
||||||
"@scure/base@1.1.1": {
|
"@scure/base@1.1.1": {
|
||||||
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
|
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
|
||||||
},
|
},
|
||||||
"@scure/base@1.1.7": {
|
"@scure/base@1.2.1": {
|
||||||
"integrity": "sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g=="
|
"integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ=="
|
||||||
},
|
},
|
||||||
"@scure/bip32@1.3.1": {
|
"@scure/bip32@1.3.1": {
|
||||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/curves@1.1.0",
|
"@noble/curves@1.1.0",
|
||||||
"@noble/hashes@1.3.2",
|
"@noble/hashes@1.3.2",
|
||||||
"@scure/base@1.1.7"
|
"@scure/base@1.1.1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@scure/bip32@1.4.0": {
|
"@scure/bip32@1.6.0": {
|
||||||
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
|
"integrity": "sha512-82q1QfklrUUdXJzjuRU7iG7D7XiFx5PHYVS0+oeNKhyDLT7WPqs6pBcM2W5ZdwOwKCwoE1Vy1se+DHjcXwCYnA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/curves@1.4.0",
|
"@noble/curves@1.7.0",
|
||||||
"@noble/hashes@1.4.0",
|
"@noble/hashes@1.6.1",
|
||||||
"@scure/base@1.1.7"
|
"@scure/base@1.2.1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@scure/bip39@1.2.1": {
|
"@scure/bip39@1.2.1": {
|
||||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/hashes@1.3.2",
|
"@noble/hashes@1.3.2",
|
||||||
"@scure/base@1.1.7"
|
"@scure/base@1.1.1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@scure/bip39@1.3.0": {
|
"@scure/bip39@1.5.0": {
|
||||||
"integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==",
|
"integrity": "sha512-Dop+ASYhnrwm9+HA/HwXg7j2ZqM6yk2fyLWb5znexjctFY3+E+eU8cIWI0Pql0Qx4hPZCijlGq4OL71g+Uz30A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/hashes@1.4.0",
|
"@noble/hashes@1.6.1",
|
||||||
"@scure/base@1.1.7"
|
"@scure/base@1.2.1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@types/asn1@0.2.4": {
|
"@types/asn1@0.2.4": {
|
||||||
@@ -340,8 +387,8 @@
|
|||||||
"commander"
|
"commander"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ldapts@7.2.1": {
|
"ldapts@7.2.2": {
|
||||||
"integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==",
|
"integrity": "sha512-UotAq24/vJEz0m3w/jgwZm7JGNw8M6vexL/5KU5pe3aIZWBkT/HRhjsPw/buRqKSK5Y0vTu5Zv8iyPgQF7ozzg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@types/asn1",
|
"@types/asn1",
|
||||||
"asn1",
|
"asn1",
|
||||||
@@ -351,11 +398,11 @@
|
|||||||
"whatwg-url"
|
"whatwg-url"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lru-cache@10.2.2": {
|
"lru-cache@10.4.3": {
|
||||||
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
},
|
},
|
||||||
"marked-alert@2.1.0_marked@12.0.2": {
|
"marked-alert@2.1.2_marked@12.0.2": {
|
||||||
"integrity": "sha512-X95Z8PCDgWa0bBfM70GxZG3LD/leUrhXc3cx3w1eFExBhswd1oXn/S4S+9H8ypPdCY7okREb4dItUOc+VJq4jQ==",
|
"integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"marked"
|
"marked"
|
||||||
]
|
]
|
||||||
@@ -382,8 +429,8 @@
|
|||||||
"nanoid@3.3.7": {
|
"nanoid@3.3.7": {
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
||||||
},
|
},
|
||||||
"nostr-tools@2.7.0": {
|
"nostr-tools@2.10.4": {
|
||||||
"integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==",
|
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@noble/ciphers",
|
"@noble/ciphers",
|
||||||
"@noble/curves@1.2.0",
|
"@noble/curves@1.2.0",
|
||||||
@@ -449,8 +496,8 @@
|
|||||||
"undici-types@6.19.8": {
|
"undici-types@6.19.8": {
|
||||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||||
},
|
},
|
||||||
"uuid@10.0.0": {
|
"uuid@11.0.3": {
|
||||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
|
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg=="
|
||||||
},
|
},
|
||||||
"webidl-conversions@7.0.0": {
|
"webidl-conversions@7.0.0": {
|
||||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
|
||||||
@@ -483,14 +530,15 @@
|
|||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@deno/gfm@0.9",
|
"jsr:@deno/gfm@0.10",
|
||||||
"jsr:@nostr/tools@^2.3.1",
|
"jsr:@nostr/tools@^2.10.4",
|
||||||
"jsr:@nostrify/nostrify@~0.36.1",
|
"jsr:@nostrify/nostrify@~0.36.2",
|
||||||
"jsr:@oak/oak@^17.1.0",
|
"jsr:@oak/oak@^17.1.3",
|
||||||
"jsr:@std/dotenv@~0.225.2",
|
"jsr:@std/dotenv@~0.225.2",
|
||||||
"jsr:@std/expect@^1.0.5",
|
"jsr:@std/expect@^1.0.8",
|
||||||
"jsr:@std/testing@^1.0.3",
|
"jsr:@std/testing@^1.0.11",
|
||||||
"npm:ldapts@^7.2.1"
|
"jsr:@std/yaml@^1.0.5",
|
||||||
|
"npm:ldapts@^7.2.2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
directory.ts
Normal file
34
directory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
feeds.ts
42
feeds.ts
@@ -2,37 +2,59 @@ import Article from "./models/article.ts";
|
|||||||
import Profile from "./models/profile.ts";
|
import Profile from "./models/profile.ts";
|
||||||
import { isoDate } from "./dates.ts";
|
import { isoDate } from "./dates.ts";
|
||||||
|
|
||||||
export function profileAtomFeed(profile: Profile, articles: Article[]) {
|
export async function profileAtomFeed(
|
||||||
|
profile: Profile,
|
||||||
|
articles: Article[],
|
||||||
|
): Promise<string> {
|
||||||
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
|
const feedId = `tag:${profile.nip05},nostr-p-${profile.pubkey}-k-30023`;
|
||||||
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
const lastUpdate = articles.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
||||||
?.updatedAt;
|
?.updatedAt;
|
||||||
|
let articlesXml = "";
|
||||||
|
|
||||||
const articlesXml = articles.map((article) => {
|
for (const article of articles) {
|
||||||
|
const contentHtml = await article.buildContentHtml();
|
||||||
const articleId =
|
const articleId =
|
||||||
`tag:${profile.nip05},nostr-d-${article.identifier}-k-30023`;
|
`tag:${profile.nip05},nostr-p-${profile.pubkey}-d-${article.identifier}-k-30023`;
|
||||||
return `
|
articlesXml += `
|
||||||
<entry>
|
<entry>
|
||||||
<id>${articleId}</id>
|
<id>${articleId}</id>
|
||||||
<title>${article.title}</title>
|
<title>${article.title}</title>
|
||||||
<link href="/${article.naddr}" />
|
<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>
|
<updated>${isoDate(article.updatedAt)}</updated>
|
||||||
|
<published>${isoDate(article.publishedAt)}</published>
|
||||||
<summary>${article.summary}</summary>
|
<summary>${article.summary}</summary>
|
||||||
<content type="html">${article.html}</content>
|
<content type="html"><![CDATA[
|
||||||
|
${cleanContentHtml(contentHtml)}
|
||||||
|
]]></content>
|
||||||
</entry>
|
</entry>
|
||||||
`;
|
`;
|
||||||
}).join("\n");
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>${profile.name} on Nostr</title>
|
<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>
|
<id>${feedId}</id>
|
||||||
<updated>${isoDate(lastUpdate)}</updated>
|
<updated>${isoDate(lastUpdate)}</updated>
|
||||||
<icon>${profile.picture}</icon>
|
<icon>${profile.avatarImageUrl}</icon>
|
||||||
<author>
|
<author>
|
||||||
<name>${name}</name>
|
<name>${profile.name}</name>
|
||||||
|
<uri>${profile.profileUrl}</uri>
|
||||||
|
<nostr:uri>nostr:${profile.nprofile}</nostr>
|
||||||
</author>
|
</author>
|
||||||
${articlesXml}
|
${articlesXml}
|
||||||
</feed>
|
</feed>
|
||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cleanContentHtml(html: string) {
|
||||||
|
const cleanHtml = html.replace(
|
||||||
|
/<a class="anchor" aria-hidden="true"[^>]*>.*?<\/a>/gs,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return cleanHtml;
|
||||||
|
}
|
||||||
|
|||||||
20
handlers/errors.ts
Normal file
20
handlers/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { errorPageHtml } from "../html.ts";
|
||||||
|
|
||||||
|
export const notFoundHandler = function (ctx: Context) {
|
||||||
|
const html = errorPageHtml(404, "Not found");
|
||||||
|
ctx.response.body = html;
|
||||||
|
ctx.response.status = 404;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badGatewayHandler = function (ctx: Context) {
|
||||||
|
const html = errorPageHtml(502, "Bad gateway");
|
||||||
|
ctx.response.body = html;
|
||||||
|
ctx.response.status = 502;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const internalServerErrorHandler = function (ctx: Context) {
|
||||||
|
const html = errorPageHtml(500, "Internal server error");
|
||||||
|
ctx.response.body = html;
|
||||||
|
ctx.response.status = 500;
|
||||||
|
};
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { nip19 } from "@nostr/tools";
|
import { nip19 } from "@nostr/tools";
|
||||||
import { log } from "../log.ts";
|
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
|
||||||
const naddrHandler = async function (ctx: Context) {
|
const naddrHandler = async function (ctx: Context) {
|
||||||
const naddr = ctx.params.path;
|
const naddr = ctx.state.path;
|
||||||
|
let data: nip19.AddressPointer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = nip19.decode(naddr);
|
data = nip19.decode(naddr).data as nip19.AddressPointer;
|
||||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
} catch (_e) {
|
||||||
|
notFoundHandler(ctx);
|
||||||
if (username && r.data.identifier) {
|
return;
|
||||||
ctx.response.redirect(`/@${username}/${r.data.identifier}`);
|
|
||||||
} else {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
if (username && data.identifier) {
|
||||||
|
ctx.response.redirect(`/@${username}/${data.identifier}`);
|
||||||
|
} else {
|
||||||
|
notFoundHandler(ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { nip19 } from "@nostr/tools";
|
import { nip19 } from "@nostr/tools";
|
||||||
import { log } from "../log.ts";
|
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
|
||||||
const nprofileHandler = async function (ctx: Context) {
|
const nprofileHandler = async function (ctx: Context) {
|
||||||
const nprofile = ctx.params.path;
|
const nprofile = ctx.state.path;
|
||||||
|
let data: nip19.ProfilePointer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = nip19.decode(nprofile);
|
data = nip19.decode(nprofile).data as nip19.ProfilePointer;
|
||||||
const username = await lookupUsernameByPubkey(r.data.pubkey);
|
} catch (_e) {
|
||||||
|
notFoundHandler(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = await lookupUsernameByPubkey(data.pubkey);
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
ctx.response.redirect(`/@${username}`);
|
ctx.response.redirect(`/@${username}`);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { nip19 } from "@nostr/tools";
|
import { nip19 } from "@nostr/tools";
|
||||||
import { log } from "../log.ts";
|
import { lookupUsernameByPubkey } from "../directory.ts";
|
||||||
import { lookupUsernameByPubkey } from "../ldap.ts";
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
|
||||||
const npubHandler = async function (ctx: Context) {
|
const npubHandler = async function (ctx: Context) {
|
||||||
const npub = ctx.params.path;
|
const npub = ctx.state.path;
|
||||||
|
let pubkey: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = nip19.decode(npub);
|
pubkey = nip19.decode(npub).data as string;
|
||||||
const username = await lookupUsernameByPubkey(r.data);
|
} catch (_e) {
|
||||||
|
notFoundHandler(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = await lookupUsernameByPubkey(pubkey);
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
ctx.response.redirect(`/@${username}`);
|
ctx.response.redirect(`/@${username}`);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { log } from "../log.ts";
|
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
|
||||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||||
import { profileAtomFeed } from "../feeds.ts";
|
import { profileAtomFeed } from "../feeds.ts";
|
||||||
import Article from "../models/article.ts";
|
|
||||||
import Profile from "../models/profile.ts";
|
import Profile from "../models/profile.ts";
|
||||||
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
|
||||||
const userAtomFeedHandler = async function (ctx: Context) {
|
const userAtomFeedHandler = async function (ctx: Context) {
|
||||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
const username = ctx.state.username;
|
||||||
const pubkey = await lookupPubkeyByUsername(username);
|
const pubkey = await lookupPubkeyByUsername(ctx.state.username);
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const profileEvent = await fetchProfileEvent(pubkey);
|
const profileEvent = await fetchProfileEvent(pubkey);
|
||||||
|
|
||||||
|
if (profileEvent) {
|
||||||
const profile = new Profile(profileEvent, username);
|
const profile = new Profile(profileEvent, username);
|
||||||
|
|
||||||
if (profileEvent && profile.nip05) {
|
if (profile.nip05) {
|
||||||
const articleEvents = await fetchArticlesByAuthor(pubkey);
|
const articles = await fetchArticlesByAuthor(pubkey, 10);
|
||||||
const articles = articleEvents.map((a) => new Article(a));
|
const atom = await profileAtomFeed(profile, articles);
|
||||||
const atom = profileAtomFeed(profile, articles);
|
|
||||||
|
|
||||||
ctx.response.headers.set("Content-Type", "application/atom+xml");
|
ctx.response.headers.set("Content-Type", "application/atom+xml");
|
||||||
ctx.response.body = atom;
|
ctx.response.body = atom;
|
||||||
} else {
|
return;
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
|
notFoundHandler(ctx);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default userAtomFeedHandler;
|
export default userAtomFeedHandler;
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { log } from "../log.ts";
|
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
|
||||||
import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
|
import { fetchProfileEvent, fetchReplaceableEvent } from "../nostr.ts";
|
||||||
import Article from "../models/article.ts";
|
import Article from "../models/article.ts";
|
||||||
import Profile from "../models/profile.ts";
|
import Profile from "../models/profile.ts";
|
||||||
import { articleHtml } from "../html.ts";
|
import { articleHtml } from "../html.ts";
|
||||||
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
import { generateOgProfileImage } from "../magick.ts";
|
||||||
|
|
||||||
const userEventHandler = async function (ctx: Context) {
|
const userEventHandler = async function (ctx: Context) {
|
||||||
const username = ctx.params.user.replace(/^(@|~)/, "");
|
const username = ctx.state.username.replace(/^(@|~)/, "");
|
||||||
const identifier = ctx.params.identifier;
|
const identifier = ctx.state.identifier;
|
||||||
const pubkey = await lookupPubkeyByUsername(username);
|
const pubkey = await lookupPubkeyByUsername(username);
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const articleEvent = await fetchReplaceableEvent(
|
const articleEvent = await fetchReplaceableEvent(
|
||||||
pubkey,
|
pubkey,
|
||||||
identifier,
|
identifier,
|
||||||
@@ -27,17 +26,12 @@ const userEventHandler = async function (ctx: Context) {
|
|||||||
if (articleEvent && profileEvent) {
|
if (articleEvent && profileEvent) {
|
||||||
const article = new Article(articleEvent);
|
const article = new Article(articleEvent);
|
||||||
const profile = new Profile(profileEvent, username);
|
const profile = new Profile(profileEvent, username);
|
||||||
const html = articleHtml(article, profile);
|
const html = await articleHtml(article, profile);
|
||||||
|
await generateOgProfileImage(profile);
|
||||||
|
|
||||||
ctx.response.body = html;
|
ctx.response.body = html;
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { log } from "../log.ts";
|
import { lookupPubkeyByUsername } from "../directory.ts";
|
||||||
import { lookupPubkeyByUsername } from "../ldap.ts";
|
|
||||||
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
import { fetchArticlesByAuthor, fetchProfileEvent } from "../nostr.ts";
|
||||||
import Article from "../models/article.ts";
|
|
||||||
import Profile from "../models/profile.ts";
|
import Profile from "../models/profile.ts";
|
||||||
import { profilePageHtml } from "../html.ts";
|
import { profilePageHtml } from "../html.ts";
|
||||||
|
import { notFoundHandler } from "../handlers/errors.ts";
|
||||||
|
import { generateOgProfileImage } from "../magick.ts";
|
||||||
|
|
||||||
const userProfileHandler = async function (ctx: Context) {
|
const userProfileHandler = async function (ctx: Context) {
|
||||||
const username = ctx.params.path.replace(/^(@|~)/, "");
|
const username = ctx.state.path.replace(/^(@|~)/, "");
|
||||||
const pubkey = await lookupPubkeyByUsername(username);
|
const pubkey = await lookupPubkeyByUsername(username);
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const profileEvent = await fetchProfileEvent(pubkey);
|
const profileEvent = await fetchProfileEvent(pubkey);
|
||||||
|
|
||||||
if (profileEvent) {
|
if (profileEvent) {
|
||||||
const profile = new Profile(profileEvent, username);
|
const profile = new Profile(profileEvent, username);
|
||||||
const articleEvents = await fetchArticlesByAuthor(pubkey);
|
const articles = await fetchArticlesByAuthor(pubkey, 210);
|
||||||
const articles = articleEvents.map((a) => new Article(a));
|
const html = await profilePageHtml(profile, articles);
|
||||||
const html = profilePageHtml(profile, articles);
|
await generateOgProfileImage(profile);
|
||||||
|
|
||||||
ctx.response.body = html;
|
ctx.response.body = html;
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(e, "yellow");
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = "Not Found";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
191
html.ts
191
html.ts
@@ -2,7 +2,13 @@ import { localizeDate } from "./dates.ts";
|
|||||||
import Article from "./models/article.ts";
|
import Article from "./models/article.ts";
|
||||||
import Profile from "./models/profile.ts";
|
import Profile from "./models/profile.ts";
|
||||||
|
|
||||||
export function htmlLayout(title: string, body: string, profile: Profile) {
|
interface HtmlLayoutOptions {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
metaHtml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -11,8 +17,9 @@ export function htmlLayout(title: string, body: string, profile: Profile) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
<title>${title}</title>
|
<title>${title}</title>
|
||||||
<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />
|
${metaHtml || ""}
|
||||||
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
|
<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" />
|
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -22,40 +29,73 @@ export function htmlLayout(title: string, body: string, profile: Profile) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function articleHtml(article: Article, profile: Profile) {
|
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 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 = `
|
const body = `
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<h1>${article.title}</h1>
|
${draftLabel}
|
||||||
<p class="meta">
|
<h1>${titleHtml(article.title)}</h1>
|
||||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
<div class="meta">
|
||||||
<span class="content">
|
<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="name"><a href="/@${profile.username}">${profile.name}</a></span>
|
||||||
<span class="date">${publishedAtFormatted}</span>
|
<span class="date">${publishedAtFormatted}</span>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${article.html}
|
<article>
|
||||||
|
${await article.buildContentHtml()}
|
||||||
|
<footer>
|
||||||
|
${openWithNostrAppHtml(article.naddr)}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
</main>
|
</main>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return htmlLayout(article.title, body, profile);
|
let metaHtml = articleMetaHtml(article, profile);
|
||||||
|
metaHtml += feedLinksHtml(profile);
|
||||||
|
|
||||||
|
return htmlLayout({ title: pageTitle, body, metaHtml });
|
||||||
}
|
}
|
||||||
|
|
||||||
function articleListItemHtml(article: Article) {
|
function titleHtml(title: string) {
|
||||||
|
return title.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleListItemHtml(article: Article): string {
|
||||||
const formattedDate = localizeDate(article.publishedAt);
|
const formattedDate = localizeDate(article.publishedAt);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<h3><a href="/${article.naddr}">${article.title}</a></h3>
|
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
|
||||||
<p>${formattedDate}</p>
|
<p class="meta">
|
||||||
|
${formattedDate}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function articleListHtml(articles: Article[]) {
|
export function articleListHtml(articles: Article[]): string {
|
||||||
if (articles.length === 0) return "";
|
if (articles.length === 0) return "";
|
||||||
let html = "";
|
let html = "";
|
||||||
|
|
||||||
@@ -71,23 +111,138 @@ export function articleListHtml(articles: Article[]) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function profilePageHtml(profile: Profile, articles: Article[]) {
|
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`;
|
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 = `
|
const body = `
|
||||||
<main class="profile-page">
|
<main class="profile-page">
|
||||||
<header>
|
<header>
|
||||||
<img class="avatar" src="${profile.picture}" alt="User Avatar" />
|
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
|
||||||
<div class="bio">
|
<div class="bio">
|
||||||
<h1>${profile.name}</h1>
|
<h1>${profile.name}</h1>
|
||||||
|
${nip05Html}
|
||||||
<p class="about">
|
<p class="about">
|
||||||
${profile.about}
|
${profile.about}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<details>
|
||||||
|
<summary>Details</summary>
|
||||||
|
<dl>
|
||||||
|
<dt>Public key</dt>
|
||||||
|
<dd>${profile.npub}</dd>
|
||||||
|
${userAddressHtml(profile)}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
<section>
|
||||||
${articleListHtml(articles)}
|
${articleListHtml(articles)}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return htmlLayout(title, body, profile);
|
let metaHtml = profileMetaHtml(profile);
|
||||||
|
metaHtml += feedLinksHtml(profile);
|
||||||
|
|
||||||
|
return htmlLayout({ title, body, metaHtml });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWithNostrAppHtml(bech32Id: string): string {
|
||||||
|
let appLinksHtml = "";
|
||||||
|
const appLinks = [
|
||||||
|
{ title: "Habla", href: `https://habla.news/a/${bech32Id}` },
|
||||||
|
{
|
||||||
|
title: "noStrudel",
|
||||||
|
href: `https://nostrudel.ninja/#/articles/${bech32Id}`,
|
||||||
|
},
|
||||||
|
{ title: "Coracle", href: `https://coracle.social/${bech32Id}` },
|
||||||
|
{ title: "YakiHonne", href: `https://yakihonne.com/article/${bech32Id}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const link of appLinks) {
|
||||||
|
appLinksHtml += `<a href="${link.href}" target="_blank">${link.title}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="open-with dropdown">
|
||||||
|
<button class="dropdown-button">Open with Nostr app</button>
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<a href="nostr:${bech32Id}" target="_blank">🔗 Nostr Link</a>
|
||||||
|
<h4 class="title">Apps</h4>
|
||||||
|
${appLinksHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedLinksHtml(profile: Profile) {
|
||||||
|
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileMetaHtml(profile: Profile) {
|
||||||
|
return `
|
||||||
|
<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}">
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
48
ldap.ts
48
ldap.ts
@@ -1,47 +1,57 @@
|
|||||||
import { Client } from "ldapts";
|
import { Client } from "ldapts";
|
||||||
import { log } from "./log.ts";
|
|
||||||
import config from "./config.ts";
|
import config from "./config.ts";
|
||||||
|
|
||||||
const { ldap } = config;
|
const { ldap, ldapEnabled } = config;
|
||||||
const client = new Client({ url: ldap.url });
|
|
||||||
|
|
||||||
export async function lookupPubkeyByUsername(username: string) {
|
let client: Client;
|
||||||
let pubkey;
|
if (ldapEnabled) {
|
||||||
|
client = new Client({ url: ldap.url as string });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupPubkeyByUsername(username: string): Promise<string | undefined> {
|
||||||
|
let pubkey: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.bind(ldap.bindDN, ldap.password);
|
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||||
|
|
||||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||||
filter: `(cn=${username})`,
|
filter: `(cn=${username})`,
|
||||||
attributes: ["nostrKey"],
|
attributes: ["nostrKey"],
|
||||||
});
|
});
|
||||||
|
|
||||||
pubkey = searchEntries[0]?.nostrKey;
|
if (
|
||||||
} catch (ex) {
|
searchEntries.length > 0 &&
|
||||||
log(ex, "red");
|
typeof searchEntries[0].nostrKey === "string"
|
||||||
} finally {
|
) {
|
||||||
|
pubkey = searchEntries[0].nostrKey.toString();
|
||||||
|
}
|
||||||
|
|
||||||
await client.unbind();
|
await client.unbind();
|
||||||
|
} catch (e) {
|
||||||
|
await client.unbind();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pubkey;
|
return pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function lookupUsernameByPubkey(pubkey: string) {
|
export async function lookupUsernameByPubkey(pubkey: string): Promise<string | undefined> {
|
||||||
let username;
|
let username: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.bind(ldap.bindDN, ldap.password);
|
await client.bind(ldap.bindDN as string, ldap.password as string);
|
||||||
|
|
||||||
const { searchEntries } = await client.search(ldap.searchDN, {
|
const { searchEntries } = await client.search(ldap.searchDN as string, {
|
||||||
filter: `(nostrKey=${pubkey})`,
|
filter: `(nostrKey=${pubkey})`,
|
||||||
attributes: ["cn"],
|
attributes: ["cn"],
|
||||||
});
|
});
|
||||||
|
|
||||||
username = searchEntries[0]?.cn;
|
if (searchEntries.length > 0) {
|
||||||
} catch (ex) {
|
username = searchEntries[0].cn.toString();
|
||||||
log(ex, "red");
|
}
|
||||||
} finally {
|
} catch (e) {
|
||||||
await client.unbind();
|
await client.unbind();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return username;
|
return username;
|
||||||
|
|||||||
111
magick.ts
Normal file
111
magick.ts
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { nip19 } from "@nostr/tools";
|
|
||||||
import { NEvent } from "../nostr.ts";
|
|
||||||
import { render as renderMarkdown } from "@deno/gfm";
|
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 {
|
export default class Article {
|
||||||
event: NEvent;
|
event: NEvent;
|
||||||
@@ -14,11 +20,24 @@ export default class Article {
|
|||||||
return tag ? tag[1] : "";
|
return tag ? tag[1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isDraft(): boolean {
|
||||||
|
return this.event.kind === 30024;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url(): string {
|
||||||
|
return `${config.base_url}/${this.naddr}`;
|
||||||
|
}
|
||||||
|
|
||||||
get title(): string {
|
get title(): string {
|
||||||
const tag = this.event.tags.find((t) => t[0] === "title");
|
const tag = this.event.tags.find((t) => t[0] === "title");
|
||||||
return tag ? tag[1] : "Untitled";
|
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 {
|
get summary(): string {
|
||||||
const tag = this.event.tags.find((t) => t[0] === "summary");
|
const tag = this.event.tags.find((t) => t[0] === "summary");
|
||||||
return tag ? tag[1] : "";
|
return tag ? tag[1] : "";
|
||||||
@@ -33,8 +52,12 @@ export default class Article {
|
|||||||
return this.event.created_at;
|
return this.event.created_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
get html(): string {
|
get content(): string {
|
||||||
return renderMarkdown(this.event.content);
|
return this.event.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDeleted(): boolean {
|
||||||
|
return !!this.event.tags.find((t) => t[0] === "deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
get naddr(): string {
|
get naddr(): string {
|
||||||
@@ -42,6 +65,14 @@ export default class Article {
|
|||||||
identifier: this.identifier,
|
identifier: this.identifier,
|
||||||
pubkey: this.event.pubkey,
|
pubkey: this.event.pubkey,
|
||||||
kind: this.event.kind,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { nip19 } from "@nostr/tools";
|
import { nip19, NostrEvent as NEvent } from "@nostr/tools";
|
||||||
import { NEvent } from "../nostr.ts";
|
import { verifyNip05Address } from "../nostr.ts";
|
||||||
|
import { getImageMagickCommand } from "../utils.ts";
|
||||||
|
import config from "../config.ts";
|
||||||
|
|
||||||
|
const magick = await getImageMagickCommand();
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
name: string;
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
displayName?: string;
|
||||||
about?: string;
|
about?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
nip05?: string;
|
nip05?: string;
|
||||||
@@ -10,8 +16,8 @@ export interface ProfileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class Profile {
|
export default class Profile {
|
||||||
event: NEvent;
|
|
||||||
private data: ProfileData;
|
private data: ProfileData;
|
||||||
|
event: NEvent;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
constructor(event: NEvent, username?: string) {
|
constructor(event: NEvent, username?: string) {
|
||||||
@@ -25,7 +31,8 @@ export default class Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return this.data.name || "Anonymous";
|
return this.data.display_name || this.data.displayName ||
|
||||||
|
this.data.name || "Anonymous";
|
||||||
}
|
}
|
||||||
|
|
||||||
get about(): string {
|
get about(): string {
|
||||||
@@ -37,7 +44,7 @@ export default class Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get nip05(): string | undefined {
|
get nip05(): string | undefined {
|
||||||
return this.data.nip05;
|
return this.data.nip05?.replace("_@", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
get lud16(): string | undefined {
|
get lud16(): string | undefined {
|
||||||
@@ -51,4 +58,39 @@ export default class Profile {
|
|||||||
get npub(): string {
|
get npub(): string {
|
||||||
return nip19.npubEncode(this.pubkey);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
nostr.ts
88
nostr.ts
@@ -1,48 +1,96 @@
|
|||||||
import { NRelay1 } from "@nostrify/nostrify";
|
import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify";
|
||||||
import config from "./config.ts";
|
import config from "./config.ts";
|
||||||
|
import Article from "./models/article.ts";
|
||||||
|
|
||||||
export interface NEvent {
|
const relayPool = new NPool({
|
||||||
content: string;
|
open: (url) => new NRelay1(url),
|
||||||
created_at: number;
|
// deno-lint-ignore require-await
|
||||||
id: string;
|
reqRouter: async (filters) =>
|
||||||
kind: number;
|
new Map(
|
||||||
pubkey: string;
|
config.relay_urls.map((url) => [url, filters]),
|
||||||
sig: string;
|
),
|
||||||
tags: Array<[string, string, string?]>;
|
// deno-lint-ignore require-await
|
||||||
|
eventRouter: async (_event) => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchWithTimeout(filters: NostrFilter[]) {
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("relay timeout")), config.query_timeout)
|
||||||
|
);
|
||||||
|
const eventsPromise = relayPool.query(filters);
|
||||||
|
|
||||||
|
const events = await Promise.race([eventsPromise, timeoutPromise]);
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const relay = new NRelay1(config.home_relay_url);
|
|
||||||
|
|
||||||
export async function fetchReplaceableEvent(
|
export async function fetchReplaceableEvent(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
) {
|
) {
|
||||||
const events = await relay.query([{
|
let events = await fetchWithTimeout([{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [30023],
|
kinds: [30023],
|
||||||
"#d": [identifier],
|
"#d": [identifier],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}]);
|
}]) as NostrEvent[];
|
||||||
|
|
||||||
|
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;
|
return events.length > 0 ? events[0] : null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchArticlesByAuthor(pubkey: string) {
|
export async function fetchArticlesByAuthor(
|
||||||
const events = await relay.query([{
|
pubkey: string,
|
||||||
|
limit: number = 10,
|
||||||
|
) {
|
||||||
|
const events = await fetchWithTimeout([{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [30023],
|
kinds: [30023],
|
||||||
limit: 10,
|
limit: limit,
|
||||||
}]);
|
}]) as NostrEvent[];
|
||||||
|
|
||||||
return events;
|
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) {
|
export async function fetchProfileEvent(pubkey: string) {
|
||||||
const events = await relay.query([{
|
const events = await fetchWithTimeout([{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [0],
|
kinds: [0],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}]);
|
}]) as NostrEvent[];
|
||||||
|
|
||||||
return events.length > 0 ? events[0] : null;
|
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
129
nostr/links.ts
Normal 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
140
prism.css
Normal 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;
|
||||||
|
}
|
||||||
94
server.ts
94
server.ts
@@ -1,17 +1,24 @@
|
|||||||
import { Application, Router, send } from "@oak/oak";
|
import { Application, Router, send } from "@oak/oak";
|
||||||
import config from "./config.ts";
|
import { createSubtrTmpDirectories } from "./utils.ts";
|
||||||
|
import config, { ensureNecessaryConfigs } from "./config.ts";
|
||||||
import naddrHandler from "./handlers/naddr.ts";
|
import naddrHandler from "./handlers/naddr.ts";
|
||||||
import nprofileHandler from "./handlers/nprofile.ts";
|
import nprofileHandler from "./handlers/nprofile.ts";
|
||||||
import npubHandler from "./handlers/npub.ts";
|
import npubHandler from "./handlers/npub.ts";
|
||||||
import userProfileHandler from "./handlers/user-profile.ts";
|
import userProfileHandler from "./handlers/user-profile.ts";
|
||||||
import userEventHandler from "./handlers/user-event.ts";
|
import userEventHandler from "./handlers/user-event.ts";
|
||||||
import userAtomFeedHandler from "./handlers/user-atom-feed.ts";
|
import userAtomFeedHandler from "./handlers/user-atom-feed.ts";
|
||||||
|
import {
|
||||||
|
badGatewayHandler,
|
||||||
|
internalServerErrorHandler,
|
||||||
|
notFoundHandler,
|
||||||
|
} from "./handlers/errors.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.get("/:path", async (ctx: ctx) => {
|
router.get("/:path", async (ctx) => {
|
||||||
const { path } = ctx.params;
|
const path = ctx.state.path = ctx.params.path;
|
||||||
|
|
||||||
|
try {
|
||||||
if (path.startsWith("naddr")) {
|
if (path.startsWith("naddr")) {
|
||||||
await naddrHandler(ctx);
|
await naddrHandler(ctx);
|
||||||
} else if (path.startsWith("nprofile")) {
|
} else if (path.startsWith("nprofile")) {
|
||||||
@@ -21,46 +28,89 @@ router.get("/:path", async (ctx: ctx) => {
|
|||||||
} else if (path.startsWith("@") || path.startsWith("~")) {
|
} else if (path.startsWith("@") || path.startsWith("~")) {
|
||||||
await userProfileHandler(ctx);
|
await userProfileHandler(ctx);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
notFoundHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
|
||||||
|
badGatewayHandler(ctx);
|
||||||
|
} else {
|
||||||
|
internalServerErrorHandler(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:user/:kind.atom", async (ctx: ctx) => {
|
router.get("/:username/:kind.atom", async (ctx) => {
|
||||||
const { user } = ctx.params;
|
ctx.state.username = ctx.params.username.replace(/^(@|~)/, "");
|
||||||
|
ctx.state.kind = ctx.params.kind;
|
||||||
|
|
||||||
if (user.startsWith("@") || user.startsWith("~") || kind === "articles") {
|
if (
|
||||||
|
ctx.state.kind === "articles" &&
|
||||||
|
(ctx.params.username.startsWith("@") ||
|
||||||
|
ctx.params.username.startsWith("~"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
await userAtomFeedHandler(ctx);
|
await userAtomFeedHandler(ctx);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
|
||||||
|
badGatewayHandler(ctx);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
internalServerErrorHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notFoundHandler(ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:user/:identifier", async (ctx: ctx) => {
|
router.get("/:username/:identifier", async (ctx) => {
|
||||||
const { user } = ctx.params;
|
const username = ctx.state.username = ctx.params.username;
|
||||||
|
ctx.state.identifier = ctx.params.identifier;
|
||||||
|
|
||||||
if (user.startsWith("@") || user.startsWith("~")) {
|
if (username.startsWith("@") || username.startsWith("~")) {
|
||||||
|
try {
|
||||||
await userEventHandler(ctx);
|
await userEventHandler(ctx);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error && e.message.match(/^connect|relay timeout/)) {
|
||||||
|
badGatewayHandler(ctx);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.status = 404;
|
internalServerErrorHandler(ctx);
|
||||||
ctx.response.body = "Not Found";
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notFoundHandler(ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/assets/:path*", async (ctx) => {
|
router.get("/assets/:path*", async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const filePath = ctx.params.path;
|
let filePath = ctx.params.path || "";
|
||||||
|
let root: string;
|
||||||
|
|
||||||
await send(ctx, filePath, {
|
if (filePath.startsWith("g/img/")) {
|
||||||
root: `${Deno.cwd()}/assets`,
|
filePath = filePath.replace(/^g\//, "");
|
||||||
});
|
root = "/tmp/substr";
|
||||||
} catch (_e) {
|
} else {
|
||||||
ctx.response.status = 404;
|
root = `${import.meta.dirname}/assets`;
|
||||||
ctx.response.body = "Not Found";
|
}
|
||||||
|
|
||||||
|
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();
|
const app = new Application();
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
app.use(router.allowedMethods());
|
app.use(router.allowedMethods());
|
||||||
|
|||||||
17
tests/feeds_test.ts
Normal file
17
tests/feeds_test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it } from "@std/testing/bdd";
|
||||||
|
import { expect } from "@std/expect";
|
||||||
|
import { cleanContentHtml } from "../feeds.ts";
|
||||||
|
|
||||||
|
describe("Feeds", () => {
|
||||||
|
describe("#cleanContentHtml", () => {
|
||||||
|
const articleHtml = Deno.readTextFileSync(
|
||||||
|
"tests/fixtures/gfm-content-1.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("removes the anchor links for headlines", () => {
|
||||||
|
const cleanHtml = cleanContentHtml(articleHtml);
|
||||||
|
expect(cleanHtml).not.toMatch(/<a class="anchor" aria-hidden="true"/);
|
||||||
|
expect(cleanHtml).not.toMatch(/<svg class="octicon octicon-link"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/fixtures/article-2.md
vendored
Normal file
41
tests/fixtures/article-2.md
vendored
Normal 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
9
tests/fixtures/article-deleted.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"content": "",
|
||||||
|
"created_at": 1716761766,
|
||||||
|
"id": "ae83c3e23e11db5fd0e6dacaece38847451e81d1429e4182a0cadd409bdce30f",
|
||||||
|
"kind": 30023,
|
||||||
|
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||||
|
"sig": "6c21eba5324af302dfbfb9dadfc2d067646a3594ffed02d2528cca8ee0f7c16b9ffb3dc640420304882be94e789d93191d830108ac52d57b2e72445025e433b2",
|
||||||
|
"tags": [["d", "66674915"], ["deleted"]]
|
||||||
|
}
|
||||||
165
tests/fixtures/gfm-content-1.html
vendored
Normal file
165
tests/fixtures/gfm-content-1.html
vendored
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<p>
|
||||||
|
This week, it finally happened: I still had a Lightning channel open with a
|
||||||
|
node that hadn't been online for the better part of a year now, so I decided
|
||||||
|
to close the channel unilaterally. But force-closing a channel means you have
|
||||||
|
to broadcast the latest commitment transaction, the pre-set fee of which was
|
||||||
|
only ~1 sat/vB for this one.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
With LND, if the channel is created as an <a
|
||||||
|
href="https://lightning.engineering/posts/2021-01-28-lnd-v0.12/"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>anchor channel</a> (by default only since version 0.12), then the commitment
|
||||||
|
transaction contains small extra outputs (currently 330 sats), which let
|
||||||
|
either channel partner spend one of them into a child transaction that can be
|
||||||
|
created with higher fees to pay for the parent transaction (CPFP). LND even
|
||||||
|
has a built-in command for that: <code>lncli wallet bumpclosefee</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
However, this channel was created in the old-school way, and was thus stuck
|
||||||
|
with its low fee. In fact, even the local bitcoin node refused to accept the
|
||||||
|
transaction into its own mempool, so the bitcoin p2p network didn't even know
|
||||||
|
it existed. So how do we get out of this pickle?
|
||||||
|
</p>
|
||||||
|
<h2 id="the-solution">
|
||||||
|
<a class="anchor" aria-hidden="true" tabindex="-1" href="#the-solution"><svg
|
||||||
|
class="octicon octicon-link"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg></a>The solution
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Enter the <a
|
||||||
|
href="https://mempool.space/accelerator"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>mempool.space Accelerator</a>. It is essentially an automated way to create
|
||||||
|
agreements with various mining pools to mine your low-fee transaction in
|
||||||
|
exchange for an out-of-band payment. Mempool.space coordinates these
|
||||||
|
agreements and out-of-band payments with miners and gets a share from the
|
||||||
|
overall fee for that.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Now, if you're in the same situation as I was, you might search for the ID of
|
||||||
|
your closing transaction and find that mempool.space cannot find it. Remember
|
||||||
|
how the local bitcoin node (with mostly default settings) didn't accept it in
|
||||||
|
the first place?
|
||||||
|
</p>
|
||||||
|
<h3 id="1-get-the-transaction-to-be-broadcast">
|
||||||
|
<a
|
||||||
|
class="anchor"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
href="#1-get-the-transaction-to-be-broadcast"
|
||||||
|
><svg
|
||||||
|
class="octicon octicon-link"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg></a>1. Get the transaction to be broadcast
|
||||||
|
</h3>
|
||||||
|
<p>In your <code>bitcoin.conf</code>, add the following line:</p>
|
||||||
|
<pre><code>minrelaytxfee=0</code></pre><p>
|
||||||
|
This sets the minimum fee to 0, meaning it will accept and broadcast your
|
||||||
|
transactions, no matter how low the fee is. Restart <code>bitcoind</code> and
|
||||||
|
wait a little bit. LND will retry broadcasting the closing transaction every
|
||||||
|
minute or so until it succeeds. At some point you should be able to find it on
|
||||||
|
mempool.space.
|
||||||
|
</p>
|
||||||
|
<h3 id="2-use-the-accelerator-to-confirm-it">
|
||||||
|
<a
|
||||||
|
class="anchor"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
href="#2-use-the-accelerator-to-confirm-it"
|
||||||
|
><svg
|
||||||
|
class="octicon octicon-link"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg></a>2. Use the Accelerator to confirm it
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Once you can see the transaction on <a
|
||||||
|
href="https://mempool.space"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>mempool.space</a>, you can just click the "Accelerate" button next to the
|
||||||
|
ETA. This will bring you to a page that shows you the estimated share of
|
||||||
|
miners that will include your transaction in their blocks, as well as some
|
||||||
|
acceleration fee options for various transaction fee levels, which you can pay
|
||||||
|
for via the Lightning Network, of course.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you haven't looked into this service before (which I had), then the fees
|
||||||
|
might be a bit of a surprise to you. This thing is <strong>not</strong> cheap!
|
||||||
|
Bumping my fee from 1 sat/vB to <del>9 sats/vB cost a whopping 51,500 sats
|
||||||
|
(</del>31 USD that day). Bumping it higher only seemed to add the difference
|
||||||
|
in the transaction fee itself, so the service seems to have cost a flat 50K
|
||||||
|
sats at the time.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Unfortunately, this channel wasn't particularly large, so the acceleration fee
|
||||||
|
amounted to ~9% of my remaining channel balance. But 91% of something is
|
||||||
|
better than 100% of nothing, so I actually felt pretty good about it.
|
||||||
|
</p>
|
||||||
|
<p>Next, you will see something like this:</p>
|
||||||
|
<p>
|
||||||
|
<img
|
||||||
|
src="https://image.nostr.build/76151cc2ae06a93a8fcd97102bf4fa63541f8f3bd19800b96ff1070c9450945c.png"
|
||||||
|
alt="Screenshot of an accelerated transaction on mempool.space"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Time to lean back and let the miners work for you. In my case, the ETA was
|
||||||
|
eerily precise. It told me that it would take ~56 minutes to confirm the
|
||||||
|
transaction, and almost exactly an hour later it was mined.
|
||||||
|
</p>
|
||||||
|
<h3 id="3-wait">
|
||||||
|
<a class="anchor" aria-hidden="true" tabindex="-1" href="#3-wait"><svg
|
||||||
|
class="octicon octicon-link"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"
|
||||||
|
>
|
||||||
|
</path>
|
||||||
|
</svg></a>3. Wait
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Now that our transaction is confirmed, our channel is not closed immediately,
|
||||||
|
of course. The <a
|
||||||
|
href="https://docs.lightning.engineering/the-lightning-network/multihop-payments/hash-time-lock-contract-htlc"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>time lock of the HTLC</a> protects our channel partner from us broadcasting
|
||||||
|
an old channel state in which our balance might be higher than in the latest
|
||||||
|
state.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In my case, it was set to 144 blocks, i.e. ~24 hours. So I checked back the
|
||||||
|
next day, et voilá: channel closed and balance restored. 🥳
|
||||||
|
</p>
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||||
import { expect } from "@std/expect";
|
import { expect } from "@std/expect";
|
||||||
import { NEvent } from "../../nostr.ts";
|
|
||||||
import Article from "../../models/article.ts";
|
import Article from "../../models/article.ts";
|
||||||
|
|
||||||
describe("Article", () => {
|
describe("Article", () => {
|
||||||
let articleEvent: NEvent;
|
|
||||||
let article: Article;
|
let article: Article;
|
||||||
|
let deletedArticle: Article;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
articleEvent = JSON.parse(
|
article = new Article(JSON.parse(
|
||||||
Deno.readTextFileSync("tests/fixtures/article-1.json"),
|
Deno.readTextFileSync("tests/fixtures/article-1.json"),
|
||||||
);
|
));
|
||||||
article = new Article(articleEvent);
|
deletedArticle = new Article(JSON.parse(
|
||||||
|
Deno.readTextFileSync("tests/fixtures/article-deleted.json"),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("#identifier", () => {
|
describe("#identifier", () => {
|
||||||
@@ -20,6 +21,18 @@ describe("Article", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("#title", () => {
|
||||||
it("returns the content of the 'title' tag", () => {
|
it("returns the content of the 'title' tag", () => {
|
||||||
expect(article.title).toMatch(
|
expect(article.title).toMatch(
|
||||||
@@ -46,17 +59,25 @@ describe("Article", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("#html", () => {
|
describe("#isDeleted", () => {
|
||||||
it("returns a rendered HTML version of the 'content'", () => {
|
it("returns a boolean based on the 'deleted' tag", () => {
|
||||||
expect(article.html).toMatch(/<h2 id="the-solution">/);
|
expect(article.isDeleted).toEqual(false);
|
||||||
|
expect(deletedArticle.isDeleted).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("#naddr", () => {
|
describe("#naddr", () => {
|
||||||
it("returns bech32 addressable event ID", () => {
|
it("returns a bech32 addressable event ID", () => {
|
||||||
expect(article.naddr).toEqual(
|
expect(article.naddr).toMatch(
|
||||||
"naddr1qvzqqqr4gupzq8meqkx80g3yuklzymy0qfx2ekk56aqc2ht4ak03z3em4r4cdcwtqqxnzdejxcenjd3hx5urgwp4676hkz",
|
/^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">/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeAll, describe, it } from "@std/testing/bdd";
|
import { beforeAll, describe, it } from "@std/testing/bdd";
|
||||||
import { expect } from "@std/expect";
|
import { expect } from "@std/expect";
|
||||||
import { NEvent } from "../../nostr.ts";
|
import { NostrEvent as NEvent } from "@nostrify/nostrify";
|
||||||
import Profile from "../../models/profile.ts";
|
import Profile from "../../models/profile.ts";
|
||||||
|
|
||||||
describe("Profile", () => {
|
describe("Profile", () => {
|
||||||
@@ -40,4 +40,12 @@ describe("Profile", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#nprofile", () => {
|
||||||
|
it("returns a bech32 profile ID", () => {
|
||||||
|
expect(profile.nprofile).toMatch(
|
||||||
|
/^nprofile1qyt8wumn8ghj7mn0wd68ytntdaek6mmn9ehhyecqyq0hjpvvw73zfed7yf/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
62
tests/nostr/links_test.ts
Normal file
62
tests/nostr/links_test.ts
Normal 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
2
users.yaml.sample
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
|
||||||
|
bitcoincore: 47750177bb6bb113784e4973f6b2e3dd27ef1eff227d6e38d0046d618969e41a
|
||||||
3
users.yaml.test
Normal file
3
users.yaml.test
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
_: b3e1b7c0ef48294bd856203bfd460625de95d3afb894e5f09b14cd1f0e7097cf
|
||||||
|
accounts: b3e1b7c1660b7db0ecb93ec55c09e67961171a5c4e9e2602f1b47477ea61c50a
|
||||||
|
raucao: 1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb
|
||||||
54
utils.ts
Normal file
54
utils.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export async function checkFileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Deno.lstat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Deno.errors.NotFound) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkExecutableExists(name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const command = new Deno.Command("which", { args: [name] });
|
||||||
|
const { success } = await command.output();
|
||||||
|
return success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSubtrTmpDirectories(): Promise<void> {
|
||||||
|
const dirs = [
|
||||||
|
"/tmp/substr/img/",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of dirs) {
|
||||||
|
await Deno.mkdir(path, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCommand(cmd: string, args: string[]) {
|
||||||
|
const command = new Deno.Command(cmd, { args });
|
||||||
|
const { code, success, stdout, stderr } = await command.output();
|
||||||
|
|
||||||
|
if (code === 1) {
|
||||||
|
console.log(new TextDecoder().decode(stdout));
|
||||||
|
console.log(new TextDecoder().decode(stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success, stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImageMagickCommand(): Promise<string | undefined> {
|
||||||
|
if (await checkExecutableExists("magick")) {
|
||||||
|
return "magick";
|
||||||
|
} else if (await checkExecutableExists("convert")) {
|
||||||
|
return "convert";
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user