mirror of
https://github.com/hzrd149/nsite-gateway.git
synced 2025-06-23 20:05:03 +00:00
Compare commits
No commits in common. "master" and "v0.5.0" have entirely different histories.
23
.env.example
23
.env.example
@ -2,9 +2,6 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@ -12,23 +9,17 @@ 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://nostr.download,https://cdn.satellite.earth"
|
BLOSSOM_SERVERS=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'
|
||||||
|
|
||||||
# A nprofile pointer for an nsite to use as the default homepage
|
# the hostname or ip of the upstream nginx proxy cache
|
||||||
# Setting this will override anything in the ./public folder
|
NGINX_CACHE_DIR='/var/nginx/cache'
|
||||||
NSITE_HOMEPAGE=""
|
|
||||||
|
|
||||||
# a local directory to download the homepage to
|
# screenshots require Puppeteer to be setup https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox
|
||||||
NSITE_HOMEPAGE_DIR="public"
|
ENABLE_SCREENSHOTS="false"
|
||||||
|
SCREENSHOTS_DIR="./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
|
# If this is set, nsite will return the 'Onion-Location' header in responses
|
||||||
# ONION_HOST=https://<hostname>.onion
|
# ONION_HOST=https://<hostname>.onion
|
||||||
|
4
.github/workflows/version-or-publish.yml
vendored
4
.github/workflows/version-or-publish.yml
vendored
@ -26,10 +26,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version: 20
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,4 +3,4 @@ build
|
|||||||
.env
|
.env
|
||||||
data
|
data
|
||||||
.netrc
|
.netrc
|
||||||
|
screenshots
|
||||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"internalConsoleOptions": "openOnSessionStart",
|
"internalConsoleOptions": "openOnSessionStart",
|
||||||
"outputCapture": "std",
|
"outputCapture": "std",
|
||||||
"env": {
|
"env": {
|
||||||
"DEBUG": "nsite,nsite:*"
|
"DEBUG": "nsite,nsite:*",
|
||||||
|
"ENABLE_SCREENSHOTS": "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
61
CHANGELOG.md
61
CHANGELOG.md
@ -1,63 +1,4 @@
|
|||||||
# nsite-gateway
|
# nsite-ts
|
||||||
|
|
||||||
## 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
|
## 0.5.0
|
||||||
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
#{
|
|
||||||
# email your-email@example.com
|
|
||||||
#}
|
|
||||||
|
|
||||||
# This will match example.com and all its subdomains (*.example.com)
|
|
||||||
example.com, *.example.com {
|
|
||||||
reverse_proxy nsite:3000
|
|
||||||
}
|
|
23
Dockerfile
23
Dockerfile
@ -1,9 +1,13 @@
|
|||||||
FROM node:22-alpine AS base
|
# syntax=docker/dockerfile:1
|
||||||
|
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 .
|
||||||
@ -23,13 +27,26 @@ 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
|
||||||
|
|
||||||
EXPOSE 3000
|
VOLUME [ "/var/cache/nginx" ]
|
||||||
|
|
||||||
|
EXPOSE 80 3000
|
||||||
ENV NSITE_PORT="3000"
|
ENV NSITE_PORT="3000"
|
||||||
|
ENV NGINX_CACHE_DIR="/var/cache/nginx"
|
||||||
|
ENV ENABLE_SCREENSHOTS="false"
|
||||||
|
|
||||||
CMD ["node", "."]
|
COPY docker-entrypoint.sh /
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
|
76
Dockerfile-screenshots
Normal file
76
Dockerfile-screenshots
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# 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
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
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.
|
|
97
README.md
97
README.md
@ -1,77 +1,46 @@
|
|||||||
# nsite-gateway
|
# nsite-ts
|
||||||
|
|
||||||
A Typescript implementation of [static websites on nostr](https://github.com/nostr-protocol/nips/pull/1538)
|
A Typescript implementation of [nsite](https://github.com/lez/nsite)
|
||||||
|
|
||||||
## 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-gateway.git
|
git clone https://github.com/hzrd149/nsite-ts.git
|
||||||
cd nsite-gateway
|
cd nsite-ts
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the service is running you can access the gateway at `http://localhost:3000`
|
Once the service is running you can access the cached version at `http://localhost:8080`
|
||||||
|
|
||||||
## Running with docker
|
If you need to test, you can directly access the ts server at `http://localhost:3000`
|
||||||
|
|
||||||
The `ghcr.io/hzrd149/nsite-gateway` image can be used to run a http instance locally
|
## Connecting to Tor and I2P relays
|
||||||
|
|
||||||
|
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
|
||||||
docker run --rm -it --name nsite -p 3000:3000 ghcr.io/hzrd149/nsite-gateway
|
PAC_PROXY=file://$(pwd)/proxy.pac node .
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
```
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
[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
|
|
@ -1,36 +1,17 @@
|
|||||||
services:
|
version: "3.7"
|
||||||
redis:
|
|
||||||
image: redis:alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --save 60 1 --loglevel warning
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
|
|
||||||
|
services:
|
||||||
nsite:
|
nsite:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/hzrd149/nsite-gateway:master
|
image: ghcr.io/hzrd149/nsite-ts: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/
|
||||||
CACHE_PATH: redis://redis:6379
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
|
|
||||||
caddy:
|
|
||||||
image: caddy:alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- type: tmpfs
|
||||||
- caddy_data:/data
|
target: /var/cache/nginx
|
||||||
- caddy_config:/config
|
tmpfs:
|
||||||
depends_on:
|
size: 100M
|
||||||
- nsite
|
ports:
|
||||||
|
- 8080:80
|
||||||
volumes:
|
- 3000:3000
|
||||||
redis-data:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
|
7
docker-entrypoint.sh
Executable file
7
docker-entrypoint.sh
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo Changing permission on volumes
|
||||||
|
chown -R nsite:nsite /var/cache/nginx
|
||||||
|
chown -R nsite:nsite /screenshots
|
||||||
|
|
||||||
|
exec "$@"
|
22
nginx/default.conf
Normal file
22
nginx/default.conf
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
33
nginx/nginx.conf
Normal file
33
nginx/nginx.conf
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
55
package.json
55
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nsite-gateway",
|
"name": "nsite-ts",
|
||||||
"version": "1.0.1",
|
"version": "0.5.0",
|
||||||
"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,7 +8,6 @@
|
|||||||
"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 ."
|
||||||
@ -19,48 +18,44 @@
|
|||||||
"public"
|
"public"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/redis": "^4.3.2",
|
"@keyv/redis": "^3.0.1",
|
||||||
"@keyv/sqlite": "^4.0.1",
|
"@keyv/sqlite": "^4.0.1",
|
||||||
"@koa/cors": "^5.0.0",
|
"@koa/cors": "^5.0.0",
|
||||||
"blossom-client-sdk": "^3.0.1",
|
"blossom-client-sdk": "^1.1.1",
|
||||||
"debug": "^4.4.0",
|
"dotenv": "^16.4.5",
|
||||||
"dotenv": "^16.4.7",
|
"follow-redirects": "^1.15.6",
|
||||||
"follow-redirects": "^1.15.9",
|
"keyv": "^5.0.1",
|
||||||
"keyv": "^5.3.2",
|
"koa": "^2.15.3",
|
||||||
"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.7",
|
"mime": "^4.0.4",
|
||||||
"nostr-tools": "^2.12.0",
|
"nostr-tools": "^2.7.2",
|
||||||
"nsite-cli": "^0.1.16",
|
"pac-proxy-agent": "^7.0.2",
|
||||||
"pac-proxy-agent": "^7.2.0",
|
"proxy-agent": "^6.4.0",
|
||||||
"proxy-agent": "^6.5.0",
|
"puppeteer": "^23.5.0",
|
||||||
"websocket-polyfill": "1.0.0",
|
"websocket-polyfill": "^1.0.0",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.0",
|
||||||
"xbytes": "^1.9.1"
|
"xbytes": "^1.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.28.1",
|
"@changesets/cli": "^2.27.8",
|
||||||
"@swc-node/register": "^1.10.10",
|
"@swc-node/register": "^1.9.0",
|
||||||
"@swc/core": "^1.11.16",
|
"@swc/core": "^1.5.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.9",
|
||||||
"@types/debug": "^4.1.12",
|
|
||||||
"@types/follow-redirects": "^1.14.4",
|
"@types/follow-redirects": "^1.14.4",
|
||||||
"@types/koa": "^2.15.0",
|
"@types/koa": "^2.14.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.17.30",
|
"@types/node": "^20.11.19",
|
||||||
"@types/proxy-from-env": "^1.0.4",
|
"@types/proxy-from-env": "^1.0.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.25.2",
|
"nodemon": "^3.0.3",
|
||||||
"nodemon": "^3.1.9",
|
"prettier": "^3.3.3",
|
||||||
"pkg": "^5.8.1",
|
"typescript": "^5.3.3"
|
||||||
"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
2905
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,51 +0,0 @@
|
|||||||
<!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>
|
|
110
public/components/nsite-card.js
Normal file
110
public/components/nsite-card.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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);
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
@ -3,65 +3,27 @@
|
|||||||
<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>Welcome to nsite-gateway</title>
|
<title>nsite</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>
|
||||||
body {
|
nostr-picture > img {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
width: 100%;
|
||||||
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>
|
||||||
<div class="container">
|
<nsite-app></nsite-app>
|
||||||
<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
Normal file
29
public/lib/lit.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
635
public/lib/milligram.css
Normal file
635
public/lib/milligram.css
Normal file
@ -0,0 +1,635 @@
|
|||||||
|
/*!
|
||||||
|
* 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 */
|
349
public/lib/normalize.css
vendored
Normal file
349
public/lib/normalize.css
vendored
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
/*! 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;
|
||||||
|
}
|
BIN
public/logo.jpg
Normal file
BIN
public/logo.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
58
public/main.js
Normal file
58
public/main.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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);
|
4
public/pool.js
Normal file
4
public/pool.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SimplePool } from "nostr-tools";
|
||||||
|
|
||||||
|
export const relays = ["wss://relay.damus.io", "wss://nos.lol", "wss://nostr.wine"];
|
||||||
|
export const pool = new SimplePool();
|
46
public/upload/index.html
Normal file
46
public/upload/index.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!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>
|
142
public/upload/upload.js
Normal file
142
public/upload/upload.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
@ -1,67 +1,36 @@
|
|||||||
import { IncomingMessage } from "node:http";
|
import { getServersFromServerListEvent, USER_BLOSSOM_SERVER_LIST_KIND } from "blossom-client-sdk";
|
||||||
|
|
||||||
import { MAX_FILE_SIZE } from "./env.js";
|
import { BLOSSOM_SERVERS, MAX_FILE_SIZE } from "./env.js";
|
||||||
import { makeRequestWithAbort } from "./helpers/http.js";
|
import { makeRequestWithAbort } from "./helpers/http.js";
|
||||||
import { blobURLs } from "./cache.js";
|
import pool from "./nostr.js";
|
||||||
import logger from "./logger.js";
|
|
||||||
|
|
||||||
const log = logger.extend("blossom");
|
export async function getUserBlossomServers(pubkey: string, relays: string[]) {
|
||||||
|
const blossomServersEvent = await pool.get(relays, { kinds: [USER_BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] });
|
||||||
|
|
||||||
/** Checks all servers for a blob and returns the URLs */
|
return blossomServersEvent ? getServersFromServerListEvent(blossomServersEvent).map((u) => u.toString()) : undefined;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads a file from multiple servers */
|
// TODO: download the file to /tmp and verify it
|
||||||
export async function streamBlob(sha256: string, servers: string[]): Promise<IncomingMessage | undefined> {
|
export async function downloadFile(sha256: string, servers = BLOSSOM_SERVERS) {
|
||||||
if (servers.length === 0) return undefined;
|
for (const server of servers) {
|
||||||
|
|
||||||
// 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 {
|
||||||
// Set up timeout to abort after 10s
|
const { response } = await makeRequestWithAbort(new URL(sha256, server));
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
const url = new URL(urlString);
|
try {
|
||||||
const response = await makeRequestWithAbort(url, controller);
|
if (!response.statusCode) throw new Error("Missing headers or status code");
|
||||||
res = response;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.statusCode) throw new Error("Missing headers or status code");
|
const size = response.headers["content-length"];
|
||||||
|
if (size && parseInt(size) > MAX_FILE_SIZE) throw new Error("File too large");
|
||||||
|
|
||||||
const size = response.headers["content-length"];
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
if (size && parseInt(size) > MAX_FILE_SIZE) throw new Error("File too large");
|
return response;
|
||||||
|
} else throw new Error("Request failed");
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) return response;
|
} catch (error) {
|
||||||
|
// Consume response data to free up memory
|
||||||
|
response.resume();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (res) res.resume();
|
// ignore error, try next server
|
||||||
continue; // Try next URL if this one fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
58
src/cache.ts
58
src/cache.ts
@ -1,19 +1,18 @@
|
|||||||
import Keyv, { KeyvOptions } from "keyv";
|
import Keyv from "keyv";
|
||||||
import { CACHE_PATH, CACHE_TIME } from "./env.js";
|
import pfs from "fs/promises";
|
||||||
import logger from "./logger.js";
|
import { CACHE_PATH } from "./env.js";
|
||||||
import { ParsedEvent } from "./events.js";
|
|
||||||
|
|
||||||
const log = logger.extend("cache");
|
try {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -21,49 +20,32 @@ async function createStore() {
|
|||||||
const store = await createStore();
|
const store = await createStore();
|
||||||
|
|
||||||
store?.on("error", (err) => {
|
store?.on("error", (err) => {
|
||||||
log("Connection Error", err);
|
console.log("Connection Error", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const json: KeyvOptions = { serialize: JSON.stringify, deserialize: JSON.parse };
|
const opts = store ? { store } : {};
|
||||||
const opts: KeyvOptions = store ? { store } : {};
|
|
||||||
|
|
||||||
/** A cache that maps a domain to a pubkey ( domain -> pubkey ) */
|
/** domain -> pubkey */
|
||||||
export const pubkeyDomains = new Keyv<string | undefined>({
|
export const userDomains = new Keyv({
|
||||||
...opts,
|
...opts,
|
||||||
...json,
|
|
||||||
namespace: "domains",
|
namespace: "domains",
|
||||||
ttl: CACHE_TIME * 1000,
|
// cache domains for an hour
|
||||||
|
ttl: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A cache that maps a pubkey to a set of blossom servers ( pubkey -> servers ) */
|
/** pubkey -> blossom servers */
|
||||||
export const pubkeyServers = new Keyv<string[] | undefined>({
|
export const userServers = new Keyv({
|
||||||
...opts,
|
...opts,
|
||||||
...json,
|
|
||||||
namespace: "servers",
|
namespace: "servers",
|
||||||
ttl: CACHE_TIME * 1000,
|
// cache servers for an hour
|
||||||
|
ttl: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A cache that maps a pubkey to a set of relays ( pubkey -> relays ) */
|
/** pubkey -> relays */
|
||||||
export const pubkeyRelays = new Keyv<string[] | undefined>({
|
export const userRelays = new Keyv({
|
||||||
...opts,
|
...opts,
|
||||||
...json,
|
|
||||||
namespace: "relays",
|
namespace: "relays",
|
||||||
ttl: CACHE_TIME * 1000,
|
// cache relays for an hour
|
||||||
});
|
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
95
src/dns.ts
@ -1,95 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
19
src/env.ts
19
src/env.ts
@ -1,9 +1,6 @@
|
|||||||
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/",
|
||||||
@ -16,11 +13,8 @@ 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;
|
||||||
@ -30,15 +24,17 @@ 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 SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || "./screenshots";
|
||||||
|
|
||||||
const ONION_HOST = process.env.ONION_HOST;
|
const ONION_HOST = process.env.ONION_HOST;
|
||||||
|
|
||||||
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,
|
||||||
@ -46,8 +42,7 @@ export {
|
|||||||
NSITE_HOST,
|
NSITE_HOST,
|
||||||
NSITE_PORT,
|
NSITE_PORT,
|
||||||
HOST,
|
HOST,
|
||||||
|
ENABLE_SCREENSHOTS,
|
||||||
|
SCREENSHOTS_DIR,
|
||||||
ONION_HOST,
|
ONION_HOST,
|
||||||
CACHE_TIME,
|
|
||||||
NIP05_NAME_DOMAINS,
|
|
||||||
PUBLIC_DOMAIN,
|
|
||||||
};
|
};
|
||||||
|
@ -1,16 +1,7 @@
|
|||||||
import { extname, join } from "path";
|
import { extname, isAbsolute, 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];
|
||||||
|
|
||||||
@ -20,7 +11,7 @@ export function getSearchPaths(path: string) {
|
|||||||
return paths.filter((p) => !!p);
|
return paths.filter((p) => !!p);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNsiteEvent(event: { pubkey: string; tags: string[][]; created_at: number }) {
|
export function parseNsiteEvent(event: { pubkey: string; tags: string[][] }) {
|
||||||
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];
|
||||||
|
|
||||||
@ -29,29 +20,16 @@ export function parseNsiteEvent(event: { pubkey: string; tags: string[][]; creat
|
|||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
path: join("/", path),
|
path: join("/", path),
|
||||||
sha256,
|
sha256,
|
||||||
created_at: event.created_at,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the first blob found for a given path */
|
export async function getNsiteBlobs(pubkey: string, path: string, relays: string[]) {
|
||||||
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] });
|
||||||
|
|
||||||
// Sort the found blobs by the order of the paths array
|
return Array.from(events)
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
59
src/helpers/dns.ts
Normal file
59
src/helpers/dns.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
@ -4,18 +4,17 @@ const { http, https } = followRedirects;
|
|||||||
|
|
||||||
import agent from "../proxy.js";
|
import agent from "../proxy.js";
|
||||||
|
|
||||||
export function makeRequestWithAbort(url: URL, controller: AbortController) {
|
export function makeRequestWithAbort(url: URL) {
|
||||||
return new Promise<IncomingMessage>((res, rej) => {
|
return new Promise<{ response: IncomingMessage; controller: AbortController }>((res, rej) => {
|
||||||
controller.signal.addEventListener("abort", () => rej(new Error("Aborted")));
|
const cancelController = new AbortController();
|
||||||
|
|
||||||
const request = (url.protocol === "https:" ? https : http).get(
|
const request = (url.protocol === "https:" ? https : http).get(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
signal: controller.signal,
|
signal: cancelController.signal,
|
||||||
agent,
|
agent,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
res(response);
|
res({ response, controller: cancelController });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
request.on("error", (err) => rej(err));
|
request.on("error", (err) => rej(err));
|
||||||
|
227
src/index.ts
227
src/index.ts
@ -2,33 +2,31 @@
|
|||||||
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 from "node:path";
|
import path, { basename } 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 { npubEncode } from "nostr-tools/nip19";
|
||||||
import { nip19 } from "nostr-tools";
|
|
||||||
|
|
||||||
import { resolvePubkeyFromHostname } from "./dns.js";
|
import { resolveNpubFromHostname } from "./helpers/dns.js";
|
||||||
import { getNsiteBlob } from "./events.js";
|
import { getNsiteBlobs, parseNsiteEvent } from "./events.js";
|
||||||
import { streamBlob } from "./blossom.js";
|
import { downloadFile, getUserBlossomServers } from "./blossom.js";
|
||||||
import {
|
import {
|
||||||
BLOSSOM_SERVERS,
|
BLOSSOM_SERVERS,
|
||||||
|
ENABLE_SCREENSHOTS,
|
||||||
HOST,
|
HOST,
|
||||||
NSITE_HOMEPAGE,
|
NGINX_CACHE_DIR,
|
||||||
NSITE_HOMEPAGE_DIR,
|
|
||||||
NSITE_HOST,
|
NSITE_HOST,
|
||||||
NSITE_PORT,
|
NSITE_PORT,
|
||||||
ONION_HOST,
|
ONION_HOST,
|
||||||
PUBLIC_DOMAIN,
|
|
||||||
SUBSCRIPTION_RELAYS,
|
SUBSCRIPTION_RELAYS,
|
||||||
} from "./env.js";
|
} from "./env.js";
|
||||||
import pool, { getUserBlossomServers, getUserOutboxes } from "./nostr.js";
|
import { userDomains, userRelays, userServers } from "./cache.js";
|
||||||
import logger from "./logger.js";
|
import { invalidatePubkeyPath } from "./nginx.js";
|
||||||
import { watchInvalidation } from "./invalidation.js";
|
import pool, { getUserOutboxes, subscribeForEvents } from "./nostr.js";
|
||||||
import { NSITE_KIND } from "./const.js";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@ -61,93 +59,110 @@ app.use(async (ctx, next) => {
|
|||||||
|
|
||||||
// handle nsite requests
|
// handle nsite requests
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
let pubkey = await resolvePubkeyFromHostname(ctx.hostname);
|
let pubkey = await userDomains.get<string | undefined>(ctx.hostname);
|
||||||
|
|
||||||
let fallthrough = false;
|
|
||||||
if (!pubkey && NSITE_HOMEPAGE && (!PUBLIC_DOMAIN || ctx.hostname === PUBLIC_DOMAIN)) {
|
|
||||||
const parsed = nip19.decode(NSITE_HOMEPAGE);
|
|
||||||
// TODO: use the relays in the nprofile
|
|
||||||
|
|
||||||
if (parsed.type === "nprofile") pubkey = parsed.data.pubkey;
|
|
||||||
else if (parsed.type === "npub") pubkey = parsed.data;
|
|
||||||
|
|
||||||
// Fallback to public dir if path cannot be found on the nsite homepage
|
|
||||||
if (pubkey) fallthrough = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// resolve pubkey if not in cache
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
if (fallthrough) return next();
|
console.log(`${ctx.hostname}: Resolving`);
|
||||||
|
pubkey = await resolveNpubFromHostname(ctx.hostname);
|
||||||
|
|
||||||
ctx.status = 404;
|
if (pubkey) {
|
||||||
ctx.body = fs.readFileSync(path.resolve(__dirname, "../public/404.html"), "utf-8");
|
await userDomains.set(ctx.hostname, pubkey);
|
||||||
return;
|
console.log(`${ctx.hostname}: Found ${pubkey}`);
|
||||||
|
} else {
|
||||||
|
await userDomains.set(ctx.hostname, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch relays
|
if (pubkey) {
|
||||||
const relays = (await getUserOutboxes(pubkey)) || [];
|
const npub = npubEncode(pubkey);
|
||||||
|
ctx.state.pubkey = pubkey;
|
||||||
|
|
||||||
// always check subscription relays
|
let relays = await userRelays.get<string[] | undefined>(pubkey);
|
||||||
relays.push(...SUBSCRIPTION_RELAYS);
|
|
||||||
|
|
||||||
if (relays.length === 0) throw new Error("No relays found");
|
// fetch relays if not in cache
|
||||||
|
if (!relays) {
|
||||||
|
console.log(`${npub}: Fetching relays`);
|
||||||
|
|
||||||
// fetch servers and events in parallel
|
relays = await getUserOutboxes(pubkey);
|
||||||
let [servers, event] = await Promise.all([
|
if (relays) {
|
||||||
getUserBlossomServers(pubkey, relays).then((s) => s || []),
|
await userRelays.set(pubkey, relays);
|
||||||
getNsiteBlob(pubkey, ctx.path, relays).then((e) => {
|
console.log(`${npub}: Found ${relays.length} relays`);
|
||||||
if (!e) return getNsiteBlob(pubkey, "/404.html", relays);
|
} else {
|
||||||
else return e;
|
relays = [];
|
||||||
}),
|
await userServers.set(pubkey, [], 30_000);
|
||||||
]);
|
console.log(`${npub}: Failed to find relays`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!event) {
|
relays.push(...SUBSCRIPTION_RELAYS);
|
||||||
if (fallthrough) return next();
|
|
||||||
|
|
||||||
ctx.status = 404;
|
if (relays.length === 0) throw new Error("No nostr relays");
|
||||||
ctx.body = `Not Found: no events found\npath: ${ctx.path}\nkind: ${NSITE_KIND}\npubkey: ${pubkey}\nrelays: ${relays.join(", ")}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// always fetch from additional servers
|
console.log(`${npub}: Searching for ${ctx.path}`);
|
||||||
servers.push(...BLOSSOM_SERVERS);
|
let blobs = await getNsiteBlobs(pubkey, ctx.path, relays);
|
||||||
|
|
||||||
if (servers.length === 0) throw new Error("Failed to find blossom servers");
|
if (blobs.length === 0) {
|
||||||
|
// fallback to custom 404 page
|
||||||
|
console.log(`${npub}: Looking for custom 404 page`);
|
||||||
|
blobs = await getNsiteBlobs(pubkey, "/404.html", relays);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (blobs.length === 0) {
|
||||||
const res = await streamBlob(event.sha256, servers);
|
console.log(`${npub}: Found 0 events`);
|
||||||
if (!res) {
|
ctx.status = 404;
|
||||||
ctx.status = 502;
|
ctx.body = "Not Found";
|
||||||
ctx.body = `Failed to find blob\npath: ${event.path}\nsha256: ${event.sha256}\nservers: ${servers.join(", ")}`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = mime.getType(event.path);
|
let servers = await userServers.get<string[] | undefined>(pubkey);
|
||||||
if (type) ctx.set("content-type", type);
|
|
||||||
else if (res.headers["content-type"]) ctx.set("content-type", res.headers["content-type"]);
|
|
||||||
|
|
||||||
// pass headers along
|
// fetch blossom servers if not in cache
|
||||||
if (res.headers["content-length"]) ctx.set("content-length", res.headers["content-length"]);
|
if (!servers) {
|
||||||
|
console.log(`${npub}: Fetching blossom servers`);
|
||||||
|
servers = await getUserBlossomServers(pubkey, relays);
|
||||||
|
|
||||||
// set Onion-Location header
|
if (servers) {
|
||||||
if (ONION_HOST) {
|
await userServers.set(pubkey, servers);
|
||||||
const url = new URL(ONION_HOST);
|
console.log(`${npub}: Found ${servers.length} servers`);
|
||||||
url.hostname = npubEncode(pubkey) + "." + url.hostname;
|
} else {
|
||||||
ctx.set("Onion-Location", url.toString().replace(/\/$/, ""));
|
servers = [];
|
||||||
|
await userServers.set(pubkey, [], 30_000);
|
||||||
|
console.log(`${npub}: Failed to find servers`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add cache headers
|
// always fetch from additional servers
|
||||||
ctx.set("ETag", res.headers["etag"] || `"${event.sha256}"`);
|
servers.push(...BLOSSOM_SERVERS);
|
||||||
ctx.set("Cache-Control", "public, max-age=3600");
|
|
||||||
ctx.set("Last-Modified", res.headers["last-modified"] || new Date(event.created_at * 1000).toUTCString());
|
for (const blob of blobs) {
|
||||||
|
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"]);
|
||||||
|
|
||||||
|
// set Onion-Location header
|
||||||
|
if (ONION_HOST) {
|
||||||
|
const url = new URL(ONION_HOST);
|
||||||
|
url.hostname = npubEncode(pubkey) + "." + url.hostname;
|
||||||
|
ctx.set("Onion-Location", url.toString().replace(/\/$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stream blob ${event.path}\n${error}`;
|
ctx.body = "Failed to find blob";
|
||||||
return;
|
} else await next();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ONION_HOST) {
|
if (ONION_HOST) {
|
||||||
@ -162,35 +177,65 @@ if (ONION_HOST) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 = NSITE_HOMEPAGE_DIR;
|
const www = path.resolve(process.cwd(), "public");
|
||||||
fs.statSync(www);
|
fs.statSync(www);
|
||||||
app.use(serve(www, serveOptions));
|
app.use(serve(www));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const www = path.resolve(__dirname, "../public");
|
const www = path.resolve(__dirname, "../public");
|
||||||
app.use(serve(www, serveOptions));
|
app.use(serve(www));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }, () => {
|
||||||
logger("Started on port", HOST);
|
console.log("Started on port", HOST);
|
||||||
});
|
});
|
||||||
|
|
||||||
// watch for invalidations
|
// invalidate nginx cache and screenshots on new events
|
||||||
watchInvalidation();
|
if (SUBSCRIPTION_RELAYS.length > 0) {
|
||||||
|
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() {
|
||||||
logger("Shutting down...");
|
console.log("Shutting down...");
|
||||||
pool.destroy();
|
pool.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import debug from "debug";
|
|
||||||
|
|
||||||
// enable default logging
|
|
||||||
if (!debug.enabled("nsite")) debug.enable("nsite,nsite:*");
|
|
||||||
|
|
||||||
const logger = debug("nsite");
|
|
||||||
|
|
||||||
export default logger;
|
|
37
src/nginx.ts
Normal file
37
src/nginx.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
43
src/nostr.ts
43
src/nostr.ts
@ -1,51 +1,20 @@
|
|||||||
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 { pubkeyRelays, pubkeyServers } from "./cache.js";
|
import { NSITE_KIND } from "./const.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;
|
||||||
|
|
||||||
const relays = mailboxes.tags
|
return mailboxes.tags.filter((t) => t[0] === "r" && (t[2] === undefined || t[2] === "write")).map((t) => t[1]);
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches a pubkeys blossom servers from the cache or relays */
|
export function subscribeForEvents(relays: string[], onevent: (event: NostrEvent) => any) {
|
||||||
export async function getUserBlossomServers(pubkey: string, relays: string[]) {
|
return pool.subscribeMany(relays, [{ kinds: [NSITE_KIND], since: Math.round(Date.now() / 1000) - 60 * 60 }], {
|
||||||
const cached = await pubkeyServers.get(pubkey);
|
onevent,
|
||||||
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) {
|
||||||
|
47
src/screenshots.ts
Normal file
47
src/screenshots.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import puppeteer, { PuppeteerLaunchOptions } from "puppeteer";
|
||||||
|
import { join } from "path";
|
||||||
|
import pfs from "fs/promises";
|
||||||
|
import { npubEncode } from "nostr-tools/nip19";
|
||||||
|
|
||||||
|
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(`${npubEncode(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(`${npubEncode(pubkey)}: Removed screenshot`);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
23
supervisord.conf
Normal file
23
supervisord.conf
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
[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
|
11
tor-and-i2p.pac
Normal file
11
tor-and-i2p.pac
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// 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";
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user