chore: remove unused code and visitor tracking
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-22
@@ -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() {
|
||||
<h1 className={`${fingerPaint.className} text-2xl`}>
|
||||
{profileName}
|
||||
</h1>
|
||||
{visitorCount > 0 && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Kbd>
|
||||
<Users className="size-3.5" />
|
||||
<span>{visitorCount} Unique Visitors</span>
|
||||
</Kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -85,53 +85,3 @@ export async function getTiktokProfile(): Promise<TiktokProfile> {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience wrapper that just returns the follower count */
|
||||
export async function getTiktokFollowerCount(): Promise<number | null> {
|
||||
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<TiktokVideo | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user