diff --git a/TODO.md b/TODO.md
index 4ef53b2..fd9fd27 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index f47809f..a9a6fc3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 898d773..dd658f6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lumi-bot",
- "version": "0.1.2",
+ "version": "0.1.3",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/src/web/public/homepage-builder.js b/src/web/public/homepage-builder.js
index 9c42b56..1cd8116 100644
--- a/src/web/public/homepage-builder.js
+++ b/src/web/public/homepage-builder.js
@@ -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." };
- const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id);
+ 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 = `Image preview`;
return;
}
+ if (type === "discord_server_overview") {
+ const id = platformId || discordServerId(source);
+ preview.innerHTML = id
+ ? `Discord widget card previewLumi will fetch public widget data for server ${escapeHtml(id)} after the page is saved. Discord Server Widget must be enabled.`
+ : `Discord widget unavailableAdd the numeric Discord server ID. Invite links can be used only as fallback links.`;
+ return;
+ }
if (embed) {
preview.innerHTML = `Embed preview. Some services block previews until saved on the public host.`;
return;
diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css
index d6a3fe2..8d81f74 100644
--- a/src/web/public/lumi-components.css
+++ b/src/web/public/lumi-components.css
@@ -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);
}
diff --git a/src/web/server.js b/src/web/server.js
index 54efea4..ab5a631 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -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) => {
- res.render("home", {
- title: "Home",
- homepageLinks: homepageLinksForUser(req.session.user),
- homepageHero: homepageHeroForUser(req.session.user, req)
- });
+ app.get("/", async (req, res, next) => {
+ try {
+ res.render("home", {
+ title: "Home",
+ homepageLinks: homepageLinksForUser(req.session.user),
+ homepageHero: await homepageHeroForUser(req.session.user, req)
+ });
+ } catch (error) {
+ next(error);
+ }
});
app.get("/api/assistant-panels", async (req, res) => {
diff --git a/src/web/views/home.ejs b/src/web/views/home.ejs
index 40904a5..bf695e9 100644
--- a/src/web/views/home.ejs
+++ b/src/web/views/home.ejs
@@ -17,8 +17,51 @@
<% if (homepageHero.image_url) { %>
+ <% } else if (homepageHero.discord_widget) { %>
+