build a template website
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
Executable
+14
@@ -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
|
||||
Executable
+4
@@ -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
|
||||
@@ -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;
|
||||
Executable
+18
@@ -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
|
||||
@@ -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: {} }
|
||||
+25
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1698
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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}}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
flask>=3.0
|
||||
flask-limiter>=3.5
|
||||
psycopg[binary,pool]>=3.2
|
||||
gunicorn>=22.0
|
||||
geoip2>=4.8
|
||||
@@ -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()] });
|
||||
Reference in New Issue
Block a user