Compare commits

...

45 Commits

Author SHA1 Message Date
hzrd149
364e947245
Merge pull request #13 from hzrd149/changeset-release/master
Version Packages
2025-06-09 18:18:08 -05:00
github-actions[bot]
53a4b9f548 Version Packages 2025-06-09 23:17:59 +00:00
hzrd149
1473eee4fd Fix returning setup page when event can't be found for pubkey 2025-06-09 18:17:32 -05:00
hzrd149
7fa6a79d3b
Merge pull request #12 from hzrd149/changeset-release/master
Version Packages
2025-04-24 11:15:22 -05:00
github-actions[bot]
808ffa77be Version Packages 2025-04-05 16:07:17 +00:00
hzrd149
243fe2cd5a add caddy example 2025-04-05 17:06:53 +01:00
hzrd149
14d767114a fallback to public folder when using nsite homepage 2025-04-05 16:41:07 +01:00
hzrd149
9a04f63712 Add support for resolving NIP-05 names on set domains 2025-04-05 16:19:51 +01:00
hzrd149
b37664bc5b Cleanup DNS pubkey resolution 2025-04-05 15:31:28 +01:00
hzrd149
ef5262f73c Remove nginx cache invalidations
Remove screenshots
Fix race condition bug
2025-04-05 15:13:05 +01:00
hzrd149
c3778507d4 remove node sea
add CACHE_TIME variable
2025-03-25 08:26:53 +00:00
hzrd149
80aab93bb7 remove publish next 2025-03-24 21:46:36 +00:00
hzrd149
225b616a3c fix node sea 2025-03-24 21:46:03 +00:00
hzrd149
638f798df1 add node SEA
change build folder
2025-03-24 21:44:40 +00:00
hzrd149
d87497e6c0 cleanup
bump dependencies
2025-03-17 17:35:11 +00:00
hzrd149
b2b8e0108e Make blossom requests in parallel 2025-03-16 22:18:44 +00:00
hzrd149
2fc6fbc2f1
Merge pull request #11 from hzrd149/changeset-release/master
Version Packages
2025-03-07 17:47:48 +00:00
github-actions[bot]
8fee897834 Version Packages 2025-03-07 17:44:26 +00:00
hzrd149
023e03ec49 rename package to nsite-gateway 2025-03-07 17:40:30 +00:00
hzrd149
13f5b2ce20
Merge pull request #10 from hzrd149/changeset-release/master
Version Packages
2025-03-04 04:29:25 -06:00
github-actions[bot]
3f218e9765 Version Packages 2025-03-04 10:28:05 +00:00
hzrd149
c4cfa61c76
Update LICENSE year 2025-03-04 04:27:47 -06:00
hzrd149
3747037f89 add license file 2025-03-04 04:26:47 -06:00
hzrd149
b781b7dfe4
Merge pull request #8 from hzrd149/changeset-release/master
Version Packages
2025-01-22 12:27:11 -06:00
github-actions[bot]
373e8fb1cd Version Packages 2025-01-22 18:13:58 +00:00
hzrd149
7b6e5560e6 small fix for logging 2025-01-22 12:13:38 -06:00
hzrd149
c84396ed62 Add option to download another nsite as a homepage
Replace homepage with simple welcome page
2025-01-22 12:09:06 -06:00
hzrd149
c4eae33451 login to npn first 2025-01-22 10:52:04 -06:00
hzrd149
e845d43a7a small fix 2025-01-22 10:47:15 -06:00
hzrd149
e34b52f0dc add publish next action 2025-01-22 10:39:28 -06:00
hzrd149
2ac847f3b1 Add colors to logging 2025-01-22 10:32:12 -06:00
hzrd149
5be0822410 Fix serving hidden files in .well-known 2025-01-22 08:51:46 -06:00
hzrd149
b02f15242c
Merge pull request #7 from hzrd149/changeset-release/master
Version Packages
2025-01-06 12:44:39 -06:00
github-actions[bot]
07869fcb4a Version Packages 2025-01-06 18:43:26 +00:00
hzrd149
6704516336 Fix package missing build folder 2025-01-06 12:43:02 -06:00
hzrd149
a2644629ba
Merge pull request #6 from hzrd149/changeset-release/master
Version Packages
2024-12-18 10:32:24 -06:00
github-actions[bot]
f42546677a Version Packages 2024-12-18 15:53:57 +00:00
hzrd149
ba71f35593 update readme 2024-12-18 09:53:29 -06:00
hzrd149
3853ab4f96
Merge pull request #5 from hzrd149/changeset-release/master
Version Packages
2024-10-18 11:31:23 +01:00
github-actions[bot]
21744469cd Version Packages 2024-10-18 10:30:02 +00:00
hzrd149
db172d4d0a Add support for custom 404.html pages 2024-10-18 11:29:29 +01:00
hzrd149
cb3b694074
Merge pull request #4 from hzrd149/changeset-release/master
Version Packages
2024-10-07 11:19:00 -05:00
github-actions[bot]
fbf1f1a28a Version Packages 2024-10-07 16:18:49 +00:00
hzrd149
06a407d28a add proxy env variables to example 2024-10-07 11:18:23 -05:00
hzrd149
7c3c9c0d6c Add ONION_HOST env variable 2024-10-06 10:01:15 -05:00
45 changed files with 2740 additions and 2948 deletions

View File

@ -2,6 +2,9 @@
# can be in-memory, redis:// or sqlite:// # can be in-memory, redis:// or sqlite://
CACHE_PATH="in-memory" CACHE_PATH="in-memory"
# How long to keep cached data (in seconds)
CACHE_TIME=3600
# A list of relays to find users relay lists (10002) and blossom servers (10063) # A list of relays to find users relay lists (10002) and blossom servers (10063)
LOOKUP_RELAYS=wss://user.kindpag.es,wss://purplepag.es LOOKUP_RELAYS=wss://user.kindpag.es,wss://purplepag.es
@ -9,14 +12,30 @@ LOOKUP_RELAYS=wss://user.kindpag.es,wss://purplepag.es
SUBSCRIPTION_RELAYS=wss://nos.lol,wss://relay.damus.io SUBSCRIPTION_RELAYS=wss://nos.lol,wss://relay.damus.io
# A list of fallback blossom servers # A list of fallback blossom servers
BLOSSOM_SERVERS=https://cdn.satellite.earth BLOSSOM_SERVERS="https://nostr.download,https://cdn.satellite.earth"
# The max file size to serve # The max file size to serve
MAX_FILE_SIZE='2 MB' MAX_FILE_SIZE="2 MB"
# the hostname or ip of the upstream nginx proxy cache # A nprofile pointer for an nsite to use as the default homepage
NGINX_CACHE_DIR='/var/nginx/cache' # Setting this will override anything in the ./public folder
NSITE_HOMEPAGE=""
# screenshots require Puppeteer to be setup https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox # a local directory to download the homepage to
ENABLE_SCREENSHOTS="false" NSITE_HOMEPAGE_DIR="public"
SCREENSHOTS_IDR="./screenshots"
# The public domain of the gateway (optional) (used to detect when to show the nsite homepage)
PUBLIC_DOMAIN="nsite.gateway.com"
# The nip-05 domain to use for name resolution
# NIP05_NAME_DOMAINS="example.com,nostr.other.site"
# If this is set, nsite will return the 'Onion-Location' header in responses
# ONION_HOST=https://<hostname>.onion
# Use a proxy auto config
# PAC_PROXY="file:///path/to/proxy.pac"
# Or set tor and i2p proxies separately
# I2P_PROXY="127.0.0.1:4447"
# TOR_PROXY="127.0.0.1:9050"

View File

@ -26,10 +26,10 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- name: Setup Node.js 20 - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version-file: .nvmrc
cache: "pnpm" cache: "pnpm"
- name: Install Dependencies - name: Install Dependencies

2
.gitignore vendored
View File

@ -3,4 +3,4 @@ build
.env .env
data data
.netrc .netrc
screenshots

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22

3
.vscode/launch.json vendored
View File

@ -26,8 +26,7 @@
"internalConsoleOptions": "openOnSessionStart", "internalConsoleOptions": "openOnSessionStart",
"outputCapture": "std", "outputCapture": "std",
"env": { "env": {
"DEBUG": "nsite,nsite:*", "DEBUG": "nsite,nsite:*"
"ENABLE_SCREENSHOTS": "true"
} }
} }
] ]

View File

@ -1,4 +1,75 @@
# nsite-ts # nsite-gateway
## 1.0.1
### Patch Changes
- 1473eee: Fix returning setup page when event can't be found for pubkey
## 1.0.0
### Major Changes
- ef5262f: Remove screenshots feature
- ef5262f: Remove nginx cache invalidations
### Minor Changes
- b37664b: Cleanup DNS pubkey resolution
- 9a04f63: Add support for resolving NIP-05 names on set domains
- b2b8e01: Make blossom requests in parallel
### Patch Changes
- ef5262f: Fix race condition when streaming blob
## 0.7.0
### Minor Changes
- 023e03e: Rename package to nsite-gateway
## 0.6.1
### Patch Changes
- 3747037: Add license file
## 0.6.0
### Minor Changes
- c84396e: Replace homepage with simple welcome page
- c84396e: Add option to download another nsite as a homepage
- 2ac847f: Add colors to logging
### Patch Changes
- 5be0822: Fix serving hidden files in .well-known
## 0.5.2
### Patch Changes
- 6704516: Fix package missing build folder
## 0.5.1
### Patch Changes
- ba71f35: bump dependnecies
## 0.5.0
### Minor Changes
- db172d4: Add support for custom 404.html pages
## 0.4.0
### Minor Changes
- 7c3c9c0: Add ONION_HOST env variable
## 0.3.0 ## 0.3.0

8
Caddyfile Normal file
View File

@ -0,0 +1,8 @@
#{
# email your-email@example.com
#}
# This will match example.com and all its subdomains (*.example.com)
example.com, *.example.com {
reverse_proxy nsite:3000
}

View File

@ -1,13 +1,9 @@
# syntax=docker/dockerfile:1 FROM node:22-alpine AS base
FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
RUN apk update && apk add --no-cache nginx supervisor
COPY supervisord.conf /etc/supervisord.conf
WORKDIR /app WORKDIR /app
COPY package.json . COPY package.json .
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
@ -27,26 +23,13 @@ FROM base AS main
RUN addgroup -S nsite && adduser -S nsite -G nsite RUN addgroup -S nsite && adduser -S nsite -G nsite
RUN chown -R nsite:nsite /app RUN chown -R nsite:nsite /app
# Setup nginx
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
# setup nsite # setup nsite
COPY --from=prod-deps /app/node_modules /app/node_modules COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build ./app/build ./build COPY --from=build ./app/build ./build
COPY ./public ./public COPY ./public ./public
COPY tor-and-i2p.pac proxy.pac
VOLUME [ "/var/cache/nginx" ] EXPOSE 3000
EXPOSE 80 3000
ENV NSITE_PORT="3000" ENV NSITE_PORT="3000"
ENV NGINX_CACHE_DIR="/var/cache/nginx"
ENV ENABLE_SCREENSHOTS="false"
COPY docker-entrypoint.sh / CMD ["node", "."]
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

View File

@ -1,76 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# Setup nsite user
RUN groupadd -r nsite && useradd -r -g nsite -G audio,video nsite && usermod -d /app nsite
# Install nginx and supervisor
RUN apt-get update && apt-get install -y nginx supervisor
# setup supervisor
COPY supervisord.conf /etc/supervisord.conf
# Setup nginx
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
RUN chown nsite:nsite -R /etc/nginx
# install google chrome for screenshots. copied from (https://pptr.dev/troubleshooting#running-puppeteer-in-docker)
# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chrome for Testing that Puppeteer
# installs, work.
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY tsconfig.json .
COPY src ./src
RUN pnpm build
FROM base AS main
# setup nsite
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build ./app/build ./build
COPY ./public ./public
COPY tor-and-i2p.pac proxy.pac
VOLUME [ "/var/cache/nginx" ]
VOLUME [ "/screenshots" ]
EXPOSE 80 3000
ENV NSITE_PORT="3000"
ENV NGINX_CACHE_DIR="/var/cache/nginx"
ENV ENABLE_SCREENSHOTS="true"
ENV SCREENSHOTS_DIR="/screenshots"
ENV PUPPETEER_SKIP_DOWNLOAD="true"
COPY docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
# change ownership of app
RUN chown nsite:nsite -R /app
# Run /docker-entrypoint as root so supervisor can run
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2025 hzrd149
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,46 +1,77 @@
# nsite-ts # nsite-gateway
A Typescript implementation of [nsite](https://github.com/lez/nsite) A Typescript implementation of [static websites on nostr](https://github.com/nostr-protocol/nips/pull/1538)
## Configuring
All configuration is done through the `.env` file. start by copying the example file and modifying it.
```sh
cp .env.example .env
```
## Running with npx
```sh
npx nsite-gateway
```
## Running with docker-compose ## Running with docker-compose
```sh ```sh
git clone https://github.com/hzrd149/nsite-ts.git git clone https://github.com/hzrd149/nsite-gateway.git
cd nsite-ts cd nsite-gateway
docker compose up docker compose up
``` ```
Once the service is running you can access the cached version at `http://localhost:8080` Once the service is running you can access the gateway at `http://localhost:3000`
If you need to test, you can directly access the ts server at `http://localhost:3000` ## Running with docker
## Connecting to Tor and I2P relays The `ghcr.io/hzrd149/nsite-gateway` image can be used to run a http instance locally
nsite-ts supports `ALL_PROXY` and other proxy env variables [here](https://www.npmjs.com/package/proxy-from-env#environment-variables)
Install Tor ([Documentation](https://community.torproject.org/onion-services/setup/install/)) and I2Pd ([Documentation](https://i2pd.readthedocs.io/en/latest/user-guide/install/))
Create a proxy.pac file
```txt
// SPDX-License-Identifier: CC0-1.0
function FindProxyForURL(url, host)
{
if (shExpMatch(host, "*.i2p"))
{
return "PROXY 127.0.0.1:4444; SOCKS5 127.0.0.1:4447";
}
if (shExpMatch(host, "*.onion"))
{
return "SOCKS5 127.0.0.1:9050";
}
return "DIRECT";
}
```
Start server with `PAC_PROXY` variable
```sh ```sh
PAC_PROXY=file://$(pwd)/proxy.pac node . docker run --rm -it --name nsite -p 3000:3000 ghcr.io/hzrd149/nsite-gateway
```
## Tor setup
First you need to install tor (`sudo apt install tor` on debian systems) or [Documentation](https://community.torproject.org/onion-services/setup/install/)
Then able the tor service
```sh
sudo systemctl enable tor
sudo systemctl start tor
```
### Setup hidden service
Modify the torrc file to enable `HiddenServiceDir` and `HiddenServicePort`
```
HiddenServiceDir /var/lib/tor/hidden_service/
HiddenServicePort 80 127.0.0.1:8080
```
Then restart tor
```sh
sudo systemctl restart tor
```
Next get the onion address using `cat /var/lib/tor/hidden_service/hostname` and set the `ONION_HOST` variable in the `.env` file
```sh
# don't forget to start with http://
ONION_HOST="http://q457mvdt5smqj726m4lsqxxdyx7r3v7gufzt46zbkop6mkghpnr7z3qd.onion"
```
### Connecting to Tor and I2P relays and blossom servers
Install Tor ([Documentation](https://community.torproject.org/onion-services/setup/install/)) and optionally I2Pd ([Documentation](https://i2pd.readthedocs.io/en/latest/user-guide/install/)) and then add the `TOR_PROXY` and `I2P_PROXY` variables to the `.env` file
```sh
TOR_PROXY=127.0.0.1:9050
I2P_PROXY=127.0.0.1:4447
``` ```

13
contrib/nsite.service Normal file
View File

@ -0,0 +1,13 @@
[Unit]
Description=nsite Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/<path-to>/nsite-gateway
ExecStart=/usr/bin/node .
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +1,36 @@
version: "3.7"
services: services:
redis:
image: redis:alpine
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis-data:/data
nsite: nsite:
build: . build: .
image: ghcr.io/hzrd149/nsite-ts:master image: ghcr.io/hzrd149/nsite-gateway:master
restart: unless-stopped
environment: environment:
LOOKUP_RELAYS: wss://user.kindpag.es,wss://purplepag.es LOOKUP_RELAYS: wss://user.kindpag.es,wss://purplepag.es
SUBSCRIPTION_RELAYS: wss://nostrue.com/,wss://nos.lol/,wss://relay.damus.io/,wss://purplerelay.com/ SUBSCRIPTION_RELAYS: wss://nostrue.com/,wss://nos.lol/,wss://relay.damus.io/,wss://purplerelay.com/
volumes: CACHE_PATH: redis://redis:6379
- type: tmpfs depends_on:
target: /var/cache/nginx - redis
tmpfs:
size: 100M caddy:
image: caddy:alpine
restart: unless-stopped
ports: ports:
- 8080:80 - "80:80"
- 3000:3000 - "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- nsite
volumes:
redis-data:
caddy_data:
caddy_config:

View File

@ -1,7 +0,0 @@
#!/bin/sh
echo Changing permission on volumes
chown -R nsite:nsite /var/cache/nginx
chown -R nsite:nsite /screenshots
exec "$@"

View File

@ -1,22 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name nsite;
location / {
proxy_cache request_cache;
proxy_cache_valid 200 60m;
proxy_cache_valid 404 10m;
proxy_cache_key $host$uri;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache $upstream_cache_status;
add_header X-Cache-Status $upstream_status;
expires 30d;
add_header Cache-Control "public, no-transform";
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:3000;
}
}

View File

@ -1,33 +0,0 @@
user nsite;
worker_processes auto;
error_log /dev/stderr notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# add custom cache
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=request_cache:10m max_size=10g inactive=60m use_temp_path=off;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
include /etc/nginx/conf.d/*.conf;
}

View File

@ -1,6 +1,6 @@
{ {
"name": "nsite-ts", "name": "nsite-gateway",
"version": "0.3.0", "version": "1.0.1",
"description": "A blossom server implementation written in Typescript", "description": "A blossom server implementation written in Typescript",
"main": "build/index.js", "main": "build/index.js",
"type": "module", "type": "module",
@ -8,6 +8,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "node build/index.js", "start": "node build/index.js",
"prepack": "tsc",
"build": "tsc", "build": "tsc",
"dev": "nodemon -i '**/data/**' --exec 'node' --loader @swc-node/register/esm src/index.ts", "dev": "nodemon -i '**/data/**' --exec 'node' --loader @swc-node/register/esm src/index.ts",
"format": "prettier -w ." "format": "prettier -w ."
@ -18,44 +19,48 @@
"public" "public"
], ],
"dependencies": { "dependencies": {
"@keyv/redis": "^3.0.1", "@keyv/redis": "^4.3.2",
"@keyv/sqlite": "^4.0.1", "@keyv/sqlite": "^4.0.1",
"@koa/cors": "^5.0.0", "@koa/cors": "^5.0.0",
"blossom-client-sdk": "^1.1.1", "blossom-client-sdk": "^3.0.1",
"dotenv": "^16.4.5", "debug": "^4.4.0",
"follow-redirects": "^1.15.6", "dotenv": "^16.4.7",
"keyv": "^5.0.1", "follow-redirects": "^1.15.9",
"koa": "^2.15.3", "keyv": "^5.3.2",
"koa": "^2.16.0",
"koa-morgan": "^1.0.1", "koa-morgan": "^1.0.1",
"koa-send": "^5.0.1", "koa-send": "^5.0.1",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"mime": "^4.0.4", "mime": "^4.0.7",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.12.0",
"pac-proxy-agent": "^7.0.2", "nsite-cli": "^0.1.16",
"proxy-agent": "^6.4.0", "pac-proxy-agent": "^7.2.0",
"puppeteer": "^23.5.0", "proxy-agent": "^6.5.0",
"websocket-polyfill": "^1.0.0", "websocket-polyfill": "1.0.0",
"ws": "^8.18.0", "ws": "^8.18.1",
"xbytes": "^1.9.1" "xbytes": "^1.9.1"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.8", "@changesets/cli": "^2.28.1",
"@swc-node/register": "^1.9.0", "@swc-node/register": "^1.10.10",
"@swc/core": "^1.5.0", "@swc/core": "^1.11.16",
"@types/better-sqlite3": "^7.6.9", "@types/better-sqlite3": "^7.6.13",
"@types/debug": "^4.1.12",
"@types/follow-redirects": "^1.14.4", "@types/follow-redirects": "^1.14.4",
"@types/koa": "^2.14.0", "@types/koa": "^2.15.0",
"@types/koa-morgan": "^1.0.8", "@types/koa-morgan": "^1.0.8",
"@types/koa-send": "^4.1.6", "@types/koa-send": "^4.1.6",
"@types/koa-static": "^4.0.4", "@types/koa-static": "^4.0.4",
"@types/koa__cors": "^5.0.0", "@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4", "@types/koa__router": "^12.0.4",
"@types/node": "^20.11.19", "@types/node": "^20.17.30",
"@types/proxy-from-env": "^1.0.4", "@types/proxy-from-env": "^1.0.4",
"@types/ws": "^8.5.10", "@types/ws": "^8.18.1",
"nodemon": "^3.0.3", "esbuild": "^0.25.2",
"prettier": "^3.3.3", "nodemon": "^3.1.9",
"typescript": "^5.3.3" "pkg": "^5.8.1",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
}, },
"resolutions": { "resolutions": {
"websocket-polyfill": "1.0.0" "websocket-polyfill": "1.0.0"

2905
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

51
public/404.html Normal file
View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - Page Not Found</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 20px;
}
.info {
background-color: #f8f9fa;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>404 - Page Not Found</h1>
<div class="info">
<p>We couldn't find an nsite for this domain.</p>
<p>This could mean either:</p>
<ul>
<li>The domain is not configured to point to an nsite</li>
</ul>
</div>
<p>
For more information about setting up an nsite, please refer to the
<a href="https://github.com/hzrd149/nsite-gateway">documentation</a>
</p>
</div>
</body>
</html>

View File

@ -1,110 +0,0 @@
import { html, css, LitElement } from "lit";
import { nip19 } from "nostr-tools";
import { pool, relays } from "../pool.js";
export class NsiteCard extends LitElement {
static styles = css`
:host {
min-width: 3in;
max-width: 4in;
border: 1px solid lightslategray;
display: flex;
flex-direction: column;
padding: 0.5em;
gap: 0.3em;
border-radius: 0.5em;
}
.title {
display: flex;
gap: 0.5em;
align-items: center;
color: initial;
text-decoration: none;
}
.title h3 {
margin: 0;
}
.avatar {
width: 3rem;
height: 3rem;
border: none;
outline: none;
border-radius: 50%;
}
.thumb {
display: flex;
overflow: hidden;
}
.thumb > img {
width: 100%;
border-radius: 0.5em;
}
.about {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
time {
margin-top: auto;
}
`;
static properties = {
nsite: { type: Object },
profile: { state: true, type: Object },
hasThumb: { state: true, type: Boolean },
};
constructor() {
super();
this.hasThumb = true;
}
connectedCallback() {
super.connectedCallback();
pool.get(relays, { kinds: [0], authors: [this.nsite.pubkey] }).then((event) => {
if (event) this.profile = JSON.parse(event.content);
});
}
handleError() {
this.hasThumb = false;
}
render() {
const npub = nip19.npubEncode(this.nsite.pubkey);
const url = new URL("/", `${location.protocol}//${npub}.${location.host}`);
return html`
${this.hasThumb
? html`
<a class="thumb" href="${url}" target="_blank">
<img src="/screenshot/${this.nsite.pubkey}.png" @error=${this.handleError} />
</a>
`
: undefined}
<a class="title" href="${url}" target="_blank">
${this.profile && html`<img src="${this.profile.image || this.profile.picture}" class="avatar" />`}
<div>
${this.profile
? html`
<h3>${this.profile.display_name || this.profile.name}</h3>
<small>${this.profile.nip05}</small>
`
: html`<h3>${npub.slice(0, 8)}</h3>`}
</div>
</a>
${this.profile && html`<p class="about">${this.profile.about}</p>`}
<time>${new Date(this.nsite.created_at * 1000).toDateString()}</time>
`;
}
}
customElements.define("nsite-card", NsiteCard);

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -3,27 +3,65 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nsite</title> <title>Welcome to nsite-gateway</title>
<script type="importmap">
{
"imports": {
"blossom-client-sdk": "https://esm.run/blossom-client-sdk",
"nostr-tools": "https://esm.run/nostr-tools",
"lit": "https://esm.run/lit",
"lit/directives/repeat.js": "https://esm.run/lit/directives/repeat.js"
}
}
</script>
<style> <style>
nostr-picture > img { body {
width: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 20px;
}
.info {
background-color: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 20px 0;
}
code {
background-color: #f1f1f1;
padding: 2px 6px;
border-radius: 3px;
font-family: Monaco, monospace;
user-select: all;
word-break: break-all;
} }
</style> </style>
<script type="module" src="./main.js"></script>
</head> </head>
<body> <body>
<nsite-app></nsite-app> <div class="container">
<h1>Welcome to nsite-gateway</h1>
<p>If you're seeing this page, nsite-gateway has been successfully installed and is working.</p>
<div class="info">
<p>
To set a custom homepage, set the <code>NSITE_HOMEPAGE</code> environment variable to your desired nprofile
</p>
<p>
Example:
<br />
<code
>NSITE_HOMEPAGE=nprofile1qqspspfsrjnurtf0jdyswm8jstustv7pu4qw3pn4u99etptvgzm4uvcpz9mhxue69uhkummnw3e82efwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t04mzfem</code
>
</p>
</div>
<p>
For more information about configuring nsite-gateway, please refer to the
<a href="https://github.com/hzrd149/nsite-gateway">documentation</a>
</p>
</div>
</body> </body>
</html> </html>

29
public/lib/lit.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,635 +0,0 @@
/*!
* Milligram v1.4.1
* https://milligram.io
*
* Copyright (c) 2020 CJ Patoilo
* Licensed under the MIT license
*/
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%;
}
body {
color: #606c76;
font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
font-size: 1.6em;
font-weight: 300;
letter-spacing: .01em;
line-height: 1.6;
}
blockquote {
border-left: 0.3rem solid #d1d1d1;
margin-left: 0;
margin-right: 0;
padding: 1rem 1.5rem;
}
blockquote *:last-child {
margin-bottom: 0;
}
.button,
button,
input[type='button'],
input[type='reset'],
input[type='submit'] {
background-color: #9b4dca;
border: 0.1rem solid #9b4dca;
border-radius: .4rem;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 1.1rem;
font-weight: 700;
height: 3.8rem;
letter-spacing: .1rem;
line-height: 3.8rem;
padding: 0 3.0rem;
text-align: center;
text-decoration: none;
text-transform: uppercase;
white-space: nowrap;
}
.button:focus, .button:hover,
button:focus,
button:hover,
input[type='button']:focus,
input[type='button']:hover,
input[type='reset']:focus,
input[type='reset']:hover,
input[type='submit']:focus,
input[type='submit']:hover {
background-color: #606c76;
border-color: #606c76;
color: #fff;
outline: 0;
}
.button[disabled],
button[disabled],
input[type='button'][disabled],
input[type='reset'][disabled],
input[type='submit'][disabled] {
cursor: default;
opacity: .5;
}
.button[disabled]:focus, .button[disabled]:hover,
button[disabled]:focus,
button[disabled]:hover,
input[type='button'][disabled]:focus,
input[type='button'][disabled]:hover,
input[type='reset'][disabled]:focus,
input[type='reset'][disabled]:hover,
input[type='submit'][disabled]:focus,
input[type='submit'][disabled]:hover {
background-color: #9b4dca;
border-color: #9b4dca;
}
.button.button-outline,
button.button-outline,
input[type='button'].button-outline,
input[type='reset'].button-outline,
input[type='submit'].button-outline {
background-color: transparent;
color: #9b4dca;
}
.button.button-outline:focus, .button.button-outline:hover,
button.button-outline:focus,
button.button-outline:hover,
input[type='button'].button-outline:focus,
input[type='button'].button-outline:hover,
input[type='reset'].button-outline:focus,
input[type='reset'].button-outline:hover,
input[type='submit'].button-outline:focus,
input[type='submit'].button-outline:hover {
background-color: transparent;
border-color: #606c76;
color: #606c76;
}
.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover,
button.button-outline[disabled]:focus,
button.button-outline[disabled]:hover,
input[type='button'].button-outline[disabled]:focus,
input[type='button'].button-outline[disabled]:hover,
input[type='reset'].button-outline[disabled]:focus,
input[type='reset'].button-outline[disabled]:hover,
input[type='submit'].button-outline[disabled]:focus,
input[type='submit'].button-outline[disabled]:hover {
border-color: inherit;
color: #9b4dca;
}
.button.button-clear,
button.button-clear,
input[type='button'].button-clear,
input[type='reset'].button-clear,
input[type='submit'].button-clear {
background-color: transparent;
border-color: transparent;
color: #9b4dca;
}
.button.button-clear:focus, .button.button-clear:hover,
button.button-clear:focus,
button.button-clear:hover,
input[type='button'].button-clear:focus,
input[type='button'].button-clear:hover,
input[type='reset'].button-clear:focus,
input[type='reset'].button-clear:hover,
input[type='submit'].button-clear:focus,
input[type='submit'].button-clear:hover {
background-color: transparent;
border-color: transparent;
color: #606c76;
}
.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover,
button.button-clear[disabled]:focus,
button.button-clear[disabled]:hover,
input[type='button'].button-clear[disabled]:focus,
input[type='button'].button-clear[disabled]:hover,
input[type='reset'].button-clear[disabled]:focus,
input[type='reset'].button-clear[disabled]:hover,
input[type='submit'].button-clear[disabled]:focus,
input[type='submit'].button-clear[disabled]:hover {
color: #9b4dca;
}
code {
background: #f4f5f6;
border-radius: .4rem;
font-size: 86%;
margin: 0 .2rem;
padding: .2rem .5rem;
white-space: nowrap;
}
pre {
background: #f4f5f6;
border-left: 0.3rem solid #9b4dca;
overflow-y: hidden;
}
pre > code {
border-radius: 0;
display: block;
padding: 1rem 1.5rem;
white-space: pre;
}
hr {
border: 0;
border-top: 0.1rem solid #f4f5f6;
margin: 3.0rem 0;
}
input[type='color'],
input[type='date'],
input[type='datetime'],
input[type='datetime-local'],
input[type='email'],
input[type='month'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='url'],
input[type='week'],
input:not([type]),
textarea,
select {
-webkit-appearance: none;
background-color: transparent;
border: 0.1rem solid #d1d1d1;
border-radius: .4rem;
box-shadow: none;
box-sizing: inherit;
height: 3.8rem;
padding: .6rem 1.0rem .7rem;
width: 100%;
}
input[type='color']:focus,
input[type='date']:focus,
input[type='datetime']:focus,
input[type='datetime-local']:focus,
input[type='email']:focus,
input[type='month']:focus,
input[type='number']:focus,
input[type='password']:focus,
input[type='search']:focus,
input[type='tel']:focus,
input[type='text']:focus,
input[type='url']:focus,
input[type='week']:focus,
input:not([type]):focus,
textarea:focus,
select:focus {
border-color: #9b4dca;
outline: 0;
}
select {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%23d1d1d1" d="M0,0l6,8l6-8"/></svg>') center right no-repeat;
padding-right: 3.0rem;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%239b4dca" d="M0,0l6,8l6-8"/></svg>');
}
select[multiple] {
background: none;
height: auto;
}
textarea {
min-height: 6.5rem;
}
label,
legend {
display: block;
font-size: 1.6rem;
font-weight: 700;
margin-bottom: .5rem;
}
fieldset {
border-width: 0;
padding: 0;
}
input[type='checkbox'],
input[type='radio'] {
display: inline;
}
.label-inline {
display: inline-block;
font-weight: normal;
margin-left: .5rem;
}
.container {
margin: 0 auto;
max-width: 112.0rem;
padding: 0 2.0rem;
position: relative;
width: 100%;
}
.row {
display: flex;
flex-direction: column;
padding: 0;
width: 100%;
}
.row.row-no-padding {
padding: 0;
}
.row.row-no-padding > .column {
padding: 0;
}
.row.row-wrap {
flex-wrap: wrap;
}
.row.row-top {
align-items: flex-start;
}
.row.row-bottom {
align-items: flex-end;
}
.row.row-center {
align-items: center;
}
.row.row-stretch {
align-items: stretch;
}
.row.row-baseline {
align-items: baseline;
}
.row .column {
display: block;
flex: 1 1 auto;
margin-left: 0;
max-width: 100%;
width: 100%;
}
.row .column.column-offset-10 {
margin-left: 10%;
}
.row .column.column-offset-20 {
margin-left: 20%;
}
.row .column.column-offset-25 {
margin-left: 25%;
}
.row .column.column-offset-33, .row .column.column-offset-34 {
margin-left: 33.3333%;
}
.row .column.column-offset-40 {
margin-left: 40%;
}
.row .column.column-offset-50 {
margin-left: 50%;
}
.row .column.column-offset-60 {
margin-left: 60%;
}
.row .column.column-offset-66, .row .column.column-offset-67 {
margin-left: 66.6666%;
}
.row .column.column-offset-75 {
margin-left: 75%;
}
.row .column.column-offset-80 {
margin-left: 80%;
}
.row .column.column-offset-90 {
margin-left: 90%;
}
.row .column.column-10 {
flex: 0 0 10%;
max-width: 10%;
}
.row .column.column-20 {
flex: 0 0 20%;
max-width: 20%;
}
.row .column.column-25 {
flex: 0 0 25%;
max-width: 25%;
}
.row .column.column-33, .row .column.column-34 {
flex: 0 0 33.3333%;
max-width: 33.3333%;
}
.row .column.column-40 {
flex: 0 0 40%;
max-width: 40%;
}
.row .column.column-50 {
flex: 0 0 50%;
max-width: 50%;
}
.row .column.column-60 {
flex: 0 0 60%;
max-width: 60%;
}
.row .column.column-66, .row .column.column-67 {
flex: 0 0 66.6666%;
max-width: 66.6666%;
}
.row .column.column-75 {
flex: 0 0 75%;
max-width: 75%;
}
.row .column.column-80 {
flex: 0 0 80%;
max-width: 80%;
}
.row .column.column-90 {
flex: 0 0 90%;
max-width: 90%;
}
.row .column .column-top {
align-self: flex-start;
}
.row .column .column-bottom {
align-self: flex-end;
}
.row .column .column-center {
align-self: center;
}
@media (min-width: 40rem) {
.row {
flex-direction: row;
margin-left: -1.0rem;
width: calc(100% + 2.0rem);
}
.row .column {
margin-bottom: inherit;
padding: 0 1.0rem;
}
}
a {
color: #9b4dca;
text-decoration: none;
}
a:focus, a:hover {
color: #606c76;
}
dl,
ol,
ul {
list-style: none;
margin-top: 0;
padding-left: 0;
}
dl dl,
dl ol,
dl ul,
ol dl,
ol ol,
ol ul,
ul dl,
ul ol,
ul ul {
font-size: 90%;
margin: 1.5rem 0 1.5rem 3.0rem;
}
ol {
list-style: decimal inside;
}
ul {
list-style: circle inside;
}
.button,
button,
dd,
dt,
li {
margin-bottom: 1.0rem;
}
fieldset,
input,
select,
textarea {
margin-bottom: 1.5rem;
}
blockquote,
dl,
figure,
form,
ol,
p,
pre,
table,
ul {
margin-bottom: 2.5rem;
}
table {
border-spacing: 0;
display: block;
overflow-x: auto;
text-align: left;
width: 100%;
}
td,
th {
border-bottom: 0.1rem solid #e1e1e1;
padding: 1.2rem 1.5rem;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
@media (min-width: 40rem) {
table {
display: table;
overflow-x: initial;
}
}
b,
strong {
font-weight: bold;
}
p {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 300;
letter-spacing: -.1rem;
margin-bottom: 2.0rem;
margin-top: 0;
}
h1 {
font-size: 4.6rem;
line-height: 1.2;
}
h2 {
font-size: 3.6rem;
line-height: 1.25;
}
h3 {
font-size: 2.8rem;
line-height: 1.3;
}
h4 {
font-size: 2.2rem;
letter-spacing: -.08rem;
line-height: 1.35;
}
h5 {
font-size: 1.8rem;
letter-spacing: -.05rem;
line-height: 1.5;
}
h6 {
font-size: 1.6rem;
letter-spacing: 0;
line-height: 1.4;
}
img {
max-width: 100%;
}
.clearfix:after {
clear: both;
content: ' ';
display: table;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
/*# sourceMappingURL=milligram.css.map */

View File

@ -1,349 +0,0 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,58 +0,0 @@
import { LitElement, html, css } from "lit";
import { repeat } from "lit/directives/repeat.js";
import "./components/nsite-card.js";
import { pool, relays } from "./pool.js";
export class NsiteApp extends LitElement {
static properties = {
selected: { state: true },
status: { state: true, type: String },
sites: { state: true, type: Array },
};
static styles = css`
.sites {
display: flex;
gap: 0.5em;
flex-wrap: wrap;
}
`;
seen = new Set();
constructor() {
super();
this.sites = [];
}
connectedCallback() {
super.connectedCallback();
pool.subscribeMany(relays, [{ kinds: [34128], "#d": ["/index.html"] }], {
onevent: (event) => {
if (this.seen.has(event.pubkey)) return;
this.seen.add(event.pubkey);
this.sites = [...this.sites, event].sort((a, b) => b.created_at - a.created_at);
},
});
}
render() {
return html`<div class="container">
<img src="/logo.jpg" style="max-height: 2in" />
<h1>nsite</h1>
<a class="navbar-item" href="https://github.com/hzrd149/nsite-ts" target="_blank">Source Code</a>
<h2 class="subtitle is-2">Latest nsites:</h2>
<div class="sites">
${repeat(
this.sites,
(nsite) => nsite.pubkey,
(nsite) => html`<nsite-card .nsite="${nsite}"></nsite-card>`,
)}
</div>
</div>`;
}
}
customElements.define("nsite-app", NsiteApp);

View File

@ -1,4 +0,0 @@
import { SimplePool } from "nostr-tools";
export const relays = ["wss://relay.damus.io", "wss://nos.lol", "wss://nostr.wine"];
export const pool = new SimplePool();

View File

@ -1,46 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nsite</title>
<script type="importmap">
{
"imports": {
"blossom-client-sdk": "https://esm.run/blossom-client-sdk",
"nostr-tools": "https://esm.run/nostr-tools"
}
}
</script>
</head>
<body>
<label>relays</label>
<br />
<textarea type="text" id="relays" cols="50" rows="4"></textarea>
<br />
<br />
<label>blossom servers</label>
<br />
<textarea type="text" id="servers" cols="50" rows="4"></textarea>
<br />
<br />
<input type="file" id="files" webkitdirectory directory multiple />
<button id="upload-button">Upload nsite</button>
<div
id="log"
style="
max-height: 50em;
max-width: 80em;
width: 100%;
border: 1px solid gray;
min-height: 8em;
margin: 0.5em 0;
overflow: auto;
font-size: 0.8em;
gap: 0.1em;
white-space: pre;
"
></div>
<script type="module" src="/upload/upload.js"></script>
</body>
</html>

View File

@ -1,142 +0,0 @@
import { multiServerUpload, BlossomClient } from "blossom-client-sdk";
import { SimplePool } from "nostr-tools";
const logContainer = document.getElementById("log");
function log(...args) {
const el = document.createElement("div");
el.innerText = args.join(" ");
logContainer.appendChild(el);
}
const uploadButton = document.getElementById("upload-button");
/** @type {HTMLInputElement} */
const filesInput = document.getElementById("files");
/**
* @param {FileSystemFileEntry} fileEntry
* @returns {File}
*/
export function readFileSystemFile(fileEntry) {
return new Promise((res, rej) => {
fileEntry.file(
(file) => res(file),
(err) => rej(err),
);
});
}
/**
* @param {FileSystemDirectoryEntry} directory
* @returns {FileSystemEntry[]}
*/
export function readFileSystemDirectory(directory) {
return new Promise((res, rej) => {
directory.createReader().readEntries(
(entries) => res(entries),
(err) => rej(err),
);
});
}
/**
* uploads a file system entry to blossom servers
* @param {FileSystemEntry} entry
* @returns {{file: File, path: string, sha256: string}[]}
*/
async function readFileSystemEntry(entry) {
const files = [];
if (entry instanceof FileSystemFileEntry && entry.isFile) {
try {
const file = await readFileSystemFile(entry);
const sha256 = await BlossomClient.getFileSha256(file);
const path = entry.fullPath;
files.push({ file, path, sha256 });
} catch (e) {
log("Failed to add" + entry.fullPath);
log(e.message);
}
} else if (entry instanceof FileSystemDirectoryEntry && entry.isDirectory) {
const entries = await readFileSystemDirectory(entry);
for (const e of entries) files.push(...(await readFileSystemEntry(e)));
}
return files;
}
/**
* uploads a file system entry to blossom servers
* @param {FileList} list
* @returns {{file: File, path: string, sha256: string}[]}
*/
async function readFileList(list) {
const files = [];
for (const file of list) {
const path = file.webkitRelativePath ? file.webkitRelativePath : file.name;
const sha256 = await BlossomClient.getFileSha256(file);
files.push({ file, path, sha256 });
}
return files;
}
const pool = new SimplePool();
/**
* uploads a file system entry to blossom servers
* @param {{file:File, path:string}} files
* @param {import("blossom-client-sdk").Signer} signer
* @param {*} auth
* @param {string[]} servers
* @param {string[]} relays
*/
async function uploadFiles(files, signer, auth, servers, relays) {
for (const { file, path, sha256 } of files) {
try {
const upload = multiServerUpload(servers, file, signer, auth);
let published = false;
for await (let { blob } of upload) {
if (!published) {
const signed = await signer({
kind: 34128,
content: "",
created_at: Math.round(Date.now() / 1000),
tags: [
["d", path],
["x", sha256],
],
});
await pool.publish(relays, signed);
log("Published", path, sha256, signed.id);
}
}
} catch (error) {
log(`Failed to upload ${path}`, error);
}
}
}
uploadButton.addEventListener("click", async () => {
if (!window.nostr) return alert("Missing NIP-07 signer");
const signer = (draft) => window.nostr.signEvent(draft);
const relays = document.getElementById("relays").value.split(/\n|,/);
const servers = document.getElementById("servers").value.split(/\n|,/);
try {
if (filesInput.files) {
const files = await readFileList(filesInput.files);
// strip leading dir
for (const file of files) file.path = file.path.replace(/^[^\/]+\//, "/");
log(`Found ${files.length} files`);
await uploadFiles(files, signer, undefined, servers, relays);
}
} catch (error) {
alert(`Failed to upload files: ${error.message}`);
}
});

View File

@ -1,36 +1,67 @@
import { getServersFromServerListEvent, USER_BLOSSOM_SERVER_LIST_KIND } from "blossom-client-sdk"; import { IncomingMessage } from "node:http";
import { BLOSSOM_SERVERS, MAX_FILE_SIZE } from "./env.js"; import { MAX_FILE_SIZE } from "./env.js";
import { makeRequestWithAbort } from "./helpers/http.js"; import { makeRequestWithAbort } from "./helpers/http.js";
import pool from "./nostr.js"; import { blobURLs } from "./cache.js";
import logger from "./logger.js";
export async function getUserBlossomServers(pubkey: string, relays: string[]) { const log = logger.extend("blossom");
const blossomServersEvent = await pool.get(relays, { kinds: [USER_BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] });
return blossomServersEvent ? getServersFromServerListEvent(blossomServersEvent).map((u) => u.toString()) : undefined; /** Checks all servers for a blob and returns the URLs */
export async function findBlobURLs(sha256: string, servers: string[]): Promise<string[]> {
const cache = await blobURLs.get(sha256);
if (cache) return cache;
const urls = await Promise.all(
servers.map(async (server) => {
const url = new URL(sha256, server);
const check = await fetch(url, { method: "HEAD" }).catch(() => null);
if (check?.status === 200) return url.toString();
else return null;
}),
);
const filtered = urls.filter((url) => url !== null);
log(`Found ${filtered.length}/${servers.length} URLs for ${sha256}`);
await blobURLs.set(sha256, filtered);
return filtered;
} }
// TODO: download the file to /tmp and verify it /** Downloads a file from multiple servers */
export async function downloadFile(sha256: string, servers = BLOSSOM_SERVERS) { export async function streamBlob(sha256: string, servers: string[]): Promise<IncomingMessage | undefined> {
for (const server of servers) { if (servers.length === 0) return undefined;
// First find all available URLs
const urls = await findBlobURLs(sha256, servers);
if (urls.length === 0) return undefined;
// Try each URL sequentially with timeout
for (const urlString of urls) {
const controller = new AbortController();
let res: IncomingMessage | undefined = undefined;
try { try {
const { response } = await makeRequestWithAbort(new URL(sha256, server)); // Set up timeout to abort after 10s
const timeout = setTimeout(() => {
controller.abort();
}, 10_000);
try { const url = new URL(urlString);
if (!response.statusCode) throw new Error("Missing headers or status code"); const response = await makeRequestWithAbort(url, controller);
res = response;
clearTimeout(timeout);
const size = response.headers["content-length"]; if (!response.statusCode) throw new Error("Missing headers or status code");
if (size && parseInt(size) > MAX_FILE_SIZE) throw new Error("File too large");
if (response.statusCode >= 200 && response.statusCode < 300) { const size = response.headers["content-length"];
return response; if (size && parseInt(size) > MAX_FILE_SIZE) throw new Error("File too large");
} else throw new Error("Request failed");
} catch (error) { if (response.statusCode >= 200 && response.statusCode < 300) return response;
// Consume response data to free up memory
response.resume();
}
} catch (error) { } catch (error) {
// ignore error, try next server if (res) res.resume();
continue; // Try next URL if this one fails
} }
} }
} }

View File

@ -1,18 +1,19 @@
import Keyv from "keyv"; import Keyv, { KeyvOptions } from "keyv";
import pfs from "fs/promises"; import { CACHE_PATH, CACHE_TIME } from "./env.js";
import { CACHE_PATH } from "./env.js"; import logger from "./logger.js";
import { ParsedEvent } from "./events.js";
try { const log = logger.extend("cache");
await pfs.mkdir("data");
} catch (error) {}
async function createStore() { async function createStore() {
if (!CACHE_PATH || CACHE_PATH === "in-memory") return undefined; if (!CACHE_PATH || CACHE_PATH === "in-memory") return undefined;
else if (CACHE_PATH.startsWith("redis://")) { else if (CACHE_PATH.startsWith("redis://")) {
const { default: KeyvRedis } = await import("@keyv/redis"); const { default: KeyvRedis } = await import("@keyv/redis");
log(`Using redis cache at ${CACHE_PATH}`);
return new KeyvRedis(CACHE_PATH); return new KeyvRedis(CACHE_PATH);
} else if (CACHE_PATH.startsWith("sqlite://")) { } else if (CACHE_PATH.startsWith("sqlite://")) {
const { default: KeyvSqlite } = await import("@keyv/sqlite"); const { default: KeyvSqlite } = await import("@keyv/sqlite");
log(`Using sqlite cache at ${CACHE_PATH}`);
return new KeyvSqlite(CACHE_PATH); return new KeyvSqlite(CACHE_PATH);
} }
} }
@ -20,32 +21,49 @@ async function createStore() {
const store = await createStore(); const store = await createStore();
store?.on("error", (err) => { store?.on("error", (err) => {
console.log("Connection Error", err); log("Connection Error", err);
process.exit(1); process.exit(1);
}); });
const opts = store ? { store } : {}; const json: KeyvOptions = { serialize: JSON.stringify, deserialize: JSON.parse };
const opts: KeyvOptions = store ? { store } : {};
/** domain -> pubkey */ /** A cache that maps a domain to a pubkey ( domain -> pubkey ) */
export const userDomains = new Keyv({ export const pubkeyDomains = new Keyv<string | undefined>({
...opts, ...opts,
...json,
namespace: "domains", namespace: "domains",
// cache domains for an hour ttl: CACHE_TIME * 1000,
ttl: 60 * 60 * 1000,
}); });
/** pubkey -> blossom servers */ /** A cache that maps a pubkey to a set of blossom servers ( pubkey -> servers ) */
export const userServers = new Keyv({ export const pubkeyServers = new Keyv<string[] | undefined>({
...opts, ...opts,
...json,
namespace: "servers", namespace: "servers",
// cache servers for an hour ttl: CACHE_TIME * 1000,
ttl: 60 * 60 * 1000,
}); });
/** pubkey -> relays */ /** A cache that maps a pubkey to a set of relays ( pubkey -> relays ) */
export const userRelays = new Keyv({ export const pubkeyRelays = new Keyv<string[] | undefined>({
...opts, ...opts,
...json,
namespace: "relays", namespace: "relays",
// cache relays for an hour ttl: CACHE_TIME * 1000,
ttl: 60 * 60 * 1000, });
/** A cache that maps a pubkey + path to sha256 hash of the blob ( pubkey/path -> sha256 ) */
export const pathBlobs = new Keyv<ParsedEvent | undefined>({
...opts,
...json,
namespace: "paths",
ttl: CACHE_TIME * 1000,
});
/** A cache that maps a sha256 hash to a set of URLs that had the blob ( sha256 -> URLs ) */
export const blobURLs = new Keyv<string[] | undefined>({
...opts,
...json,
namespace: "blobs",
ttl: CACHE_TIME * 1000,
}); });

95
src/dns.ts Normal file
View File

@ -0,0 +1,95 @@
import dns from "node:dns";
import { nip05, nip19 } from "nostr-tools";
import { pubkeyDomains as pubkeyDomains } from "./cache.js";
import logger from "./logger.js";
import { NIP05_NAME_DOMAINS } from "./env.js";
export function getCnameRecords(hostname: string): Promise<string[]> {
return new Promise<string[]>((res, rej) => {
dns.resolveCname(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
export function getTxtRecords(hostname: string): Promise<string[][]> {
return new Promise<string[][]>((res, rej) => {
dns.resolveTxt(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
function extractPubkeyFromHostname(hostname: string): string | undefined {
const [npub] = hostname.split(".");
if (npub.startsWith("npub")) {
const parsed = nip19.decode(npub);
if (parsed.type !== "npub") throw new Error("Expected npub");
return parsed.data;
}
}
const log = logger.extend("DNS");
export async function resolvePubkeyFromHostname(hostname: string): Promise<string | undefined> {
if (hostname === "localhost") return undefined;
const cached = await pubkeyDomains.get(hostname);
if (cached) return cached;
// check if domain contains an npub
let pubkey = extractPubkeyFromHostname(hostname);
if (!pubkey) {
// try to get npub from CNAME
try {
const cnameRecords = await getCnameRecords(hostname);
for (const cname of cnameRecords) {
const p = extractPubkeyFromHostname(cname);
if (p) {
pubkey = p;
break;
}
}
} catch (error) {}
}
if (!pubkey) {
// Try to get npub from TXT records
try {
const txtRecords = await getTxtRecords(hostname);
for (const txt of txtRecords) {
for (const entry of txt) {
const p = extractPubkeyFromHostname(entry);
if (p) {
pubkey = p;
break;
}
}
}
} catch (error) {}
}
// Try to get npub from NIP-05
if (!pubkey && NIP05_NAME_DOMAINS) {
for (const domain of NIP05_NAME_DOMAINS) {
try {
const [name] = hostname.split(".");
const result = await nip05.queryProfile(name + "@" + domain);
if (result) {
pubkey = result.pubkey;
break;
}
} catch (err) {}
}
}
log(`Resolved ${hostname} to ${pubkey}`);
await pubkeyDomains.set(hostname, pubkey);
return pubkey;
}

View File

@ -1,6 +1,9 @@
import "dotenv/config"; import "dotenv/config";
import xbytes from "xbytes"; import xbytes from "xbytes";
const NSITE_HOMEPAGE = process.env.NSITE_HOMEPAGE;
const NSITE_HOMEPAGE_DIR = process.env.NSITE_HOMEPAGE_DIR || "public";
const LOOKUP_RELAYS = process.env.LOOKUP_RELAYS?.split(",").map((u) => u.trim()) ?? [ const LOOKUP_RELAYS = process.env.LOOKUP_RELAYS?.split(",").map((u) => u.trim()) ?? [
"wss://user.kindpag.es/", "wss://user.kindpag.es/",
"wss://purplepag.es/", "wss://purplepag.es/",
@ -13,8 +16,11 @@ const BLOSSOM_SERVERS = process.env.BLOSSOM_SERVERS?.split(",").map((u) => u.tri
const MAX_FILE_SIZE = process.env.MAX_FILE_SIZE ? xbytes.parseSize(process.env.MAX_FILE_SIZE) : Infinity; const MAX_FILE_SIZE = process.env.MAX_FILE_SIZE ? xbytes.parseSize(process.env.MAX_FILE_SIZE) : Infinity;
const NGINX_CACHE_DIR = process.env.NGINX_CACHE_DIR;
const CACHE_PATH = process.env.CACHE_PATH; const CACHE_PATH = process.env.CACHE_PATH;
const CACHE_TIME = process.env.CACHE_TIME ? parseInt(process.env.CACHE_TIME) : 60 * 60;
const NIP05_NAME_DOMAINS = process.env.NIP05_NAME_DOMAINS?.split(",").map((d) => d.trim());
const PUBLIC_DOMAIN = process.env.PUBLIC_DOMAIN;
const PAC_PROXY = process.env.PAC_PROXY; const PAC_PROXY = process.env.PAC_PROXY;
const TOR_PROXY = process.env.TOR_PROXY; const TOR_PROXY = process.env.TOR_PROXY;
@ -24,15 +30,15 @@ const NSITE_HOST = process.env.NSITE_HOST || "0.0.0.0";
const NSITE_PORT = process.env.NSITE_PORT ? parseInt(process.env.NSITE_PORT) : 3000; const NSITE_PORT = process.env.NSITE_PORT ? parseInt(process.env.NSITE_PORT) : 3000;
const HOST = `${NSITE_HOST}:${NSITE_PORT}`; const HOST = `${NSITE_HOST}:${NSITE_PORT}`;
const ENABLE_SCREENSHOTS = process.env.ENABLE_SCREENSHOTS === "true"; const ONION_HOST = process.env.ONION_HOST;
const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || "./screenshots";
export { export {
NSITE_HOMEPAGE,
NSITE_HOMEPAGE_DIR,
SUBSCRIPTION_RELAYS, SUBSCRIPTION_RELAYS,
LOOKUP_RELAYS, LOOKUP_RELAYS,
BLOSSOM_SERVERS, BLOSSOM_SERVERS,
MAX_FILE_SIZE, MAX_FILE_SIZE,
NGINX_CACHE_DIR,
CACHE_PATH, CACHE_PATH,
PAC_PROXY, PAC_PROXY,
TOR_PROXY, TOR_PROXY,
@ -40,6 +46,8 @@ export {
NSITE_HOST, NSITE_HOST,
NSITE_PORT, NSITE_PORT,
HOST, HOST,
ENABLE_SCREENSHOTS, ONION_HOST,
SCREENSHOTS_DIR, CACHE_TIME,
NIP05_NAME_DOMAINS,
PUBLIC_DOMAIN,
}; };

View File

@ -1,7 +1,16 @@
import { extname, isAbsolute, join } from "path"; import { extname, join } from "path";
import { NSITE_KIND } from "./const.js"; import { NSITE_KIND } from "./const.js";
import { requestEvents } from "./nostr.js"; import { requestEvents } from "./nostr.js";
import { pathBlobs } from "./cache.js";
export type ParsedEvent = {
pubkey: string;
path: string;
sha256: string;
created_at: number;
};
/** Returns all the `d` tags that should be searched for a given path */
export function getSearchPaths(path: string) { export function getSearchPaths(path: string) {
const paths = [path]; const paths = [path];
@ -11,7 +20,7 @@ export function getSearchPaths(path: string) {
return paths.filter((p) => !!p); return paths.filter((p) => !!p);
} }
export function parseNsiteEvent(event: { pubkey: string; tags: string[][] }) { export function parseNsiteEvent(event: { pubkey: string; tags: string[][]; created_at: number }) {
const path = event.tags.find((t) => t[0] === "d" && t[1])?.[1]; const path = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
const sha256 = event.tags.find((t) => t[0] === "x" && t[1])?.[1]; const sha256 = event.tags.find((t) => t[0] === "x" && t[1])?.[1];
@ -20,16 +29,29 @@ export function parseNsiteEvent(event: { pubkey: string; tags: string[][] }) {
pubkey: event.pubkey, pubkey: event.pubkey,
path: join("/", path), path: join("/", path),
sha256, sha256,
created_at: event.created_at,
}; };
} }
export async function getNsiteBlobs(pubkey: string, path: string, relays: string[]) { /** Returns the first blob found for a given path */
export async function getNsiteBlob(pubkey: string, path: string, relays: string[]): Promise<ParsedEvent | undefined> {
const key = pubkey + path;
const cached = await pathBlobs.get(key);
if (cached) return cached;
// NOTE: hack, remove "/" paths since it breaks some relays // NOTE: hack, remove "/" paths since it breaks some relays
const paths = getSearchPaths(path).filter((p) => p !== "/"); const paths = getSearchPaths(path).filter((p) => p !== "/");
const events = await requestEvents(relays, { kinds: [NSITE_KIND], "#d": paths, authors: [pubkey] }); const events = await requestEvents(relays, { kinds: [NSITE_KIND], "#d": paths, authors: [pubkey] });
return Array.from(events) // Sort the found blobs by the order of the paths array
const options = Array.from(events)
.map(parseNsiteEvent) .map(parseNsiteEvent)
.filter((e) => !!e) .filter((e) => !!e)
.sort((a, b) => paths.indexOf(a.path) - paths.indexOf(b.path)); .sort((a, b) => paths.indexOf(a.path) - paths.indexOf(b.path));
// Remember the blob for this path
if (options.length > 0) await pathBlobs.set(key, options[0]);
return options[0];
} }

View File

@ -1,59 +0,0 @@
import dns from "node:dns";
import { nip19 } from "nostr-tools";
export function getCnameRecords(hostname: string) {
return new Promise<string[]>((res, rej) => {
dns.resolveCname(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
export function getTxtRecords(hostname: string) {
return new Promise<string[][]>((res, rej) => {
dns.resolveTxt(hostname, (err, records) => {
if (err) rej(err);
else res(records);
});
});
}
function extractNpubFromHostname(hostname: string) {
const [npub] = hostname.split(".");
if (npub.startsWith("npub")) {
const parsed = nip19.decode(npub);
if (parsed.type !== "npub") throw new Error("Expected npub");
return parsed.data;
}
}
export async function resolveNpubFromHostname(hostname: string) {
// check if domain contains an npub
let pubkey = extractNpubFromHostname(hostname);
if (pubkey) return pubkey;
if (hostname === "localhost") return undefined;
// try to get npub from CNAME or TXT records
try {
const cnameRecords = await getCnameRecords(hostname);
for (const cname of cnameRecords) {
const p = extractNpubFromHostname(cname);
if (p) return p;
}
} catch (error) {}
try {
const txtRecords = await getTxtRecords(hostname);
for (const txt of txtRecords) {
for (const entry of txt) {
const p = extractNpubFromHostname(entry);
if (p) return p;
}
}
} catch (error) {}
}

View File

@ -4,17 +4,18 @@ const { http, https } = followRedirects;
import agent from "../proxy.js"; import agent from "../proxy.js";
export function makeRequestWithAbort(url: URL) { export function makeRequestWithAbort(url: URL, controller: AbortController) {
return new Promise<{ response: IncomingMessage; controller: AbortController }>((res, rej) => { return new Promise<IncomingMessage>((res, rej) => {
const cancelController = new AbortController(); controller.signal.addEventListener("abort", () => rej(new Error("Aborted")));
const request = (url.protocol === "https:" ? https : http).get( const request = (url.protocol === "https:" ? https : http).get(
url, url,
{ {
signal: cancelController.signal, signal: controller.signal,
agent, agent,
}, },
(response) => { (response) => {
res({ response, controller: cancelController }); res(response);
}, },
); );
request.on("error", (err) => rej(err)); request.on("error", (err) => rej(err));

View File

@ -2,29 +2,33 @@
import "./polyfill.js"; import "./polyfill.js";
import Koa from "koa"; import Koa from "koa";
import serve from "koa-static"; import serve from "koa-static";
import path, { basename } from "node:path"; import path from "node:path";
import cors from "@koa/cors"; import cors from "@koa/cors";
import fs from "node:fs"; import fs from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import mime from "mime"; import mime from "mime";
import morgan from "koa-morgan"; import morgan from "koa-morgan";
import send from "koa-send"; import { npubEncode } from "nostr-tools/nip19";
import { nip19 } from "nostr-tools";
import { resolveNpubFromHostname } from "./helpers/dns.js"; import { resolvePubkeyFromHostname } from "./dns.js";
import { getNsiteBlobs, parseNsiteEvent } from "./events.js"; import { getNsiteBlob } from "./events.js";
import { downloadFile, getUserBlossomServers } from "./blossom.js"; import { streamBlob } from "./blossom.js";
import { import {
BLOSSOM_SERVERS, BLOSSOM_SERVERS,
ENABLE_SCREENSHOTS,
HOST, HOST,
NGINX_CACHE_DIR, NSITE_HOMEPAGE,
NSITE_HOMEPAGE_DIR,
NSITE_HOST, NSITE_HOST,
NSITE_PORT, NSITE_PORT,
ONION_HOST,
PUBLIC_DOMAIN,
SUBSCRIPTION_RELAYS, SUBSCRIPTION_RELAYS,
} from "./env.js"; } from "./env.js";
import { userDomains, userRelays, userServers } from "./cache.js"; import pool, { getUserBlossomServers, getUserOutboxes } from "./nostr.js";
import { invalidatePubkeyPath } from "./nginx.js"; import logger from "./logger.js";
import pool, { getUserOutboxes, subscribeForEvents } from "./nostr.js"; import { watchInvalidation } from "./invalidation.js";
import { NSITE_KIND } from "./const.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -57,158 +61,136 @@ app.use(async (ctx, next) => {
// handle nsite requests // handle nsite requests
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
let pubkey = await userDomains.get<string | undefined>(ctx.hostname); let pubkey = await resolvePubkeyFromHostname(ctx.hostname);
// resolve pubkey if not in cache let fallthrough = false;
if (!pubkey) { if (!pubkey && NSITE_HOMEPAGE && (!PUBLIC_DOMAIN || ctx.hostname === PUBLIC_DOMAIN)) {
console.log(`${ctx.hostname}: Resolving`); const parsed = nip19.decode(NSITE_HOMEPAGE);
pubkey = await resolveNpubFromHostname(ctx.hostname); // TODO: use the relays in the nprofile
if (pubkey) { if (parsed.type === "nprofile") pubkey = parsed.data.pubkey;
await userDomains.set(ctx.hostname, pubkey); else if (parsed.type === "npub") pubkey = parsed.data;
console.log(`${ctx.hostname}: Found ${pubkey}`);
} else { // Fallback to public dir if path cannot be found on the nsite homepage
await userDomains.set(ctx.hostname, ""); if (pubkey) fallthrough = true;
}
} }
if (pubkey) { if (!pubkey) {
ctx.state.pubkey = pubkey; if (fallthrough) return next();
let relays = await userRelays.get<string[] | undefined>(pubkey); ctx.status = 404;
ctx.body = fs.readFileSync(path.resolve(__dirname, "../public/404.html"), "utf-8");
return;
}
// fetch relays if not in cache // fetch relays
if (!relays) { const relays = (await getUserOutboxes(pubkey)) || [];
console.log(`${pubkey}: Fetching relays`);
relays = await getUserOutboxes(pubkey); // always check subscription relays
if (relays) { relays.push(...SUBSCRIPTION_RELAYS);
await userRelays.set(pubkey, relays);
console.log(`${pubkey}: Found ${relays.length} relays`);
} else {
relays = [];
await userServers.set(pubkey, [], 30_000);
console.log(`${pubkey}: Failed to find relays`);
}
}
relays.push(...SUBSCRIPTION_RELAYS); if (relays.length === 0) throw new Error("No relays found");
if (relays.length === 0) throw new Error("No nostr relays"); // fetch servers and events in parallel
let [servers, event] = await Promise.all([
getUserBlossomServers(pubkey, relays).then((s) => s || []),
getNsiteBlob(pubkey, ctx.path, relays).then((e) => {
if (!e) return getNsiteBlob(pubkey, "/404.html", relays);
else return e;
}),
]);
console.log(`${pubkey}: Searching for ${ctx.path}`); if (!event) {
const blobs = await getNsiteBlobs(pubkey, ctx.path, relays); if (fallthrough) return next();
if (blobs.length === 0) { ctx.status = 404;
console.log(`${pubkey}: Found 0 events`); ctx.body = `Not Found: no events found\npath: ${ctx.path}\nkind: ${NSITE_KIND}\npubkey: ${pubkey}\nrelays: ${relays.join(", ")}`;
ctx.status = 404; return;
ctx.body = "Not Found"; }
// always fetch from additional servers
servers.push(...BLOSSOM_SERVERS);
if (servers.length === 0) throw new Error("Failed to find blossom servers");
try {
const res = await streamBlob(event.sha256, servers);
if (!res) {
ctx.status = 502;
ctx.body = `Failed to find blob\npath: ${event.path}\nsha256: ${event.sha256}\nservers: ${servers.join(", ")}`;
return; return;
} }
let servers = await userServers.get<string[] | undefined>(pubkey); const type = mime.getType(event.path);
if (type) ctx.set("content-type", type);
else if (res.headers["content-type"]) ctx.set("content-type", res.headers["content-type"]);
// fetch blossom servers if not in cache // pass headers along
if (!servers) { if (res.headers["content-length"]) ctx.set("content-length", res.headers["content-length"]);
console.log(`${pubkey}: Fetching blossom servers`);
servers = await getUserBlossomServers(pubkey, relays);
if (servers) { // set Onion-Location header
await userServers.set(pubkey, servers); if (ONION_HOST) {
console.log(`${pubkey}: Found ${servers.length} servers`); const url = new URL(ONION_HOST);
} else { url.hostname = npubEncode(pubkey) + "." + url.hostname;
servers = []; ctx.set("Onion-Location", url.toString().replace(/\/$/, ""));
await userServers.set(pubkey, [], 30_000);
console.log(`${pubkey}: Failed to find servers`);
}
} }
// always fetch from additional servers // add cache headers
servers.push(...BLOSSOM_SERVERS); ctx.set("ETag", res.headers["etag"] || `"${event.sha256}"`);
ctx.set("Cache-Control", "public, max-age=3600");
for (const blob of blobs) { ctx.set("Last-Modified", res.headers["last-modified"] || new Date(event.created_at * 1000).toUTCString());
const res = await downloadFile(blob.sha256, servers);
if (res) {
const type = mime.getType(blob.path);
if (type) ctx.set("Content-Type", type);
else if (res.headers["content-type"]) ctx.set("content-type", res.headers["content-type"]);
// pass headers along
if (res.headers["content-length"]) ctx.set("content-length", res.headers["content-length"]);
if (res.headers["last-modified"]) ctx.set("last-modified", res.headers["last-modified"]);
ctx.status = 200;
ctx.body = res;
return;
}
}
ctx.status = 200;
ctx.body = res;
return;
} catch (error) {
ctx.status = 500; ctx.status = 500;
ctx.body = "Failed to find blob"; ctx.body = `Failed to stream blob ${event.path}\n${error}`;
} else await next(); return;
}
}); });
if (ONION_HOST) {
app.use((ctx, next) => {
// set Onion-Location header if it was not set before
if (!ctx.get("Onion-Location") && ONION_HOST) {
ctx.set("Onion-Location", ONION_HOST);
}
return next();
});
}
// serve static files from public // serve static files from public
const serveOptions: serve.Options = {
hidden: true,
maxAge: 60 * 60 * 1000,
index: "index.html",
};
try { try {
const www = path.resolve(process.cwd(), "public"); const www = NSITE_HOMEPAGE_DIR;
fs.statSync(www); fs.statSync(www);
app.use(serve(www)); app.use(serve(www, serveOptions));
} catch (error) { } catch (error) {
const www = path.resolve(__dirname, "../public"); const www = path.resolve(__dirname, "../public");
app.use(serve(www)); app.use(serve(www, serveOptions));
}
// get screenshots for websites
if (ENABLE_SCREENSHOTS) {
app.use(async (ctx, next) => {
if (ctx.method === "GET" && ctx.path.startsWith("/screenshot")) {
const [pubkey, etx] = basename(ctx.path).split(".");
if (pubkey) {
const { hasScreenshot, takeScreenshot, getScreenshotPath } = await import("./screenshots.js");
if (!(await hasScreenshot(pubkey))) await takeScreenshot(pubkey);
await send(ctx, getScreenshotPath(pubkey));
} else throw Error("Missing pubkey");
} else return next();
});
} }
// start the server
app.listen({ host: NSITE_HOST, port: NSITE_PORT }, () => { app.listen({ host: NSITE_HOST, port: NSITE_PORT }, () => {
console.log("Started on port", HOST); logger("Started on port", HOST);
}); });
// invalidate nginx cache and screenshots on new events // watch for invalidations
if (SUBSCRIPTION_RELAYS.length > 0) { watchInvalidation();
console.log(`Listening for new nsite events`);
subscribeForEvents(SUBSCRIPTION_RELAYS, async (event) => {
try {
const nsite = parseNsiteEvent(event);
if (nsite) {
if (NGINX_CACHE_DIR) {
console.log(`${nsite.pubkey}: Invalidating ${nsite.path}`);
await invalidatePubkeyPath(nsite.pubkey, nsite.path);
}
// invalidate screenshot for nsite
if (ENABLE_SCREENSHOTS && (nsite.path === "/" || nsite.path === "/index.html")) {
const { removeScreenshot } = await import("./screenshots.js");
await removeScreenshot(nsite.pubkey);
}
}
} catch (error) {
console.log(`Failed to invalidate ${event.id}`);
}
});
}
process.on("unhandledRejection", (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason); console.error("Unhandled Rejection at:", promise, "reason:", reason);
}); });
async function shutdown() { async function shutdown() {
console.log("Shutting down..."); logger("Shutting down...");
pool.destroy(); pool.destroy();
process.exit(0); process.exit(0);
} }

31
src/invalidation.ts Normal file
View File

@ -0,0 +1,31 @@
import { npubEncode } from "nostr-tools/nip19";
import { SUBSCRIPTION_RELAYS } from "./env.js";
import { parseNsiteEvent } from "./events.js";
import pool from "./nostr.js";
import { NSITE_KIND } from "./const.js";
import logger from "./logger.js";
import { pathBlobs } from "./cache.js";
const log = logger.extend("invalidation");
export function watchInvalidation() {
if (SUBSCRIPTION_RELAYS.length === 0) return;
logger(`Listening for new nsite events on: ${SUBSCRIPTION_RELAYS.join(", ")}`);
pool.subscribeMany(SUBSCRIPTION_RELAYS, [{ kinds: [NSITE_KIND], since: Math.round(Date.now() / 1000) - 60 * 60 }], {
onevent: async (event) => {
try {
const parsed = parseNsiteEvent(event);
if (parsed) {
pathBlobs.delete(parsed.pubkey + parsed.path);
log(`Invalidated ${npubEncode(parsed.pubkey) + parsed.path}`);
}
} catch (error) {
console.log(`Failed to invalidate ${event.id}`);
}
},
});
}

8
src/logger.ts Normal file
View File

@ -0,0 +1,8 @@
import debug from "debug";
// enable default logging
if (!debug.enabled("nsite")) debug.enable("nsite,nsite:*");
const logger = debug("nsite");
export default logger;

View File

@ -1,37 +0,0 @@
import pfs from "node:fs/promises";
import crypto from "node:crypto";
import { join } from "node:path";
import { NGINX_CACHE_DIR } from "./env.js";
import { userDomains } from "./cache.js";
export async function invalidatePubkeyPath(pubkey: string, path: string) {
const iterator = userDomains.iterator?.(undefined);
if (!iterator) return;
const promises: Promise<boolean | undefined>[] = [];
for await (const [domain, key] of iterator) {
if (key === pubkey) {
promises.push(invalidateNginxCache(domain, path));
}
}
await Promise.allSettled(promises);
}
export async function invalidateNginxCache(host: string, path: string) {
if (!NGINX_CACHE_DIR) return Promise.resolve(false);
try {
const key = `${host}${path}`;
const md5 = crypto.createHash("md5").update(key).digest("hex");
// NOTE: hard coded to cache levels 1:2
const cachePath = join(NGINX_CACHE_DIR, md5.slice(-1), md5.slice(-3, -1), md5);
await pfs.rm(cachePath);
console.log(`Invalidated ${key} (${md5})`);
} catch (error) {
// ignore errors
}
}

View File

@ -1,20 +1,51 @@
import { Filter, NostrEvent, SimplePool } from "nostr-tools"; import { Filter, NostrEvent, SimplePool } from "nostr-tools";
import { getServersFromServerListEvent, USER_BLOSSOM_SERVER_LIST_KIND } from "blossom-client-sdk";
import { LOOKUP_RELAYS } from "./env.js"; import { LOOKUP_RELAYS } from "./env.js";
import { NSITE_KIND } from "./const.js"; import { pubkeyRelays, pubkeyServers } from "./cache.js";
import logger from "./logger.js";
import { npubEncode } from "nostr-tools/nip19";
const pool = new SimplePool(); const pool = new SimplePool();
const log = logger.extend("nostr");
/** Fetches a pubkeys mailboxes from the cache or relays */
export async function getUserOutboxes(pubkey: string) { export async function getUserOutboxes(pubkey: string) {
const cached = await pubkeyRelays.get(pubkey);
if (cached) return cached;
const mailboxes = await pool.get(LOOKUP_RELAYS, { kinds: [10002], authors: [pubkey] }); const mailboxes = await pool.get(LOOKUP_RELAYS, { kinds: [10002], authors: [pubkey] });
if (!mailboxes) return; if (!mailboxes) return;
return mailboxes.tags.filter((t) => t[0] === "r" && (t[2] === undefined || t[2] === "write")).map((t) => t[1]); const relays = mailboxes.tags
.filter((t) => t[0] === "r" && (t[2] === undefined || t[2] === "write"))
.map((t) => t[1]);
log(`Found ${relays.length} relays for ${npubEncode(pubkey)}`);
await pubkeyRelays.set(pubkey, relays);
await pubkeyRelays.set(pubkey, relays);
return relays;
} }
export function subscribeForEvents(relays: string[], onevent: (event: NostrEvent) => any) { /** Fetches a pubkeys blossom servers from the cache or relays */
return pool.subscribeMany(relays, [{ kinds: [NSITE_KIND], since: Math.round(Date.now() / 1000) - 60 * 60 }], { export async function getUserBlossomServers(pubkey: string, relays: string[]) {
onevent, const cached = await pubkeyServers.get(pubkey);
}); if (cached) return cached;
const blossomServersEvent = await pool.get(relays, { kinds: [USER_BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] });
const servers = blossomServersEvent
? getServersFromServerListEvent(blossomServersEvent).map((u) => u.toString())
: undefined;
// Save servers if found
if (servers) {
log(`Found ${servers.length} blossom servers for ${npubEncode(pubkey)}`);
await pubkeyServers.set(pubkey, servers);
}
return servers;
} }
export function requestEvents(relays: string[], filter: Filter) { export function requestEvents(relays: string[], filter: Filter) {

View File

@ -1,46 +0,0 @@
import { nip19 } from "nostr-tools";
import puppeteer, { PuppeteerLaunchOptions } from "puppeteer";
import { join } from "path";
import pfs from "fs/promises";
import { NSITE_PORT, SCREENSHOTS_DIR } from "./env.js";
try {
await pfs.mkdir(SCREENSHOTS_DIR, { recursive: true });
} catch (error) {}
export function getScreenshotPath(pubkey: string) {
return join(SCREENSHOTS_DIR, pubkey + ".png");
}
export async function hasScreenshot(pubkey: string) {
try {
await pfs.stat(getScreenshotPath(pubkey));
return true;
} catch (error) {
return false;
}
}
export async function takeScreenshot(pubkey: string) {
console.log(`${pubkey}: Generating screenshot`);
const opts: PuppeteerLaunchOptions = {
args: ["--no-sandbox"],
};
if (process.env.PUPPETEER_SKIP_DOWNLOAD) opts.executablePath = "google-chrome-stable";
const browser = await puppeteer.launch(opts);
const page = await browser.newPage();
const url = new URL(`http://${nip19.npubEncode(pubkey)}.localhost:${NSITE_PORT}`);
await page.goto(url.toString());
await page.screenshot({ path: getScreenshotPath(pubkey) });
await browser.close();
}
export async function removeScreenshot(pubkey: string) {
try {
await pfs.rm(getScreenshotPath(pubkey));
console.log(`${pubkey}: Removed screenshot`);
} catch (error) {}
}

View File

@ -1,23 +0,0 @@
[supervisord]
nodaemon=true
user=root
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nsite]
user=nsite
group=nsite
command=node /app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@ -1,11 +0,0 @@
// SPDX-License-Identifier: CC0-1.0
function FindProxyForURL(url, host) {
if (shExpMatch(host, "*.i2p")) {
return "PROXY 127.0.0.1:4444; SOCKS5 127.0.0.1:4447";
}
if (shExpMatch(host, "*.onion")) {
return "SOCKS5 127.0.0.1:9050";
}
return "DIRECT";
}