feat: implement TikTok OAuth flow and integrate profile data into home page

This commit is contained in:
2026-06-30 20:41:01 +07:00 Unverified
parent de3ee40fa4
commit 2e3983e38c
8 changed files with 494 additions and 31 deletions
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
/**
* GET /api/auth/tiktok/callback
* Handles the OAuth callback from TikTok.
* Exchanges the authorization code for access + refresh tokens.
* Displays the tokens so you can copy them to your .env file.
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error) {
return NextResponse.json(
{ error, description: searchParams.get("error_description") },
{ status: 400 }
);
}
if (!code) {
return NextResponse.json(
{ error: "No authorization code received" },
{ status: 400 }
);
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
if (!clientKey || !clientSecret) {
return NextResponse.json(
{ error: "TIKTOK_CLIENT_KEY or TIKTOK_CLIENT_SECRET not set" },
{ status: 500 }
);
}
// Exchange the code for tokens
const tokenRes = await fetch(
"https://open.tiktokapis.com/v2/oauth/token/",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
}
);
const tokenData = await tokenRes.json();
if (tokenData.error) {
return NextResponse.json(
{ error: tokenData.error, description: tokenData.error_description },
{ status: 400 }
);
}
// Return an HTML page showing the tokens to copy
const html = `
<!DOCTYPE html>
<html>
<head><title>TikTok OAuth Success</title></head>
<body style="font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 20px; background: #0a0a0a; color: #e5e5e5;">
<h1 style="color: #22c55e;">✅ TikTok OAuth Success!</h1>
<p>Copy these values to your <code>.env</code> file:</p>
<div style="background: #1a1a1a; padding: 16px; border-radius: 8px; margin: 16px 0; word-break: break-all;">
<p><strong>TIKTOK_ACCESS_TOKEN=</strong><br/><code>${tokenData.access_token}</code></p>
<p><strong>TIKTOK_REFRESH_TOKEN=</strong><br/><code>${tokenData.refresh_token}</code></p>
<p><strong>TIKTOK_OPEN_ID=</strong><br/><code>${tokenData.open_id}</code></p>
</div>
<p style="color: #a3a3a3; font-size: 14px;">
Access token expires in ${Math.floor(tokenData.expires_in / 3600)} hours.<br/>
Refresh token expires in ${Math.floor(tokenData.refresh_expires_in / 86400)} days.<br/>
The server will auto-refresh the access token using the refresh token.
</p>
<details style="margin-top: 20px;">
<summary style="cursor: pointer;">Full response</summary>
<pre style="background: #1a1a1a; padding: 12px; border-radius: 6px; overflow-x: auto;">${JSON.stringify(tokenData, null, 2)}</pre>
</details>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
/**
* GET /api/auth/tiktok
* Redirects to TikTok OAuth authorization page.
* Visit this URL once to start the OAuth flow and get your refresh token.
*/
export async function GET() {
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
if (!clientKey) {
return NextResponse.json(
{ error: "TIKTOK_CLIENT_KEY not set in environment" },
{ status: 500 }
);
}
const csrfState = Math.random().toString(36).substring(2);
const params = new URLSearchParams({
client_key: clientKey,
scope: "user.info.basic,user.info.profile,user.info.stats",
response_type: "code",
redirect_uri: redirectUri,
state: csrfState,
});
const authUrl = `https://www.tiktok.com/v2/auth/authorize/?${params.toString()}`;
return NextResponse.redirect(authUrl);
}
+11 -7
View File
@@ -9,7 +9,7 @@ import { Users } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
export default async function Home() {
const counts = await getFollowerCounts();
const { counts, tiktokProfile } = await getFollowerCounts();
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || "unknown";
@@ -24,16 +24,20 @@ export default async function Home() {
console.error("Failed to track visitor:", e);
}
// Use TikTok profile data with config fallbacks
const profileName = tiktokProfile.displayName || config.profile.name;
const profileAvatar = tiktokProfile.avatarUrl || config.profile.avatar;
const jsonLd = {
"@context": "https://schema.org",
"@type": "ProfilePage",
name: config.profile.name,
name: profileName,
description: config.profile.desc,
url: "https://erika.sudloh.com",
mainEntity: {
"@type": "Person",
name: config.profile.name,
image: "https://erika.sudloh.com/avatar.webp",
name: profileName,
image: profileAvatar.startsWith("/") ? `https://erika.sudloh.com${profileAvatar}` : profileAvatar,
sameAs: config.links.map((l) => l.link),
},
};
@@ -53,8 +57,8 @@ export default async function Home() {
<CardHeader className="flex flex-col items-center gap-4 text-center">
<div className="relative w-24 h-24 overflow-hidden rounded-full">
<Image
src={config.profile.avatar}
alt={`${config.profile.name} avatar`}
src={profileAvatar}
alt={`${profileName} avatar`}
fill
priority
className="object-cover"
@@ -62,7 +66,7 @@ export default async function Home() {
</div>
<div>
<h1 className="text-2xl font-bold bg-linear-to-r from-pink-500 to-yellow-200 bg-clip-text text-transparent">
{config.profile.name}
{profileName}
</h1>
<p className="text-sm text-zinc-400 mt-1">
{config.profile.desc}
+120
View File
@@ -0,0 +1,120 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "นโยบายความเป็นส่วนตัว",
description: "นโยบายความเป็นส่วนตัวของเว็บไซต์ Erika!",
};
export default function PrivacyPolicyPage() {
return (
<main className="min-h-screen flex flex-col items-center p-4 md:p-8">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center gap-2">
<Link href="/">
<Button variant="ghost" size="icon">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight">นโยบายความเป็นส่วนตัว</h1>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">1. ข้อมูลที่เราเก็บรวบรวม</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed space-y-2">
<p>เมื่อคุณใช้งานเว็บไซต์ Erika! เราอาจเก็บรวบรวมข้อมูลดังต่อไปนี้:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>ข้อมูลบัญชี Discord (ชื่อผู้ใช้, Discord ID) เมื่อคุณเข้าสู่ระบบ</li>
<li>ข้อมูลที่คุณส่งผ่านฟอร์มบนเว็บไซต์</li>
<li>ข้อมูล IP Address เพื่อนับจำนวนผู้เข้าชม</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">2. วัตถุประสงค์ในการใช้ข้อมูล</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed space-y-2">
<p>เราใช้ข้อมูลที่เก็บรวบรวมเพื่อ:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>ยืนยันตัวตนผู้ใช้งานผ่าน Discord</li>
<li>ประมวลผลข้อมูลที่ส่งผ่านฟอร์ม</li>
<li>ปรับปรุงและพัฒนาเว็บไซต์</li>
<li>นับสถิติผู้เข้าชมเว็บไซต์</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">3. การแบ่งปันข้อมูล</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เราจะไม่ขาย แลกเปลี่ยน หรือเปิดเผยข้อมูลส่วนบุคคลของคุณให้แก่บุคคลภายนอก
ยกเว้นกรณีที่กฎหมายกำหนด
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">4. การเก็บรักษาข้อมูล</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
ข้อมูลของคุณจะถูกเก็บรักษาไว้ตราบเท่าที่จำเป็นต่อการให้บริการ
คุณสามารถขอลบข้อมูลของคุณได้โดยติดต่อผู้ดูแลผ่านทาง Discord server
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">5. ความปลอดภัยของข้อมูล</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เราใช้มาตรการรักษาความปลอดภัยที่เหมาะสมเพื่อปกป้องข้อมูลของคุณ
อย่างไรก็ตาม ไม่มีวิธีการส่งข้อมูลผ่านอินเทอร์เน็ตที่ปลอดภัย 100%
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">6. การเปลี่ยนแปลงนโยบาย</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เราอาจปรับปรุงนโยบายความเป็นส่วนตัวนี้เป็นครั้งคราว
การเปลี่ยนแปลงจะมีผลทันทีเมื่อเผยแพร่บนเว็บไซต์
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">7. ติดต่อเรา</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "}
<a
href="https://discord.com/invite/3cZQbvW6cF"
target="_blank"
rel="noopener noreferrer"
className="underline text-foreground hover:text-primary"
>
Discord server
</a>
</CardContent>
</Card>
<Separator />
<p className="text-xs text-muted-foreground text-center pb-4">
อัปเดตล่าสุด: 30 มิถุนายน 2569
</p>
</div>
</main>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "ข้อกำหนดการใช้งาน ",
description: "ข้อกำหนดและเงื่อนไขการใช้งานเว็บไซต์ Erika!",
};
export default function TermsOfServicePage() {
return (
<main className="min-h-screen flex flex-col items-center p-4 md:p-8">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center gap-2">
<Link href="/">
<Button variant="ghost" size="icon">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight">ข้อกำหนดการใช้งาน</h1>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">1. การยอมรับข้อกำหนด</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
การเข้าใช้งานเว็บไซต์ Erika! ถือว่าคุณยอมรับข้อกำหนดและเงื่อนไขทั้งหมดที่ระบุไว้ในหน้านี้
หากคุณไม่ยอมรับข้อกำหนดเหล่านี้ กรุณาหยุดใช้งานเว็บไซต์
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">2. การใช้งานเว็บไซต์</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed space-y-2">
<p>คุณตกลงที่จะใช้งานเว็บไซต์นี้เพื่อวัตถุประสงค์ที่ถูกต้องตามกฎหมายเท่านั้น และจะไม่:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>ใช้เว็บไซต์ในทางที่ผิดกฎหมายหรือฉ้อโกง</li>
<li>พยายามเข้าถึงระบบโดยไม่ได้รับอนุญาต</li>
<li>รบกวนการทำงานของเว็บไซต์หรือเซิร์ฟเวอร์</li>
<li>ส่งเนื้อหาที่ไม่เหมาะสมหรือสแปมผ่านฟอร์มต่างๆ</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">3. บัญชีผู้ใช้</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
การเข้าสู่ระบบผ่าน Discord ถือว่าคุณอนุญาตให้เราเข้าถึงข้อมูลพื้นฐานของบัญชี Discord ของคุณ
คุณมีหน้าที่รับผิดชอบในการรักษาความปลอดภัยของบัญชีตนเอง
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">4. เนื้อหาที่ผู้ใช้ส่ง</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เนื้อหาที่คุณส่งผ่านฟอร์มบนเว็บไซต์จะถูกเก็บรักษาไว้เพื่อวัตถุประสงค์ของช่อง Erika!
เราขอสงวนสิทธิ์ในการลบเนื้อหาที่ไม่เหมาะสมโดยไม่ต้องแจ้งล่วงหน้า
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">5. การจำกัดความรับผิดชอบ</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เว็บไซต์นี้ให้บริการตามสภาพที่เป็นอยู่ (&quot;as is&quot;) โดยไม่มีการรับประกันใดๆ
เราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดจากการใช้งานเว็บไซต์นี้
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">6. การเปลี่ยนแปลงข้อกำหนด</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground leading-relaxed">
เราขอสงวนสิทธิ์ในการแก้ไขข้อกำหนดเหล่านี้ได้ตลอดเวลาโดยไม่ต้องแจ้งล่วงหน้า
การใช้งานเว็บไซต์ต่อไปหลังจากมีการเปลี่ยนแปลง ถือว่าคุณยอมรับข้อกำหนดใหม่
</CardContent>
</Card>
<Separator />
<p className="text-xs text-muted-foreground text-center pb-4">
อัปเดตล่าสุด: 30 มิถุนายน 2569
</p>
</div>
</main>
);
}
+32 -10
View File
@@ -1,7 +1,9 @@
import { getYoutubeSubscriberCount } from "./youtube";
import { getRobloxFollowerCount } from "./roblox";
import { getDiscordMemberCount } from "./discord";
import { getTiktokFollowerCount } from "./tiktok";
import { getTiktokProfile, type TiktokProfile } from "./tiktok";
export type { TiktokProfile };
export interface FollowerCounts {
youtube: number | null;
@@ -16,38 +18,58 @@ export interface FollowerInfo {
}
const CACHE_KEY = "erika:follower_counts";
const CACHE_KEY_TIKTOK = "erika:tiktok_profile";
const CACHE_TTL = 3600; // 1 hour
export async function getFollowerCounts(): Promise<FollowerCounts> {
export interface FetchResult {
counts: FollowerCounts;
tiktokProfile: TiktokProfile;
}
export async function getFollowerCounts(): Promise<FetchResult> {
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
const cached = await redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached);
const [cachedCounts, cachedProfile] = await Promise.all([
redis.get(CACHE_KEY),
redis.get(CACHE_KEY_TIKTOK),
]);
if (cachedCounts && cachedProfile) {
return {
counts: JSON.parse(cachedCounts),
tiktokProfile: JSON.parse(cachedProfile),
};
}
} catch {
// Redis unavailable, fall through to fetch
}
const [youtube, roblox, discord, tiktok] = await Promise.all([
const [youtube, roblox, discord, tiktokProfileResult] = await Promise.all([
getYoutubeSubscriberCount(),
getRobloxFollowerCount(),
getDiscordMemberCount(),
getTiktokFollowerCount(),
getTiktokProfile(),
]);
const counts: FollowerCounts = { youtube, roblox, discord, tiktok };
const counts: FollowerCounts = {
youtube,
roblox,
discord,
tiktok: tiktokProfileResult.followerCount,
};
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
await redis.set(CACHE_KEY, JSON.stringify(counts), { ex: CACHE_TTL });
await Promise.all([
redis.set(CACHE_KEY, JSON.stringify(counts), { ex: CACHE_TTL }),
redis.set(CACHE_KEY_TIKTOK, JSON.stringify(tiktokProfileResult), { ex: CACHE_TTL }),
]);
} catch {
// Redis unavailable, skip caching
}
return counts;
return { counts, tiktokProfile: tiktokProfileResult };
}
const followerLabels: Record<keyof FollowerCounts, string> = {
+93 -14
View File
@@ -1,23 +1,102 @@
export async function getTiktokFollowerCount(): Promise<number | null> {
const apiKey = process.env.TIKTOK_API_KEY;
const userId = process.env.TIKTOK_USER_ID;
if (!apiKey || !userId) return null;
/**
* Fetches TikTok follower count using the official TikTok API v2.
*
* Required env vars:
* - TIKTOK_CLIENT_KEY
* - TIKTOK_CLIENT_SECRET
* - TIKTOK_REFRESH_TOKEN (obtained via one-time OAuth flow at /api/auth/tiktok)
*/
let cachedAccessToken: string | null = null;
let tokenExpiresAt = 0;
async function getAccessToken(): Promise<string | null> {
// Return cached token if still valid (with 5 min buffer)
if (cachedAccessToken && Date.now() < tokenExpiresAt - 300_000) {
return cachedAccessToken;
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
const refreshToken = process.env.TIKTOK_REFRESH_TOKEN;
if (!clientKey || !clientSecret || !refreshToken) return null;
try {
const res = await fetch(
`https://api.scrapecreators.com/v1/tiktok/profile?user_id=${encodeURIComponent(userId)}`,
{
headers: { 'x-api-key': apiKey },
next: { revalidate: 86400 },
signal: AbortSignal.timeout(5000),
}
);
const res = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
const count = data.statsV2?.followerCount;
return count != null ? Number(count) : null;
if (data.access_token) {
cachedAccessToken = data.access_token;
tokenExpiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
return cachedAccessToken;
}
return null;
} catch {
return null;
}
}
export interface TiktokProfile {
followerCount: number | null;
displayName: string | null;
username: string | null;
avatarUrl: string | null;
}
export async function getTiktokProfile(): Promise<TiktokProfile> {
const empty: TiktokProfile = {
followerCount: null,
displayName: null,
username: null,
avatarUrl: null,
};
const accessToken = await getAccessToken();
if (!accessToken) return empty;
try {
const res = await fetch(
"https://open.tiktokapis.com/v2/user/info/?fields=follower_count,display_name,username,avatar_url",
{
headers: { Authorization: `Bearer ${accessToken}` },
next: { revalidate: 86400 },
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) return empty;
const data = await res.json();
const user = data.data?.user;
if (!user) return empty;
return {
followerCount: user.follower_count != null ? Number(user.follower_count) : null,
displayName: user.display_name ?? null,
username: user.username ?? null,
avatarUrl: user.avatar_url ?? null,
};
} catch {
return empty;
}
}
/** Convenience wrapper that just returns the follower count */
export async function getTiktokFollowerCount(): Promise<number | null> {
const profile = await getTiktokProfile();
return profile.followerCount;
}
+12
View File
@@ -9,6 +9,18 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
{
protocol: 'https',
hostname: 'p16-sign-sg.tiktokcdn.com',
},
{
protocol: 'https',
hostname: 'p16-sign.tiktokcdn-us.com',
},
{
protocol: 'https',
hostname: '*.tiktokcdn.com',
},
],
},
};