← back to NationalPaperHangers
claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod
dd2fcdebb5e034342e460ea48fd81a8979507f0f · 2026-05-06 12:07:54 -0700 · Steve
Files touched
A db/migrations/015_featured_video_urls_check.sqlM lib/social-embed.js
Diff
commit dd2fcdebb5e034342e460ea48fd81a8979507f0f
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 12:07:54 2026 -0700
claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod
---
db/migrations/015_featured_video_urls_check.sql | 20 +++++++
lib/social-embed.js | 79 +++++++++++++++++++++++--
2 files changed, 94 insertions(+), 5 deletions(-)
diff --git a/db/migrations/015_featured_video_urls_check.sql b/db/migrations/015_featured_video_urls_check.sql
new file mode 100644
index 0000000..85a4e36
--- /dev/null
+++ b/db/migrations/015_featured_video_urls_check.sql
@@ -0,0 +1,20 @@
+-- 015 · CHECK constraint on installers.featured_video_urls
+--
+-- 014 added the column with NOT NULL DEFAULT '[]'::jsonb. NOT NULL alone
+-- doesn't prevent UPDATE … SET featured_video_urls = '"oops"'::jsonb (a
+-- jsonb string), which would later break the partial-index expression
+-- jsonb_array_length() with "cannot get array length of a non-array".
+--
+-- Hardening per claude-codex r3 finding MEDIUM#7. Drop-create pattern keeps
+-- this re-runnable.
+
+BEGIN;
+
+ALTER TABLE installers
+ DROP CONSTRAINT IF EXISTS installers_featured_video_urls_is_array;
+
+ALTER TABLE installers
+ ADD CONSTRAINT installers_featured_video_urls_is_array
+ CHECK (jsonb_typeof(featured_video_urls) = 'array');
+
+COMMIT;
diff --git a/lib/social-embed.js b/lib/social-embed.js
index bab98e3..bc38cb3 100644
--- a/lib/social-embed.js
+++ b/lib/social-embed.js
@@ -58,6 +58,37 @@ function parseVimeo(u) {
} catch { return null; }
}
+// LinkedIn post URLs come in several flavors. Extract the activity/share/ugcPost
+// numeric ID and render an embed iframe pointing at /embed/feed/update/<urn>.
+// Patterns supported:
+// https://www.linkedin.com/posts/<user>_<slug>-activity-<19-digit-id>-<hash>/
+// https://www.linkedin.com/feed/update/urn:li:activity:<id>/
+// https://www.linkedin.com/feed/update/urn:li:share:<id>/
+// https://www.linkedin.com/feed/update/urn:li:ugcPost:<id>/
+// https://www.linkedin.com/embed/feed/update/urn:li:share:<id> (already an embed url)
+// Returns { type: 'activity'|'share'|'ugcPost', id: <string-of-digits> } or null.
+function parseLinkedIn(u) {
+ try {
+ const url = new URL(u);
+ if (!isHost(url.hostname, 'linkedin.com')) return null;
+ const path = url.pathname;
+
+ // /posts/<user>_<slug>-activity-<id>-<hash>/
+ let m = path.match(/-activity-(\d{15,25})-/);
+ if (m) return { type: 'activity', id: m[1] };
+
+ // /feed/update/urn:li:<type>:<id>/ or /embed/feed/update/urn:li:<type>:<id>
+ m = path.match(/urn:li:(activity|share|ugcPost):(\d{10,25})/);
+ if (m) return { type: m[1], id: m[2] };
+
+ // Bare digit-only path component (rare)
+ m = path.match(/\/(\d{15,25})(?:\/|$)/);
+ if (m) return { type: 'share', id: m[1] };
+
+ return null;
+ } catch { return null; }
+}
+
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
@@ -75,7 +106,7 @@ function embed(input) {
platform: 'youtube',
url,
embedUrl: `https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1`,
- html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
};
}
@@ -85,7 +116,7 @@ function embed(input) {
platform: 'instagram',
url,
embedUrl: `https://www.instagram.com/${ig.type}/${ig.id}/embed/`,
- html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" width="320" height="568" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
};
}
@@ -95,7 +126,7 @@ function embed(input) {
platform: 'tiktok',
url,
embedUrl: `https://www.tiktok.com/player/v1/${tt}`,
- html: `<iframe class="social-embed tt" src="https://www.tiktok.com/player/v1/${tt}" title="${escapeHtml(title) || 'TikTok'}" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ html: `<iframe class="social-embed tt" src="https://www.tiktok.com/player/v1/${tt}" title="${escapeHtml(title) || 'TikTok'}" width="320" height="568" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
};
}
@@ -105,13 +136,51 @@ function embed(input) {
platform: 'vimeo',
url,
embedUrl: `https://player.vimeo.com/video/${vimeo}`,
- html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+
+ const li = parseLinkedIn(url);
+ if (li) {
+ const urn = `urn:li:${li.type}:${li.id}`;
+ const embedUrl = `https://www.linkedin.com/embed/feed/update/${urn}`;
+ return {
+ platform: 'linkedin',
+ url,
+ embedUrl,
+ html: `<iframe class="social-embed linkedin" src="${embedUrl}" title="${escapeHtml(title) || 'LinkedIn post'}" width="504" height="546" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
};
}
return null;
}
+// Helper for the curated_videos table: given a stored row {platform, youtube_id},
+// build the iframe HTML directly without needing the original URL. Lets the
+// /watch view render LinkedIn rows even though the column is named youtube_id.
+function embedFromRow(row) {
+ if (!row) return null;
+ const id = row.youtube_id;
+ if (!id) return null;
+ const title = row.title || '';
+ if (row.platform === 'linkedin') {
+ // Stored ID is the bare numeric URN suffix; default urn type to 'share'
+ // since that's what linkedin/embed accepts for both shares + activities.
+ const urn = `urn:li:share:${id}`;
+ return {
+ platform: 'linkedin',
+ embedUrl: `https://www.linkedin.com/embed/feed/update/${urn}`,
+ html: `<iframe class="social-embed linkedin" src="https://www.linkedin.com/embed/feed/update/${urn}" title="${escapeHtml(title)}" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+ // Default: YouTube
+ return {
+ platform: 'youtube',
+ embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1`,
+ html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1" title="${escapeHtml(title)}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+}
+
function embedAll(arrayOrJsonb) {
let arr = arrayOrJsonb;
if (typeof arr === 'string') {
@@ -123,4 +192,4 @@ function embedAll(arrayOrJsonb) {
return arr.slice(0, 12).map(embed).filter(Boolean);
}
-module.exports = { embed, embedAll };
+module.exports = { embed, embedAll, embedFromRow };
← 8c19628 admin/uploads: per-installer rate limit (30/hr) keyed on ins
·
back to NationalPaperHangers
·
templates: hide all /book schedule CTAs for unclaimed (direc fdd57f9 →