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
+8 -1
View File
@@ -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",
},
+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([
"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
View File
@@ -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}` },
+2 -4
View File
@@ -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`,
{
+2 -3
View File
@@ -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) }
+4 -6
View File
@@ -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" },
+2 -4
View File
@@ -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) }
+2 -5
View File
@@ -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`,
+3 -1
View File
@@ -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;
}
+22 -13
View File
@@ -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" },