diff --git a/README.md b/README.md index bb1cb3e..44660ba 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Erika is a Next.js application for a creator landing page, Discord-authenticated - Bun 1.3+ - PostgreSQL -- Redis for visitor counts and server-sent events +- Redis for follower-count caching and server-sent events - Discord OAuth and bot credentials for authentication and role-based access ## Development diff --git a/app/page.tsx b/app/page.tsx index 3c883bb..4ace824 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,11 +6,8 @@ import Image from "next/image"; import Link from "next/link"; import { Finger_Paint } from "next/font/google"; import { getFollowerCounts } from "@/lib/followers"; -import { headers } from "next/headers"; -import { Share2, Trophy, Users } from "lucide-react"; -import { Kbd } from "@/components/ui/kbd"; +import { Share2, Trophy } from "lucide-react"; import Background from "@/public/background/space.webp"; -import { trackUniqueVisitor } from "@/lib/visitor/unique-visitors"; import { formatCount, getCountForLink, @@ -106,16 +103,7 @@ function LinkList({ } export default async function Home() { - const visitorCountPromise = headers() - .then(trackUniqueVisitor) - .catch((error) => { - console.error("Failed to track visitor:", error); - return 0; - }); - const [{ counts }, visitorCount] = await Promise.all([ - getFollowerCounts(), - visitorCountPromise, - ]); + const { counts } = await getFollowerCounts(); // Use configured profile data. const profileName = config.profile.name; @@ -166,14 +154,6 @@ export default async function Home() {

{profileName}

- {visitorCount > 0 && ( -
- - - {visitorCount} Unique Visitors - -
- )} diff --git a/lib/config/server.ts b/lib/config/server.ts index 847192d..6c5a539 100644 --- a/lib/config/server.ts +++ b/lib/config/server.ts @@ -14,14 +14,6 @@ export function getCanonicalUrl() { return requiredEnvironmentUrl("NEXTAUTH_URL"); } -export function getVisitorTrackingConfig() { - return { - secret: readFeatureEnvironment("Visitor tracking", [ - "NEXTAUTH_SECRET", - ] as const).NEXTAUTH_SECRET, - }; -} - export function getDiscordServerConfig() { const config = readFeatureEnvironment("Discord", [ "DISCORD_BOT_TOKEN", diff --git a/lib/followers/tiktok.ts b/lib/followers/tiktok.ts index 11edac6..2dd3689 100644 --- a/lib/followers/tiktok.ts +++ b/lib/followers/tiktok.ts @@ -85,53 +85,3 @@ export async function getTiktokProfile(): Promise { return empty; } } - -/** Convenience wrapper that just returns the follower count */ -export async function getTiktokFollowerCount(): Promise { - const profile = await getTiktokProfile(); - return profile.followerCount; -} - -export interface TiktokVideo { - id: string; - title: string; - coverImageUrl: string; - shareUrl: string; - createTime: number; -} - -export async function getLatestTiktokVideo(): Promise { - const profile = await getTiktokProfile(); - if (!profile.username) { - console.error("[TikTok API] Could not get username from profile to fetch TikWM."); - return null; - } - - try { - const res = await fetch(`https://www.tikwm.com/api/user/posts?unique_id=@${profile.username}&count=1`, { - cache: "no-store", - signal: AbortSignal.timeout(5000), - }); - - if (!res.ok) return null; - - const data = await res.json(); - const videos = data.data?.videos; - - if (!videos || videos.length === 0) return null; - - const v = videos[0]; - const shareUrl = `https://www.tiktok.com/@${profile.username}/video/${v.video_id}`; - - return { - id: v.video_id, - title: v.title ?? "", - coverImageUrl: v.cover ?? "", - shareUrl: shareUrl, - createTime: v.create_time ?? 0, - }; - } catch (e) { - console.error("[TikTok API] Error fetching latest video via TikWM:", e); - return null; - } -} diff --git a/lib/leaderboard/database.ts b/lib/leaderboard/database.ts index 66d0ad9..76dad9f 100644 --- a/lib/leaderboard/database.ts +++ b/lib/leaderboard/database.ts @@ -1,5 +1,5 @@ import "server-only"; -import { and, eq, gte, inArray, sql } from "drizzle-orm"; +import { and, eq, gte, inArray } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core"; import postgres from "postgres"; @@ -69,29 +69,6 @@ export async function getXpLeaderboardPage( return buildXpLeaderboard(rows, requestedPage, searchQuery); } -export async function getMemberXpStats(userId: string) { - const database = getLeaderboardDatabase(); - const [sourceRows, leaderboard] = await Promise.all([ - database - .select({ - level: userLevels.level, - totalXp: userLevels.xp, - }) - .from(userLevels) - .where(eq(userLevels.userId, userId)) - .limit(1), - getXpLeaderboardPage(1, userId), - ]); - const rankedRow = leaderboard.rows.find((row) => row.userId === userId); - const sourceRow = sourceRows[0]; - - return { - level: sourceRow?.level ?? 0, - totalXp: sourceRow?.totalXp ?? "0", - rank: rankedRow?.rank ?? null, - }; -} - export async function getVoiceLeaderboardPage( currentChannelIds: string[], requestedPage: number, @@ -131,28 +108,3 @@ export async function getVoiceLeaderboardPage( searchQuery, ); } - -export async function getMemberVoiceStats( - userId: string, - currentChannelIds: string[], -) { - const leaderboard = await getVoiceLeaderboardPage( - currentChannelIds, - 1, - undefined, - userId, - ); - const rankedRow = leaderboard.rows.find((row) => row.userId === userId); - - return { - totalSeconds: rankedRow?.totalSeconds ?? "0", - rank: rankedRow?.rank ?? null, - }; -} - -export async function getLeaderboardDatabaseReadOnlyStatus() { - const result = await getLeaderboardDatabase().execute<{ - transaction_read_only: string; - }>(sql`show transaction_read_only`); - return result[0]?.transaction_read_only ?? null; -} diff --git a/lib/visitor/unique-visitors.test.ts b/lib/visitor/unique-visitors.test.ts deleted file mode 100644 index 5f17e18..0000000 --- a/lib/visitor/unique-visitors.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - getClientAddress, - hashClientAddress, -} from "./visitor-identifiers"; - -describe("privacy-preserving visitor tracking", () => { - test("uses the first valid forwarded address", () => { - const headers = new Headers({ - "x-forwarded-for": "203.0.113.9, 10.0.0.1", - "x-real-ip": "192.0.2.2", - }); - - expect(getClientAddress(headers)).toBe("203.0.113.9"); - }); - - test("rejects untrusted non-IP identifiers", () => { - expect( - getClientAddress(new Headers({ "x-forwarded-for": "unknown" })), - ).toBeNull(); - }); - - test("creates a stable identifier without retaining the address", () => { - const address = "203.0.113.9"; - const identifier = hashClientAddress(address, "test-secret"); - - expect(identifier).toBe(hashClientAddress(address, "test-secret")); - expect(identifier).not.toContain(address); - expect(identifier).toHaveLength(64); - }); -}); diff --git a/lib/visitor/unique-visitors.ts b/lib/visitor/unique-visitors.ts deleted file mode 100644 index f5e23a3..0000000 --- a/lib/visitor/unique-visitors.ts +++ /dev/null @@ -1,38 +0,0 @@ -import "server-only"; - -import { getVisitorTrackingConfig } from "@/lib/config/server"; -import { getRedisClient } from "@/lib/redis"; -import { - getClientAddress, - hashClientAddress, -} from "@/lib/visitor/visitor-identifiers"; - -const VISITOR_SET_KEY = "erika:unique_visitors:v2"; -const LEGACY_VISITOR_SET_KEY = "erika:unique_visitors"; - -export async function trackUniqueVisitor(headers: Headers) { - const client = await getRedisClient(); - const address = getClientAddress(headers); - - if (address) { - const { secret } = getVisitorTrackingConfig(); - const identifier = hashClientAddress(address, secret); - const [isLegacyVisitor, isPrivateVisitor] = await Promise.all([ - client.sismember(LEGACY_VISITOR_SET_KEY, address), - client.sismember(VISITOR_SET_KEY, identifier), - ]); - - if (isLegacyVisitor) { - // Keep the legacy visitor in the old total without counting a revisit twice. - if (isPrivateVisitor) await client.srem(VISITOR_SET_KEY, identifier); - } else { - await client.sadd(VISITOR_SET_KEY, identifier); - } - } - - const [legacyCount, privateCount] = await Promise.all([ - client.scard(LEGACY_VISITOR_SET_KEY), - client.scard(VISITOR_SET_KEY), - ]); - return legacyCount + privateCount; -} diff --git a/lib/visitor/visitor-identifiers.ts b/lib/visitor/visitor-identifiers.ts deleted file mode 100644 index cf3f67b..0000000 --- a/lib/visitor/visitor-identifiers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createHmac } from "node:crypto"; -import { isIP } from "node:net"; - -export function getClientAddress(headers: Headers) { - const forwardedAddress = headers - .get("x-forwarded-for") - ?.split(",", 1)[0] - ?.trim(); - const address = forwardedAddress || headers.get("x-real-ip")?.trim(); - return address && isIP(address) ? address : null; -} - -export function hashClientAddress(address: string, secret: string) { - return createHmac("sha256", secret).update(address).digest("hex"); -} diff --git a/skill/erika/references/behavior.md b/skill/erika/references/behavior.md index 6a66ad1..c7145f6 100644 --- a/skill/erika/references/behavior.md +++ b/skill/erika/references/behavior.md @@ -16,6 +16,5 @@ Do not remove or bypass these without good reason: 1. `db/index.ts` uses a `globalThis` singleton and small pool size to prevent dev hot-reload connection leaks and reduce idle RAM. 2. The NextAuth session callback caches `discordId` lookups in memory with a TTL. 3. `lib/discord.ts` caches Discord API roles/profiles in memory for 60 seconds to avoid slow repeated external calls. -4. `app/page.tsx` batches Redis unique visitor tracking and only counts when `sadd` adds a new IP. -5. Docker Compose runs a single app replica; this is intentional for low RAM usage. -6. Nginx must disable proxy buffering for the Next.js upstream so App Router streaming and Server Actions do not hang. +4. Docker Compose runs a single app replica; this is intentional for low RAM usage. +5. Nginx must disable proxy buffering for the Next.js upstream so App Router streaming and Server Actions do not hang. diff --git a/skill/erika/references/libraries-and-data.md b/skill/erika/references/libraries-and-data.md index e2564a7..e1c6a54 100644 --- a/skill/erika/references/libraries-and-data.md +++ b/skill/erika/references/libraries-and-data.md @@ -6,7 +6,7 @@ |---|---| | `lib/config.ts` | Linktree config for `/`: profile, social links, and `config_p2` with Form link. This does not configure the form system. | | `lib/icons.tsx` | SVG icon components for social platforms and Form | -| `lib/redis.ts` | Redis singleton using Bun `RedisClient`; used for follower counts and unique visitors | +| `lib/redis.ts` | Redis singleton using Bun `RedisClient`; used for follower counts and server-sent events | | `lib/auth.ts` | Shared session, admin, and form role-access authorization helpers | | `lib/discord.ts` | Discord API utilities for guild roles/member profiles with in-memory TTL caching | | `lib/sanitize-html.ts` | Sanitizes admin-authored rich text before storage/rendering | diff --git a/skill/erika/references/project.md b/skill/erika/references/project.md index d0f0cea..1318628 100644 --- a/skill/erika/references/project.md +++ b/skill/erika/references/project.md @@ -23,7 +23,7 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea | `DATABASE_URL` | PostgreSQL connection string | | `NEXTAUTH_SECRET` | Secret for signing NextAuth sessions | | `NEXTAUTH_URL` | Canonical URL of the site, such as `https://erika.sudloh.com` | -| `REDIS_URL` | Redis connection string, used for follower-count caching and unique visitors | +| `REDIS_URL` | Redis connection string, used for follower-count caching and server-sent events | | `BASE_URL` | Public base URL, used in metadata/SEO and generated links | | `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Discord OAuth app credentials for user login | | `ADMIN_DISCORD_IDS` | Comma-separated Discord user IDs with admin access | diff --git a/skill/erika/references/routes-and-actions.md b/skill/erika/references/routes-and-actions.md index 69af49a..1dd8eac 100644 --- a/skill/erika/references/routes-and-actions.md +++ b/skill/erika/references/routes-and-actions.md @@ -5,7 +5,7 @@ | File | Purpose | |---|---| | `app/layout.tsx` | Root layout with fonts, theme provider, tooltip provider, toaster, page progress, and SEO metadata | -| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, unique visitor count, and video background | +| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, and video background | | `app/globals.css` | Global CSS styles | ## `/form` User-Facing Forms