diff --git a/app/api/auth/tiktok/callback/route.ts b/app/api/auth/tiktok/callback/route.ts new file mode 100644 index 0000000..51d1ee6 --- /dev/null +++ b/app/api/auth/tiktok/callback/route.ts @@ -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 = ` + + +TikTok OAuth Success + +

✅ TikTok OAuth Success!

+

Copy these values to your .env file:

+ +
+

TIKTOK_ACCESS_TOKEN=
${tokenData.access_token}

+

TIKTOK_REFRESH_TOKEN=
${tokenData.refresh_token}

+

TIKTOK_OPEN_ID=
${tokenData.open_id}

+
+ +

+ Access token expires in ${Math.floor(tokenData.expires_in / 3600)} hours.
+ Refresh token expires in ${Math.floor(tokenData.refresh_expires_in / 86400)} days.
+ The server will auto-refresh the access token using the refresh token. +

+ +
+ Full response +
${JSON.stringify(tokenData, null, 2)}
+
+ +`; + + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); +} diff --git a/app/api/auth/tiktok/route.ts b/app/api/auth/tiktok/route.ts new file mode 100644 index 0000000..8b91130 --- /dev/null +++ b/app/api/auth/tiktok/route.ts @@ -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); +} diff --git a/app/page.tsx b/app/page.tsx index e1d430c..fff87b7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() {
{`${config.profile.name}

- {config.profile.name} + {profileName}

{config.profile.desc} diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..65e6965 --- /dev/null +++ b/app/privacy/page.tsx @@ -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 ( +

+
+
+ + + +

นโยบายความเป็นส่วนตัว

+
+ + + + 1. ข้อมูลที่เราเก็บรวบรวม + + +

เมื่อคุณใช้งานเว็บไซต์ Erika! เราอาจเก็บรวบรวมข้อมูลดังต่อไปนี้:

+
    +
  • ข้อมูลบัญชี Discord (ชื่อผู้ใช้, Discord ID) เมื่อคุณเข้าสู่ระบบ
  • +
  • ข้อมูลที่คุณส่งผ่านฟอร์มบนเว็บไซต์
  • +
  • ข้อมูล IP Address เพื่อนับจำนวนผู้เข้าชม
  • +
+
+
+ + + + 2. วัตถุประสงค์ในการใช้ข้อมูล + + +

เราใช้ข้อมูลที่เก็บรวบรวมเพื่อ:

+
    +
  • ยืนยันตัวตนผู้ใช้งานผ่าน Discord
  • +
  • ประมวลผลข้อมูลที่ส่งผ่านฟอร์ม
  • +
  • ปรับปรุงและพัฒนาเว็บไซต์
  • +
  • นับสถิติผู้เข้าชมเว็บไซต์
  • +
+
+
+ + + + 3. การแบ่งปันข้อมูล + + + เราจะไม่ขาย แลกเปลี่ยน หรือเปิดเผยข้อมูลส่วนบุคคลของคุณให้แก่บุคคลภายนอก + ยกเว้นกรณีที่กฎหมายกำหนด + + + + + + 4. การเก็บรักษาข้อมูล + + + ข้อมูลของคุณจะถูกเก็บรักษาไว้ตราบเท่าที่จำเป็นต่อการให้บริการ + คุณสามารถขอลบข้อมูลของคุณได้โดยติดต่อผู้ดูแลผ่านทาง Discord server + + + + + + 5. ความปลอดภัยของข้อมูล + + + เราใช้มาตรการรักษาความปลอดภัยที่เหมาะสมเพื่อปกป้องข้อมูลของคุณ + อย่างไรก็ตาม ไม่มีวิธีการส่งข้อมูลผ่านอินเทอร์เน็ตที่ปลอดภัย 100% + + + + + + 6. การเปลี่ยนแปลงนโยบาย + + + เราอาจปรับปรุงนโยบายความเป็นส่วนตัวนี้เป็นครั้งคราว + การเปลี่ยนแปลงจะมีผลทันทีเมื่อเผยแพร่บนเว็บไซต์ + + + + + + 7. ติดต่อเรา + + + หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "} + + Discord server + + + + + + +

+ อัปเดตล่าสุด: 30 มิถุนายน 2569 +

+
+
+ ); +} diff --git a/app/tos/page.tsx b/app/tos/page.tsx new file mode 100644 index 0000000..5f1c232 --- /dev/null +++ b/app/tos/page.tsx @@ -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 ( +
+
+
+ + + +

ข้อกำหนดการใช้งาน

+
+ + + + 1. การยอมรับข้อกำหนด + + + การเข้าใช้งานเว็บไซต์ Erika! ถือว่าคุณยอมรับข้อกำหนดและเงื่อนไขทั้งหมดที่ระบุไว้ในหน้านี้ + หากคุณไม่ยอมรับข้อกำหนดเหล่านี้ กรุณาหยุดใช้งานเว็บไซต์ + + + + + + 2. การใช้งานเว็บไซต์ + + +

คุณตกลงที่จะใช้งานเว็บไซต์นี้เพื่อวัตถุประสงค์ที่ถูกต้องตามกฎหมายเท่านั้น และจะไม่:

+
    +
  • ใช้เว็บไซต์ในทางที่ผิดกฎหมายหรือฉ้อโกง
  • +
  • พยายามเข้าถึงระบบโดยไม่ได้รับอนุญาต
  • +
  • รบกวนการทำงานของเว็บไซต์หรือเซิร์ฟเวอร์
  • +
  • ส่งเนื้อหาที่ไม่เหมาะสมหรือสแปมผ่านฟอร์มต่างๆ
  • +
+
+
+ + + + 3. บัญชีผู้ใช้ + + + การเข้าสู่ระบบผ่าน Discord ถือว่าคุณอนุญาตให้เราเข้าถึงข้อมูลพื้นฐานของบัญชี Discord ของคุณ + คุณมีหน้าที่รับผิดชอบในการรักษาความปลอดภัยของบัญชีตนเอง + + + + + + 4. เนื้อหาที่ผู้ใช้ส่ง + + + เนื้อหาที่คุณส่งผ่านฟอร์มบนเว็บไซต์จะถูกเก็บรักษาไว้เพื่อวัตถุประสงค์ของช่อง Erika! + เราขอสงวนสิทธิ์ในการลบเนื้อหาที่ไม่เหมาะสมโดยไม่ต้องแจ้งล่วงหน้า + + + + + + 5. การจำกัดความรับผิดชอบ + + + เว็บไซต์นี้ให้บริการตามสภาพที่เป็นอยู่ ("as is") โดยไม่มีการรับประกันใดๆ + เราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดจากการใช้งานเว็บไซต์นี้ + + + + + + 6. การเปลี่ยนแปลงข้อกำหนด + + + เราขอสงวนสิทธิ์ในการแก้ไขข้อกำหนดเหล่านี้ได้ตลอดเวลาโดยไม่ต้องแจ้งล่วงหน้า + การใช้งานเว็บไซต์ต่อไปหลังจากมีการเปลี่ยนแปลง ถือว่าคุณยอมรับข้อกำหนดใหม่ + + + + + +

+ อัปเดตล่าสุด: 30 มิถุนายน 2569 +

+
+
+ ); +} diff --git a/lib/followers/index.ts b/lib/followers/index.ts index b8962a8..1c43921 100644 --- a/lib/followers/index.ts +++ b/lib/followers/index.ts @@ -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 { +export interface FetchResult { + counts: FollowerCounts; + tiktokProfile: TiktokProfile; +} + +export async function getFollowerCounts(): Promise { 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 = { diff --git a/lib/followers/tiktok.ts b/lib/followers/tiktok.ts index 2110121..e7e8832 100644 --- a/lib/followers/tiktok.ts +++ b/lib/followers/tiktok.ts @@ -1,23 +1,102 @@ -export async function getTiktokFollowerCount(): Promise { - 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 { + // 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 { + 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 { + const profile = await getTiktokProfile(); + return profile.followerCount; +} diff --git a/next.config.ts b/next.config.ts index 3b5024a..efd5f49 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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', + }, ], }, };