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) { %> +
+
+ + + <%= homepageHero.discord_widget.name %> + <%= homepageHero.discord_widget.presence_count %> online now + +
+ <% if ((homepageHero.discord_widget.channels || []).length) { %> +
+ Visible channels +
+ <% homepageHero.discord_widget.channels.forEach((channel) => { %># <%= channel %><% }) %> +
+
+ <% } %> + <% if ((homepageHero.discord_widget.members || []).length) { %> +
+ Visible members +
+ <% homepageHero.discord_widget.members.forEach((member) => { %> + + <% if (member.avatar_url) { %><% } %> + <%= member.username %> + <% if (member.status) { %><%= member.status %><% } %> + + <% }) %> +
+
+ <% } %> + <% if (homepageHero.discord_widget.invite_url) { %> + Join Discord + <% } %> +
<% } else if (homepageHero.embed_url) { %> - + <% const heroFrameStyle = `${homepageHero.aspect_ratio ? `aspect-ratio:${homepageHero.aspect_ratio};` : ""}${homepageHero.embed_height ? `min-height:${homepageHero.embed_height}px;` : ""}`; %> + + <% } else if (homepageHero.fallback_image_url || homepageHero.fallback_text || homepageHero.fallback_url) { %> +
+ <% if (homepageHero.fallback_image_url) { %><% } %> + Featured content unavailable + <%= homepageHero.fallback_text || homepageHero.render_error || "Open the fallback link instead." %> + <% if (homepageHero.fallback_url) { %>Open fallback<% } %> +
<% } else if (homepageHero.render_error) { %>
Hero unavailable