nasty! it's a browser
This commit is contained in:
parent
f598beb406
commit
ba44f8e65b
|
|
@ -13,6 +13,9 @@
|
||||||
<div class="text-content">
|
<div class="text-content">
|
||||||
<h1>@defunkt</h1>
|
<h1>@defunkt</h1>
|
||||||
<p>This is my website. I am Chris.</p>
|
<p>This is my website. I am Chris.</p>
|
||||||
|
<p><a href="/other.html">Other.html</a></p>
|
||||||
|
<p><a href="http://corey.localhost:3000">corey</a></p>
|
||||||
|
<p><a href="https://google.com">google</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile burger">
|
<div class="tile burger">
|
||||||
|
|
|
||||||
4
nose/chris/pub/other.html
Normal file
4
nose/chris/pub/other.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<html>
|
||||||
|
<head><title>other</title></head>
|
||||||
|
<body><h1>other</h1></body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export default () =>
|
|
||||||
"pong"
|
|
||||||
2
nose/ping/index.tsx
Normal file
2
nose/ping/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export default () =>
|
||||||
|
<html><head><title>pong </title></head><body>pong - {Date.now()}</body></html>
|
||||||
96
public/browser-nav.js
Normal file
96
public/browser-nav.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// browser-nav.js - Injected into NOSE apps to get the UI browser working.
|
||||||
|
(function () {
|
||||||
|
const ALLOWED_DOMAINS = ['localhost', '.local', '.nose.space']
|
||||||
|
|
||||||
|
function isAllowedOrigin(url) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url, window.location.href)
|
||||||
|
return ALLOWED_DOMAINS.some(domain =>
|
||||||
|
urlObj.hostname === domain || urlObj.hostname.endsWith(domain)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept navigation attempts
|
||||||
|
function interceptNavigation(e) {
|
||||||
|
const target = e.target.closest('a')
|
||||||
|
if (!target || !target.href) return
|
||||||
|
|
||||||
|
// Allow relative URLs and hash links
|
||||||
|
if (target.getAttribute('href').startsWith('#')) return
|
||||||
|
if (target.getAttribute('href').startsWith('/')) return
|
||||||
|
|
||||||
|
// Check if external
|
||||||
|
if (!isAllowedOrigin(target.href)) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'NAV_BLOCKED',
|
||||||
|
data: { url: target.href, reason: 'External domain not allowed' }
|
||||||
|
}, '*')
|
||||||
|
} else {
|
||||||
|
alert('Navigation to external sites is not allowed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for clicks on links
|
||||||
|
document.addEventListener('click', interceptNavigation, true)
|
||||||
|
|
||||||
|
// Intercept programmatic navigation
|
||||||
|
const originalPushState = history.pushState
|
||||||
|
const originalReplaceState = history.replaceState
|
||||||
|
|
||||||
|
history.pushState = function (state, title, url) {
|
||||||
|
if (url && !isAllowedOrigin(url)) {
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'NAV_BLOCKED',
|
||||||
|
data: { url, reason: 'External domain not allowed' }
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return originalPushState.apply(this, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replaceState = function (state, title, url) {
|
||||||
|
if (url && !isAllowedOrigin(url)) {
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'NAV_BLOCKED',
|
||||||
|
data: { url, reason: 'External domain not allowed' }
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return originalReplaceState.apply(this, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for navigation commands from parent
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
console.log(event)
|
||||||
|
if (event.data.type === 'NAV_COMMAND') {
|
||||||
|
switch (event.data.action) {
|
||||||
|
case 'back': history.back(); break
|
||||||
|
case 'forward': history.forward(); break
|
||||||
|
case 'reload': window.location.reload(); break
|
||||||
|
case 'stop': window.stop(); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type: 'NAV_READY' }, '*')
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'URL_CHANGED',
|
||||||
|
data: { url: location.href }
|
||||||
|
}, '*')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
54
src/css/browser.css
Normal file
54
src/css/browser.css
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
:root {
|
||||||
|
--browser-bar-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.browser {
|
||||||
|
display: block;
|
||||||
|
background-color: white;
|
||||||
|
z-index: 10;
|
||||||
|
border: none;
|
||||||
|
margin-top: var(--browser-bar-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mode="tall"] iframe.browser {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser.active {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#browser-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
z-index: 15;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--browser-bar-height);
|
||||||
|
background-color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
#browser-controls span,
|
||||||
|
#browser-controls a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
background-color: var(--gray);
|
||||||
|
color: var(--c64-light-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#forward-button {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-button {
|
||||||
|
position: absolute;
|
||||||
|
right: -10;
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
--purple: #7C3AED;
|
--purple: #7C3AED;
|
||||||
--blue: #1565C0;
|
--blue: #1565C0;
|
||||||
--magenta: #ff66cc;
|
--magenta: #ff66cc;
|
||||||
|
--gray: #BEBEBE;
|
||||||
|
--grey: #BEBEBE;
|
||||||
|
|
||||||
--c64-light-blue: #6C6FF6;
|
--c64-light-blue: #6C6FF6;
|
||||||
--c64-dark-blue: #40318D;
|
--c64-dark-blue: #40318D;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const Layout: FC = async ({ children, title }) => (
|
||||||
<link href="/css/reset.css" rel="stylesheet" />
|
<link href="/css/reset.css" rel="stylesheet" />
|
||||||
<link href="/css/main.css" rel="stylesheet" />
|
<link href="/css/main.css" rel="stylesheet" />
|
||||||
<link href="/css/game.css" rel="stylesheet" />
|
<link href="/css/game.css" rel="stylesheet" />
|
||||||
|
<link href="/css/browser.css" rel="stylesheet" />
|
||||||
|
|
||||||
<script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} />
|
<script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} />
|
||||||
<script src={process.env.NODE_ENV === "production" ? `/bundle.js?${GIT_SHA}` : "/js/main.js"} type="module" async></script>
|
<script src={process.env.NODE_ENV === "production" ? `/bundle.js?${GIT_SHA}` : "/js/main.js"} type="module" async></script>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ export const Terminal: FC = async () => (
|
||||||
<link rel="stylesheet" href="/css/terminal.css" />
|
<link rel="stylesheet" href="/css/terminal.css" />
|
||||||
<link rel="stylesheet" href="/css/editor.css" />
|
<link rel="stylesheet" href="/css/editor.css" />
|
||||||
|
|
||||||
|
<div id="browser-controls" style="display:none">
|
||||||
|
<a id="back-button" href="#"></a>
|
||||||
|
<a id="forward-button" href="#"></a>
|
||||||
|
<a id="stop-button" href="#"></a>
|
||||||
|
<a id="reload-button" href="#">@</a>
|
||||||
|
<a id="close-button" href="#">╳</a>
|
||||||
|
<span id="browser-address">chris.nose-pluto.local</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="command-line">
|
<div id="command-line">
|
||||||
<span id="command-prompt">></span>
|
<span id="command-prompt">></span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
|
||||||
138
src/js/browser.ts
Normal file
138
src/js/browser.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
////
|
||||||
|
// Our tiny browser, only for NOSE apps.
|
||||||
|
// Check /public/browser-nav.js for the 2nd part.
|
||||||
|
|
||||||
|
import { $, $$ } from "./dom"
|
||||||
|
import { focusInput } from "./focus"
|
||||||
|
|
||||||
|
const controls = $("browser-controls") as HTMLDivElement
|
||||||
|
const address = $("browser-address") as HTMLSpanElement
|
||||||
|
let iframe: HTMLIFrameElement
|
||||||
|
let iframeWindow: Window
|
||||||
|
|
||||||
|
export function isBrowsing(): boolean {
|
||||||
|
return document.querySelector("iframe.browser.active") !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBrowser(url: string) {
|
||||||
|
iframe = $$("iframe.browser.active") as HTMLIFrameElement
|
||||||
|
iframeWindow = iframe.contentWindow as Window
|
||||||
|
iframe.src = url
|
||||||
|
iframe.sandbox.add("allow-scripts", "allow-same-origin", "allow-forms")
|
||||||
|
iframe.height = "540"
|
||||||
|
iframe.width = "960"
|
||||||
|
iframe.tabIndex = 0
|
||||||
|
|
||||||
|
window.addEventListener("message", handleAppMessage)
|
||||||
|
window.addEventListener("keydown", handleBrowserKeydown)
|
||||||
|
controls.addEventListener("click", handleClick)
|
||||||
|
iframe.addEventListener("load", handlePageLoad)
|
||||||
|
|
||||||
|
controls.style.display = ""
|
||||||
|
const main = document.querySelector("#content")
|
||||||
|
main?.prepend(iframe)
|
||||||
|
|
||||||
|
setAddress(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAppMessage(event: MessageEvent) {
|
||||||
|
const origin = event.origin
|
||||||
|
if (!origin.includes('localhost') && !origin.match(/\.local$/)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data } = event.data
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'NAV_READY':
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'URL_CHANGED':
|
||||||
|
setAddress(data.url)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'TITLE_CHANGED':
|
||||||
|
// TODO: browser titles
|
||||||
|
console.log('Page title:', data.title)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'NAV_BLOCKED':
|
||||||
|
showNavigationError(data.url, data.reason)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBrowser() {
|
||||||
|
iframe.remove()
|
||||||
|
controls.style.display = "none"
|
||||||
|
window.removeEventListener("keydown", handleBrowserKeydown)
|
||||||
|
window.removeEventListener("message", handleAppMessage)
|
||||||
|
controls.removeEventListener("click", handleClick)
|
||||||
|
iframe.removeEventListener("load", handlePageLoad)
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
|
||||||
|
e.preventDefault()
|
||||||
|
closeBrowser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
const target = e.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
switch (target.id) {
|
||||||
|
case "back-button":
|
||||||
|
navigateBack(); break
|
||||||
|
case "forward-button":
|
||||||
|
navigateForward(); break
|
||||||
|
case "stop-button":
|
||||||
|
stopLoading(); break
|
||||||
|
case "reload-button":
|
||||||
|
reloadBrowser(); break
|
||||||
|
case "close-button":
|
||||||
|
closeBrowser(); break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageLoad() {
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAddress(url: string) {
|
||||||
|
address.textContent = url.replace(/https?:\/\//, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateBack() {
|
||||||
|
sendNavCommand('back')
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateForward() {
|
||||||
|
sendNavCommand('forward')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadBrowser() {
|
||||||
|
sendNavCommand('reload')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoading() {
|
||||||
|
sendNavCommand('stop')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNavCommand(action: 'back' | 'forward' | 'reload' | 'stop') {
|
||||||
|
if (!iframe.contentWindow) return
|
||||||
|
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'NAV_COMMAND',
|
||||||
|
action
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNavigationError(url: string, reason: string) {
|
||||||
|
alert(`NAVIGATION BLOCKED\n\n${url}\n\n${reason}`)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
////
|
||||||
|
// Links can be #command (will run in the prompt) or real links, which run in
|
||||||
|
// a NOSE browser so you always stay in the UI's safe embrace.
|
||||||
|
|
||||||
import { runCommand } from "./shell"
|
import { runCommand } from "./shell"
|
||||||
import { focusInput } from "./focus"
|
import { focusInput } from "./focus"
|
||||||
import { status } from "./statusbar"
|
import { openBrowser, isBrowsing } from "./browser"
|
||||||
|
|
||||||
export function initHyperlink() {
|
export function initHyperlink() {
|
||||||
window.addEventListener("click", handleClick)
|
window.addEventListener("click", handleClick)
|
||||||
|
|
@ -21,8 +25,8 @@ async function handleClick(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await runCommand(href.slice(1))
|
await runCommand(href.slice(1))
|
||||||
focusInput()
|
focusInput()
|
||||||
} else {
|
} else if (!isBrowsing()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
status(href)
|
openBrowser(href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +78,40 @@ app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
|
||||||
// app routes
|
// app routes
|
||||||
//
|
//
|
||||||
|
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
await next()
|
||||||
|
|
||||||
|
const url = new URL(c.req.url)
|
||||||
|
const domains = url.hostname.split(".")
|
||||||
|
const isSubdomain = domains.length > (url.hostname.endsWith("localhost") ? 1 : 2)
|
||||||
|
|
||||||
|
if (!isSubdomain) return
|
||||||
|
|
||||||
|
const contentType = c.res.headers.get('content-type')
|
||||||
|
if (!contentType?.includes('text/html')) return
|
||||||
|
|
||||||
|
const secFetchDest = c.req.header('Sec-Fetch-Dest')
|
||||||
|
const isIframe = secFetchDest === 'iframe'
|
||||||
|
|
||||||
|
if (!isIframe) return
|
||||||
|
|
||||||
|
const originalBody = await c.res.text()
|
||||||
|
const shimScript = `<script src="/browser-nav.js"></script>`
|
||||||
|
|
||||||
|
let modifiedBody = originalBody
|
||||||
|
if (originalBody.includes('</head>')) {
|
||||||
|
modifiedBody = originalBody.replace('</head>', `${shimScript}\n</head>`)
|
||||||
|
} else if (originalBody.includes('<body>')) {
|
||||||
|
modifiedBody = originalBody.replace('<body>', `<body>\n${shimScript}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return modified response
|
||||||
|
c.res = new Response(modifiedBody, {
|
||||||
|
status: c.res.status,
|
||||||
|
headers: c.res.headers
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
const url = new URL(c.req.url)
|
const url = new URL(c.req.url)
|
||||||
const localhost = url.hostname.endsWith("localhost")
|
const localhost = url.hostname.endsWith("localhost")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user