improve homepage hero embeds

This commit is contained in:
Franz Rolfsvaag 2026-06-17 16:43:00 +02:00
parent 782b93b3c1
commit 454480f9a7
7 changed files with 554 additions and 47 deletions

47
TODO.md
View File

@ -42,6 +42,51 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
## Homepage Hero Embed Requirements
- Continue improving service-specific "why unavailable" messages where external providers expose enough signal.
### Discord Server Widget Hero
- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known.
### Twitch Stream Hero
- Support additional configured parent domains for alternate hostnames.
- Support stream-only and stream-with-chat layouts.
- Respect Twitch minimum player size requirements.
- If live-only mode is enabled, check live status or gracefully hide/fallback when offline.
- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain.
- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain.
### YouTube Live Hero
- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup.
- For static live heroes, extract and use the provided live video ID.
- For automatic channel live detection, require YouTube API configuration.
- Cache live lookup results to avoid excessive API calls.
- Support optional live-only mode.
- Support optional YouTube live chat only when a valid live video ID exists.
- Support autoplay and muted settings.
- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content.
- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos.
- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup.
### YouTube Video Hero
- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos.
- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available.
### External Embedded Content Hero
- Support generic external iframe embeds only for sites that explicitly allow embedding.
- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`.
- Show an admin warning when an external site refuses to be embedded.
### Hero Embed Admin UX
- Show “why unavailable” information in the admin UI when a hero cannot render.
## Update Page UX Improvements
- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`.
@ -80,6 +125,8 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
## Done
- 2026-06-17: Bumped core package version to v0.1.3.
- 2026-06-17: Completed homepage hero embed pass for Discord widgets, YouTube video playback options, external embed fallback/sandbox controls, admin validation, platform-specific fields, and Test preview behavior.
- 2026-06-17: Fixed core update snapshots for large ZIP-origin installs by replacing the full-install ZIP backup with a filesystem snapshot directory, avoiding the 2 GiB ZIP limit for large preserved files such as local AI models.
- 2026-06-17: Completed `/admin/updates` UX pass: viewport-fixed dismissible notifications, auto-dismiss for non-critical results, async core/plugin check actions without page refresh or scroll jumps, in-place update-card data refresh, loading states, and collapsed advanced Manual ZIP fallback below repo update containers.
- 2026-06-17: Completed homepage hero reliability pass: server-side hero validation before save, admin-visible validation errors, home-page fallback message for broken legacy heroes, automatic YouTube/Twitch/Discord embed derivation, correct Twitch parent host at render time, and image/embed conflict handling.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -109,6 +109,17 @@
image_url: "",
embed_url: "",
video_id: "",
fallback_image_url: "",
fallback_text: "",
fallback_url: "",
start_seconds: 0,
show_controls: true,
loop: false,
embed_height: 0,
aspect_ratio: "",
allowed_domains: "",
iframe_allow: "",
allow_unsafe_permissions: false,
availability_mode: "always",
autoplay_mode: "off",
duration_seconds: 0,
@ -128,6 +139,8 @@
};
const youtubeVideoId = (value) => {
const raw = String(value || "").trim();
if (/^[A-Za-z0-9_-]{11}$/.test(raw)) return raw;
const url = parseUrl(value);
if (!url) return "";
if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || "";
@ -155,20 +168,64 @@
return url?.searchParams.get("id") || "";
};
const deriveEmbedUrl = (type, sourceUrl, platformId = "") => {
const youtubeStartSeconds = (value) => {
const raw = String(value || "").trim();
if (/^\d+$/.test(raw)) return Number(raw);
const url = parseUrl(raw);
const token = url?.searchParams.get("start") || url?.searchParams.get("t") || "";
if (/^\d+$/.test(token)) return Number(token);
const match = token.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/i);
if (!match) return 0;
return ((Number(match[1]) || 0) * 3600) + ((Number(match[2]) || 0) * 60) + (Number(match[3]) || 0);
};
const youtubeEmbedUrl = (id, options = {}) => {
if (!id) return "";
const url = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
if (options.autoplay && options.autoplay !== "off") {
url.searchParams.set("autoplay", "1");
if (options.autoplay !== "sound") url.searchParams.set("mute", "1");
}
if (options.showControls === false) url.searchParams.set("controls", "0");
if (options.loop) {
url.searchParams.set("loop", "1");
url.searchParams.set("playlist", id);
}
if (options.startSeconds > 0) url.searchParams.set("start", String(options.startSeconds));
return url.toString();
};
const safeHttpUrl = (value) => {
const url = parseUrl(value);
return url && ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
};
const isLocalHost = () => ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
const isDiscordUrl = (value) => {
const url = parseUrl(value);
const host = url?.hostname.replace(/^www\./, "").toLowerCase();
return host === "discord.gg" || host === "discord.com" || host?.endsWith(".discord.com");
};
const deriveEmbedUrl = (type, sourceUrl, platformId = "", options = {}) => {
const host = window.location.hostname || "localhost";
if (type === "custom_embed") {
const youtube = youtubeVideoId(sourceUrl);
if (youtube) return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(youtube)}`;
const twitch = twitchChannelName(sourceUrl);
if (twitch) return `https://player.twitch.tv/?channel=${encodeURIComponent(twitch)}&parent=${encodeURIComponent(host)}&muted=true`;
const discord = discordServerId(sourceUrl);
if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`;
return "";
if (isDiscordUrl(sourceUrl)) return "";
return safeHttpUrl(sourceUrl);
}
if (type === "youtube_video") {
const id = platformId || youtubeVideoId(sourceUrl);
return id ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` : "";
return youtubeEmbedUrl(id, {
autoplay: options.autoplay,
showControls: options.showControls,
loop: options.loop,
startSeconds: options.startSeconds || youtubeStartSeconds(sourceUrl)
});
}
if (type === "youtube_channel") {
const id = platformId || youtubeChannelId(sourceUrl);
@ -180,7 +237,7 @@
}
if (type === "discord_server_overview") {
const id = platformId || discordServerId(sourceUrl);
return id ? `https://discord.com/widget?id=${encodeURIComponent(id)}&theme=dark` : "";
return id ? `discord-widget:${id}` : "";
}
return "";
};
@ -190,7 +247,19 @@
if (item.type === "none") return { ok: true, message: "Fallback message is ready." };
if (item.type === "static_image") return item.image_url ? { ok: true, message: "Image banner is ready." } : { ok: false, message: "Add an image URL before this banner can be shown." };
if (item.type === "custom_link") return item.source_url ? { ok: true, message: "Featured link is ready." } : { ok: false, message: "Add a link before this hero can be shown." };
if (item.type === "custom_embed") {
if (!item.title) return { ok: false, message: "Add a title for this external embed." };
const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id);
if (isDiscordUrl(embed || item.source_url)) return { ok: false, message: "Use the Discord server widget hero for Discord. Invite links cannot be embedded." };
const embedUrl = parseUrl(embed);
if (embedUrl?.protocol === "http:" && !isLocalHost()) return { ok: false, message: "Use HTTPS embed URLs unless Lumi is running locally." };
return embed ? { ok: true, message: "External embed is ready. The target site must allow iframe embedding." } : { ok: false, message: "Add a valid HTTPS embed URL. Normal website pages often block iframes." };
}
if (item.type === "discord_server_overview") {
const id = item.video_id || discordServerId(item.source_url);
return id ? { ok: true, message: "Discord widget card is ready. The Discord server widget must be enabled." } : { ok: false, message: "Add the numeric Discord server ID. Invite links cannot be embedded directly." };
}
const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id, item);
return embed ? { ok: true, message: "Embed preview is ready." } : { ok: false, message: "Add a supported URL or platform ID so Lumi can build an embed." };
};
@ -226,8 +295,12 @@
const sourceUrl = row.querySelector("[data-field='source_url']").value.trim();
const platformId = row.querySelector("[data-field='video_id']").value.trim();
const embedInput = row.querySelector("[data-field='embed_url']");
const derivedEmbed = deriveEmbedUrl(type, sourceUrl, platformId);
if (!embedInput.value.trim() && derivedEmbed) embedInput.value = derivedEmbed;
const startSeconds = Number(row.querySelector("[data-field='start_seconds']")?.value) || 0;
const showControls = row.querySelector("[data-field='show_controls']")?.checked !== false;
const loop = row.querySelector("[data-field='loop']")?.checked === true;
const autoplay = row.querySelector("[data-field='autoplay_mode']").value;
const derivedEmbed = deriveEmbedUrl(type, sourceUrl, platformId, { autoplay, showControls, loop, startSeconds });
if (!embedInput.value.trim() && derivedEmbed && !derivedEmbed.startsWith("discord-widget:")) embedInput.value = derivedEmbed;
return {
enabled: row.querySelector("[data-field='enabled']").checked,
type,
@ -239,6 +312,17 @@
image_url: row.querySelector("[data-field='image_url']").value.trim(),
embed_url: embedInput.value.trim(),
video_id: platformId,
fallback_image_url: row.querySelector("[data-field='fallback_image_url']")?.value.trim() || "",
fallback_text: row.querySelector("[data-field='fallback_text']")?.value.trim() || "",
fallback_url: row.querySelector("[data-field='fallback_url']")?.value.trim() || "",
start_seconds: startSeconds,
show_controls: showControls,
loop,
embed_height: Number(row.querySelector("[data-field='embed_height']")?.value) || 0,
aspect_ratio: row.querySelector("[data-field='aspect_ratio']")?.value.trim() || "",
allowed_domains: row.querySelector("[data-field='allowed_domains']")?.value.trim() || "",
iframe_allow: row.querySelector("[data-field='iframe_allow']")?.value.trim() || "",
allow_unsafe_permissions: row.querySelector("[data-field='allow_unsafe_permissions']")?.checked === true,
availability_mode: row.querySelector("[data-field='availability_mode']").value,
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
@ -262,18 +346,28 @@
if (kind !== "heroes") return;
const type = row.querySelector("[data-field='type']")?.value || "static_image";
const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type);
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
const platformId = ["youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream"].includes(type);
const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
const image = type === "static_image";
const fallback = type === "none";
const fallbackConfig = type !== "none";
const youtubeVideo = type === "youtube_video";
const customEmbed = type === "custom_embed";
const discord = type === "discord_server_overview";
row.querySelectorAll("[data-relevance]").forEach((item) => {
const relevance = item.dataset.relevance;
const visible =
relevance === "video" ? videoLike :
relevance === "platform-id" ? platformId :
relevance === "embed" ? embedded :
relevance === "source" ? source :
relevance === "image" ? image :
relevance === "fallback" ? fallback :
relevance === "fallback-config" ? fallbackConfig :
relevance === "youtube-video" ? youtubeVideo :
relevance === "custom-embed" ? customEmbed :
relevance === "discord" ? discord :
true;
item.hidden = !visible;
});
@ -365,14 +459,26 @@
addField(row, "Short description", textInput(item.description, "What's happening now"), "description");
addField(row, "Display priority", numberInput(item.priority ?? index, 0), "priority", { help: "Lower numbers are checked first." });
addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Normal URL or platform link", textInput(item.source_url, "Paste a YouTube, Twitch, Discord, website, or image URL"), "source_url", { relevance: "source", help: "Paste the normal page URL. Lumi will build an embed URL when possible." });
addField(row, "Normal URL or platform link", textInput(item.source_url, "Paste a YouTube, Twitch, Discord, website, or image URL"), "source_url", { relevance: "source", help: "Paste a normal URL when possible. For Discord, use this for the optional invite/fallback link." });
addField(row, "Image URL", textInput(item.image_url, "https://example.com/banner.png"), "image_url", { relevance: "image", help: "Direct image URL for image banner heroes." });
addField(row, "Embed URL", textInput(item.embed_url, "Filled automatically for supported services"), "embed_url", { relevance: "embed", help: "Optional advanced field. Leave blank to auto-convert common service URLs." });
addField(row, "Platform ID", textInput(item.video_id, "Optional video, channel, or server ID"), "video_id", { relevance: "video", help: "Use only if the normal URL cannot be detected." });
addField(row, "Embed URL", textInput(item.embed_url, "https://example.com/embed"), "embed_url", { relevance: "embed", help: "Use an actual embed/player URL. Discord invite links are not iframes." });
addField(row, "Platform ID", textInput(item.video_id, "Video, channel, Twitch name, or Discord server ID"), "video_id", { relevance: "platform-id", help: "Discord requires the numeric server ID and the server widget must be enabled." });
addField(row, "Availability", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video", help: "Live-only heroes hide unless live state says they are available." });
addField(row, "Autoplay", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
addField(row, "YouTube start time", numberInput(item.start_seconds, 0), "start_seconds", { relevance: "youtube-video", help: "Optional start time in seconds. Lumi also reads t= or start= from pasted YouTube URLs." });
addField(row, "Show YouTube controls", checkbox(item.show_controls !== false), "show_controls", { relevance: "youtube-video" });
addField(row, "Loop YouTube video", checkbox(item.loop === true), "loop", { relevance: "youtube-video" });
addField(row, "Trusted embed domains", textInput(item.allowed_domains, "example.com, player.example.com"), "allowed_domains", { relevance: "custom-embed", help: "Optional comma-separated allowlist. Leave blank to allow any HTTPS embed URL." });
addField(row, "Embed aspect ratio", textInput(item.aspect_ratio, "16/9"), "aspect_ratio", { relevance: "custom-embed", help: "Optional ratio such as 16/9 or 4/3." });
addField(row, "Embed minimum height", numberInput(item.embed_height, 0), "embed_height", { relevance: "custom-embed", help: "Optional minimum height in pixels." });
addField(row, "Iframe permissions", textInput(item.iframe_allow, "fullscreen; autoplay"), "iframe_allow", { relevance: "custom-embed", help: "Advanced. Camera, microphone, and clipboard are blocked unless explicitly allowed below." });
addField(row, "Allow sensitive iframe permissions", checkbox(item.allow_unsafe_permissions === true), "allow_unsafe_permissions", { relevance: "custom-embed", help: "Only use for trusted embeds that truly need camera, microphone, or clipboard access." });
addField(row, "Discord setup note", textInput("Enable Server Settings > Widget in Discord, then paste the numeric server ID.", ""), "discord_note", { relevance: "discord", help: "Discord invite links cannot be embedded directly. Lumi renders the public widget data as a card." }).disabled = true;
addField(row, "Fallback image URL", textInput(item.fallback_image_url, "https://example.com/fallback.png"), "fallback_image_url", { relevance: "fallback-config", help: "Optional image shown if the embed/widget is unavailable." });
addField(row, "Fallback text", textInput(item.fallback_text, "This content is unavailable right now."), "fallback_text", { relevance: "fallback-config", help: "Optional message shown when the hero cannot render." });
addField(row, "Fallback link", textInput(item.fallback_url, "https://example.com"), "fallback_url", { relevance: "fallback-config", help: "Optional link shown when the hero cannot render." });
addField(row, "Display timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video", help: "Optional duration hint for rotating heroes." });
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["link", "Show fallback link/card"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback-config" });
typeSelect.addEventListener("change", () => updateHeroRelevance(row));
}
@ -473,10 +579,15 @@
const item = {
enabled: row.querySelector("[data-field='enabled']")?.checked,
type: row.querySelector("[data-field='type']")?.value,
title: row.querySelector("[data-field='title']")?.value.trim(),
source_url: row.querySelector("[data-field='source_url']")?.value.trim(),
image_url: row.querySelector("[data-field='image_url']")?.value.trim(),
embed_url: row.querySelector("[data-field='embed_url']")?.value.trim(),
video_id: row.querySelector("[data-field='video_id']")?.value.trim()
video_id: row.querySelector("[data-field='video_id']")?.value.trim(),
autoplay: row.querySelector("[data-field='autoplay_mode']")?.value,
showControls: row.querySelector("[data-field='show_controls']")?.checked !== false,
loop: row.querySelector("[data-field='loop']")?.checked === true,
startSeconds: Number(row.querySelector("[data-field='start_seconds']")?.value) || 0
};
const status = heroValidation(item);
target.classList.toggle("danger", !status.ok);
@ -492,14 +603,27 @@
const image = row.querySelector("[data-field='image_url']")?.value.trim() || "";
const embedInput = row.querySelector("[data-field='embed_url']");
const platformId = row.querySelector("[data-field='video_id']")?.value.trim() || "";
const embed = embedInput?.value.trim() || deriveEmbedUrl(type, source, platformId);
if (embedInput && !embedInput.value.trim() && embed) embedInput.value = embed;
const options = {
autoplay: row.querySelector("[data-field='autoplay_mode']")?.value,
showControls: row.querySelector("[data-field='show_controls']")?.checked !== false,
loop: row.querySelector("[data-field='loop']")?.checked === true,
startSeconds: Number(row.querySelector("[data-field='start_seconds']")?.value) || 0
};
const embed = embedInput?.value.trim() || deriveEmbedUrl(type, source, platformId, options);
if (embedInput && !embedInput.value.trim() && embed && !embed.startsWith("discord-widget:")) embedInput.value = embed;
const preview = row.querySelector("[data-homepage-preview]");
if (!preview) return;
if (type === "static_image" && image) {
preview.innerHTML = `<img src="${escapeHtml(image)}" alt="" class="homepage-builder-preview-media"><small>Image preview</small>`;
return;
}
if (type === "discord_server_overview") {
const id = platformId || discordServerId(source);
preview.innerHTML = id
? `<strong>Discord widget card preview</strong><small>Lumi will fetch public widget data for server ${escapeHtml(id)} after the page is saved. Discord Server Widget must be enabled.</small>`
: `<strong>Discord widget unavailable</strong><small>Add the numeric Discord server ID. Invite links can be used only as fallback links.</small>`;
return;
}
if (embed) {
preview.innerHTML = `<iframe src="${escapeHtml(embed)}" title="Homepage hero preview" class="homepage-builder-preview-media" loading="lazy" allowfullscreen></iframe><small>Embed preview. Some services block previews until saved on the public host.</small>`;
return;

View File

@ -107,6 +107,70 @@ pre {
object-fit: cover;
}
.homepage-discord-card {
display: grid;
align-content: start;
gap: var(--lumi-space-4);
padding: var(--lumi-space-4);
overflow: auto;
}
.homepage-discord-card-header {
display: flex;
align-items: center;
gap: var(--lumi-space-3);
}
.homepage-discord-icon {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: var(--lumi-radius-md);
background: var(--lumi-accent);
color: var(--lumi-accent-contrast);
font-size: 1.4rem;
font-weight: 900;
}
.homepage-discord-card small,
.homepage-discord-label {
color: var(--lumi-text-muted);
}
.homepage-discord-label {
display: block;
margin-bottom: var(--lumi-space-2);
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.homepage-discord-pills,
.homepage-discord-members {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-2);
}
.homepage-discord-pills span,
.homepage-discord-members span {
display: inline-flex;
align-items: center;
gap: var(--lumi-space-2);
padding: var(--lumi-space-2);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
background: color-mix(in srgb, var(--lumi-surface) 80%, transparent);
}
.homepage-discord-members img {
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
}
.homepage-link-strip {
display: flex;
flex-wrap: wrap;
@ -786,6 +850,13 @@ input[type="color"] {
text-align: center;
}
.homepage-hero-error img {
max-width: 100%;
max-height: 12rem;
border-radius: var(--lumi-radius-sm);
object-fit: cover;
}
.update-recovery-banner {
border-left: 4px solid var(--lumi-danger);
}

View File

@ -1688,6 +1688,9 @@ function parseJsonSetting(key, fallback) {
return fallback;
}
const DISCORD_WIDGET_CACHE_MS = 5 * 60 * 1000;
const discordWidgetCache = new Map();
function safeExternalUrl(value) {
try {
const url = new URL(String(value || ""));
@ -1705,6 +1708,52 @@ function safeHomepageLinkUrl(value) {
return safeExternalUrl(raw);
}
function isLocalHostname(hostname) {
return ["localhost", "127.0.0.1", "::1"].includes(String(hostname || "").toLowerCase());
}
function isDevRequest(req = null) {
return process.env.NODE_ENV !== "production" || isLocalHostname(homepageEmbedParent(req));
}
function safeEmbedUrl(value, options = {}) {
try {
const url = new URL(String(value || "").trim());
if (url.protocol === "https:") return url.toString();
if (url.protocol === "http:" && options.allowInsecure) return url.toString();
return "";
} catch {
return "";
}
}
function parseDomainList(value) {
return String(value || "")
.split(",")
.map((item) => item.trim().toLowerCase().replace(/^\*\./, ""))
.filter(Boolean);
}
function embedDomainAllowed(embedUrl, item) {
const domains = parseDomainList(item.allowed_domains);
if (!domains.length) return true;
try {
const host = new URL(embedUrl).hostname.toLowerCase();
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
} catch {
return false;
}
}
function isDiscordUrl(value) {
try {
const host = new URL(value).hostname.toLowerCase().replace(/^www\./, "");
return host === "discord.gg" || host === "discord.com" || host.endsWith(".discord.com");
} catch {
return false;
}
}
function permissionAllows(user, permission = "public") {
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
return role === "public" ? true : hasAccess(user, role);
@ -1748,13 +1797,19 @@ function homepageLinksForUser(user) {
.sort((a, b) => a.sort_order - b.sort_order);
}
function homepageHeroForUser(user, req = null) {
async function homepageHeroForUser(user, req = null) {
const entries = parseJsonSetting("homepage_hero_entries", [])
.filter((item) => item && item.enabled !== false)
.filter((item) => permissionAllows(user, item.permission))
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
for (const item of entries) {
const hero = normalizeHomepageHero(item, { parentHost: homepageEmbedParent(req) });
let hero = normalizeHomepageHero(item, {
parentHost: homepageEmbedParent(req),
allowInsecureEmbeds: isDevRequest(req)
});
if (hero?.available && hero.type === "discord_server_overview") {
hero = await enrichDiscordHero(hero, item);
}
if (hero?.available) return hero;
if (hero?.render_error && hasAccess(user, "admin")) return hero;
}
@ -1769,18 +1824,37 @@ function normalizeHomepageHero(item, options = {}) {
: null;
}
const sourceUrl = safeExternalUrl(item.source_url);
const embedUrl = safeExternalUrl(item.embed_url) || deriveHomepageEmbedUrl(type, item, options);
const embedUrl = safeEmbedUrl(item.embed_url, { allowInsecure: options.allowInsecureEmbeds }) || deriveHomepageEmbedUrl(type, item, options);
const imageUrl = safeExternalUrl(item.image_url);
const fallback = homepageHeroFallback(item);
const title = String(item.title || "Featured content").slice(0, 120);
const description = String(item.description || "").slice(0, 500);
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl };
if (type === "youtube_video") {
const videoId = item.video_id || youtubeVideoId(sourceUrl);
if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl };
if (type === "custom_embed" && embedUrl && !isDiscordUrl(embedUrl) && embedDomainAllowed(embedUrl, item)) {
return {
type,
available: true,
title,
description,
embed_url: embedUrl,
source_url: sourceUrl,
iframe_sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups",
iframe_allow: customEmbedAllow(item),
aspect_ratio: safeAspectRatio(item.aspect_ratio),
embed_height: safeEmbedHeight(item.embed_height),
...fallback
};
}
if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) {
if (type === "youtube_video") {
const videoId = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
if (videoId) return { type, available: true, title, description, embed_url: youtubeVideoEmbedUrl(videoId, item, sourceUrl), source_url: sourceUrl, iframe_allow: "autoplay; encrypted-media; picture-in-picture; web-share" };
}
if (type === "discord_server_overview") {
const guildId = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl);
if (guildId) return { type, available: true, title, description, source_url: sourceUrl, discord_guild_id: guildId, ...fallback };
}
if (["youtube_channel", "twitch_stream"].includes(type) && (embedUrl || sourceUrl)) {
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
return { type, available: Boolean(embedUrl), title, description, embed_url: embedUrl, source_url: sourceUrl, render_error: embedUrl ? "" : heroRenderError(type) };
}
@ -1790,14 +1864,17 @@ function normalizeHomepageHero(item, options = {}) {
title,
description,
source_url: sourceUrl,
...fallback,
render_error: heroRenderError(type)
};
}
function youtubeVideoId(value) {
const raw = String(value || "").trim();
if (/^[A-Za-z0-9_-]{11}$/.test(raw)) return raw;
try {
const url = new URL(value || "");
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
const url = new URL(raw);
if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || "";
const pathMatch = url.pathname.match(/\/(?:embed|shorts|live)\/([^/?#]+)/);
return pathMatch?.[1] || url.searchParams.get("v") || "";
} catch {
@ -1805,6 +1882,38 @@ function youtubeVideoId(value) {
}
}
function youtubeStartSeconds(value, fallbackSource = "") {
const explicit = Number(value);
if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
try {
const url = new URL(fallbackSource || "");
const raw = url.searchParams.get("start") || url.searchParams.get("t") || "";
if (/^\d+$/.test(raw)) return Number(raw);
const match = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/i);
if (!match) return 0;
return ((Number(match[1]) || 0) * 3600) + ((Number(match[2]) || 0) * 60) + (Number(match[3]) || 0);
} catch {
return 0;
}
}
function youtubeVideoEmbedUrl(videoId, item = {}, sourceUrl = "") {
const url = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`);
const autoplay = item.autoplay_mode && item.autoplay_mode !== "off";
if (autoplay) {
url.searchParams.set("autoplay", "1");
if (item.autoplay_mode !== "sound") url.searchParams.set("mute", "1");
}
if (item.show_controls === false) url.searchParams.set("controls", "0");
if (item.loop === true) {
url.searchParams.set("loop", "1");
url.searchParams.set("playlist", videoId);
}
const start = youtubeStartSeconds(item.start_seconds, sourceUrl || item.source_url);
if (start > 0) url.searchParams.set("start", String(start));
return url.toString();
}
function youtubeChannelId(value) {
try {
const url = new URL(value || "");
@ -1841,19 +1950,21 @@ function discordServerId(value) {
function deriveHomepageEmbedUrl(type, item, options = {}) {
const sourceUrl = safeExternalUrl(item.source_url);
const parentHost = options.parentHost || "localhost";
const allowInsecure = Boolean(options.allowInsecureEmbeds);
if (type === "custom_embed") {
if (!sourceUrl) return "";
const manual = safeEmbedUrl(item.embed_url, { allowInsecure });
if (manual && !isDiscordUrl(manual)) return manual;
const direct = safeEmbedUrl(item.source_url, { allowInsecure });
if (!direct || isDiscordUrl(direct)) return "";
const youtube = youtubeVideoId(sourceUrl);
if (youtube) return `https://www.youtube-nocookie.com/embed/${youtube}`;
const twitch = twitchChannelName(sourceUrl);
if (twitch) return twitchEmbedUrl(twitch, parentHost);
const discord = discordServerId(sourceUrl);
if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`;
return "";
return direct;
}
if (type === "youtube_video") {
const video = item.video_id || youtubeVideoId(sourceUrl);
return video ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(video)}` : "";
const video = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
return video ? youtubeVideoEmbedUrl(video, item, sourceUrl) : "";
}
if (type === "youtube_channel") {
const channel = item.video_id || youtubeChannelId(sourceUrl);
@ -1863,13 +1974,116 @@ function deriveHomepageEmbedUrl(type, item, options = {}) {
const channel = item.video_id || twitchChannelName(sourceUrl);
return channel ? twitchEmbedUrl(channel, parentHost) : "";
}
if (type === "discord_server_overview") {
const server = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl);
return server ? `https://discord.com/widget?id=${encodeURIComponent(server)}&theme=dark` : "";
}
return "";
}
function homepageHeroFallback(item) {
return {
fallback_image_url: safeExternalUrl(item.fallback_image_url),
fallback_url: safeExternalUrl(item.fallback_url) || safeExternalUrl(item.source_url),
fallback_text: String(item.fallback_text || "").slice(0, 500),
fallback_behavior: ["hide", "message", "link"].includes(item.fallback_behavior) ? item.fallback_behavior : "message"
};
}
function safeAspectRatio(value) {
const raw = String(value || "").trim();
return /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(raw) ? raw.replace(/\s+/g, "") : "";
}
function safeEmbedHeight(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return 0;
return Math.min(1200, Math.max(180, Math.floor(number)));
}
function customEmbedAllow(item) {
const base = new Set(["fullscreen", "autoplay"]);
String(item.iframe_allow || "")
.split(";")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
.forEach((part) => {
if (["fullscreen", "autoplay", "encrypted-media", "picture-in-picture"].includes(part)) {
base.add(part);
}
if (item.allow_unsafe_permissions === true && ["camera", "microphone", "clipboard-write"].includes(part)) {
base.add(part);
}
});
return Array.from(base).join("; ");
}
async function enrichDiscordHero(hero, item) {
try {
const widget = await fetchDiscordWidget(hero.discord_guild_id);
return {
...hero,
discord_widget: {
name: String(widget.name || hero.title || "Discord server").slice(0, 120),
invite_url: safeExternalUrl(widget.instant_invite) || hero.fallback_url,
presence_count: Number(widget.presence_count) || 0,
channels: Array.isArray(widget.channels)
? widget.channels.slice(0, 8).map((channel) => String(channel.name || "").slice(0, 80)).filter(Boolean)
: [],
members: Array.isArray(widget.members)
? widget.members.slice(0, 8).map((member) => ({
username: String(member.username || "").slice(0, 80),
status: String(member.status || "").slice(0, 30),
avatar_url: safeExternalUrl(member.avatar_url)
})).filter((member) => member.username)
: []
}
};
} catch (error) {
if (hero.fallback_behavior === "hide") return { ...hero, available: false };
return {
...hero,
available: Boolean(hero.fallback_url || hero.fallback_text || hero.fallback_image_url),
render_error: error?.message || "Discord Server Widget is unavailable. Enable the server widget in Discord or add a fallback link."
};
}
}
async function fetchDiscordWidget(guildId) {
const id = String(guildId || "").trim();
if (!/^\d{10,30}$/.test(id)) {
throw new Error("Add a valid Discord server ID.");
}
const now = Date.now();
const cached = discordWidgetCache.get(id);
if (cached && cached.expires > now) {
if (cached.error) throw new Error(cached.error);
return cached.data;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`https://discord.com/api/guilds/${encodeURIComponent(id)}/widget.json`, {
headers: { Accept: "application/json" },
signal: controller.signal
});
if (!response.ok) {
const message = response.status === 403 || response.status === 404
? "Discord Server Widget is disabled or unavailable for this server."
: `Discord widget request failed with HTTP ${response.status}.`;
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, error: message });
throw new Error(message);
}
const data = await response.json();
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, data });
return data;
} catch (error) {
const message = error?.name === "AbortError"
? "Discord widget request timed out."
: error?.message || "Discord widget could not be loaded.";
discordWidgetCache.set(id, { expires: now + Math.min(DISCORD_WIDGET_CACHE_MS, 60 * 1000), error: message });
throw new Error(message);
} finally {
clearTimeout(timer);
}
}
function homepageEmbedParent(req) {
const host = String(req?.hostname || req?.get?.("host") || "localhost")
.split(":")[0]
@ -1898,6 +2112,10 @@ function validateHomepageHeroEntries(entries) {
const errors = [];
entries.forEach((item, index) => {
if (!item || item.enabled === false) return;
if (item.type === "custom_embed" && !String(item.title || "").trim()) {
errors.push(`hero ${index + 1}: Add a title for this external embed.`);
return;
}
const hero = normalizeHomepageHero({ ...item, availability_mode: "always" }, { parentHost: "localhost" });
if (hero?.available) return;
if (item.type === "none" && item.fallback_behavior !== "hide") return;
@ -2465,12 +2683,16 @@ function createWebServer({ loadPlugins, discordClient }) {
next();
});
app.get("/", (req, res) => {
app.get("/", async (req, res, next) => {
try {
res.render("home", {
title: "Home",
homepageLinks: homepageLinksForUser(req.session.user),
homepageHero: homepageHeroForUser(req.session.user, req)
homepageHero: await homepageHeroForUser(req.session.user, req)
});
} catch (error) {
next(error);
}
});
app.get("/api/assistant-panels", async (req, res) => {

View File

@ -17,8 +17,51 @@
</div>
<% if (homepageHero.image_url) { %>
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
<% } else if (homepageHero.discord_widget) { %>
<div class="homepage-hero-media homepage-discord-card">
<div class="homepage-discord-card-header">
<span class="homepage-discord-icon" aria-hidden="true"><%= homepageHero.discord_widget.name.slice(0, 1).toUpperCase() %></span>
<span>
<strong><%= homepageHero.discord_widget.name %></strong>
<small><%= homepageHero.discord_widget.presence_count %> online now</small>
</span>
</div>
<% if ((homepageHero.discord_widget.channels || []).length) { %>
<div>
<span class="homepage-discord-label">Visible channels</span>
<div class="homepage-discord-pills">
<% homepageHero.discord_widget.channels.forEach((channel) => { %><span># <%= channel %></span><% }) %>
</div>
</div>
<% } %>
<% if ((homepageHero.discord_widget.members || []).length) { %>
<div>
<span class="homepage-discord-label">Visible members</span>
<div class="homepage-discord-members">
<% homepageHero.discord_widget.members.forEach((member) => { %>
<span>
<% if (member.avatar_url) { %><img src="<%= member.avatar_url %>" alt="" loading="lazy" /><% } %>
<strong><%= member.username %></strong>
<% if (member.status) { %><small><%= member.status %></small><% } %>
</span>
<% }) %>
</div>
</div>
<% } %>
<% if (homepageHero.discord_widget.invite_url) { %>
<a class="button" href="<%= homepageHero.discord_widget.invite_url %>" target="_blank" rel="noopener noreferrer">Join Discord</a>
<% } %>
</div>
<% } else if (homepageHero.embed_url) { %>
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
<% const heroFrameStyle = `${homepageHero.aspect_ratio ? `aspect-ratio:${homepageHero.aspect_ratio};` : ""}${homepageHero.embed_height ? `min-height:${homepageHero.embed_height}px;` : ""}`; %>
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="<%= homepageHero.iframe_allow || "accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" %>" <%- homepageHero.iframe_sandbox ? `sandbox="${homepageHero.iframe_sandbox}"` : "" %> <%- heroFrameStyle ? `style="${heroFrameStyle}"` : "" %> allowfullscreen></iframe>
<% } else if (homepageHero.fallback_image_url || homepageHero.fallback_text || homepageHero.fallback_url) { %>
<div class="homepage-hero-media homepage-hero-error" role="status">
<% if (homepageHero.fallback_image_url) { %><img src="<%= homepageHero.fallback_image_url %>" alt="" loading="lazy" /><% } %>
<strong>Featured content unavailable</strong>
<span><%= homepageHero.fallback_text || homepageHero.render_error || "Open the fallback link instead." %></span>
<% if (homepageHero.fallback_url) { %><a class="button subtle" href="<%= homepageHero.fallback_url %>" target="_blank" rel="noopener noreferrer">Open fallback</a><% } %>
</div>
<% } else if (homepageHero.render_error) { %>
<div class="homepage-hero-media homepage-hero-error" role="status">
<strong>Hero unavailable</strong>