build a template website

This commit is contained in:
rainyy21
2026-10-05 19:40:24 -04:00
commit c86f93f4ca
36 changed files with 2581 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
{$SITE_ADDRESS:localhost} {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'"
}
# Tracker is first-party: same domain, path /api
handle /api/* {
reverse_proxy tracker:8000
}
handle {
root * /srv
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
try_files {path} /index.html
file_server
}
}
+13
View File
@@ -0,0 +1,13 @@
# Web: build the React app, serve it with Caddy (automatic HTTPS)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY index.html vite.config.js links.json ./
COPY public ./public
COPY src ./src
RUN npm run build
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
+36
View File
@@ -0,0 +1,36 @@
# Rain link hub
React (Vite) front end, a cookieless first-party tracker (Flask + Postgres), and a Compose stack with HTTPS, nightly backups and uptime monitoring.
## Edit your links
Everything lives in `links.json` (handle, taglines, featured link, social links). The front end, the tracker's allowlist and the link checker all read this one file.
Also set the real URL in `index.html` and add a 1200x630 `public/og.png` for link previews.
## Develop
npm install
npm run dev # tracking is off in dev
## Deploy
1. `cp .env.example .env` and fill it in (letters and digits only in passwords).
2. Point your domain's DNS at the server. Caddy gets the HTTPS certificate automatically.
3. Put the SSH key for the backup machine at `backup-ssh/id_ed25519`.
4. `docker compose up -d --build`
Note: the DB roles are created only on the first start of an empty volume. If you change the passwords later, change them in Postgres too.
## What gets tracked
Visits, clicks per link, referrer host, country, device. No cookies, no stored IPs. A visitor is a hash of IP + user agent with a key that changes daily, so uniques are per day. Tracking is skipped when the browser sends Do Not Track.
Country comes from the `CF-IPCountry` header if you sit behind Cloudflare, otherwise from a MaxMind `GeoLite2-Country.mmdb` in `./geoip`, otherwise it shows `XX`.
Bots (user agent filter), cross-site requests and unknown link ids are dropped silently. Limits per IP: visits 20/min, clicks 40/min.
## Reading the data from another project (WireGuard)
Set `PG_BIND` to this server's WireGuard address (for example `10.8.0.1:5432`). Connect as `stats_ro` (read-only, even if you try to write) and query the views:
`stats_daily`, `stats_link_clicks`, `stats_referrers`, `stats_countries`, `stats_devices`, plus the raw `visits`, `clicks`, `link_health` tables.
## Backups and monitoring
- `backup` runs `pg_dump` at 03:00 and copies it to `BACKUP_TARGET` with rsync. Prune old dumps on that machine (for example a cron `find ... -mtime +30 -delete`). Restore with `pg_restore -d rainhub file.dump`.
- Uptime Kuma is on `KUMA_BIND` (default localhost:3001). Add monitors for `https://your-domain/` and `https://your-domain/api/health`. Optionally add two push monitors and paste their URLs in `.env`: one reports backup success or failure, one reports dead links.
- `linkcheck` checks every link daily and writes the result to `link_health`. X, Discord and similar sites often block bots, so a 403 counts as alive.
## Later
Kubernetes manifests, once the Compose setup is running the way you like.
View File
+4
View File
@@ -0,0 +1,4 @@
FROM postgres:16-alpine
RUN apk add --no-cache rsync openssh-client
COPY backup/backup.sh backup/entrypoint.sh /backup/
ENTRYPOINT ["/backup/entrypoint.sh"]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
. /etc/backup.env
push() { [ -n "${KUMA_BACKUP_PUSH_URL:-}" ] && wget -qO- "$KUMA_BACKUP_PUSH_URL?status=$1&msg=$2" >/dev/null 2>&1 || true; }
run() {
f="/tmp/rainhub-$(date +%Y%m%d-%H%M%S).dump"
PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h db -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" > "$f"
rsync -e "ssh -i /ssh/id_ed25519 -p ${BACKUP_PORT:-22} -o StrictHostKeyChecking=accept-new" "$f" "$BACKUP_TARGET/"
rm -f "$f"
}
if run; then push up OK; echo "backup ok"; else push down backup-failed; echo "backup FAILED"; exit 1; fi
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
printenv | sed "s/'/'\\\\''/g; s/^\([^=]*\)=\(.*\)$/export \1='\2'/" > /etc/backup.env
echo "0 3 * * * /backup/backup.sh >> /proc/1/fd/1 2>&1" | crontab -
exec crond -f -l 8
+39
View File
@@ -0,0 +1,39 @@
CREATE TABLE visits (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
visitor text NOT NULL, -- daily-rotating hash, never an IP
referrer text, -- host only, NULL = direct
country char(2) NOT NULL DEFAULT 'XX',
device text NOT NULL -- mobile | tablet | desktop
);
CREATE TABLE clicks (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
link_id text NOT NULL,
visitor text NOT NULL,
country char(2) NOT NULL DEFAULT 'XX',
device text NOT NULL
);
CREATE TABLE link_health (
link_id text PRIMARY KEY,
url text NOT NULL,
ok boolean NOT NULL,
status int,
note text,
checked_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visits_ts ON visits (ts);
CREATE INDEX clicks_ts ON clicks (ts);
CREATE INDEX clicks_link ON clicks (link_id);
-- Ready-made views for the stats site
CREATE VIEW stats_daily AS
SELECT ts::date AS day, count(*) AS visits, count(DISTINCT visitor) AS uniques FROM visits GROUP BY 1;
CREATE VIEW stats_link_clicks AS
SELECT link_id, count(*) AS clicks FROM clicks GROUP BY 1;
CREATE VIEW stats_referrers AS
SELECT coalesce(referrer, 'direct') AS referrer, count(*) AS visits FROM visits GROUP BY 1;
CREATE VIEW stats_countries AS
SELECT country, count(*) AS visits FROM visits GROUP BY 1;
CREATE VIEW stats_devices AS
SELECT device, count(*) AS visits FROM visits GROUP BY 1;
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Runs once, on first start of an empty database volume.
set -e
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<SQL
CREATE ROLE tracker_rw LOGIN PASSWORD '$TRACKER_PASSWORD';
GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO tracker_rw;
GRANT USAGE ON SCHEMA public TO tracker_rw;
GRANT INSERT ON visits, clicks TO tracker_rw;
GRANT USAGE ON SEQUENCE visits_id_seq, clicks_id_seq TO tracker_rw;
GRANT SELECT, INSERT, UPDATE ON link_health TO tracker_rw;
CREATE ROLE stats_ro LOGIN PASSWORD '$STATS_RO_PASSWORD';
ALTER ROLE stats_ro SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO stats_ro;
GRANT USAGE ON SCHEMA public TO stats_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO stats_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO stats_ro;
SQL
+68
View File
@@ -0,0 +1,68 @@
services:
web:
build: .
restart: unless-stopped
ports: ["80:80", "443:443"]
environment:
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
volumes: [caddy_data:/data, caddy_config:/config]
depends_on: [tracker]
tracker:
build: { context: ., dockerfile: tracker/Dockerfile }
restart: unless-stopped
environment:
DATABASE_URL: postgresql://tracker_rw:${TRACKER_PASSWORD}@db:5432/${POSTGRES_DB}
TRACKER_SECRET: ${TRACKER_SECRET}
SITE_HOST: ${SITE_HOST}
volumes: ["./geoip:/data:ro"]
depends_on: { db: { condition: service_healthy } }
linkcheck:
build: { context: ., dockerfile: tracker/Dockerfile }
restart: unless-stopped
command: ["sh", "-c", "while true; do python check_links.py; sleep 86400; done"]
environment:
DATABASE_URL: postgresql://tracker_rw:${TRACKER_PASSWORD}@db:5432/${POSTGRES_DB}
KUMA_PUSH_URL: ${KUMA_LINKS_PUSH_URL:-}
depends_on: { db: { condition: service_healthy } }
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
TRACKER_PASSWORD: ${TRACKER_PASSWORD}
STATS_RO_PASSWORD: ${STATS_RO_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
# Published only on the WireGuard address, so the read-only user is reachable over WireGuard
ports: ["${PG_BIND:-127.0.0.1:5432}:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
retries: 10
backup:
build: { context: ., dockerfile: backup/Dockerfile }
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
BACKUP_TARGET: ${BACKUP_TARGET}
BACKUP_PORT: ${BACKUP_PORT:-22}
KUMA_BACKUP_PUSH_URL: ${KUMA_BACKUP_PUSH_URL:-}
volumes: ["./backup-ssh:/ssh:ro"]
depends_on: { db: { condition: service_healthy } }
kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
ports: ["${KUMA_BIND:-127.0.0.1:3001}:3001"]
volumes: [kuma_data:/app/data]
volumes: { pgdata: {}, caddy_data: {}, caddy_config: {}, kuma_data: {} }
View File
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a111e">
<title>Rainyy · links</title>
<meta name="description" content="Pull up a chair by the window.">
<!-- Link previews: replace the URL, and add a 1200x630 image at public/og.png -->
<meta property="og:type" content="website">
<meta property="og:title" content="Rainyy">
<meta property="og:description" content="Pull up a chair by the window.">
<meta property="og:url" content="https://links.example.com/">
<meta property="og:image" content="https://links.example.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27><text y=%27.9em%27 font-size=%2790%27>🌧️</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600&family=DM+Sans:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
{
"profile": {
"handle": "Rainyy",
"taglines": [
"Dev, tinkerer, night owl.",
"Pull up a chair by the window.",
"Self-hosting everything I can.",
"Stay a while, it's cozy in the rain.",
"Rain on the window, friends inside.",
"Follow the drizzle, find your people."
]
},
"featured": {
"id": "den",
"label": "The Den",
"sub": "My self-hosted furry base",
"href": "https://example.com",
"icon": "🏠"
},
"links": [
{
"id": "discord",
"label": "Discord",
"href": "https://discord.com/users/532010887024869386",
"icon": "/discord-icon-svgrepo-com.svg"
},
{
"id": "x",
"label": "Twitter / X",
"href": "https://x.com/raindrop_rain12",
"icon": "/x-symbol-svgrepo-com.svg"
},
{
"id": "bsky",
"label": "Bluesky",
"href": "https://bsky.app/profile/rain12.bsky.social",
"icon": "/bluesky.svg"
},
{
"id": "tg",
"label": "Telegram",
"href": "https://t.me/RainyRain12",
"icon": "/telegram-svgrepo-com.svg"
}
]
}
+1698
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"name": "rain-hub",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" },
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"devDependencies": { "@vitejs/plugin-react": "^4.3.1", "vite": "^5.4.0" }
}
View File
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bluesky" viewBox="0 0 16 16">
<path d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero">
</path>
</g>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>telegram</title>
<path d="M22.122 10.040c0.006-0 0.014-0 0.022-0 0.209 0 0.403 0.065 0.562 0.177l-0.003-0.002c0.116 0.101 0.194 0.243 0.213 0.403l0 0.003c0.020 0.122 0.031 0.262 0.031 0.405 0 0.065-0.002 0.129-0.007 0.193l0-0.009c-0.225 2.369-1.201 8.114-1.697 10.766-0.21 1.123-0.623 1.499-1.023 1.535-0.869 0.081-1.529-0.574-2.371-1.126-1.318-0.865-2.063-1.403-3.342-2.246-1.479-0.973-0.52-1.51 0.322-2.384 0.221-0.23 4.052-3.715 4.127-4.031 0.004-0.019 0.006-0.040 0.006-0.062 0-0.078-0.029-0.149-0.076-0.203l0 0c-0.052-0.034-0.117-0.053-0.185-0.053-0.045 0-0.088 0.009-0.128 0.024l0.002-0.001q-0.198 0.045-6.316 4.174c-0.445 0.351-1.007 0.573-1.619 0.599l-0.006 0c-0.867-0.105-1.654-0.298-2.401-0.573l0.074 0.024c-0.938-0.306-1.683-0.467-1.619-0.985q0.051-0.404 1.114-0.827 6.548-2.853 8.733-3.761c1.607-0.853 3.47-1.555 5.429-2.010l0.157-0.031zM15.93 1.025c-8.302 0.020-15.025 6.755-15.025 15.060 0 8.317 6.742 15.060 15.060 15.060s15.060-6.742 15.060-15.060c0-8.305-6.723-15.040-15.023-15.060h-0.002q-0.035-0-0.070 0z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 460.775 460.775" xml:space="preserve">
<path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react';
import data from '../links.json';
import { REDUCED, rnd } from './lib/env.js';
import { useRain } from './lib/rain.js';
import { trackVisit } from './tracker.js';
import Mascot from './components/Mascot.jsx';
import LinkButton from './components/LinkButton.jsx';
const LEVEL = 0.5; // fixed rain intensity
export default function App() {
const { profile, featured, links } = data;
// const taglines = profile.taglines;
const [rain, setRain] = useState(!REDUCED);
const [flash, setFlash] = useState(0);
// const [tg, setTg] = useState(0);
const canvasRef = useRef(null);
const levelRef = useRef(LEVEL);
useRain(canvasRef, rain, levelRef);
useEffect(() => { trackVisit(); }, []);
// useEffect(() => { const t = setInterval(() => setTg((n) => n + 1), 4500); return () => clearInterval(t); }, []);
useEffect(() => {
if (!rain || REDUCED) return;
let t;
const next = () => {
t = setTimeout(() => { if (!document.hidden) setFlash((n) => n + 1); next(); }, (22 - 16 * LEVEL) * 1000 * rnd(0.5, 1.5));
};
next();
return () => clearTimeout(t);
}, [rain]);
return (
<div>
<canvas className="rain" ref={canvasRef} aria-hidden="true" />
<div key={flash} className={'flash' + (flash ? ' on' : '')} aria-hidden="true" />
<main>
<Mascot />
<h1>{profile.handle}</h1>
{/* <p className="tag" key={tg % taglines.length}>{taglines[tg % taglines.length]}</p> */}
<nav className="links" aria-label="Links">
{/* <LinkButton item={featured} featured /> */}
{links.map((l) => <LinkButton key={l.id} item={l} />)}
</nav>
<footer>
<button className="tog" aria-pressed={rain} onClick={() => setRain(!rain)}>{rain ? 'Rain on' : 'Rain off'}</button>
</footer>
</main>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import { REDUCED, rnd } from '../lib/env.js';
import { ripple } from '../lib/effects.js';
import { trackClick } from '../tracker.js';
export default function LinkButton({ item, featured }) {
const fog = useRef(null);
const dirty = useRef(0);
const deco = useRef(null);
if (!deco.current) {
deco.current = {
beads: [rnd(12, 40), rnd(55, 88)].map((l, i) => ({ l, d: rnd(0, 6) + i })),
drips: [rnd(10, 40), rnd(55, 90)].map((l, i) => ({ l, d: rnd(0, 5) + i * 2 })),
};
}
const paint = (a) => {
const c = fog.current;
if (!c) return;
const x = c.getContext('2d');
x.globalCompositeOperation = 'source-over';
x.fillStyle = `rgba(175,200,228,${a})`;
x.fillRect(0, 0, c.width, c.height);
};
useEffect(() => {
const c = fog.current;
if (!c) return;
const r = c.parentNode.getBoundingClientRect();
c.width = r.width;
c.height = r.height;
paint(1);
const t = setInterval(() => { if (dirty.current > 0) { dirty.current--; paint(0.1); } }, 250);
return () => clearInterval(t);
}, []);
const wipe = (e) => {
const c = fog.current;
if (!c) return;
const r = c.getBoundingClientRect();
const x = c.getContext('2d');
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(e.clientX - r.left, e.clientY - r.top, 24, 0, 6.3);
x.fill();
dirty.current = 25;
};
const onClick = () => {
if (navigator.vibrate) navigator.vibrate(10);
trackClick(item.id);
};
const external = item.href.startsWith('http');
return (
<div className="wrap">
<a
className={'btn' + (featured ? ' feat' : '')}
href={item.href}
onPointerDown={(e) => { ripple(e); wipe(e); }}
onPointerMove={wipe}
onClick={onClick}
target={external ? '_blank' : undefined}
rel="noopener"
aria-label={item.label + (item.sub ? ', ' + item.sub : '')}
>
{!REDUCED && <canvas className="fog" ref={fog} aria-hidden="true" />}
{!REDUCED && deco.current.beads.map((b, i) => (
<span key={i} className="bead" style={{ left: b.l + '%', animationDelay: -b.d + 's' }} />
))}
<span className="ico" aria-hidden="true">
{item.icon.startsWith('/') ? (
<img src={item.icon} alt="" className="ico-svg" />
) : (
item.icon
)}
</span>
<span>
{item.label}
{item.sub && <span className="sub">{item.sub}</span>}
</span>
</a>
{!REDUCED && deco.current.drips.map((d, i) => (
<span key={i} className="drip" style={{ left: d.l + '%', animationDelay: -d.d + 's' }} />
))}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Mascot() {
return (
<div className="mascot" role="img" aria-label="Rain umbrella mascot">
<img src="/rain_ubrella.png" alt="Rain umbrella mascot" width="132" height="153" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { REDUCED, rnd } from './env.js';
export function ripple(e) {
const el = e.currentTarget, r = el.getBoundingClientRect(), s = Math.max(r.width, r.height) * 2, n = document.createElement("span");
n.className = "ripple"; n.style.cssText = `width:${s}px;height:${s}px;left:${e.clientX - r.left - s / 2}px;top:${e.clientY - r.top - s / 2}px`;
n.addEventListener("animationend", () => n.remove()); el.appendChild(n);
if (REDUCED) return;
for (let i = 0; i < 7; i++) {
const p = document.createElement("span"), a = rnd(-3.1, 0), d = rnd(18, 42);
p.className = "sp"; p.style.cssText = `left:${e.clientX}px;top:${e.clientY}px;--dx:${Math.cos(a) * d}px;--dy:${Math.sin(a) * d + 14}px`;
p.addEventListener("animationend", () => p.remove()); document.body.appendChild(p);
}
}
+5
View File
@@ -0,0 +1,5 @@
export const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
export const LOW = (navigator.hardwareConcurrency || 8) <= 4 || (navigator.deviceMemory || 8) <= 4;
export const CAP = LOW ? 0.5 : 1; // particle cap by device speed
export const rnd = (a, b) => a + Math.random() * (b - a);
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { CAP, rnd } from './env.js';
export function useRain(canvasRef, on, levelRef) {
useEffect(() => {
const c = canvasRef.current, ctx = c.getContext("2d");
let w, H, raf = 0, run = false;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resize = () => { w = innerWidth; H = innerHeight; c.width = w * dpr; c.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); };
resize();
const mk = (near) => ({ x: rnd(0, w + 80), y: rnd(-H, H), v: near ? rnd(5, 8) : rnd(12, 20), l: near ? rnd(10, 18) : rnd(12, 30), a: near ? .5 : rnd(.12, .35) });
const far = Array.from({ length: 300 }, () => mk(false)), near = Array.from({ length: 36 }, () => mk(true));
const glass = Array.from({ length: 9 }, () => ({ x: rnd(0, w), y: rnd(0, H), r: rnd(2, 4.5), v: 0, wait: rnd(0, 200), t: [] }));
const draw = () => {
const L = levelRef.current, nf = Math.round((40 + 260 * L) * CAP), nn = Math.round((4 + 32 * L) * CAP), wind = .08 + .25 * L, sp = .8 + .6 * L;
ctx.clearRect(0, 0, w, H); ctx.lineCap = "round";
for (let i = 0; i < nf; i++) {
const s = far[i]; s.y += s.v * sp; s.x -= s.v * sp * wind;
if (s.y > H + 30 || s.x < -40) { s.y = rnd(-60, -5); s.x = rnd(0, w + 80); }
ctx.strokeStyle = `rgba(175,210,245,${s.a})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(s.x + s.l * wind, s.y - s.l); ctx.stroke();
}
for (let i = 0; i < nn; i++) {
const s = near[i]; s.y += s.v * sp; s.x -= s.v * sp * wind * .6;
if (s.y > H + 30 || s.x < -40) { s.y = rnd(-60, -5); s.x = rnd(0, w + 80); }
ctx.fillStyle = "rgba(190,225,255,.25)"; ctx.beginPath(); ctx.ellipse(s.x, s.y, 1.6, s.l / 2, .12, 0, 6.3); ctx.fill();
}
for (const g of glass) {
if (g.wait > 0) g.wait--; else {
g.v = Math.min(g.v + .02, 1.6) * (Math.random() < .03 ? .2 : 1); g.y += g.v; g.x += rnd(-.12, .12);
g.t.push([g.x, g.y]); if (g.t.length > 28) g.t.shift();
if (g.y > H + 10) { g.y = rnd(-40, H * .4); g.x = rnd(0, w); g.v = 0; g.t = []; g.wait = rnd(40, 260); }
}
if (g.t.length > 1) { ctx.strokeStyle = "rgba(170,210,255,.10)"; ctx.lineWidth = g.r * .6; ctx.beginPath(); g.t.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.stroke(); }
const gr = ctx.createRadialGradient(g.x - g.r * .3, g.y - g.r * .3, 0, g.x, g.y, g.r);
gr.addColorStop(0, "rgba(230,245,255,.55)"); gr.addColorStop(1, "rgba(120,170,220,.12)");
ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, 6.3); ctx.fill();
}
raf = requestAnimationFrame(draw);
};
const start = () => { if (!run && on && !document.hidden) { run = true; raf = requestAnimationFrame(draw); } };
const stop = () => { run = false; cancelAnimationFrame(raf); };
const vis = () => (document.hidden ? stop() : start());
ctx.clearRect(0, 0, w, H); start();
document.addEventListener("visibilitychange", vis); addEventListener("resize", resize);
return () => { stop(); document.removeEventListener("visibilitychange", vis); removeEventListener("resize", resize); ctx.clearRect(0, 0, w, H); };
}, [on]);
}
+16
View File
@@ -0,0 +1,16 @@
export function makeSound() {
let ac, g, hp;
return {
start() {
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
ac = new AC();
const buf = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate), d = buf.getChannelData(0); let last = 0;
for (let i = 0; i < d.length; i++) { last = (last + .04 * (Math.random() * 2 - 1)) / 1.04; d[i] = last * 3.5; }
const src = ac.createBufferSource(); src.buffer = buf; src.loop = true;
hp = ac.createBiquadFilter(); hp.type = "highpass"; g = ac.createGain(); g.gain.value = 0;
src.connect(hp); hp.connect(g); g.connect(ac.destination); src.start();
},
set(l) { if (!g) return; g.gain.setTargetAtTime(.08 + .5 * l, ac.currentTime, .3); hp.frequency.value = 700 - 500 * l; },
stop() { try { ac && ac.close(); } catch (e) {} ac = g = hp = null; },
};
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
+50
View File
@@ -0,0 +1,50 @@
:root{--bg:#0a111e;--bg2:#101b2d;--text:#e6f0fa;--dim:#a9bbd0;--teal:#5fd1c8;--amber:#ffb347;--edge:rgba(170,210,255,.2);
box-sizing:border-box;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
*,*::before,*::after{box-sizing:inherit}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);color-scheme:dark;font-family:'DM Sans',system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-tap-highlight-color:transparent}
#root{min-height:100vh}
body{background:radial-gradient(120% 80% at 50% 0%,var(--bg2),var(--bg) 70%) fixed}
canvas.rain{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:0}
.flash{position:fixed;inset:0;background:#cfe3ff;opacity:0;pointer-events:none;z-index:1}
.flash.on{animation:fl .6s ease-out}
@keyframes fl{0%{opacity:0}8%{opacity:.3}20%{opacity:.04}32%{opacity:.2}100%{opacity:0}}
main{position:relative;z-index:2;max-width:440px;margin:0 auto;padding:26px 20px 28px;min-height:100vh;display:flex;flex-direction:column;align-items:center}
.mascot{filter:drop-shadow(0 0 16px rgba(95,209,200,.4))}
.umb{transform-origin:70px 54px;animation:bob 4s ease-in-out infinite}
.tail{transform-origin:92px 108px;animation:tail 2.6s ease-in-out infinite}
.e1{transform-origin:61px 68px;animation:twitch 6s infinite}
.eyes{transform-origin:70px 80px;animation:blink 5s infinite}
.rd{animation:rdrip 1.6s ease-in infinite}.r2{animation-delay:.5s}.r3{animation-delay:.9s}
@keyframes bob{50%{transform:translateY(-2px) rotate(1.5deg)}}
@keyframes tail{50%{transform:rotate(-14deg)}}
@keyframes twitch{0%,90%,100%{transform:rotate(0)}94%{transform:rotate(-14deg)}}
@keyframes blink{0%,94%,100%{transform:scaleY(1)}97%{transform:scaleY(.1)}}
@keyframes rdrip{0%{transform:translateY(0);opacity:0}15%{opacity:1}100%{transform:translateY(34px);opacity:0}}
h1{font:600 28px 'Fredoka',system-ui,sans-serif;margin:8px 0 4px;letter-spacing:.02em}
.tag{margin:0 0 22px;color:var(--dim);font-size:15px;text-align:center;min-height:22px;animation:fade .7s}
@keyframes fade{from{opacity:0}}
.links{width:100%;display:flex;flex-direction:column;gap:14px}
.wrap{position:relative}
.btn{position:relative;overflow:hidden;display:flex;align-items:center;gap:14px;width:100%;min-height:56px;padding:0 18px;border-radius:16px;color:var(--text);text-decoration:none;font-weight:500;font-size:16px;text-shadow:0 1px 6px rgba(0,0,0,.7);
background:rgba(14,26,46,.5);border:1px solid var(--edge);-webkit-backdrop-filter:blur(10px) saturate(1.2);backdrop-filter:blur(10px) saturate(1.2);transition:transform .15s}
.btn:active{transform:scale(.985)}
.btn:focus-visible,.tog:focus-visible{outline:2px solid var(--teal);outline-offset:3px}
.btn>*{position:relative;z-index:1}
.btn>canvas.fog{position:absolute;inset:0;width:100%;height:100%;z-index:0;opacity:.3;pointer-events:none}
.ico{flex:none;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;font:600 15px 'Fredoka',system-ui,sans-serif;color:var(--teal);background:rgba(95,209,200,.14);text-shadow:none}
.ico-svg{width:20px;height:20px;filter:brightness(0) saturate(100%) invert(73%) sepia(30%) saturate(600%) hue-rotate(130deg) brightness(95%) contrast(90%)}
.sub{display:block;color:var(--dim);font-size:12.5px;font-weight:400;margin-top:1px}
.btn.feat{min-height:84px;border-color:rgba(255,179,71,.55);font-size:18px;background:linear-gradient(135deg,rgba(255,179,71,.22),rgba(14,26,46,.5));box-shadow:0 0 34px rgba(255,179,71,.18),inset 0 0 22px rgba(255,179,71,.08)}
.btn.feat .ico{width:44px;height:44px;border-radius:14px;font-size:20px;background:var(--amber)}
.bead{position:absolute!important;top:-6px;width:5px;height:8px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:rgba(200,230,255,.55);opacity:0;animation:slide 7s ease-in infinite;z-index:1;pointer-events:none}
@keyframes slide{0%{transform:translateY(0);opacity:0}8%{opacity:.8}60%{transform:translateY(46px)}100%{transform:translateY(88px);opacity:0}}
.drip{position:absolute;top:calc(100% - 3px);width:6px;height:8px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:rgba(200,230,255,.7);opacity:0;animation:drip 6s ease-in infinite;pointer-events:none}
@keyframes drip{0%{transform:scale(0);opacity:0}30%{transform:scale(1);opacity:.9}55%{transform:translateY(1px) scaleY(1.4);opacity:.9}100%{transform:translateY(26px) scaleY(1.6);opacity:0}}
.ripple{position:absolute;border-radius:50%;background:rgba(190,230,255,.35);transform:scale(0);animation:rp .6s ease-out forwards;pointer-events:none;z-index:1}
@keyframes rp{to{transform:scale(1);opacity:0}}
.sp{position:fixed;width:5px;height:5px;margin:-2px;border-radius:50%;background:rgba(200,230,255,.85);pointer-events:none;z-index:9;animation:sp .55s ease-out forwards}
@keyframes sp{to{transform:translate(var(--dx),var(--dy)) scale(.2);opacity:0}}
footer{margin-top:auto;padding-top:26px;display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.tog{min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--edge);background:rgba(14,26,46,.55);color:var(--dim);font:500 13px 'DM Sans',system-ui,sans-serif;cursor:pointer}
.tog[aria-pressed=true]{color:var(--teal);border-color:rgba(95,209,200,.55)}
@media (prefers-reduced-motion:reduce){.flash.on,.ripple,.tag{animation:none}.mascot *{animation:none!important}}
+22
View File
@@ -0,0 +1,22 @@
// Cookieless, first-party tracking. Requests go to /api/t/* on the same domain.
let visitSent = false;
const off = () => import.meta.env.DEV || navigator.doNotTrack === '1';
function send(url, body) {
const data = JSON.stringify(body);
try {
if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }))) return;
} catch (e) { /* fall through to fetch */ }
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: data, keepalive: true }).catch(() => {});
}
export function trackVisit() {
if (visitSent || off()) return;
visitSent = true;
send('/api/t/visit', { ref: document.referrer });
}
export function trackClick(link) {
if (off()) return;
send('/api/t/click', { link });
}
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY tracker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tracker/app.py tracker/check_links.py ./
COPY links.json ./links.json
RUN useradd -r app && chown -R app /app
USER app
EXPOSE 8000
# One worker so the in-memory rate limiter sees every request; threads handle concurrency.
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
+151
View File
@@ -0,0 +1,151 @@
"""Cookieless first-party tracker for the rain link hub.
Stores no IPs. "Unique visitors" are a daily-rotating HMAC of IP + user agent,
so a person counts once per day and cannot be followed across days.
"""
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
import re
from urllib.parse import urlparse
from flask import Flask, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from psycopg_pool import ConnectionPool
from werkzeug.middleware.proxy_fix import ProxyFix
log = logging.getLogger("tracker")
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DATABASE_URL = os.environ.get("DATABASE_URL", "")
SECRET = os.environ.get("TRACKER_SECRET", "dev-only-change-me")
SITE_HOST = os.environ.get("SITE_HOST", "").lower()
LINKS_FILE = os.environ.get("LINKS_FILE", "/app/links.json")
with open(LINKS_FILE, encoding="utf-8") as f:
_data = json.load(f)
LINK_IDS = {x["id"] for x in [_data["featured"], *_data["links"]]}
BOT_RE = re.compile(
r"bot|crawl|spider|slurp|curl|wget|python-requests|httpx|aiohttp|go-http|headless|"
r"phantom|preview|facebookexternalhit|embedly|monitor|uptime|lighthouse|scrapy",
re.I,
)
try: # optional offline country lookup (drop GeoLite2-Country.mmdb into ./geoip)
import geoip2.database
_geo = geoip2.database.Reader("/data/GeoLite2-Country.mmdb")
except Exception:
_geo = None
limiter = Limiter(get_remote_address, app=app, storage_uri="memory://")
_pool = None
def pool():
global _pool
if _pool is None:
_pool = ConnectionPool(DATABASE_URL, min_size=1, max_size=5, open=True)
return _pool
def insert(sql, params):
with pool().connection() as conn:
conn.execute(sql, params)
def visitor_hash(ip, ua):
day_key = hmac.new(SECRET.encode(), dt.date.today().isoformat().encode(), hashlib.sha256).digest()
return hmac.new(day_key, f"{ip}|{ua}".encode(), hashlib.sha256).hexdigest()[:32]
def country_of(ip):
cc = request.headers.get("CF-IPCountry", "")
if not (len(cc) == 2 and cc.isalpha()) and _geo:
try:
cc = _geo.country(ip).country.iso_code or ""
except Exception:
cc = ""
return cc.upper() if len(cc) == 2 and cc.isalpha() else "XX"
def device_of(ua):
u = ua.lower()
if "ipad" in u or "tablet" in u:
return "tablet"
if "mobi" in u or "android" in u or "iphone" in u:
return "mobile"
return "desktop"
def referrer_host(ref):
try:
host = (urlparse(ref or "").hostname or "").lower().removeprefix("www.")
except ValueError:
return None
if not host or host == SITE_HOST.removeprefix("www."):
return None
return host[:100]
def context():
"""Return request context, or None if the request should be silently ignored."""
ua = request.headers.get("User-Agent", "")
if len(ua) < 20 or BOT_RE.search(ua):
return None
if request.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin":
return None
ip = request.remote_addr or ""
return {"ua": ua, "visitor": visitor_hash(ip, ua), "country": country_of(ip), "device": device_of(ua)}
def body():
return request.get_json(silent=True, force=True) or {}
def safe_insert(sql, params):
try:
insert(sql, params)
except Exception:
log.exception("insert failed")
@app.post("/api/t/visit")
@limiter.limit("20/minute;200/hour")
def visit():
ctx = context()
if ctx:
safe_insert(
"INSERT INTO visits (visitor, referrer, country, device) VALUES (%s,%s,%s,%s)",
(ctx["visitor"], referrer_host(str(body().get("ref", ""))), ctx["country"], ctx["device"]),
)
return "", 204
@app.post("/api/t/click")
@limiter.limit("40/minute;400/hour")
def click():
ctx = context()
link = body().get("link")
if ctx and isinstance(link, str) and link in LINK_IDS:
safe_insert(
"INSERT INTO clicks (link_id, visitor, country, device) VALUES (%s,%s,%s,%s)",
(link, ctx["visitor"], ctx["country"], ctx["device"]),
)
return "", 204
@app.get("/api/health")
@limiter.exempt
def health():
try:
with pool().connection() as conn:
conn.execute("SELECT 1")
return {"ok": True}
except Exception:
return {"ok": False}, 503
+75
View File
@@ -0,0 +1,75 @@
"""Checks every link in links.json and records the result in link_health.
Sites like X and Discord often block bots, so 401/403/429/999 count as "alive but
blocking checks". Anything else outside 2xx/3xx is flagged dead. If KUMA_PUSH_URL is
set, the result is pushed to an Uptime Kuma push monitor.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
import psycopg
UA = "Mozilla/5.0 (compatible; rain-hub-linkcheck)"
SOFT = {401, 403, 429, 999}
def fetch(url, method):
req = urllib.request.Request(url, method=method, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, None
except urllib.error.HTTPError as e:
return e.code, None
except Exception as e:
return None, str(e)[:200]
def check(url):
status, err = fetch(url, "HEAD")
if status is None or status >= 400:
status, err = fetch(url, "GET")
if status is None:
return False, None, err
if 200 <= status < 400:
return True, status, None
if status in SOFT:
return True, status, "site blocks automated checks"
return False, status, "unexpected status"
def main():
with open(os.environ.get("LINKS_FILE", "/app/links.json"), encoding="utf-8") as f:
data = json.load(f)
items = [data["featured"], *data["links"]]
dead = []
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
for it in items:
url = it["href"]
if not url.startswith("http"):
continue
ok, status, note = check(url)
if not ok:
dead.append(it["id"])
conn.execute(
"""INSERT INTO link_health (link_id, url, ok, status, note, checked_at)
VALUES (%s,%s,%s,%s,%s, now())
ON CONFLICT (link_id) DO UPDATE SET url=EXCLUDED.url, ok=EXCLUDED.ok,
status=EXCLUDED.status, note=EXCLUDED.note, checked_at=now()""",
(it["id"], url, ok, status, note),
)
print(("OK " if ok else "DEAD"), it["id"], status, note or "")
push = os.environ.get("KUMA_PUSH_URL")
if push:
msg = "dead links: " + ", ".join(dead) if dead else "all links ok"
q = urllib.parse.urlencode({"status": "down" if dead else "up", "msg": msg})
try:
urllib.request.urlopen(f"{push}?{q}", timeout=10)
except Exception as e:
print("kuma push failed:", e)
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
flask>=3.0
flask-limiter>=3.5
psycopg[binary,pool]>=3.2
gunicorn>=22.0
geoip2>=4.8
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Tracking is disabled in dev, so no API proxy is needed.
export default defineConfig({ plugins: [react()] });