Skip to content

Commit ba73220

Browse files
committed
feat: bitsocial-previewer
Multi-tenant link-preview + redirect server for Bitsocial clients. Resolves a client profile by Host header (5chan -> s.5chan.app, seedit -> s.seedit.app), renders OpenGraph/Twitter cards via @pkcprotocol/pkc-js getComment over public IPFS gateways, then redirects browsers into the app's hash route.
0 parents  commit ba73220

18 files changed

Lines changed: 1509 additions & 0 deletions

.dockerignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
.git
3+
.env
4+
*.log
5+
test
6+
.github
7+
deploy
8+
README.md
9+
.DS_Store

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copy to .env to run outside docker-compose. (The server deploy sets these
2+
# inline in docker-compose.yml.)
3+
4+
PORT=3924
5+
BIND_HOST=127.0.0.1
6+
7+
# Resolve shared comments from the public IPFS gateways the 5chan app uses —
8+
# these have broad content coverage. (A box's local IPFS node generally can NOT
9+
# resolve arbitrary shared comment cids: it's thinly peered and doesn't pin
10+
# individual comment blocks.) With none set, config.js falls back to a subset.
11+
IPFS_GATEWAY_URLS=https://gateway.forumindex.com,https://ipfsgateway.xyz,https://gateway.plebpubsub.xyz,https://ipfs.io
12+
13+
# Max time to resolve a comment before serving a generic card. The fetch keeps
14+
# warming the cache in the background, so the retry is instant.
15+
GETCOMMENT_TIMEOUT_MS=10000
16+
17+
DEBUG=bitsocial-previewer:*
18+
19+
# Advanced / not recommended on the seeder box: delegate to a local pkc daemon
20+
# instead of gateways. Its node could not resolve arbitrary shared cids in tests.
21+
# PKC_RPC_URLS=ws://127.0.0.1:9138

.github/workflows/docker.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: build-and-push
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
tags: ['v*']
7+
workflow_dispatch:
8+
9+
jobs:
10+
docker:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
packages: write
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: docker/metadata-action@v5
19+
id: meta
20+
with:
21+
images: ghcr.io/bitsocialnet/bitsocial-previewer
22+
tags: |
23+
type=raw,value=latest,enable={{is_default_branch}}
24+
type=ref,event=tag
25+
type=sha,format=short
26+
27+
- uses: docker/login-action@v3
28+
with:
29+
registry: ghcr.io
30+
username: ${{ github.actor }}
31+
password: ${{ secrets.GITHUB_TOKEN }}
32+
33+
- uses: docker/build-push-action@v6
34+
with:
35+
context: .
36+
push: true
37+
tags: ${{ steps.meta.outputs.tags }}
38+
labels: ${{ steps.meta.outputs.labels }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
.env
3+
*.log
4+
.DS_Store

Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Debian-slim (not alpine): pkc-js pulls in heavy libp2p deps that are happiest
2+
# on glibc. Node 22+ is required: pkc-js depends on undici@8, which needs
3+
# node >=22.19 (on node 20 it crashes with "markAsUncloneable is not a function").
4+
FROM node:22-bookworm-slim
5+
6+
WORKDIR /app
7+
8+
# Install production deps first for better layer caching.
9+
COPY package.json ./
10+
RUN npm install --omit=dev --no-audit --no-fund
11+
12+
COPY . .
13+
14+
ENV NODE_ENV=production
15+
EXPOSE 3924
16+
17+
CMD ["node", "start.js"]

LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# bitsocial-previewer
2+
3+
Link-preview + redirect server for Bitsocial clients. When someone shares a
4+
post link on Twitter/Telegram/Discord/etc., this server renders the
5+
OpenGraph/Twitter card for that post, then redirects real browsers into the app.
6+
7+
It is **multi-tenant**: one running instance serves many clients and many mirror
8+
domains, picking the right profile from the request's `Host` header — so a single
9+
deployment (and a single TLS setup) covers every client and every mirror.
10+
11+
```
12+
┌──────────── s.5chan.app/biz/thread/<cid>
13+
share link ───────► │ bitsocial-previewer ──► OG/Twitter card (for scrapers)
14+
(no #/ hash) └──────────── 302-ish JS redirect ──► 5chan.app/#/biz/thread/<cid>
15+
```
16+
17+
## How it works
18+
19+
1. 5chan 0.9.1 copies **path-based** share links (no `#/` hash) so the path
20+
reaches the server: `https://s.5chan.app/<board>/thread/<cid>`.
21+
2. The server matches the path to a client profile + route, fetches the post by
22+
cid via `@pkcprotocol/pkc-js` (`getComment`), and builds the card.
23+
3. Browsers get redirected into the app's hash route; scrapers read the tags.
24+
25+
`<board>` is usually a short **directory code** (e.g. `biz`). We pass it straight
26+
through to the app (which resolves it); the cid is authoritative for the preview,
27+
so the previewer never needs to resolve codes itself.
28+
29+
### Recognised share-link formats (5chan)
30+
31+
| Incoming on `s.5chan.app` | Preview | Redirects to |
32+
|---|---|---|
33+
| `/<board>/thread/<cid>` | rich (title/text/image) | `5chan.app/#/<board>/thread/<cid>` |
34+
| `/<board>/catalog` | generic board card | `5chan.app/#/<board>/catalog` |
35+
| `/<board>` | generic board card | `5chan.app/#/<board>` |
36+
| `/p/<address>/c/<cid>` (legacy) | rich | `5chan.app/#/<address>/thread/<cid>` |
37+
38+
Unresolvable cid → still redirects to the app with a generic card (short cache),
39+
so a share link never dead-ends.
40+
41+
## Layout
42+
43+
| File | Purpose |
44+
|---|---|
45+
| [`lib/clients.js`](lib/clients.js) | **The per-client design.** Profiles (hostnames, app URL, routes) + host→profile + route matching. |
46+
| [`lib/html.js`](lib/html.js) | OG/Twitter tag rendering + redirect (HTML-escaped, XSS-safe). Pure. |
47+
| [`lib/pkc.js`](lib/pkc.js) | `@pkcprotocol/pkc-js` init + cached `getComment`. |
48+
| [`lib/media.js`](lib/media.js) | Comment media URL + external-link `og:image` scraping. |
49+
| [`start.js`](start.js) | Express wiring. |
50+
| [`config.js`](config.js) | Env-driven config (port, bind host, pkc options). |
51+
52+
## Run locally
53+
54+
```bash
55+
npm install
56+
npm start # listens on 127.0.0.1:3924, public-gateway fallback
57+
# in another shell:
58+
curl -s 'http://localhost:3924/biz/thread/<cid>' | grep -i og:
59+
```
60+
61+
```bash
62+
npm test # routing + HTML unit tests (no network/install needed for the pure logic)
63+
```
64+
65+
## Deploy (Caddy)
66+
67+
Deployed under `/opt/bitsocial-previewer` like the box's other services. Config
68+
(gateways, port) is inline in [`docker-compose.yml`](docker-compose.yml). The
69+
previewer resolves shared comments through the public IPFS gateways the 5chan app
70+
uses — a fetch is sub-second once a gateway has the content, and a hard timeout
71+
falls back to a generic card so a request never hangs. (A box's local IPFS node
72+
generally can't resolve arbitrary shared comment cids: thinly peered, and it
73+
doesn't pin individual comment blocks.)
74+
75+
```bash
76+
# on the box, in /opt/bitsocial-previewer (source synced or git-cloned):
77+
docker compose up -d --build # builds + runs on 127.0.0.1:3924
78+
docker compose logs -f --tail=50
79+
```
80+
81+
CI also publishes `ghcr.io/bitsocialnet/bitsocial-previewer:latest`
82+
([workflow](.github/workflows/docker.yml)); once that package is made public you
83+
can `docker compose pull && docker compose up -d` instead of building on the box.
84+
85+
Point the local Caddy at it with [`deploy/Caddyfile.snippet`](deploy/Caddyfile.snippet),
86+
then do the Cloudflare DNS change in [`deploy/cloudflare.md`](deploy/cloudflare.md).
87+
88+
## Add a client or a mirror
89+
90+
- **New mirror domain for 5chan:** add it to the `5chan` profile's `hostnames`
91+
in [`lib/clients.js`](lib/clients.js), add a Caddy block + a DNS A record.
92+
- **New client (e.g. seedit):** fill in the `seedit` profile template
93+
(hostnames, `appBaseUrl`, route formats) and enable it.
94+
95+
## License
96+
97+
[GPL-3.0-or-later](LICENSE) — matching the Bitsocial core stack it builds on
98+
(`@pkcprotocol/pkc-js`, `bitsocial-react-hooks`, 5chan, seedit).

config.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Runtime config, driven entirely by environment variables so the same image
2+
// runs locally and on the server. See .env.example for the production values.
3+
4+
const list = (value) =>
5+
(value || '')
6+
.split(',')
7+
.map((s) => s.trim())
8+
.filter(Boolean)
9+
10+
// Build @pkcprotocol/pkc-js options. Two modes:
11+
//
12+
// • RPC mode (recommended on VPS B): set PKC_RPC_URLS to the local daemon the
13+
// seeder already runs (ws://127.0.0.1:9138). getComment is served by that
14+
// warm, content-pinned node — no second IPFS node, fastest + most reliable.
15+
//
16+
// • Standalone mode (default, runs anywhere): in-process pkc-js reading from
17+
// IPFS gateways. On the server, point IPFS_GATEWAY_URLS at the local gateway
18+
// (http://127.0.0.1:6473); with no env set it falls back to public gateways
19+
// so `npm start` works on a laptop for review.
20+
const buildPkcOptions = () => {
21+
const options = {}
22+
23+
const rpc = list(process.env.PKC_RPC_URLS)
24+
if (rpc.length) options.pkcRpcClientsOptions = rpc
25+
26+
const kubo = list(process.env.KUBO_RPC_URLS)
27+
if (kubo.length) options.kuboRpcClientsOptions = kubo
28+
29+
const pubsub = list(process.env.PUBSUB_KUBO_RPC_URLS)
30+
if (pubsub.length) options.pubsubKuboRpcClientsOptions = pubsub
31+
32+
const gateways = list(process.env.IPFS_GATEWAY_URLS)
33+
if (gateways.length) {
34+
options.ipfsGatewayUrls = gateways
35+
} else if (!rpc.length) {
36+
// public fallback only when not delegating to a local daemon
37+
options.ipfsGatewayUrls = ['https://ipfs.io', 'https://ipfsgateway.xyz', 'https://gateway.forumindex.com']
38+
}
39+
40+
return options
41+
}
42+
43+
export default {
44+
port: Number(process.env.PORT || 3924),
45+
// Bind to loopback by default: with host networking on the server, only the
46+
// local Caddy reaches it (never the public internet). Set BIND_HOST=0.0.0.0
47+
// when running in bridge networking with a published port.
48+
bindHost: process.env.BIND_HOST || '127.0.0.1',
49+
// Max time to resolve a comment before the request falls back to a generic card.
50+
getCommentTimeoutMs: Number(process.env.GETCOMMENT_TIMEOUT_MS || 10_000),
51+
pkcOptions: buildPkcOptions(),
52+
}

deploy/Caddyfile.snippet

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Add this to the server's Caddyfile. Caddy auto-provisions the Let's Encrypt
2+
# certificate the moment s.5chan.app resolves to this box (DNS-only A record).
3+
4+
s.5chan.app {
5+
encode zstd gzip
6+
reverse_proxy 127.0.0.1:3924
7+
}
8+
9+
# Mirror domains: list more hostnames on one block (they share the cert + the
10+
# same upstream), and add each to the 5chan profile's `hostnames` in
11+
# lib/clients.js so the server recognises them:
12+
#
13+
# s2.5chan.app, s.5chan.eth.limo {
14+
# encode zstd gzip
15+
# reverse_proxy 127.0.0.1:3924
16+
# }

deploy/cloudflare.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Cloudflare DNS for `s.5chan.app`
2+
3+
Goal: stop redirecting `s.5chan.app``5chan.app`, and instead point it at the
4+
previewer on the VPS so Caddy can serve link previews.
5+
6+
## Steps (DNS-only A record + Caddy)
7+
8+
1. **Remove the old redirect.** In the `5chan.app` zone → **Rules**, delete the
9+
Redirect Rule / Page Rule that sends `s.5chan.app``5chan.app`. Also delete
10+
any placeholder DNS record for `s` that the redirect relied on.
11+
12+
2. **Add the A record.** Zone → **DNS → Records → Add record**:
13+
- Type: `A`
14+
- Name: `s`
15+
- IPv4 address: `<your VPS IP>`
16+
- Proxy status: **DNS only** (grey cloud)
17+
- TTL: Auto
18+
19+
Grey-cloud lets Caddy obtain a Let's Encrypt cert directly and lets social
20+
scrapers (Twitter/Telegram/Discord/…) crawl the previews without challenges.
21+
22+
3. **Wait for propagation**, then confirm TLS + a real preview:
23+
```bash
24+
curl -sI https://s.5chan.app/health
25+
curl -s https://s.5chan.app/<board>/thread/<cid> | grep -i 'og:'
26+
```
27+
The HTML should contain `og:title` / `og:description` and redirect a browser
28+
into `https://5chan.app/#/<board>/thread/<cid>`.
29+
30+
4. **Validate the card** with the Twitter/Telegram/Discord link-preview tools.
31+
32+
## Later (optional): put Cloudflare in front
33+
34+
To gain Cloudflare CDN/WAF, flip the A record to **Proxied** (orange) and set
35+
**SSL/TLS → Overview → Full (strict)**. Because proxied TLS-ALPN renewal is
36+
blocked, Caddy then needs either a **Cloudflare Origin Certificate** installed,
37+
or the **DNS-01** challenge via a Cloudflare API token. Keep bot-fight settings
38+
relaxed for `s.5chan.app` so OG scrapers aren't challenged.

0 commit comments

Comments
 (0)