load skopec.dev live in its window via an allowlisted header-stripping proxy (/api/frame)
This commit is contained in:
parent
d5dfea060f
commit
a4a12bfe2a
5 changed files with 147 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
agents.md
18
agents.md
|
|
@ -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
108
src/app/api/frame/route.ts
Normal 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>`;
|
||||
}
|
||||
|
|
@ -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: "🫂",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue