feat: implement TikTok OAuth flow and integrate profile data into home page
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
เว็บไซต์นี้ให้บริการตามสภาพที่เป็นอยู่ ("as is") โดยไม่มีการรับประกันใดๆ
|
||||
เราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดจากการใช้งานเว็บไซต์นี้
|
||||
</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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user