chore(config): centralize feature settings

This commit is contained in:
2026-08-09 21:20:19 +07:00 Unverified
parent 26c0b5af18
commit 950d3d4afe
22 changed files with 238 additions and 70 deletions
+5 -3
View File
@@ -15,11 +15,13 @@ import {
import { writeSubmission } from "@/lib/form/submission-write"; import { writeSubmission } from "@/lib/form/submission-write";
import { submissionWebhookMessage } from "@/lib/form/submission-webhook"; import { submissionWebhookMessage } from "@/lib/form/submission-webhook";
import { isFormAcceptingSubmissions } from "@/lib/form/form-availability"; import { isFormAcceptingSubmissions } from "@/lib/form/form-availability";
import { getCanonicalUrl } from "@/lib/config/server";
function getSubmissionResultLink(formId: string, submissionId: string) { function getSubmissionResultLink(formId: string, submissionId: string) {
const baseUrl = process.env.BASE_URL || process.env.NEXTAUTH_URL; return new URL(
if (!baseUrl) return `/admin/form/${formId}/result/${submissionId}`; `/admin/form/${formId}/result/${submissionId}`,
return new URL(`/admin/form/${formId}/result/${submissionId}`, baseUrl).toString(); getCanonicalUrl(),
).toString();
} }
export async function submitForm( export async function submitForm(
+2 -1
View File
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { config } from "@/lib/config";
export default function Page() { export default function Page() {
redirect('https://discord.com/invite/3cZQbvW6cF'); redirect(config.site.discordInviteUrl);
return <div>Redirecting...</div>; return <div>Redirecting...</div>;
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
import Background from "@/public/background/404.png"; import Background from "@/public/background/404.png";
import Profile from "@/public/404-image.jpg"; import Profile from "@/public/404-image.jpg";
import { Activity, Home } from "lucide-react"; import { Activity, Home } from "lucide-react";
import { config } from "@/lib/config";
const MESSAGES = [ const MESSAGES = [
"ฟอร์มนี้อาจจะถูก Erika กินไปแล้ว...", "ฟอร์มนี้อาจจะถูก Erika กินไปแล้ว...",
@@ -54,7 +55,7 @@ export default function NotFound() {
กลับหน้าเลือก กลับหน้าเลือก
</Button> </Button>
</Link> </Link>
<Link href="https://status.sudloh.com"> <Link href={config.site.statusUrl}>
<Button variant="link" className="text-secondary-foreground"> <Button variant="link" className="text-secondary-foreground">
<Activity /> <Activity />
สถานะเว็บไซต์ สถานะเว็บไซต์
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter, Anuphan } from "next/font/google"; import { Inter, Anuphan } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { config } from "@/lib/config";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
@@ -15,7 +16,7 @@ const anuphan = Anuphan({
display: "swap", display: "swap",
}); });
const siteUrl = "https://erika.sudloh.com"; const siteUrl = config.site.url;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Erika!", title: "Erika!",
+2 -1
View File
@@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
import Background from "@/public/background/404.png"; import Background from "@/public/background/404.png";
import Profile from "@/public/404-image.jpg"; import Profile from "@/public/404-image.jpg";
import { Activity, Home } from "lucide-react"; import { Activity, Home } from "lucide-react";
import { config } from "@/lib/config";
const MESSAGES = [ const MESSAGES = [
"Erika กินหน้านี้ไปแล้ว...", "Erika กินหน้านี้ไปแล้ว...",
@@ -54,7 +55,7 @@ export default function NotFound() {
กลับหน้าแรก กลับหน้าแรก
</Button> </Button>
</Link> </Link>
<Link href="https://status.sudloh.com"> <Link href={config.site.statusUrl}>
<Button variant="link" className="text-secondary-foreground"> <Button variant="link" className="text-secondary-foreground">
<Activity /> <Activity />
สถานะเว็บไซต์ สถานะเว็บไซต์
+2 -2
View File
@@ -47,11 +47,11 @@ export default async function Home() {
"@type": "ProfilePage", "@type": "ProfilePage",
name: profileName, name: profileName,
description: config.profile.desc, description: config.profile.desc,
url: "https://erika.sudloh.com", url: config.site.url,
mainEntity: { mainEntity: {
"@type": "Person", "@type": "Person",
name: profileName, name: profileName,
image: profileAvatar.startsWith("/") ? `https://erika.sudloh.com${profileAvatar}` : profileAvatar, image: profileAvatar.startsWith("/") ? `${config.site.url}${profileAvatar}` : profileAvatar,
sameAs: links1.map((l) => l.link), sameAs: links1.map((l) => l.link),
}, },
}; };
+2 -1
View File
@@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { config } from "@/lib/config";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
@@ -99,7 +100,7 @@ export default function PrivacyPolicyPage() {
<CardContent className="text-sm text-muted-foreground leading-relaxed"> <CardContent className="text-sm text-muted-foreground leading-relaxed">
หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "} หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "}
<a <a
href="https://discord.com/invite/3cZQbvW6cF" href={config.site.discordInviteUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline text-foreground hover:text-primary" className="underline text-foreground hover:text-primary"
+2 -1
View File
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { config } from "@/lib/config";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
@@ -9,6 +10,6 @@ export default function robots(): MetadataRoute.Robots {
disallow: ["/api/", "/admin/"], disallow: ["/api/", "/admin/"],
}, },
], ],
sitemap: "https://erika.sudloh.com/sitemap.xml", sitemap: `${config.site.url}/sitemap.xml`,
}; };
} }
+2 -1
View File
@@ -1,7 +1,8 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { config } from "@/lib/config";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://erika.sudloh.com"; const baseUrl = config.site.url;
return [ return [
{ {
+8 -1
View File
@@ -13,7 +13,14 @@ export interface ProfileConfig {
avatar: string; avatar: string;
} }
const DISCORD_INVITE_URL = "https://discord.com/invite/3cZQbvW6cF";
export const config = { export const config = {
site: {
url: "https://erika.sudloh.com",
discordInviteUrl: DISCORD_INVITE_URL,
statusUrl: "https://status.sudloh.com",
},
profile: { profile: {
name: "Erika!", name: "Erika!",
desc: "แหล่งรวมลิ้งค์ของช่อง Erika!", desc: "แหล่งรวมลิ้งค์ของช่อง Erika!",
@@ -34,7 +41,7 @@ export const config = {
}, },
{ {
name: "Discord server", name: "Discord server",
link: "https://discord.com/invite/3cZQbvW6cF", link: DISCORD_INVITE_URL,
desc: "เข้าเซิร์ฟเวอร์ในดิส", desc: "เข้าเซิร์ฟเวอร์ในดิส",
icon: "discord", icon: "discord",
}, },
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import {
optionalEnvironmentValue,
readFeatureEnvironment,
requiredEnvironmentUrl,
requiredEnvironmentValue,
} from "@/lib/config/environment";
describe("feature environment configuration", () => {
test("returns trimmed required and optional values", () => {
const environment = { REQUIRED: " value ", OPTIONAL: " optional " };
expect(requiredEnvironmentValue("REQUIRED", environment)).toBe("value");
expect(optionalEnvironmentValue("OPTIONAL", environment)).toBe("optional");
expect(optionalEnvironmentValue("MISSING", environment)).toBeNull();
});
test("reports the feature and missing variable", () => {
expect(() =>
readFeatureEnvironment("Discord", ["TOKEN", "GUILD"] as const, {
TOKEN: "token",
}),
).toThrow("Discord is not configured: Missing required environment variable: GUILD");
});
test("accepts absolute URLs and removes a trailing slash", () => {
expect(requiredEnvironmentUrl("BASE_URL", { BASE_URL: "https://example.com/" }))
.toBe("https://example.com");
expect(() => requiredEnvironmentUrl("BASE_URL", { BASE_URL: "/relative" }))
.toThrow("must be an absolute URL");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod";
const nonEmptyString = z.string().trim().min(1);
const urlString = z.url();
export type Environment = Record<string, string | undefined>;
export function requiredEnvironmentValue(
name: string,
environment: Environment = process.env,
) {
const parsed = nonEmptyString.safeParse(environment[name]);
if (!parsed.success) {
throw new Error(`Missing required environment variable: ${name}`);
}
return parsed.data;
}
export function optionalEnvironmentValue(
name: string,
environment: Environment = process.env,
) {
const parsed = nonEmptyString.safeParse(environment[name]);
return parsed.success ? parsed.data : null;
}
export function requiredEnvironmentUrl(
name: string,
environment: Environment = process.env,
) {
const value = requiredEnvironmentValue(name, environment);
const parsed = urlString.safeParse(value);
if (!parsed.success) {
throw new Error(`Environment variable ${name} must be an absolute URL`);
}
return parsed.data.replace(/\/$/, "");
}
export function readFeatureEnvironment<Names extends readonly string[]>(
feature: string,
names: Names,
environment: Environment = process.env,
): { [Name in Names[number]]: string } {
try {
return Object.fromEntries(
names.map((name) => [name, requiredEnvironmentValue(name, environment)]),
) as { [Name in Names[number]]: string };
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid configuration";
throw new Error(`${feature} is not configured: ${message}`, { cause: error });
}
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import {
optionalEnvironmentValue,
readFeatureEnvironment,
requiredEnvironmentUrl,
} from "@/lib/config/environment";
export function getCanonicalUrl() {
if (optionalEnvironmentValue("BASE_URL")) {
return requiredEnvironmentUrl("BASE_URL");
}
return requiredEnvironmentUrl("NEXTAUTH_URL");
}
export function getDiscordServerConfig() {
const config = readFeatureEnvironment("Discord", [
"DISCORD_BOT_TOKEN",
"DISCORD_GUILD_ID",
] as const);
return {
token: config.DISCORD_BOT_TOKEN,
guildId: config.DISCORD_GUILD_ID,
};
}
export function getYoutubeFollowerConfig() {
const config = readFeatureEnvironment("YouTube follower counts", [
"YOUTUBE_API_KEY",
"YOUTUBE_CHANNEL_ID",
] as const);
return { apiKey: config.YOUTUBE_API_KEY, channelId: config.YOUTUBE_CHANNEL_ID };
}
export function getRobloxFollowerConfig() {
return {
userId: readFeatureEnvironment("Roblox follower counts", [
"ROBLOX_USER_ID",
] as const).ROBLOX_USER_ID,
};
}
export function getTiktokApiConfig() {
const accessToken = optionalEnvironmentValue("TIKTOK_ACCESS_TOKEN");
if (accessToken) {
return {
clientKey: null,
clientSecret: null,
refreshToken: null,
accessToken,
};
}
const config = readFeatureEnvironment("TikTok", [
"TIKTOK_CLIENT_KEY",
"TIKTOK_CLIENT_SECRET",
"TIKTOK_REFRESH_TOKEN",
] as const);
return {
clientKey: config.TIKTOK_CLIENT_KEY,
clientSecret: config.TIKTOK_CLIENT_SECRET,
refreshToken: config.TIKTOK_REFRESH_TOKEN,
accessToken: null,
};
}
export function getYoutubePublishingConfig() {
const config = readFeatureEnvironment("YouTube publishing", [
"YOUTUBE_CLIENT_ID",
"YOUTUBE_CLIENT_SECRET",
"YOUTUBE_REFRESH_TOKEN",
] as const);
return {
clientId: config.YOUTUBE_CLIENT_ID,
clientSecret: config.YOUTUBE_CLIENT_SECRET,
refreshToken: config.YOUTUBE_REFRESH_TOKEN,
};
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { config } from "@/lib/config";
const DISCORD_WEBHOOK_HOSTS = new Set([ const DISCORD_WEBHOOK_HOSTS = new Set([
"discord.com", "discord.com",
"canary.discord.com", "canary.discord.com",
@@ -31,7 +33,7 @@ export function previewWebhookTemplate(
if (key === "username" || key === "displayname") return "Example User"; if (key === "username" || key === "displayname") return "Example User";
if (key === "id") return "123456789"; if (key === "id") return "123456789";
if (key === "link") { if (key === "link") {
return "https://erika.sudloh.com/admin/form/example-form/result/example-submission"; return `${config.site.url}/admin/form/example-form/result/example-submission`;
} }
if (key === "update") return "Question 1: Previous answer → Example answer"; if (key === "update") return "Question 1: Previous answer → Example answer";
if (/^\d+$/.test(key)) return `Example answer ${key}`; if (/^\d+$/.test(key)) return `Example answer ${key}`;
+9 -20
View File
@@ -3,6 +3,7 @@ import {
discordAvatarDecorationUrl, discordAvatarDecorationUrl,
discordGuildTagBadgeUrl, discordGuildTagBadgeUrl,
} from "@/lib/discord/profile-appearance"; } from "@/lib/discord/profile-appearance";
import { getDiscordServerConfig } from "@/lib/config/server";
export interface DiscordRole { export interface DiscordRole {
id: string; id: string;
@@ -31,23 +32,16 @@ interface CacheEntry<T> {
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null }; const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
const profileCache = new Map<string, CacheEntry<DiscordMemberProfile>>(); const profileCache = new Map<string, CacheEntry<DiscordMemberProfile>>();
const CACHE_TTL = 60_000; const CACHE_TTL = 60_000;
const DEFAULT_DISCORD_GUILD_ID = "1302977523310923847";
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null { function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
if (entry && Date.now() < entry.expiresAt) return entry.data; if (entry && Date.now() < entry.expiresAt) return entry.data;
return null; return null;
} }
function getDiscordGuildId() {
return process.env.DISCORD_GUILD_ID || DEFAULT_DISCORD_GUILD_ID;
}
export async function addDiscordGuildMember(discordId: string, accessToken: string): Promise<boolean> { export async function addDiscordGuildMember(discordId: string, accessToken: string): Promise<boolean> {
const token = process.env.DISCORD_BOT_TOKEN; if (!discordId || !accessToken) return false;
const guildId = getDiscordGuildId();
if (!token || !guildId || !discordId || !accessToken) return false;
try { try {
const { token, guildId } = getDiscordServerConfig();
const res = await fetch( const res = await fetch(
`https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(discordId)}`, `https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(discordId)}`,
{ {
@@ -69,7 +63,7 @@ export async function addDiscordGuildMember(discordId: string, accessToken: stri
); );
return false; return false;
} catch (error) { } catch (error) {
console.error(`Failed to add Discord member ${discordId} to guild ${guildId}`, error); console.error(`Failed to add Discord member ${discordId}`, error);
return false; return false;
} }
} }
@@ -78,11 +72,8 @@ export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
const cached = getCached(rolesCache.entry); const cached = getCached(rolesCache.entry);
if (cached) return cached; if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = getDiscordGuildId();
if (!token || !guildId) return [];
try { try {
const { token, guildId } = getDiscordServerConfig();
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, { const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
headers: { Authorization: `Bot ${token}` }, headers: { Authorization: `Bot ${token}` },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
@@ -113,11 +104,10 @@ export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
} }
export async function getGuildMemberRoles(discordId: string): Promise<string[]> { export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
const token = process.env.DISCORD_BOT_TOKEN; if (!discordId) return [];
const guildId = getDiscordGuildId();
if (!token || !guildId || !discordId) return [];
try { try {
const { token, guildId } = getDiscordServerConfig();
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` }, headers: { Authorization: `Bot ${token}` },
cache: "no-store", cache: "no-store",
@@ -137,11 +127,10 @@ export async function getDiscordMemberProfile(discordId: string): Promise<Discor
const cached = getCached(profileCache.get(discordId)); const cached = getCached(profileCache.get(discordId));
if (cached) return cached; if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN; if (!discordId) return null;
const guildId = getDiscordGuildId();
if (!token || !guildId || !discordId) return null;
try { try {
const { token, guildId } = getDiscordServerConfig();
const [memberRes, userRes, allRoles] = await Promise.all([ const [memberRes, userRes, allRoles] = await Promise.all([
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` }, headers: { Authorization: `Bot ${token}` },
+2 -4
View File
@@ -1,11 +1,9 @@
import "server-only"; import "server-only";
import { getDiscordServerConfig } from "@/lib/config/server";
export async function getDiscordMemberCount(): Promise<number | null> { export async function getDiscordMemberCount(): Promise<number | null> {
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) return null;
try { try {
const { token, guildId } = getDiscordServerConfig();
const res = await fetch( const res = await fetch(
`https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}?with_counts=true`, `https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}?with_counts=true`,
{ {
+2 -3
View File
@@ -1,10 +1,9 @@
import "server-only"; import "server-only";
import { getRobloxFollowerConfig } from "@/lib/config/server";
export async function getRobloxFollowerCount(): Promise<number | null> { export async function getRobloxFollowerCount(): Promise<number | null> {
const userId = process.env.ROBLOX_USER_ID;
if (!userId) return null;
try { try {
const { userId } = getRobloxFollowerConfig();
const res = await fetch( const res = await fetch(
`https://friends.roproxy.com/v1/users/${encodeURIComponent(userId)}/friends/count`, `https://friends.roproxy.com/v1/users/${encodeURIComponent(userId)}/friends/count`,
{ cache: "no-store", signal: AbortSignal.timeout(5000) } { cache: "no-store", signal: AbortSignal.timeout(5000) }
+4 -6
View File
@@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { getTiktokApiConfig } from "@/lib/config/server";
let cachedAccessToken: string | null = null; let cachedAccessToken: string | null = null;
let tokenExpiresAt = 0; let tokenExpiresAt = 0;
@@ -9,13 +10,10 @@ async function getAccessToken(): Promise<string | null> {
return cachedAccessToken; 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 { try {
const { accessToken, clientKey, clientSecret, refreshToken } = getTiktokApiConfig();
if (accessToken) return accessToken;
if (!clientKey || !clientSecret || !refreshToken) return null;
const res = await fetch("https://open.tiktokapis.com/v2/oauth/token/", { const res = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
+2 -4
View File
@@ -1,11 +1,9 @@
import "server-only"; import "server-only";
import { getYoutubeFollowerConfig } from "@/lib/config/server";
export async function getYoutubeSubscriberCount(): Promise<number | null> { export async function getYoutubeSubscriberCount(): Promise<number | null> {
const apiKey = process.env.YOUTUBE_API_KEY;
const channelId = process.env.YOUTUBE_CHANNEL_ID;
if (!apiKey || !channelId) return null;
try { try {
const { apiKey, channelId } = getYoutubeFollowerConfig();
const res = await fetch( const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`, `https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
{ cache: "no-store", signal: AbortSignal.timeout(5000) } { cache: "no-store", signal: AbortSignal.timeout(5000) }
+2 -5
View File
@@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { getDiscordServerConfig } from "@/lib/config/server";
export type DiscordVoiceChannel = { export type DiscordVoiceChannel = {
id: string; id: string;
@@ -52,11 +53,7 @@ export async function getCurrentDiscordVoiceChannels() {
return channelCache.data; return channelCache.data;
} }
const token = process.env.DISCORD_BOT_TOKEN; const { token, guildId } = getDiscordServerConfig();
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) {
throw new Error("Discord channel access is not configured.");
}
const response = await fetch( const response = await fetch(
`https://discord.com/api/v10/guilds/${guildId}/channels`, `https://discord.com/api/v10/guilds/${guildId}/channels`,
+3 -1
View File
@@ -1,3 +1,5 @@
import { config } from "@/lib/config";
const ALLOWED_TAGS = new Set([ const ALLOWED_TAGS = new Set([
"a", "a",
"b", "b",
@@ -32,7 +34,7 @@ export function sanitizeUrl(url: string) {
if (!trimmed) return ""; if (!trimmed) return "";
try { try {
const parsed = new URL(trimmed, "https://erika.sudloh.com"); const parsed = new URL(trimmed, config.site.url);
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") { if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") {
return trimmed; return trimmed;
} }
+22 -13
View File
@@ -13,6 +13,11 @@ import {
type VideoPublishJobRecord, type VideoPublishJobRecord,
type VideoPublishUploadRecord, type VideoPublishUploadRecord,
} from "@/lib/video/video-publishing"; } from "@/lib/video/video-publishing";
import {
getTiktokApiConfig,
getYoutubeFollowerConfig,
getYoutubePublishingConfig,
} from "@/lib/config/server";
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]); const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
@@ -39,9 +44,13 @@ export function validateThumbnailFile(file: File) {
} }
export async function getLatestYoutubeVideos(): Promise<LatestVideo[]> { export async function getLatestYoutubeVideos(): Promise<LatestVideo[]> {
const apiKey = process.env.YOUTUBE_API_KEY; let apiKey: string;
const channelId = process.env.YOUTUBE_CHANNEL_ID; let channelId: string;
if (!apiKey || !channelId) return []; try {
({ apiKey, channelId } = getYoutubeFollowerConfig());
} catch {
return [];
}
const channels = await fetch( const channels = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`, `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
{ cache: "no-store" }, { cache: "no-store" },
@@ -90,11 +99,15 @@ export async function getLatestYoutubeVideos(): Promise<LatestVideo[]> {
} }
async function getTiktokAccessToken() { async function getTiktokAccessToken() {
const refreshToken = process.env.TIKTOK_REFRESH_TOKEN; let config: ReturnType<typeof getTiktokApiConfig>;
const clientKey = process.env.TIKTOK_CLIENT_KEY; try {
const clientSecret = process.env.TIKTOK_CLIENT_SECRET; config = getTiktokApiConfig();
if (!refreshToken || !clientKey || !clientSecret) } catch {
return process.env.TIKTOK_ACCESS_TOKEN ?? null; return null;
}
if (config.accessToken) return config.accessToken;
const { refreshToken, clientKey, clientSecret } = config;
if (!refreshToken || !clientKey || !clientSecret) return null;
const response = await fetch("https://open.tiktokapis.com/v2/oauth/token/", { const response = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -151,11 +164,7 @@ export async function getLatestTiktokVideos(): Promise<LatestVideo[]> {
} }
async function youtubeAccessToken() { async function youtubeAccessToken() {
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN; const { refreshToken, clientId, clientSecret } = getYoutubePublishingConfig();
const clientId = process.env.YOUTUBE_CLIENT_ID;
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
if (!refreshToken || !clientId || !clientSecret)
throw new Error("YouTube OAuth is not configured.");
const response = await fetch("https://oauth2.googleapis.com/token", { const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },