Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from 'nitropack/runtime'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'
import { withSigning } from './utils/withSigning'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from 'nitropack/runtime'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'
import { withSigning } from './utils/withSigning'
Expand Down
2 changes: 1 addition & 1 deletion packages/script/src/runtime/server/gravatar-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from 'nitropack/runtime'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'
import { withSigning } from './utils/withSigning'
Expand Down
179 changes: 2 additions & 177 deletions packages/script/src/runtime/server/instagram-embed.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,14 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { $fetch } from 'ofetch'
import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml'
import { rewriteUrl, rewriteUrlsInText, RSRC_RE, scopeCss } from './utils/instagram-embed'
import { withSigning } from './utils/withSigning'

export const RSRC_RE = /url\(\/rsrc\.php([^)]+)\)/g
export const AMP_RE = /&/g
export const SCONTENT_RE = /https:\/\/scontent[^"'\s),]+\.cdninstagram\.com[^"'\s),]+/g
export const STATIC_CDN_RE = /https:\/\/static\.cdninstagram\.com[^"'\s),]+/g
export const LOOKASIDE_RE = /https:\/\/lookaside\.instagram\.com[^"'\s),]+/g
export const INSTAGRAM_IMAGE_HOSTS = ['scontent.cdninstagram.com', 'lookaside.instagram.com']
export const INSTAGRAM_ASSET_HOST = 'static.cdninstagram.com'
export { proxyAssetUrl, proxyImageUrl, rewriteUrl, rewriteUrlsInText, scopeCss } from './utils/instagram-embed'

const CHARSET_RE = /@charset\s[^;]+;/gi
const IMPORT_RE = /@import\s[^;]+;/gi
const WHITESPACE_RE = /\s/
const EMBED_INSTAGRAM_SUFFIX_RE = /\/embed\/instagram$/
const AT_RULE_NAME_RE = /@([\w-]+)/
const MULTI_SPACE_RE = /\s+/g
const SRCSET_SPLIT_RE = /\s+/

export function proxyImageUrl(url: string, prefix = '/_scripts'): string {
return `${prefix}/embed/instagram-image?url=${encodeURIComponent(url.replace(AMP_RE, '&'))}`
}

export function proxyAssetUrl(url: string, prefix = '/_scripts'): string {
return `${prefix}/embed/instagram-asset?url=${encodeURIComponent(url.replace(AMP_RE, '&'))}`
}

export function rewriteUrl(url: string, prefix = '/_scripts'): string {
try {
const parsed = new URL(url)
if (parsed.hostname === INSTAGRAM_ASSET_HOST)
return proxyAssetUrl(url, prefix)
if (INSTAGRAM_IMAGE_HOSTS.some(h => parsed.hostname === h || parsed.hostname.endsWith(`.cdninstagram.com`)))
return proxyImageUrl(url, prefix)
}
catch {}
return url
}

export function rewriteUrlsInText(text: string, prefix = '/_scripts'): string {
return text
.replace(SCONTENT_RE, m => proxyImageUrl(m, prefix))
.replace(STATIC_CDN_RE, m => proxyAssetUrl(m, prefix))
.replace(LOOKASIDE_RE, m => proxyImageUrl(m, prefix))
}

/**
* Remove a node from the AST by converting it to an empty text node.
* Setting node.name = '' produces broken HTML (attributes still render as `< attr="...">`).
Expand All @@ -58,135 +21,6 @@ function removeNode(node: any): void {
node.children = []
}

/**
* Scope CSS rules under a parent selector and strip global/page-level rules.
* Removes :root, html, body selectors and @charset/@import at-rules.
*/
export function scopeCss(css: string, scopeSelector: string): string {
// Remove @charset and @import at-rules
let result = css.replace(CHARSET_RE, '')
result = result.replace(IMPORT_RE, '')

// Process the CSS rule by rule using a simple state machine
return processRules(result, scopeSelector)
}

function processRules(css: string, scopeSelector: string): string {
const output: string[] = []
let i = 0

while (i < css.length) {
// Skip whitespace
while (i < css.length && WHITESPACE_RE.test(css[i]!)) i++
if (i >= css.length)
break

// Handle @-rules
if (css[i] === '@') {
const atRule = extractAtRule(css, i)
if (atRule) {
// Skip @charset, @import (already removed above)
// For @media, @supports, @keyframes etc., include as-is
const atName = atRule.content.match(AT_RULE_NAME_RE)?.[1]?.toLowerCase()
if (atName === 'media' || atName === 'supports' || atName === 'layer') {
// Scope the inner rules
const braceStart = atRule.content.indexOf('{')
const innerCss = atRule.content.slice(braceStart + 1, -1)
const scopedInner = processRules(innerCss, scopeSelector)
output.push(`${atRule.content.slice(0, braceStart + 1) + scopedInner}}`)
}
else if (atName === 'keyframes' || atName === '-webkit-keyframes' || atName === 'font-face') {
// Keep as-is (keyframes/font-face are global by nature)
output.push(atRule.content)
}
// Skip other at-rules (e.g., @page)
i = atRule.end
continue
}
}

// Extract a regular rule (selector { ... })
const bracePos = css.indexOf('{', i)
if (bracePos === -1)
break

const selector = css.slice(i, bracePos).trim()
const block = extractBlock(css, bracePos)
if (!block)
break

i = block.end

// Skip empty selectors
if (!selector)
continue

// Strip rules targeting :root, html, body (page-level selectors)
const selectors = selector.split(',').map(s => s.trim())
const filteredSelectors = selectors.filter((s) => {
const normalized = s.replace(MULTI_SPACE_RE, ' ').trim().toLowerCase()
return normalized !== ':root'
&& normalized !== 'html'
&& normalized !== 'body'
&& !normalized.startsWith(':root ')
&& !normalized.startsWith('html ')
&& !normalized.startsWith('body ')
&& normalized !== 'html, body'
})

if (filteredSelectors.length === 0)
continue

// Scope each selector
const scopedSelectors = filteredSelectors.map((s) => {
// Don't scope selectors that are already scoped or are pseudo-elements on :root
return `${scopeSelector} ${s}`
})

output.push(`${scopedSelectors.join(', ')} ${block.content}`)
}

return output.join('\n')
}

function extractAtRule(css: string, start: number): { content: string, end: number } | null {
const bracePos = css.indexOf('{', start)
const semiPos = css.indexOf(';', start)

// Simple at-rule (no block)
if (semiPos !== -1 && (bracePos === -1 || semiPos < bracePos)) {
return { content: css.slice(start, semiPos + 1), end: semiPos + 1 }
}

if (bracePos === -1)
return null

const block = extractBlock(css, bracePos)
if (!block)
return null

return {
content: css.slice(start, bracePos) + block.content,
end: block.end,
}
}

function extractBlock(css: string, openBrace: number): { content: string, end: number } | null {
let depth = 0
for (let j = openBrace; j < css.length; j++) {
if (css[j] === '{') {
depth++
}
else if (css[j] === '}') {
depth--
if (depth === 0) {
return { content: css.slice(openBrace, j + 1), end: j + 1 }
}
}
}
return null
}

export default withSigning(defineEventHandler(async (event) => {
// Derive the scripts prefix from the handler's own route path.
// The route is registered as `<prefix>/embed/instagram`, so strip `/embed/instagram`.
Expand Down Expand Up @@ -247,26 +81,22 @@ export default withSigning(defineEventHandler(async (event) => {
if (node.type !== ELEMENT_NODE)
return

// Collect stylesheet URLs
if (node.name === 'link' && node.attributes.rel === 'stylesheet' && node.attributes.href) {
cssUrls.push(node.attributes.href)
removeNode(node)
return
}

// Remove script, noscript, and style tags (inline styles contain global :root rules)
if (node.name === 'script' || node.name === 'noscript' || node.name === 'style') {
removeNode(node)
return
}

// Rewrite image/asset URLs in attributes
for (const attr of ['src', 'poster']) {
if (node.attributes[attr])
node.attributes[attr] = rewriteUrl(node.attributes[attr], prefix)
}

// srcset is comma-separated "<url> <descriptor>" entries
if (node.attributes.srcset) {
node.attributes.srcset = node.attributes.srcset
.split(',')
Expand All @@ -279,18 +109,15 @@ export default withSigning(defineEventHandler(async (event) => {
.join(', ')
}

// Rewrite URLs in style attributes
if (node.attributes.style)
node.attributes.style = rewriteUrlsInText(node.attributes.style, prefix)
})

// Also rewrite URLs in text nodes (inline styles in CSS blocks, etc.)
walkSync(ast, (node) => {
if (node.type === TEXT_NODE && node.value)
node.value = rewriteUrlsInText(node.value, prefix)
})

// Extract body content only (avoid leaking <html id="facebook"> onto the page)
let bodyNode: any = null
walkSync(ast, (node) => {
if (node.type === ELEMENT_NODE && node.name === 'body')
Expand All @@ -301,7 +128,6 @@ export default withSigning(defineEventHandler(async (event) => {
? bodyNode.children.map((child: any) => renderSync(child)).join('')
: renderSync(ast)

// Fetch all CSS files in parallel
const cssContents = await Promise.all(
cssUrls.map(url =>
$fetch<string>(url, {
Expand All @@ -310,7 +136,6 @@ export default withSigning(defineEventHandler(async (event) => {
),
)

// Combine CSS, rewrite image URLs, and scope under embed root
let combinedCss = cssContents.join('\n')
combinedCss = combinedCss.replace(
RSRC_RE,
Expand Down
3 changes: 1 addition & 2 deletions packages/script/src/runtime/server/proxy-handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy'
import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, setResponseHeader } from 'h3'
import { useNitroApp } from 'nitropack/runtime'
import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
import {
anonymizeIP,
mergePrivacy,
Expand Down
Loading
Loading