chore(config): centralize feature settings
This commit is contained in:
@@ -15,11 +15,13 @@ import {
|
||||
import { writeSubmission } from "@/lib/form/submission-write";
|
||||
import { submissionWebhookMessage } from "@/lib/form/submission-webhook";
|
||||
import { isFormAcceptingSubmissions } from "@/lib/form/form-availability";
|
||||
import { getCanonicalUrl } from "@/lib/config/server";
|
||||
|
||||
function getSubmissionResultLink(formId: string, submissionId: string) {
|
||||
const baseUrl = process.env.BASE_URL || process.env.NEXTAUTH_URL;
|
||||
if (!baseUrl) return `/admin/form/${formId}/result/${submissionId}`;
|
||||
return new URL(`/admin/form/${formId}/result/${submissionId}`, baseUrl).toString();
|
||||
return new URL(
|
||||
`/admin/form/${formId}/result/${submissionId}`,
|
||||
getCanonicalUrl(),
|
||||
).toString();
|
||||
}
|
||||
|
||||
export async function submitForm(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export default function Page() {
|
||||
redirect('https://discord.com/invite/3cZQbvW6cF');
|
||||
redirect(config.site.discordInviteUrl);
|
||||
return <div>Redirecting...</div>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import Background from "@/public/background/404.png";
|
||||
import Profile from "@/public/404-image.jpg";
|
||||
import { Activity, Home } from "lucide-react";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const MESSAGES = [
|
||||
"ฟอร์มนี้อาจจะถูก Erika กินไปแล้ว...",
|
||||
@@ -54,7 +55,7 @@ export default function NotFound() {
|
||||
กลับหน้าเลือก
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://status.sudloh.com">
|
||||
<Link href={config.site.statusUrl}>
|
||||
<Button variant="link" className="text-secondary-foreground">
|
||||
<Activity />
|
||||
สถานะเว็บไซต์
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Inter, Anuphan } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -15,7 +16,7 @@ const anuphan = Anuphan({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const siteUrl = "https://erika.sudloh.com";
|
||||
const siteUrl = config.site.url;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Erika!",
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import Background from "@/public/background/404.png";
|
||||
import Profile from "@/public/404-image.jpg";
|
||||
import { Activity, Home } from "lucide-react";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const MESSAGES = [
|
||||
"Erika กินหน้านี้ไปแล้ว...",
|
||||
@@ -54,7 +55,7 @@ export default function NotFound() {
|
||||
กลับหน้าแรก
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://status.sudloh.com">
|
||||
<Link href={config.site.statusUrl}>
|
||||
<Button variant="link" className="text-secondary-foreground">
|
||||
<Activity />
|
||||
สถานะเว็บไซต์
|
||||
|
||||
+2
-2
@@ -47,11 +47,11 @@ export default async function Home() {
|
||||
"@type": "ProfilePage",
|
||||
name: profileName,
|
||||
description: config.profile.desc,
|
||||
url: "https://erika.sudloh.com",
|
||||
url: config.site.url,
|
||||
mainEntity: {
|
||||
"@type": "Person",
|
||||
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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { config } from "@/lib/config";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -99,7 +100,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardContent className="text-sm text-muted-foreground leading-relaxed">
|
||||
หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/3cZQbvW6cF"
|
||||
href={config.site.discordInviteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-foreground hover:text-primary"
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -9,6 +10,6 @@ export default function robots(): MetadataRoute.Robots {
|
||||
disallow: ["/api/", "/admin/"],
|
||||
},
|
||||
],
|
||||
sitemap: "https://erika.sudloh.com/sitemap.xml",
|
||||
sitemap: `${config.site.url}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://erika.sudloh.com";
|
||||
const baseUrl = config.site.url;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
+8
-1
@@ -13,7 +13,14 @@ export interface ProfileConfig {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const DISCORD_INVITE_URL = "https://discord.com/invite/3cZQbvW6cF";
|
||||
|
||||
export const config = {
|
||||
site: {
|
||||
url: "https://erika.sudloh.com",
|
||||
discordInviteUrl: DISCORD_INVITE_URL,
|
||||
statusUrl: "https://status.sudloh.com",
|
||||
},
|
||||
profile: {
|
||||
name: "Erika!",
|
||||
desc: "แหล่งรวมลิ้งค์ของช่อง Erika!",
|
||||
@@ -34,7 +41,7 @@ export const config = {
|
||||
},
|
||||
{
|
||||
name: "Discord server",
|
||||
link: "https://discord.com/invite/3cZQbvW6cF",
|
||||
link: DISCORD_INVITE_URL,
|
||||
desc: "เข้าเซิร์ฟเวอร์ในดิส",
|
||||
icon: "discord",
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const DISCORD_WEBHOOK_HOSTS = new Set([
|
||||
"discord.com",
|
||||
"canary.discord.com",
|
||||
@@ -31,7 +33,7 @@ export function previewWebhookTemplate(
|
||||
if (key === "username" || key === "displayname") return "Example User";
|
||||
if (key === "id") return "123456789";
|
||||
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 (/^\d+$/.test(key)) return `Example answer ${key}`;
|
||||
|
||||
+9
-20
@@ -3,6 +3,7 @@ import {
|
||||
discordAvatarDecorationUrl,
|
||||
discordGuildTagBadgeUrl,
|
||||
} from "@/lib/discord/profile-appearance";
|
||||
import { getDiscordServerConfig } from "@/lib/config/server";
|
||||
|
||||
export interface DiscordRole {
|
||||
id: string;
|
||||
@@ -31,23 +32,16 @@ interface CacheEntry<T> {
|
||||
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
|
||||
const profileCache = new Map<string, CacheEntry<DiscordMemberProfile>>();
|
||||
const CACHE_TTL = 60_000;
|
||||
const DEFAULT_DISCORD_GUILD_ID = "1302977523310923847";
|
||||
|
||||
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
|
||||
if (entry && Date.now() < entry.expiresAt) return entry.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDiscordGuildId() {
|
||||
return process.env.DISCORD_GUILD_ID || DEFAULT_DISCORD_GUILD_ID;
|
||||
}
|
||||
|
||||
export async function addDiscordGuildMember(discordId: string, accessToken: string): Promise<boolean> {
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = getDiscordGuildId();
|
||||
if (!token || !guildId || !discordId || !accessToken) return false;
|
||||
if (!discordId || !accessToken) return false;
|
||||
|
||||
try {
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
const res = await fetch(
|
||||
`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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -78,11 +72,8 @@ export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
|
||||
const cached = getCached(rolesCache.entry);
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = getDiscordGuildId();
|
||||
if (!token || !guildId) return [];
|
||||
|
||||
try {
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
@@ -113,11 +104,10 @@ export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
|
||||
}
|
||||
|
||||
export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = getDiscordGuildId();
|
||||
if (!token || !guildId || !discordId) return [];
|
||||
if (!discordId) return [];
|
||||
|
||||
try {
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store",
|
||||
@@ -137,11 +127,10 @@ export async function getDiscordMemberProfile(discordId: string): Promise<Discor
|
||||
const cached = getCached(profileCache.get(discordId));
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = getDiscordGuildId();
|
||||
if (!token || !guildId || !discordId) return null;
|
||||
if (!discordId) return null;
|
||||
|
||||
try {
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
const [memberRes, userRes, allRoles] = await Promise.all([
|
||||
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import "server-only";
|
||||
import { getDiscordServerConfig } from "@/lib/config/server";
|
||||
|
||||
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 {
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
const res = await fetch(
|
||||
`https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}?with_counts=true`,
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import "server-only";
|
||||
import { getRobloxFollowerConfig } from "@/lib/config/server";
|
||||
|
||||
export async function getRobloxFollowerCount(): Promise<number | null> {
|
||||
const userId = process.env.ROBLOX_USER_ID;
|
||||
if (!userId) return null;
|
||||
|
||||
try {
|
||||
const { userId } = getRobloxFollowerConfig();
|
||||
const res = await fetch(
|
||||
`https://friends.roproxy.com/v1/users/${encodeURIComponent(userId)}/friends/count`,
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(5000) }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { getTiktokApiConfig } from "@/lib/config/server";
|
||||
|
||||
let cachedAccessToken: string | null = null;
|
||||
let tokenExpiresAt = 0;
|
||||
@@ -9,13 +10,10 @@ async function getAccessToken(): Promise<string | null> {
|
||||
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 { 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/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import "server-only";
|
||||
import { getYoutubeFollowerConfig } from "@/lib/config/server";
|
||||
|
||||
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 {
|
||||
const { apiKey, channelId } = getYoutubeFollowerConfig();
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(5000) }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { getDiscordServerConfig } from "@/lib/config/server";
|
||||
|
||||
export type DiscordVoiceChannel = {
|
||||
id: string;
|
||||
@@ -52,11 +53,7 @@ export async function getCurrentDiscordVoiceChannels() {
|
||||
return channelCache.data;
|
||||
}
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId) {
|
||||
throw new Error("Discord channel access is not configured.");
|
||||
}
|
||||
const { token, guildId } = getDiscordServerConfig();
|
||||
|
||||
const response = await fetch(
|
||||
`https://discord.com/api/v10/guilds/${guildId}/channels`,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"b",
|
||||
@@ -32,7 +34,7 @@ export function sanitizeUrl(url: string) {
|
||||
if (!trimmed) return "";
|
||||
|
||||
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:") {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
type VideoPublishJobRecord,
|
||||
type VideoPublishUploadRecord,
|
||||
} from "@/lib/video/video-publishing";
|
||||
import {
|
||||
getTiktokApiConfig,
|
||||
getYoutubeFollowerConfig,
|
||||
getYoutubePublishingConfig,
|
||||
} from "@/lib/config/server";
|
||||
|
||||
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[]> {
|
||||
const apiKey = process.env.YOUTUBE_API_KEY;
|
||||
const channelId = process.env.YOUTUBE_CHANNEL_ID;
|
||||
if (!apiKey || !channelId) return [];
|
||||
let apiKey: string;
|
||||
let channelId: string;
|
||||
try {
|
||||
({ apiKey, channelId } = getYoutubeFollowerConfig());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const channels = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
|
||||
{ cache: "no-store" },
|
||||
@@ -90,11 +99,15 @@ export async function getLatestYoutubeVideos(): Promise<LatestVideo[]> {
|
||||
}
|
||||
|
||||
async function getTiktokAccessToken() {
|
||||
const refreshToken = process.env.TIKTOK_REFRESH_TOKEN;
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||
if (!refreshToken || !clientKey || !clientSecret)
|
||||
return process.env.TIKTOK_ACCESS_TOKEN ?? null;
|
||||
let config: ReturnType<typeof getTiktokApiConfig>;
|
||||
try {
|
||||
config = getTiktokApiConfig();
|
||||
} catch {
|
||||
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/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -151,11 +164,7 @@ export async function getLatestTiktokVideos(): Promise<LatestVideo[]> {
|
||||
}
|
||||
|
||||
async function youtubeAccessToken() {
|
||||
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
|
||||
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 { refreshToken, clientId, clientSecret } = getYoutubePublishingConfig();
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
|
||||
Reference in New Issue
Block a user