ipx-web: the page in its own container, behind Caddy (#203)
web/build.mjs builds a static site, web/dist, with the icons and typeface beside the pages; Dockerfile.web serves it with Caddy, which passes /api and /media to ipx on the same origin, so cookies, live updates and Cloudflare Access work as before. ipx serves no page any more: build.rs and every include_str! of the web files are gone, and its image is built without node. What ipx did to the page as it served it is kept another way. Caddy asks ipx before serving a page (forward_auth): /api/page-check sends a browser not signed in to /login and a non-admin away from /admin, and refuses them /admin.js; /api/login-check sends someone Access vouches for past the form; a page asked for with ?token= goes to /api/signin, which sets the cookie. The theme and the admin link, once written into the page per person, are set by web/boot.js in <head> from the theme cookie and the last visit, before the first frame, and /api/me brings the version and the account's theme from before. Caddy strips the Cf-Access-* headers from every peer but TUNNEL_PEER, the address cloudflared's requests come from, which is the boundary trusted_proxies drew; ipx then trusts only Caddy's address. The browser suite runs Caddy in front of its daemon, so it tests what production would run. Not deployed: production's cutover is on #203. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
75
web/Caddyfile
Normal file
75
web/Caddyfile
Normal file
@@ -0,0 +1,75 @@
|
||||
# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
|
||||
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
|
||||
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
|
||||
# WEB_BIND where to listen (:8080)
|
||||
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
|
||||
# requests come from. Nobody else's are, so nobody else can claim to be signed in
|
||||
# by Cloudflare. Unset passes none.
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
http://{$WEB_BIND::8080} {
|
||||
root * {$WEB_ROOT:/srv}
|
||||
|
||||
# Before anything else, so no page or API call sees them: only ipx sets these.
|
||||
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
||||
request_header @forged -Cf-Access-Authenticated-User-Email
|
||||
request_header @forged -Cf-Access-Jwt-Assertion
|
||||
|
||||
@api path /api/* /media/*
|
||||
handle @api {
|
||||
# Live updates are a stream: flushed as they come, never buffered.
|
||||
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
|
||||
# sends the browser on to the page without it.
|
||||
@tokened {
|
||||
path / /admin
|
||||
query token=*
|
||||
}
|
||||
handle @tokened {
|
||||
rewrite * /api/signin?{query}
|
||||
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
|
||||
}
|
||||
|
||||
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
|
||||
# sent to /login, anyone but an admin asking for /admin to the app.
|
||||
@page path / /index.html /admin /admin.html /admin.js
|
||||
handle @page {
|
||||
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
||||
uri /api/page-check
|
||||
}
|
||||
rewrite / /index.html
|
||||
rewrite /admin /admin.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
}
|
||||
|
||||
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
||||
@login path /login /login.html
|
||||
handle @login {
|
||||
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
||||
uri /api/login-check
|
||||
}
|
||||
rewrite * /login.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
}
|
||||
|
||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
||||
# keeps them a year; the icons a day, under names that do not change.
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
||||
header /favicon.ico Content-Type image/png
|
||||
@icons path *.png *.svg *.ico *.woff2
|
||||
header @icons Cache-Control "public, max-age=86400"
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@
|
||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" data-src="app.css">
|
||||
<script src="/boot.js"></script>
|
||||
</head>
|
||||
<body class="adminpage">
|
||||
<!-- The server sends this page, and its script, to admins only. -->
|
||||
<!-- Caddy sends this page, and its script, to admins only: it asks ipx first (/api/page-check). -->
|
||||
<header id="topbar">
|
||||
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
|
||||
|
||||
@@ -888,6 +888,9 @@ body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
||||
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||
body.adminpage #topbar .logo{border-radius:6px}
|
||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
|
||||
script marks an admin's from the last visit before the first frame. */
|
||||
html:not([data-admin]) #topbar a#admin{display:none}
|
||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||
body.adminpage #log{max-width:none}
|
||||
|
||||
6
web/boot.js
Normal file
6
web/boot.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Runs in <head>, before the first frame (#203): this browser's theme, from its cookie, and
|
||||
// whether the last visit here was an admin's, so the link to Admin is there from the start. The
|
||||
// server wrote both into the page while it served it; the page is a static file now.
|
||||
(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie);
|
||||
if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];}
|
||||
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
||||
@@ -1,12 +1,12 @@
|
||||
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
||||
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
||||
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
||||
// include_str!s the results, so the binary still carries everything and nothing is served
|
||||
// from disk.
|
||||
// Builds the frontend (#203): each page's TypeScript from web/src, types stripped and minified
|
||||
// by swc into a script of its own (app.js, login.js), the page minified, its <script data-src>
|
||||
// pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
|
||||
// is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
|
||||
//
|
||||
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||
// URL, and the page, which the browser checks on every visit, is what carries it.
|
||||
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
||||
// server ignores the query.
|
||||
//
|
||||
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||
import crypto from 'node:crypto';
|
||||
@@ -81,7 +81,7 @@ export function buildPage(name, { minify = true } = {}) {
|
||||
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
|
||||
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
|
||||
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||
if (!minify) return { html: out, js, script };
|
||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||
@@ -95,6 +95,16 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
||||
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
|
||||
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
|
||||
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
|
||||
// nothing here shows any more but the classic theme.
|
||||
for (const [from, to] of [
|
||||
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
||||
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||
for (const name of Object.keys(PAGES)) {
|
||||
const { html, js, script } = buildPage(name);
|
||||
fs.writeFileSync(path.join(out, name), html);
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" data-src="app.css">
|
||||
<script src="/boot.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
||||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||||
A phone hides the item group, which it has no table for. -->
|
||||
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
||||
<span id="applogo" title="iPX"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
||||
<div class="tgroup">
|
||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||
|
||||
@@ -719,6 +719,12 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
|
||||
api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
$('#applogo').title=`iPX ${u.version}`;
|
||||
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||||
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||||
try{ localStorage.setItem('ipx.admin',u.admin?'1':'0'); }catch{}
|
||||
// A browser with no theme of its own takes the one the account kept from before, once.
|
||||
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
||||
}).catch(()=>{});
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||
|
||||
@@ -55,9 +55,9 @@ function saveTheme(){
|
||||
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
// Without a cookie, the server sent the theme the account kept from before; this browser takes
|
||||
// it as its own, once, so nobody has to choose again.
|
||||
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
|
||||
// The head script put this browser's theme from its cookie on the page. Without one, /api/me's
|
||||
// answer brings the account's from before (dialogs.ts).
|
||||
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
|
||||
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
|
||||
// becomes its cookie, once.
|
||||
let name: string | null = null, mode: string | null = null;
|
||||
|
||||
Reference in New Issue
Block a user