load skopec.dev live in its window via an allowlisted header-stripping proxy (/api/frame)

This commit is contained in:
Martin Dvořák 2026-09-30 06:42:36 +00:00
commit a4a12bfe2a
5 changed files with 147 additions and 12 deletions

View file

@ -42,9 +42,10 @@ heatmap**, a gym SBD panel and links to friends' sites.
A spinner covers the frame while the site boots. Discord sends `DENY` and keeps a link card
instead.
- **Friend sites in windows** — clicking a friend's desktop icon opens their site in a
window. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they load live;
`skopec.dev` sends `X-Frame-Options: SAMEORIGIN`, so the window frames the bundled mirror
at `/skopec.html` and the `www` button opens the real site.
window. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they load directly;
`skopec.dev` sends `X-Frame-Options: SAMEORIGIN`, so it loads through `/api/frame`, a
header-stripping proxy that serves the live page from our origin. The `www` button always
opens the real site.
- **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on
Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled
from mcstatus.io through the site's own ping API.
@ -156,6 +157,7 @@ they contend for `:3000` and `.next/dev/lock`.
| --- | --- | --- |
| `/api/heatmap` | `GET` | Proxies the user's contribution heatmap from `git.dvorakmortonxd.dev`. |
| `/api/activities` | `GET` | Fetches the GitDXD activity feed and keeps only the user's own events. |
| `/api/frame?url=…` | `GET` | Header-stripping proxy for a friend's site that refuses framing (`skopec.dev`). **Allow-listed hosts only**; fetches the HTML server-side (so the site's `X-Frame-Options` never reaches the browser), injects a `<base>` for relative assets and a click shim for in-site links. Rejects other hosts, non-http(s) schemes and credentials, and never follows redirects. |
| `/api/ping?url=…` | `GET` | Health-checks an **allow-listed** service (the site's own `*.dvorakmortonxd.dev` hosts and the friends' sites). Handles `minecraft://host:port` via mcstatus.io; treats any non-5xx response as "up". Other hosts and schemes are rejected and redirects are never followed, so it can't be used to probe the LAN (SSRF guard). |
## 📁 Project Structure

View file

@ -84,6 +84,7 @@ src/app/
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
activities/route.ts → proxy for the user's GitDXD activity feed
ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
components/
ThemeRoot.tsx → picks which shell to show (the router of the UI)
TerminalLayout.tsx → "terminal" theme (Gruvbox)
@ -299,10 +300,16 @@ list reordering, not free placement. Don't confuse the two.
is the `embed` flag; `friend.url` is what the frame loads and `friend.realUrl`
is what the www button opens — `createWebsiteApp`'s `embedUrl` option lets the
two differ. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they
frame the live site; `skopec.dev` sends `X-Frame-Options: SAMEORIGIN` on every
path, so `friends[0].url` points at the bundled `/public/skopec.html` mirror
and only the button is live. Making a blocking site load *live* would need a
header-stripping proxy (allowlisted) — not implemented.
frame the live site. `skopec.dev` sends `X-Frame-Options: SAMEORIGIN` on every
path, so `friend.proxy` routes it through `/api/frame`.
- `/api/frame` is a **host-allowlisted** header-stripping proxy for sites that
refuse framing. It fetches the HTML server-side (so the upstream
`X-Frame-Options` never reaches the browser), injects `<base href="origin/">`
so relative assets still resolve to the real site, and injects a click shim so
same-host navigation is proxied too. It only proxies `ALLOWED_HOSTS`
(`skopec.dev`), never follows redirects, rejects credentials and non-http(s),
and returns HTML only — it is public, so keep it that narrow. The framed page
executes under our origin, so only allow-list hosts you trust.
- **Embedded iframes swallow the pointer.** While dragging/resizing a window,
the moment the cursor passes over a service `<iframe>` the site's own document
receives the `pointermove`/`pointerup`, so a gesture that listens on `window`
@ -346,6 +353,9 @@ list reordering, not free placement. Don't confuse the two.
local dir and `LD_LIBRARY_PATH`. Frame the test from an `http(s)` origin (e.g.
a file dropped in `public/` served at `localhost:3000`); a `file://`
top-level blocks the iframe's storage and makes working sites look blank.
For a site that refuses framing, `/api/frame?url=<site>` should return `200`
with no `X-Frame-Options` and `frame-ancestors 'self'`, while `?url=` for any
other host returns `403`.
---

108
src/app/api/frame/route.ts Normal file
View file

@ -0,0 +1,108 @@
import { NextResponse } from 'next/server';
// Server-side proxy for friend sites that refuse to be framed. `skopec.dev`
// answers every path with `X-Frame-Options: SAMEORIGIN`, so a plain iframe comes
// up blank. This route fetches the HTML itself — because we build our own
// response, the upstream framing headers never reach the browser — injects a
// `<base>` so the site's relative assets still resolve against its own origin,
// and adds a small click shim so in-site navigation is proxied too.
//
// This is a public, unauthenticated endpoint that makes the server fetch a URL
// for the caller, so it is deliberately narrow: allow-listed hosts only, http(s)
// only, no credentials, redirects never followed, HTML only. That keeps it from
// becoming an open proxy or an SSRF gadget. Keep ALLOWED_HOSTS in sync with the
// friends in data.ts.
const ALLOWED_HOSTS = ['skopec.dev', 'www.skopec.dev'];
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/\.$/, '');
return ALLOWED_HOSTS.includes(host);
}
const FETCH_TIMEOUT_MS = 10_000;
const MAX_HTML_BYTES = 3_000_000;
function fail(status: number, message: string) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const raw = searchParams.get('url');
if (!raw) return fail(400, 'missing url');
let target: URL;
try {
target = new URL(raw);
} catch {
return fail(400, 'invalid url');
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return fail(400, 'unsupported protocol');
}
if (target.username || target.password) return fail(400, 'credentials not allowed');
if (!isAllowedHost(target.hostname)) return fail(403, 'host not allowed');
let upstream: Response;
try {
upstream = await fetch(target, {
// Never chase a Location off the allowlist (that would reopen the SSRF).
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
// A browser-ish UA so bot filters don't challenge the server request.
'user-agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml',
},
});
} catch {
return fail(502, 'upstream fetch failed');
}
if (upstream.status >= 300 && upstream.status < 400) {
return fail(502, 'upstream redirect not followed');
}
if (!upstream.ok) return fail(502, `upstream returned ${upstream.status}`);
const contentType = (upstream.headers.get('content-type') ?? '').toLowerCase();
if (!contentType.includes('text/html')) return fail(415, 'upstream is not HTML');
let html = await upstream.text();
if (html.length > MAX_HTML_BYTES) return fail(413, 'upstream response too large');
// Relative URLs (style.css, favicon.svg, hardware.html, …) must resolve
// against the real site, so inject a <base> for the directory we fetched,
// before any <base> the page itself provides.
const origin = `${target.protocol}//${target.host}`;
const directory = target.pathname.replace(/[^/]*$/, '');
const head = `<base href="${origin}${directory}">${clickShim(target.hostname)}`;
html = /<head\b[^>]*>/i.test(html)
? html.replace(/<head\b[^>]*>/i, (tag) => tag + head)
: head + html;
return new NextResponse(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
// Only our own pages may frame the proxied copy.
'content-security-policy': "frame-ancestors 'self'",
'x-content-type-options': 'nosniff',
'cache-control': 'public, max-age=60',
},
});
}
/**
* Rewrites same-origin link clicks to go back through this proxy, so following
* e.g. `hardware.html` doesn't navigate the frame to the real (unframeable)
* site. Cross-host links, new-tab links, in-page `#anchors` and non-http schemes
* are left alone.
*/
function clickShim(hostname: string): string {
return `<script>(function(){var HOST=${JSON.stringify(
hostname
)};document.addEventListener("click",function(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var t=e.target;var a=t&&t.closest?t.closest("a"):null;if(!a)return;if(a.target&&a.target!=="_self")return;var href=a.getAttribute("href");if(!href||href.charAt(0)==="#")return;var u;try{u=new URL(href,document.baseURI);}catch(_){return;}if(u.protocol!=="http:"&&u.protocol!=="https:")return;if(u.hostname!==HOST&&u.hostname!=="www."+HOST)return;e.preventDefault();window.location.href="/api/frame?url="+encodeURIComponent(u.href);},true);})();</script>`;
}

View file

@ -62,15 +62,18 @@ const SERVICE_SHORTCUTS: Shortcut[] = [
// Friends' sites, opened in a live window when they allow framing. bukovinafilip
// and vlcak.me send no framing headers, so `url` (== `realUrl`) is framed
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so we
// frame the same-origin mirror at `/skopec.html` instead; the www button still
// opens the live site. To embed a site that blocks framing live, it would need a
// header-stripping proxy.
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so
// `proxy: true` routes it through /api/frame, which strips the framing headers
// and serves the live page from our origin. The www button always opens the live
// site (`realUrl`).
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
id: `friend-${i}`,
label: f.name,
url: f.realUrl || f.url,
embedUrl: f.url,
embedUrl:
f.proxy && f.realUrl
? `/api/frame?url=${encodeURIComponent(f.realUrl)}`
: f.url,
embed: f.iframe,
color: "#c0392b",
icon: "🫂",

View file

@ -19,11 +19,19 @@ export interface TechProject {
export interface Friend {
name: string;
/** What the desktop window frames. A same-origin mirror when the live site blocks framing. */
url: string;
/** The live site: the window's `www` button and the status check use this. */
realUrl: string;
img: string;
desc: string;
/** Render in an iframe in a desktop window. */
iframe: boolean;
/**
* Load `realUrl` through `/api/frame` (which strips framing headers) instead
* of framing `url`. Use for sites that send a restrictive `X-Frame-Options`.
*/
proxy?: boolean;
}
export const socials: Social[] = [
@ -233,11 +241,15 @@ export const techProjects: TechProject[] = [
export const friends: Friend[] = [
{
name: "Josef Skopec",
// skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so the
// window goes through /api/frame for the live site; /skopec.html is the
// bundled mirror that was used before the proxy existed (kept as a fallback).
url: "/skopec.html",
realUrl: "https://skopec.dev/",
img: "/skopec.svg",
desc: "skopec.dev",
iframe: true,
proxy: true,
},
{
name: "Filip Bukovina",