Focus on content
This commit is contained in:
@@ -6,6 +6,93 @@ public accounts, Dex sign-in, and Stripe membership under **/members/**.
|
||||
Optional SHKeeper support adds prepaid crypto membership alongside Stripe.
|
||||
Both payment methods feed the same LDAP group reconciliation and local expiry.
|
||||
|
||||
## Creative direction
|
||||
|
||||
eom.dev is Eric Meehan's independent home for music, films about travel and
|
||||
public performances, and creative engineering. The homepage leads with the
|
||||
work, not an offer to install or manage other people's self-hosted services.
|
||||
Open-source software and decentralized publishing are its foundation; educational
|
||||
content and source code remain available so others can build their own spaces.
|
||||
|
||||
The homepage's main paths are **Films**, **Music**, **Workshop**, and **Gaming**.
|
||||
Gaming has its own game/server cards and latest uploads, separate from the
|
||||
engineering feed. Writing on Discourse is linked in the community band and footer.
|
||||
Wikideck is listed with the games; Luanti remains the first gaming card.
|
||||
Featured
|
||||
recordings link directly to existing PeerTube publications and credit the
|
||||
performers. The latest uploads are loaded from the public PeerTube API; selected
|
||||
static recordings remain as a clearly labeled fallback when live requests fail.
|
||||
Community services, free registration, existing games, and membership benefits
|
||||
remain available. Support funds the creative work and independent publishing,
|
||||
without promising managed hosting, consulting, or a fixed release schedule.
|
||||
Membership pricing, billing, and access rules are unchanged.
|
||||
|
||||
Edit [htdocs/index.html](htdocs/index.html) for the work and featured recordings,
|
||||
[htdocs/common/header.html](htdocs/common/header.html) for search/social metadata,
|
||||
and [htdocs/common/topnav.html](htdocs/common/topnav.html) for navigation.
|
||||
The responsive Catppuccin styling lives in
|
||||
[htdocs/static/home.css](htdocs/static/home.css); membership introduction copy
|
||||
lives in [membership/templates/members.html](membership/templates/members.html).
|
||||
|
||||
### Automatic publications
|
||||
|
||||
The **My projects** cards use
|
||||
[htdocs/static/gitea-projects.mjs](htdocs/static/gitea-projects.mjs) to read Eric's
|
||||
anonymous public activity feed on Gitea, rather than an owned-repository list or
|
||||
site-wide search. This captures contributions across organizations. It selects
|
||||
the three most recently pushed distinct repositories by activity timestamp,
|
||||
scanning up to 15 pages of 10 recent events and stopping once three projects are
|
||||
found. Small pages keep requests lighter. It is a bounded recent-work view,
|
||||
not an exhaustive project catalog or a claim of repository ownership.
|
||||
|
||||
Only Eric's explicitly public push events and public repositories qualify.
|
||||
Cards show the organization/repository name, description, public push date,
|
||||
and a commit summary/link when available. Titles and summaries are plain text;
|
||||
email addresses and other author metadata are not displayed. Gitea requests are
|
||||
anonymous, HTTPS-only, size-limited to 2 MiB, and time out after 10 seconds.
|
||||
The cards refresh every 15 minutes while visible, retain clearly labeled
|
||||
fallback/previous content on failure, and link to the full public activity page.
|
||||
No Creator Launch project or service offer is involved.
|
||||
|
||||
[htdocs/static/peertube-feeds.mjs](htdocs/static/peertube-feeds.mjs) loads public
|
||||
JSON from https://peertube.eom.dev without cookies or API keys. Channel sections
|
||||
request their three newest videos, sorted by publication date: personal/travel
|
||||
channel `eric_channel`, music `eric_music`, engineering `eric_tech`, and
|
||||
gaming `eric_gaming`, each in its own section. Their names are
|
||||
configured in each homepage section's `data-channels` attribute. Newly created
|
||||
channels appear in the collection browser automatically; placing one in a
|
||||
themed homepage section requires updating that section's names. Channel API
|
||||
routes use names, not the numeric IDs accepted by RSS feed selectors.
|
||||
|
||||
The collection browser discovers Eric's public channels and playlists using
|
||||
the account API. It defaults to **Originals**, preserves the selected collection
|
||||
on refresh, and shows its three newest published videos, not playlist position.
|
||||
Playlist membership is paginated before sorting (up to 1,000 entries); overlapping
|
||||
entries are deduplicated. Empty collections are labeled honestly.
|
||||
|
||||
Data loads on each visit and refreshes every 15 minutes while the tab is visible.
|
||||
"Checked" describes the request time; PeerTube/proxy caching can delay newly
|
||||
published items. Failure retains selected examples or previously checked cards
|
||||
with a notice rather than claiming they are current. JavaScript-disabled visitors
|
||||
can use the permanent channel and playlist links. Requests use 10-second timeouts
|
||||
and a 2 MiB response limit. Titles are rendered as text, and watch/thumbnail URLs
|
||||
are restricted to the PeerTube origin. No descriptions, HTML, or players are embedded.
|
||||
|
||||
### Local preview and content checks
|
||||
|
||||
With Node.js 24 or later, run `node scripts/preview.mjs` from this repository
|
||||
to preview the assembled homepage at http://127.0.0.1:8082/. An optional numeric
|
||||
port argument selects a different port. The preview serves only the public
|
||||
webroot, expands the three allowlisted common SSI includes, and binds only to
|
||||
loopback. It does not start the database or emulate registration, sign-in,
|
||||
membership, or payments; those routes show a preview-only unavailable notice.
|
||||
|
||||
Run `node --test tests/*.test.mjs` for offline checks of metadata, navigation,
|
||||
featured work, preserved community/project links, preview request security, and
|
||||
publication loading, sorting, pagination, and failure behavior.
|
||||
File-symlink tests may be skipped on Windows without symlink privileges;
|
||||
directory-junction containment is checked separately.
|
||||
|
||||
## Membership flow
|
||||
|
||||
1. Anyone can request a free level-0 account. A one-hour, single-use email link allows
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<footer>
|
||||
<div class="footer">
|
||||
<a class="footer-brand" href="/">eom.dev</a>
|
||||
<p>Independent projects, shared openly.</p>
|
||||
<p>Music, films, projects, and games. Independently published.</p>
|
||||
<nav class="footer-links" aria-label="Footer navigation">
|
||||
<a href="/#music">Music</a>
|
||||
<a href="/#films">Films</a>
|
||||
<a href="/#gaming">Gaming</a>
|
||||
<a href="https://discourse.eom.dev/">Writing</a>
|
||||
<a href="https://peertube.eom.dev/videos/browse">Browse PeerTube</a>
|
||||
<a href="https://gitea.eom.dev/explore/repos">Source code</a>
|
||||
<a href="https://peertube.eom.dev/videos/browse">Videos</a>
|
||||
<a href="https://discourse.eom.dev/latest">Community</a>
|
||||
<a href="mailto:eric@eom.dev">Contact</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Practical self-hosting, creative coding, and community experiments from Eric Meehan's home lab in Raleigh, North Carolina. Learn from the work, try the projects, and explore the source.">
|
||||
<meta name="description" content="Music, travel films, musician recordings, and creative engineering by Eric Meehan in Raleigh, North Carolina. Independently published with open-source tools.">
|
||||
<meta name="theme-color" content="#1e1e2e">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="eom.dev">
|
||||
<meta property="og:title" content="eom.dev — Build your own corner of the internet">
|
||||
<meta property="og:description" content="Practical self-hosting, creative coding, and community experiments from Eric Meehan's home lab.">
|
||||
<meta property="og:title" content="Eric Meehan — Music, films & creative engineering">
|
||||
<meta property="og:description" content="Music. Places. Things worth making. Explore Eric Meehan's music, films, and creative engineering, published on an independent corner of the internet.">
|
||||
<meta property="og:url" content="https://www.eom.dev/">
|
||||
<meta property="og:image" content="https://www.eom.dev/static/profile_2026.jpg">
|
||||
<meta property="og:image:alt" content="Eric Meehan, software engineer and musician">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="eom.dev — Build your own corner of the internet">
|
||||
<meta name="twitter:description" content="Practical self-hosting, creative coding, and community experiments from Eric Meehan's home lab.">
|
||||
<meta name="twitter:title" content="Eric Meehan — Music, films & creative engineering">
|
||||
<meta name="twitter:description" content="Music. Places. Things worth making. Explore Eric Meehan's music, films, and creative engineering, published on an independent corner of the internet.">
|
||||
<link rel="canonical" href="https://www.eom.dev/">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" sizes="any">
|
||||
<link rel="alternate icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/static/pure.css">
|
||||
<link rel="stylesheet" href="/static/home.css">
|
||||
<title>eom.dev — Build your own corner of the internet</title>
|
||||
<title>Eric Meehan — Music, films & creative engineering · eom.dev</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
<a class="pure-menu-heading" href="/" aria-label="eom.dev home">eom<span class="brand-dot">.</span>dev</a>
|
||||
<nav aria-label="Main navigation">
|
||||
<ul class="pure-menu-list">
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="#services">Services</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="#projects">Projects</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="https://discourse.eom.dev/latest">Community</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link nav-support" href="/members/">Membership</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="/#films">Films</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="/#music">Music</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="/#projects">Projects</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link" href="/#gaming">Gaming</a></li>
|
||||
<li class="pure-menu-item nav-about"><a class="pure-menu-link" href="/#about">About</a></li>
|
||||
<li class="pure-menu-item"><a class="pure-menu-link nav-support" href="/#support">Support</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
+234
-77
@@ -6,16 +6,16 @@
|
||||
<main id="main-content">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero-inner">
|
||||
<p class="eyebrow">A practical, independent corner of the internet</p>
|
||||
<h1 id="hero-title">Build your own corner of the internet.</h1>
|
||||
<p class="hero-copy">Practical self-hosting, creative coding, and community experiments from Eric Meehan's home lab. Learn from the work, try the projects, and see how they are made.</p>
|
||||
<p class="eyebrow">Eric Meehan · Musician & engineer</p>
|
||||
<h1 id="hero-title">Music. Places.<br>Things worth making.</h1>
|
||||
<p class="hero-copy">I make music and films, build open-source projects, and host games. My camera comes along for the places I visit and the musicians I meet.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="#projects">Explore the projects</a>
|
||||
<a class="button button-secondary" href="https://discourse.eom.dev/latest">Join the conversation</a>
|
||||
<a class="button button-secondary" href="/members/register/">Create an eom.dev account</a>
|
||||
<a class="quiet-link" href="#services">Browse community services</a>
|
||||
<a class="button button-primary" href="#films">Watch the films</a>
|
||||
<a class="button button-secondary" href="#music">Explore the music</a>
|
||||
<a class="quiet-link" href="#projects">Explore my projects</a>
|
||||
<a class="quiet-link" href="#gaming">Find a game to play</a>
|
||||
</div>
|
||||
<p class="hero-note">Independent software, open-source projects, music, and games—made in Raleigh, North Carolina.</p>
|
||||
<p class="hero-note">Made in Raleigh and wherever curiosity takes me. Published on my own open-source-powered corner of the internet.</p>
|
||||
</div>
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<span class="orbit orbit-one"></span>
|
||||
@@ -25,75 +25,151 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section services-section" id="services" aria-labelledby="services-title">
|
||||
<section class="section intro-section" id="work" aria-labelledby="work-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">One account, a community of services</p>
|
||||
<h2 id="services-title">Find your place on eom.dev</h2>
|
||||
<p>A free account includes forums, chat and Git hosting. Gold (Level 3) and above add Mastodon and PeerTube account access. Public posts and videos can still be browsed without a paid membership.</p>
|
||||
<p class="eyebrow">One creative life, a few ways in</p>
|
||||
<h2 id="work-title">Explore the work.</h2>
|
||||
<p>Films, music, open-source projects, and games: four ways into what I make and share.</p>
|
||||
</div>
|
||||
<div class="project-grid">
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://discourse.eom.dev/uploads/default/optimized/1X/_129430568242d1b7f853bb13ebea28b3f6af4e7_2_32x32.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Discourse · Forums</h3>
|
||||
<p>Ask questions, share projects and join longer conversations. Bronze and above also get exclusive member content.</p>
|
||||
<a class="text-link" href="https://discourse.eom.dev/">Visit Discourse <span aria-hidden="true">→</span></a>
|
||||
<div class="path-grid">
|
||||
<article class="path-card path-play">
|
||||
<p class="card-kicker">Out in the world</p>
|
||||
<h3>Films & places</h3>
|
||||
<p>Travel, unexpected detours, and musicians performing out in public. A reason to get outside and pay attention.</p>
|
||||
<a class="text-link" href="#films">Come along <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://element.eom.dev/vector-icons/144.5a63bf2.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Matrix · Chat</h3>
|
||||
<p>Keep in touch in community rooms through Element. Bronze and above also get exclusive member content.</p>
|
||||
<a class="text-link" href="https://element.eom.dev/">Open Matrix chat <span aria-hidden="true">→</span></a>
|
||||
<article class="path-card path-create">
|
||||
<p class="card-kicker">Sound & expression</p>
|
||||
<h3>Music</h3>
|
||||
<p>My own recordings, covers, and performances by musicians I encounter. Room to listen, experiment, and find the next song.</p>
|
||||
<a class="text-link" href="#music">Start listening <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://gitea.eom.dev/assets/img/favicon.svg" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Gitea · Git hosting</h3>
|
||||
<p>Host code, collaborate on repositories and explore the projects behind the lab.</p>
|
||||
<a class="text-link" href="https://gitea.eom.dev/">Explore Gitea <span aria-hidden="true">→</span></a>
|
||||
<article class="path-card path-build">
|
||||
<p class="card-kicker">Curiosity at work</p>
|
||||
<h3>Creative engineering</h3>
|
||||
<p>Open-source tools and experiments built out of curiosity. Explore the work and how it's made.</p>
|
||||
<a class="text-link" href="#projects">Explore the projects <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Gold · Levels 3–5</p>
|
||||
<h3>Mastodon · Social networking</h3>
|
||||
<p>Participate with an eom.dev Mastodon account and connect with the wider fediverse.</p>
|
||||
<a class="text-link" href="https://mastodon.eom.dev/">Visit Mastodon <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://peertube.eom.dev/client/assets/images/favicon.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Gold · Levels 3–5</p>
|
||||
<h3>PeerTube · Video sharing</h3>
|
||||
<p>Use an eom.dev PeerTube account to take part in the video community. Public recordings remain available to watch.</p>
|
||||
<a class="text-link" href="https://peertube.eom.dev/">Visit PeerTube <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://owncast.eom.dev/favicon.ico" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Public live channel</p>
|
||||
<h3>Owncast · Live streams</h3>
|
||||
<p>Watch live performances, experiments and work in progress when the channel is on air.</p>
|
||||
<a class="text-link" href="https://owncast.eom.dev/">Watch Owncast <span aria-hidden="true">→</span></a>
|
||||
<article class="path-card path-game">
|
||||
<p class="card-kicker">Shared worlds & friendly competition</p>
|
||||
<h3>Gaming</h3>
|
||||
<p>Game servers, community worlds, and experiments you can play together. Drop in, explore, or follow the gaming streams.</p>
|
||||
<a class="text-link" href="#gaming">Find your next game <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
</div>
|
||||
<p><a class="text-link" href="/members/">Compare membership benefits and included services <span aria-hidden="true">→</span></a></p>
|
||||
</section>
|
||||
|
||||
<section class="section films-section" id="films" aria-labelledby="films-title" data-peertube-feed data-channels="eric_channel" data-title="Films & places">
|
||||
<div class="section-heading section-heading-row">
|
||||
<div>
|
||||
<p class="eyebrow">A camera, an open ear, somewhere to go</p>
|
||||
<h2 id="films-title">Films & places.</h2>
|
||||
<p>Travel films and everyday discoveries, from planned trips to unexpected detours. Recordings of the musicians I meet are in the <a href="#music">music section</a>.</p>
|
||||
</div>
|
||||
<a class="text-link" href="https://peertube.eom.dev/video-channels/eric_channel">More travel & everyday discoveries <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<p class="feed-status" data-feed-status role="status" aria-live="polite">Selected recordings below. Latest uploads load automatically when available.</p>
|
||||
<div class="film-grid latest-grid" data-feed-items>
|
||||
<article class="project-card media-card">
|
||||
<a class="media-image" href="https://peertube.eom.dev/videos/watch/b16e17cc-2545-40ad-b9fa-b9200400aff5" aria-label="Watch I Got Heat Stroke in the Great Smokey Mountains">
|
||||
<img src="https://peertube.eom.dev/lazy-static/thumbnails/c75393d3-9acd-4a9d-bbd0-7f370662c208.png" width="560" height="315" loading="lazy" decoding="async" alt="">
|
||||
<span class="media-label" aria-hidden="true">Watch film ↗</span>
|
||||
</a>
|
||||
<div class="media-copy">
|
||||
<p class="card-kicker">Travel film · North Carolina</p>
|
||||
<h3>I Got Heat Stroke in the Great Smokey Mountains</h3>
|
||||
<p>A backpacking trip takes an unexpected turn: kayaking, Asheville, and the buskers I met along the way.</p>
|
||||
<a class="text-link" href="https://peertube.eom.dev/videos/watch/b16e17cc-2545-40ad-b9fa-b9200400aff5">Watch the trip <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card media-card">
|
||||
<a class="media-image" href="https://peertube.eom.dev/videos/watch/678e7a68-9beb-48b2-b4cc-8cfede773a81" aria-label="Watch La Banda Maguey at La Fiesta del Pueblo">
|
||||
<img src="https://peertube.eom.dev/lazy-static/thumbnails/bae0a54a-27b7-4604-ba10-556c21d32474.png" width="560" height="315" loading="lazy" decoding="async" alt="">
|
||||
<span class="media-label" aria-hidden="true">Watch performance ↗</span>
|
||||
</a>
|
||||
<div class="media-copy">
|
||||
<p class="card-kicker">Performance recording · Raleigh</p>
|
||||
<h3>La Banda Maguey at La Fiesta del Pueblo</h3>
|
||||
<p>La Banda Maguey performing in downtown Raleigh. A little of the festival, recorded and shared.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="https://peertube.eom.dev/videos/watch/678e7a68-9beb-48b2-b4cc-8cfede773a81">Watch the performance <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://www.bandamaguey.com/">Meet the musicians</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section music-section" id="music" aria-labelledby="music-title" data-peertube-feed data-channels="eric_music" data-title="Music">
|
||||
<div class="music-copy">
|
||||
<p class="eyebrow">From the guitar to the recording</p>
|
||||
<h2 id="music-title">Making room for music.</h2>
|
||||
<p>Original recordings, covers, and performances by musicians I meet along the way.</p>
|
||||
<p>Listen to the latest uploads here, or explore more in the <a class="text-link" href="#peertube-library">channels and collections</a>.</p>
|
||||
<div class="about-links">
|
||||
<a class="button button-primary" href="https://peertube.eom.dev/video-playlists/bde0cf4d-318f-4865-b205-b11104a7c46b">Explore my originals</a>
|
||||
<a class="text-link" href="https://peertube.eom.dev/video-channels/eric_music">Browse the music channel <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="feed-status" data-feed-status role="status" aria-live="polite">Selected recording below. Latest uploads load automatically when available.</p>
|
||||
<div class="film-grid latest-grid" data-feed-items>
|
||||
<a class="music-feature media-image" href="https://peertube.eom.dev/videos/watch/f9613754-234e-4053-b194-341154f007c5" aria-label="Watch and listen to Tidal Waltz">
|
||||
<img src="https://peertube.eom.dev/lazy-static/thumbnails/32b4d3ba-a871-4f5c-942d-4001948c1295.jpg" width="560" height="315" loading="lazy" decoding="async" alt="">
|
||||
<span class="media-label" aria-hidden="true">Tidal Waltz · Music & film ↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section featured-section" id="projects" aria-labelledby="projects-title">
|
||||
<div class="section-heading section-heading-row">
|
||||
<div>
|
||||
<p class="eyebrow">Made here, shared openly</p>
|
||||
<h2 id="projects-title">A few things to explore</h2>
|
||||
<p class="eyebrow">The workshop · Made here, shared openly</p>
|
||||
<h2 id="projects-title">My projects.</h2>
|
||||
<p>Tools, experiments, and open-source work I build and contribute to across organizations. Explore my recent public activity below.</p>
|
||||
</div>
|
||||
<a class="text-link" href="https://gitea.eom.dev/explore/repos">Browse all repositories <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://gitea.eom.dev/eric?tab=activity">Follow my public activity <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<div class="project-grid">
|
||||
<div id="gitea-projects">
|
||||
<p class="feed-status" data-project-status role="status" aria-live="polite">Selected project below. Recent public pushes load automatically when available.</p>
|
||||
<div class="project-grid latest-grid" data-project-items>
|
||||
<article class="project-card">
|
||||
<div class="project-mark mark-blue" aria-hidden="true">▧</div>
|
||||
<p class="card-kicker">Experimental card game</p>
|
||||
<h3>Wikideck</h3>
|
||||
<p>Mine, collect and trade illustrated Wikipedia cards. This is an experimental, unaudited project; its public network has not launched.</p>
|
||||
<div class="project-mark mark-blue" aria-hidden="true"></></div>
|
||||
<p class="card-kicker">Open-source tools & infrastructure</p>
|
||||
<h3>The code behind this corner of the internet</h3>
|
||||
<p>Explore the repositories behind my projects and independent publishing setup. Take a look at how things work, learn from the code, or adapt it for your own space.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="https://gitea.eom.dev/WikiDeck/wikideck">Explore Wikideck <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://gitea.eom.dev/explore/repos">Browse the source <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workshop-recordings" data-peertube-feed data-channels="eric_tech" data-title="Engineering">
|
||||
<h3>Latest engineering videos</h3>
|
||||
<p>Build notes, technical explorations, and the process behind the projects.</p>
|
||||
<p class="feed-status" data-feed-status role="status" aria-live="polite">Latest uploads load automatically when available. Browse the engineering channel directly if it is unavailable.</p>
|
||||
<div class="film-grid latest-grid" data-feed-items>
|
||||
<p><a class="text-link" href="https://peertube.eom.dev/video-channels/eric_tech">Browse the engineering channel</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section featured-section gaming-section" id="gaming" aria-labelledby="gaming-title">
|
||||
<div class="section-heading section-heading-row">
|
||||
<div>
|
||||
<p class="eyebrow">Game servers & shared worlds</p>
|
||||
<h2 id="gaming-title">Come play. Make yourself at home.</h2>
|
||||
<p>Luanti is the main community world I host. The Button is ready to play, and Wikideck is an experiment still in development. Explore below or catch up with the gaming streams.</p>
|
||||
</div>
|
||||
<a class="text-link" href="https://peertube.eom.dev/video-channels/eric_gaming">Browse the gaming channel <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<div class="project-grid gaming-grid">
|
||||
<article class="project-card">
|
||||
<div class="project-mark mark-green" aria-hidden="true">▦</div>
|
||||
<p class="card-kicker">Season V · Public server</p>
|
||||
<h3>Luanti</h3>
|
||||
<p>Explore a low-gravity world with eleven team islands, markets and an adventure-map spawn. Connect with a Luanti client at <code>luanti.eom.dev:30000</code>.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="https://discourse.eom.dev/t/luanti-season-v/690">Season V details <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
@@ -107,26 +183,51 @@
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
<div class="project-mark mark-green" aria-hidden="true">▦</div>
|
||||
<p class="card-kicker">Season V · Public server</p>
|
||||
<h3>Luanti</h3>
|
||||
<p>Explore a low-gravity world with eleven team islands, markets and an adventure-map spawn. Connect with a Luanti client at <code>luanti.eom.dev:30000</code>.</p>
|
||||
<div class="project-mark mark-blue" aria-hidden="true">▧</div>
|
||||
<p class="card-kicker">Experimental card game</p>
|
||||
<h3>Wikideck</h3>
|
||||
<p>Mine, collect and trade illustrated Wikipedia cards. This is an experimental, unaudited project; its public network has not launched.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="https://discourse.eom.dev/t/luanti-season-v/690">Season V details <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://gitea.eom.dev/WikiDeck/wikideck">Explore Wikideck <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="workshop-recordings" data-peertube-feed data-channels="eric_gaming" data-title="Gaming">
|
||||
<h3>Latest gaming videos & streams</h3>
|
||||
<p>Catch up on the games and worlds we spend time in together.</p>
|
||||
<p class="feed-status" data-feed-status role="status" aria-live="polite">Latest uploads load automatically when available. Browse the gaming channel directly if it is unavailable.</p>
|
||||
<div class="film-grid latest-grid" data-feed-items>
|
||||
<p><a class="text-link" href="https://peertube.eom.dev/video-channels/eric_gaming">Browse gaming videos and streams</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section films-section" id="peertube-library" aria-labelledby="library-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Channels & collections · Always something to explore</p>
|
||||
<h2 id="library-title">Find a thread. Follow it.</h2>
|
||||
<p>Original music, live performances, trips, and experiments, organized on PeerTube. New public channels and playlists appear here automatically; each collection shows its three most recently published videos.</p>
|
||||
</div>
|
||||
<div class="collection-controls">
|
||||
<label for="peertube-collection">Choose a channel or playlist</label>
|
||||
<select id="peertube-collection" disabled><option>Loading channels and playlists…</option></select>
|
||||
<a class="text-link" data-library-link href="https://peertube.eom.dev/accounts/eric/videos">Browse all publications <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<p class="feed-status" data-library-status role="status" aria-live="polite">The collection browser loads automatically when available. You can always browse directly on PeerTube.</p>
|
||||
<div class="film-grid latest-grid" data-library-items></div>
|
||||
<noscript><p>Enable JavaScript to see the latest uploads here, or follow the channel and playlist links to browse on PeerTube.</p></noscript>
|
||||
</section>
|
||||
|
||||
<section class="community-band" aria-labelledby="community-title">
|
||||
<div>
|
||||
<p class="eyebrow">Built in public</p>
|
||||
<p class="eyebrow">Stay in the loop</p>
|
||||
<h2 id="community-title">Follow the work. Add your voice.</h2>
|
||||
<p>Project notes, questions, and community plans live on the forum. Watch live when a stream is on; catch up with the recordings any time.</p>
|
||||
<p>Follow new recordings and works in progress on Mastodon, talk on the forum, or catch a live stream.</p>
|
||||
<p>I share project notes, things I've learned, and ideas on Discourse. <a class="text-link" href="https://discourse.eom.dev/">Writing on Discourse <span aria-hidden="true">→</span></a></p>
|
||||
</div>
|
||||
<div class="community-actions">
|
||||
<a class="button button-primary" href="https://discourse.eom.dev/latest">Visit the forum</a>
|
||||
<a class="button button-secondary" href="https://peertube.eom.dev/videos/browse">Watch recent videos</a>
|
||||
<a class="button button-secondary" href="https://mastodon.eom.dev/@eric/">Follow on Mastodon</a>
|
||||
<a class="quiet-link" href="https://owncast.eom.dev/">Check the live channel</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -136,9 +237,10 @@
|
||||
<img src="/static/profile_2026.jpg" width="300" height="300" loading="lazy" alt="Eric Meehan">
|
||||
</div>
|
||||
<div class="about-copy">
|
||||
<p class="eyebrow">The person behind the projects</p>
|
||||
<p class="eyebrow">The person behind the work</p>
|
||||
<h2 id="about-title">Hi, I'm Eric.</h2>
|
||||
<p>I'm a software engineer and musician in Raleigh, North Carolina. I use this independently run network to build and share engineering and creative projects—and to document what works, what doesn't, and how you can try it yourself.</p>
|
||||
<p>I'm a musician in Raleigh, North Carolina, and I work full-time as a software engineer. This is where I follow my curiosity through music, films, and projects of my own.</p>
|
||||
<p>I use open-source software to own the means of production and publish on my own terms. I believe in a decentralized internet, and I share code and educational content to help others build independent spaces too.</p>
|
||||
<div class="about-links">
|
||||
<a class="text-link" href="https://mastodon.eom.dev/@eric/">Follow on Mastodon <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="mailto:eric@eom.dev">Get in touch</a>
|
||||
@@ -147,25 +249,80 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section services-section" id="services" aria-labelledby="services-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">The open-source home behind the work</p>
|
||||
<h2 id="services-title">An independent space to connect.</h2>
|
||||
<p>These services are where I publish and the community connects. Browse public videos and posts without a paid membership. Free accounts include forums, chat, and Git hosting; Gold (Level 3) and above add Mastodon and PeerTube account access.</p>
|
||||
</div>
|
||||
<div class="project-grid">
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://discourse.eom.dev/uploads/default/optimized/1X/_129430568242d1b7f853bb13ebea28b3f6af4e7_2_32x32.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Discourse · Forums</h3>
|
||||
<p>Talk about the music, films, and projects. Bronze and above also get exclusive member content.</p>
|
||||
<a class="text-link" href="https://discourse.eom.dev/">Visit the forum <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://element.eom.dev/vector-icons/144.5a63bf2.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Matrix · Chat</h3>
|
||||
<p>Keep in touch in community rooms through Element. Bronze and above also get exclusive member content.</p>
|
||||
<a class="text-link" href="https://element.eom.dev/">Open Matrix chat <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://gitea.eom.dev/assets/img/favicon.svg" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Free · Level 0 and above</p>
|
||||
<h3>Gitea · Source code</h3>
|
||||
<p>Explore the code behind the projects, learn from it, or collaborate on something of your own.</p>
|
||||
<a class="text-link" href="https://gitea.eom.dev/">Explore Gitea <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Gold · Levels 3–5</p>
|
||||
<h3>Mastodon · Updates</h3>
|
||||
<p>Follow public updates, or participate with an eom.dev account and connect with the wider fediverse.</p>
|
||||
<a class="text-link" href="https://mastodon.eom.dev/">Visit Mastodon <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://peertube.eom.dev/client/assets/images/favicon.png" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Gold · Levels 3–5</p>
|
||||
<h3>PeerTube · Films & music</h3>
|
||||
<p>Watch public recordings freely. Gold and above can also take part with an eom.dev PeerTube account.</p>
|
||||
<a class="text-link" href="https://peertube.eom.dev/">Visit PeerTube <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
<article class="project-card service-card">
|
||||
<div class="project-mark service-mark"><img src="https://owncast.eom.dev/favicon.ico" width="32" height="32" loading="lazy" decoding="async" alt=""></div>
|
||||
<p class="card-kicker">Public live channel</p>
|
||||
<h3>Owncast · Live streams</h3>
|
||||
<p>Drop in for performances, experiments, and work in progress when the channel is on air.</p>
|
||||
<a class="text-link" href="https://owncast.eom.dev/">Check the live channel <span aria-hidden="true">→</span></a>
|
||||
</article>
|
||||
</div>
|
||||
<p><a class="text-link" href="/members/register/">Create a free community account <span aria-hidden="true">→</span></a> · <a class="text-link" href="/members/">Compare membership benefits</a></p>
|
||||
</section>
|
||||
|
||||
<section class="support-section" id="support" aria-labelledby="support-title">
|
||||
<div class="support-copy">
|
||||
<p class="eyebrow">Keep the lights on</p>
|
||||
<h2 id="support-title">Support the independent work.</h2>
|
||||
<p>Contributions help cover the servers, storage, and time behind these projects. If you need hands-on help, you can also book a consultation.</p>
|
||||
<p class="eyebrow">More room to make</p>
|
||||
<h2 id="support-title">Help the next song, film, or experiment happen.</h2>
|
||||
<p>Memberships and one-time contributions fund the time, recording, travel, and independent publishing behind my creative work.</p>
|
||||
<p>Your support gives me room to keep making and sharing. Releases follow the work, with no fixed schedule or promise of a particular project.</p>
|
||||
</div>
|
||||
<div class="support-actions">
|
||||
<a class="button button-primary" href="/members/">Explore memberships</a>
|
||||
<a class="button button-secondary" href="https://nextcloud.eom.dev/apps/appointments/pub/McQqQYl49cD5ZzZ9/form">Book a free introductory call</a>
|
||||
<a class="quiet-link" href="https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00">Make a one-time contribution</a>
|
||||
<a class="button button-secondary" href="https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00">Make a one-time contribution</a>
|
||||
<details class="crypto-support">
|
||||
<summary>Other ways to support</summary>
|
||||
<p>Monero contributions can be sent to this public address:</p>
|
||||
<code class="wallet-address">48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw</code>
|
||||
<p>For P2Pool, <a href="/xmrig.json">view the XMRig configuration</a> and replace the example wallet with your own.</p>
|
||||
<p>The <a href="/xmrig.json">XMRig configuration</a> is an example for running your own miner on P2Pool, not a contribution link. Replace the example wallet with your own.</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
||||
<script type="module" src="/static/gitea-projects.mjs"></script>
|
||||
<!--#include virtual="/common/footer.html" -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
/** Recent public pushes performed by eric, across owners. Not a repository/authorship census.
|
||||
* Standalone public-data client: no credentials, storage, avatars, HTML sinks or raw feed logging.
|
||||
*/
|
||||
export const ORIGIN = 'https://gitea.eom.dev';
|
||||
export const PAGE_SIZE = 10;
|
||||
export const MAX_PAGES = 15;
|
||||
export const MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_CONTENT_BYTES = 64 * 1024;
|
||||
export const REFRESH_MS = 15 * 60 * 1000;
|
||||
const ENDPOINT = `${ORIGIN}/api/v1/users/eric/activities/feeds`;
|
||||
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const isID = value => Number.isSafeInteger(value) && value > 0;
|
||||
const safeSegment = value => typeof value === 'string' && value.length <= 100 &&
|
||||
/^[A-Za-z0-9_.-]+$/.test(value) && !/[\r\n]/.test(value) && value !== '.' && value !== '..';
|
||||
const text = (value, max) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
||||
|
||||
/** Check the raw string BEFORE URL parsing can erase traversal, whitespace or port 443. */
|
||||
export function validateRepositoryURL(value, fullName) {
|
||||
if (typeof fullName !== 'string' || fullName.length > 201) return null;
|
||||
const parts = fullName.split('/');
|
||||
if (parts.length !== 2 || !parts.every(safeSegment)) return null;
|
||||
if (typeof value !== 'string' || /[\s\\%?#\x00-\x1f\x7f]/.test(value) ||
|
||||
value !== `${ORIGIN}/${fullName}`) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && url.origin === ORIGIN && !url.username &&
|
||||
!url.password && !url.port && !url.hash && !url.search ? url.href : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Gitea activity.created is RFC3339. Reject impossible calendars and future timestamps. */
|
||||
export function normalizeDate(value, { now = Date.now() } = {}) {
|
||||
if (typeof value !== 'string' || value.length > 40 || !Number.isFinite(now)) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
||||
if (!match || /[\r\n]/.test(value)) return null;
|
||||
const [, year, month, day, hour, minute, second, zone] = match;
|
||||
if (+hour > 23 || +minute > 59 || +second > 59 ||
|
||||
(zone !== 'Z' && (+zone.slice(1, 3) > 23 || +zone.slice(4) > 59))) return null;
|
||||
const check = new Date(0);
|
||||
check.setUTCFullYear(+year, +month - 1, +day);
|
||||
if (check.getUTCFullYear() !== +year || check.getUTCMonth() !== +month - 1 || check.getUTCDate() !== +day) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp <= now + 60_000 ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
function commitMetadata(content, url, description) {
|
||||
let head;
|
||||
// Byte-bound embedded JSON as well as the outer response. Never copy authors/emails/CompareURL.
|
||||
if (typeof content === 'string' && content.length <= MAX_CONTENT_BYTES &&
|
||||
new TextEncoder().encode(content).byteLength <= MAX_CONTENT_BYTES) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (isRecord(parsed) && isRecord(parsed.HeadCommit)) head = parsed.HeadCommit;
|
||||
} catch { /* Malformed embedded JSON falls back to the public repository description. */ }
|
||||
}
|
||||
const summary = typeof head?.Message === 'string'
|
||||
? text(head.Message.split(/[\r\n]/, 1)[0], 200) : '';
|
||||
// Sha1 is an opaque Git identifier here, not a cryptographic security primitive.
|
||||
const hash = typeof head?.Sha1 === 'string' && /^(?:[\da-fA-F]{40}|[\da-fA-F]{64})$/.test(head.Sha1) &&
|
||||
!/[\r\n]/.test(head.Sha1) ? head.Sha1.toLowerCase() : null;
|
||||
return { summary: summary || description.slice(0, 200), commitURL: hash ? `${url}/commit/${hash}` : null };
|
||||
}
|
||||
|
||||
/** Strict public/actor gates; sort by push time, deduplicate repo.id, keep the newest three. */
|
||||
export function normalizeActivities(events, { now = Date.now() } = {}) {
|
||||
if (!Array.isArray(events) || events.length > PAGE_SIZE * MAX_PAGES) throw new Error('Invalid activity list');
|
||||
const projects = [];
|
||||
for (const event of events) {
|
||||
if (!isRecord(event) || !isID(event.id) || event.op_type !== 'commit_repo' ||
|
||||
event.is_private !== false || !isRecord(event.act_user) || event.act_user.login !== 'eric' ||
|
||||
!isRecord(event.repo) || event.repo.private !== false || !isID(event.repo.id)) continue;
|
||||
const repo = event.repo;
|
||||
const url = validateRepositoryURL(repo.html_url, repo.full_name);
|
||||
const created = normalizeDate(event.created, { now });
|
||||
if (!url || !created || repo.name !== repo.full_name.split('/')[1]) continue;
|
||||
const description = text(repo.description, 240);
|
||||
projects.push({ id: repo.id, fullName: repo.full_name, url, created, description,
|
||||
...commitMetadata(event.content, url, description), eventID: event.id });
|
||||
}
|
||||
projects.sort((a, b) => Date.parse(b.created) - Date.parse(a.created) || b.eventID - a.eventID ||
|
||||
compareText(a.fullName, b.fullName) || compareText(a.summary, b.summary));
|
||||
const seen = new Set();
|
||||
return projects.filter(project => !seen.has(project.id) && seen.add(project.id)).slice(0, 3)
|
||||
.map(({ eventID, ...project }) => project);
|
||||
}
|
||||
|
||||
const abortError = () => new DOMException('Public activity request aborted', 'AbortError');
|
||||
|
||||
/** Fixed endpoint only. Timer covers headers AND streamed body, even with non-cooperative mocks. */
|
||||
export async function fetchActivityPage(page, {
|
||||
fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES
|
||||
} = {}) {
|
||||
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGES) throw new Error('Invalid activity page');
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 ||
|
||||
!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
|
||||
const url = `${ENDPOINT}?only-performed-by=true&limit=${PAGE_SIZE}&page=${page}`;
|
||||
const controller = new AbortController();
|
||||
let reader;
|
||||
let rejectAbort;
|
||||
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
||||
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
const timer = setTimeout(cancel, timeoutMs);
|
||||
const work = async () => {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const response = await fetchImpl(url, { method: 'GET', signal: controller.signal,
|
||||
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', cache: 'no-store',
|
||||
headers: { Accept: 'application/json' } });
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (!response.ok || response.redirected || (response.url && response.url !== url)) throw new Error('Public activity request failed');
|
||||
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
|
||||
throw new Error('Unexpected content type');
|
||||
}
|
||||
const length = response.headers.get('content-length');
|
||||
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
|
||||
const totalHeader = response.headers.get('x-total-count');
|
||||
const total = totalHeader === null ? null : Number(totalHeader);
|
||||
if (totalHeader !== null && (!/^\d+$/.test(totalHeader) || !Number.isSafeInteger(total) || total < 0)) {
|
||||
throw new Error('Invalid activity total');
|
||||
}
|
||||
if (!response.body?.getReader) throw new Error('Streaming response required');
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let body = '';
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) throw new Error('Response too large');
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
const events = JSON.parse(body + decoder.decode());
|
||||
if (!Array.isArray(events) || events.length > PAGE_SIZE) throw new Error('Invalid activity page body');
|
||||
return { events, total };
|
||||
};
|
||||
try { return await Promise.race([work(), aborted]); }
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
controller.abort();
|
||||
if (reader) void Promise.resolve(reader.cancel()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded window, not all history. Any required page failure rejects the entire refresh. */
|
||||
export async function fetchRecentProjects(dependencies = {}) {
|
||||
const now = typeof dependencies.now === 'function' ? dependencies.now() : Date.now();
|
||||
const events = [];
|
||||
let total = null;
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const result = await fetchActivityPage(page, dependencies);
|
||||
if (page === 1) total = result.total; // Optional feed event count; never a project count.
|
||||
events.push(...result.events);
|
||||
const items = normalizeActivities(events, { now });
|
||||
if (items.length >= 3 || result.events.length < PAGE_SIZE || page === MAX_PAGES) {
|
||||
return { items, pages: page, scannedEvents: events.length, total };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function element(document, tag, className, content) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (content !== undefined) node.textContent = content;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Safe DOM only. Validate normalized inputs again at the public rendering boundary. */
|
||||
export function createCard(document, project) {
|
||||
const url = validateRepositoryURL(project?.url, project?.fullName);
|
||||
const created = normalizeDate(project?.created);
|
||||
if (!url || !created) throw new Error('Invalid project card');
|
||||
const card = element(document, 'article', 'project-card');
|
||||
card.append(element(document, 'h3', '', project.fullName));
|
||||
const description = text(project.description, 240);
|
||||
const summary = text(project.summary, 200);
|
||||
if (description) card.append(element(document, 'p', 'project-description', description));
|
||||
if (summary) card.append(element(document, 'p', 'project-summary', summary));
|
||||
const time = element(document, 'time', 'project-meta', `Public push · ${new Date(created).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC'
|
||||
})}`);
|
||||
time.dateTime = created;
|
||||
card.append(time);
|
||||
const links = element(document, 'div', 'project-links');
|
||||
const explore = element(document, 'a', 'text-link', 'Explore repository →');
|
||||
explore.href = url;
|
||||
explore.rel = 'noopener noreferrer';
|
||||
explore.referrerPolicy = 'no-referrer';
|
||||
links.append(explore);
|
||||
const commitURL = project.commitURL;
|
||||
const hash = typeof commitURL === 'string' && commitURL.startsWith(`${url}/commit/`)
|
||||
? commitURL.slice(`${url}/commit/`.length) : '';
|
||||
if (/^(?:[\da-f]{40}|[\da-f]{64})$/.test(hash) && !/[\r\n]/.test(hash)) {
|
||||
const commit = element(document, 'a', 'text-link', 'View this commit');
|
||||
commit.href = `${url}/commit/${hash}`;
|
||||
commit.rel = 'noopener noreferrer';
|
||||
commit.referrerPolicy = 'no-referrer';
|
||||
links.append(commit);
|
||||
}
|
||||
card.append(links);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** DOM contract: #gitea-projects > [data-project-items], [data-project-status].
|
||||
* The grid/fallback and permanent activity-heading link belong to the host markup.
|
||||
*/
|
||||
export function initGiteaProjects(document = globalThis.document, {
|
||||
fetchImpl = globalThis.fetch, now = Date.now,
|
||||
setIntervalImpl = globalThis.setInterval, clearIntervalImpl = globalThis.clearInterval,
|
||||
...requestBounds
|
||||
} = {}) {
|
||||
const root = document?.querySelector('#gitea-projects');
|
||||
const items = root?.querySelector('[data-project-items]');
|
||||
const status = root?.querySelector('[data-project-status]');
|
||||
if (!items || !status) return { ready: Promise.resolve(), refresh: async () => {}, stop() {} };
|
||||
status.setAttribute('role', 'status');
|
||||
let stopped = false;
|
||||
let generation = 0;
|
||||
let pending;
|
||||
let interval;
|
||||
let checkedAt;
|
||||
let needsRefresh = true;
|
||||
const previous = () => checkedAt === undefined ? 'showing selected projects.' : 'showing previously checked results.';
|
||||
const invalidate = () => { generation++; pending?.abort(); pending = undefined; };
|
||||
const refresh = async () => {
|
||||
if (stopped || document.hidden) return;
|
||||
invalidate();
|
||||
const current = generation;
|
||||
const controller = new AbortController();
|
||||
pending = controller;
|
||||
needsRefresh = true;
|
||||
status.textContent = `Checking recent public pushes; ${previous()}`;
|
||||
try {
|
||||
const result = await fetchRecentProjects({ ...requestBounds, fetchImpl, now, signal: controller.signal });
|
||||
if (stopped || document.hidden || current !== generation) return;
|
||||
const cards = result.items.map(project => createCard(document, project));
|
||||
if (!cards.length) cards.push(element(document, 'p', 'project-empty', 'No recent public pushes found.'));
|
||||
items.replaceChildren(...cards);
|
||||
checkedAt = now();
|
||||
needsRefresh = false;
|
||||
status.textContent = `Recent public pushes · Checked ${new Date(checkedAt).toISOString()} · ${result.scannedEvents} recent activity events checked (up to 150; not all history).`;
|
||||
} catch {
|
||||
if (!stopped && !document.hidden && current === generation) {
|
||||
status.textContent = `Recent public pushes unavailable; ${previous()}`;
|
||||
}
|
||||
} finally {
|
||||
if (current === generation) pending = undefined;
|
||||
}
|
||||
};
|
||||
const pauseTimer = () => { if (interval !== undefined) clearIntervalImpl(interval); interval = undefined; };
|
||||
const startTimer = () => {
|
||||
if (!stopped && !document.hidden && interval === undefined) interval = setIntervalImpl(() => {
|
||||
if (!pending) void refresh();
|
||||
}, REFRESH_MS);
|
||||
};
|
||||
const visibility = () => {
|
||||
if (stopped) return;
|
||||
if (document.hidden) {
|
||||
pauseTimer();
|
||||
invalidate();
|
||||
status.textContent = `Recent public pushes paused; ${previous()}`;
|
||||
} else {
|
||||
startTimer();
|
||||
if (needsRefresh || checkedAt === undefined || now() - checkedAt >= REFRESH_MS) void refresh();
|
||||
else status.textContent = `Recent public pushes · Previously checked ${new Date(checkedAt).toISOString()}.`;
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
startTimer();
|
||||
if (document.hidden) status.textContent = 'Recent public pushes paused; showing selected projects.';
|
||||
const ready = refresh();
|
||||
return { ready, refresh, stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
pauseTimer();
|
||||
invalidate();
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
} };
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initGiteaProjects(), { once: true });
|
||||
else initGiteaProjects();
|
||||
}
|
||||
+184
-6
@@ -217,6 +217,7 @@ main {
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
@@ -353,7 +354,9 @@ main {
|
||||
}
|
||||
|
||||
.intro-section,
|
||||
.featured-section {
|
||||
.featured-section,
|
||||
.films-section,
|
||||
.services-section {
|
||||
max-width: 1600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -414,6 +417,23 @@ main {
|
||||
border-top: 3px solid var(--blue);
|
||||
}
|
||||
|
||||
.path-game {
|
||||
border-top: 3px solid var(--red);
|
||||
}
|
||||
|
||||
.path-grid,
|
||||
.gaming-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gaming-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gaming-grid .project-card:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.path-play {
|
||||
border-top: 3px solid var(--peach);
|
||||
}
|
||||
@@ -446,6 +466,156 @@ main {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.film-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--page-deep);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.media-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 2rem);
|
||||
padding: 0.45rem 0.8rem;
|
||||
color: #cdd6f4;
|
||||
background: #181825;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.media-copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.35rem, 2.5vw, 2rem);
|
||||
}
|
||||
|
||||
.media-copy > .text-link {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.music-section {
|
||||
display: block;
|
||||
background: var(--page-deep);
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.music-copy {
|
||||
max-width: 650px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.music-copy > p:not(.eyebrow) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.music-feature {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
|
||||
.latest-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.feed-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.feed-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.media-card .media-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.media-copy time {
|
||||
margin-block: 0.25rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.collection-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.8rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.collection-controls label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collection-controls select {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.8rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.collection-controls select:focus-visible {
|
||||
outline: 3px solid var(--peach);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.workshop-recordings {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.project-summary {
|
||||
padding-left: 0.8rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.project-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.service-mark img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
@@ -621,6 +791,7 @@ main {
|
||||
}
|
||||
|
||||
.support-copy {
|
||||
min-width: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
@@ -749,13 +920,13 @@ main {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.home-menu .pure-menu-item:nth-child(3) {
|
||||
.home-menu .nav-about {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 4.5rem 1.35rem 3.5rem;
|
||||
}
|
||||
|
||||
@@ -794,7 +965,9 @@ main {
|
||||
}
|
||||
|
||||
.path-grid,
|
||||
.project-grid {
|
||||
.project-grid,
|
||||
.film-grid,
|
||||
.music-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -835,7 +1008,7 @@ main {
|
||||
}
|
||||
|
||||
.support-section {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
padding: 3.5rem 1.25rem;
|
||||
}
|
||||
@@ -855,7 +1028,12 @@ main {
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.home-menu .pure-menu-item:nth-child(2) {
|
||||
.home-menu .pure-menu-link {
|
||||
padding-inline: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.home-menu .pure-menu-item:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+420
@@ -0,0 +1,420 @@
|
||||
/** Public PeerTube publications. No cookies, embeds, HTML sinks or persistent storage. */
|
||||
export const ORIGIN = 'https://peertube.eom.dev';
|
||||
export const ORIGINALS_UUID = 'bde0cf4d-318f-4865-b205-b11104a7c46b';
|
||||
export const MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const REFRESH_MS = 15 * 60 * 1000;
|
||||
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
const uuidPattern = new RegExp(`^${UUID}$`);
|
||||
const watchPattern = new RegExp(`^/(?:w/[A-Za-z0-9]+|videos/watch/${UUID})$`);
|
||||
const CHANNEL_NAME = '[A-Za-z][A-Za-z0-9_.-]{0,99}';
|
||||
const isChannelName = value => typeof value === 'string' && new RegExp(`^${CHANNEL_NAME}$`).test(value) && !/[\r\n]/.test(value);
|
||||
|
||||
/** Reject ambiguous input BEFORE URL normalization can erase traversal or an explicit port. */
|
||||
export function validateOriginURL(value) {
|
||||
if (typeof value !== 'string' || value.length > 2048 || /[\s\\%<>"'\x00-\x1f\x7f]/.test(value)) return null;
|
||||
if (!value.startsWith('/') && !value.startsWith(`${ORIGIN}/`)) return null;
|
||||
if (value.startsWith('//') || /(?:^|\/)\.{1,2}(?:\/|[?#]|$)/.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value, ORIGIN);
|
||||
if (url.origin !== ORIGIN || url.username || url.password || url.port || url.hash) return null;
|
||||
return url;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function validateWatchURL(value) {
|
||||
const url = validateOriginURL(value);
|
||||
return url && !url.search && watchPattern.test(url.pathname) ? url.href : null;
|
||||
}
|
||||
|
||||
export function validateThumbnailURL(value) {
|
||||
const url = validateOriginURL(value);
|
||||
return url && !url.search && /^\/(?:lazy-static|static)\/thumbnails\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jpg|jpeg|png|webp|avif)$/i.test(url.pathname) ? url.href : null;
|
||||
}
|
||||
|
||||
function text(value, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim().slice(0, 500) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeDate(value, { now = Date.now(), maxFutureMs = Infinity } = {}) {
|
||||
if (typeof value !== 'string' || value.length > 100 || !value.trim()) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp) || timestamp > now + maxFutureMs) return null;
|
||||
// Date.parse normalizes impossible ISO/RFC calendar dates (e.g. February 30).
|
||||
const calendar = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
const rfc = /^(?:[A-Za-z]{3},\s*)?(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})\s/i.exec(value);
|
||||
if (!calendar && !rfc) return null;
|
||||
const year = +(calendar ? calendar[1] : rfc[3]);
|
||||
const month = calendar ? +calendar[2] - 1 : ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'].indexOf(rfc[2].toLowerCase());
|
||||
const day = +(calendar ? calendar[3] : rfc[1]);
|
||||
const check = new Date(0);
|
||||
check.setUTCFullYear(year, month, day);
|
||||
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month || check.getUTCDate() !== day) return null;
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
||||
|
||||
/** URL identity deliberately does not guess that a short ID equals a UUID. */
|
||||
export function sortAndDedupe(items, limit = 3, dateOptions) {
|
||||
if (!Array.isArray(items) || items.length > 1000) throw new Error('Too many publications');
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 1000) throw new Error('Invalid publication limit');
|
||||
const normalized = items.flatMap(item => {
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const url = validateWatchURL(item.url);
|
||||
const publishedAt = normalizeDate(item.publishedAt, dateOptions);
|
||||
if (!url || !publishedAt) return [];
|
||||
return [{ url, publishedAt, title: text(item.title, 'Untitled video') || 'Untitled video',
|
||||
source: text(item.source, 'PeerTube'), thumbnail: validateThumbnailURL(item.thumbnail) }];
|
||||
});
|
||||
normalized.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt) || compareText(a.url, b.url) || compareText(a.title, b.title) || compareText(a.source, b.source));
|
||||
const seen = new Set();
|
||||
return normalized.filter(item => !seen.has(item.url) && seen.add(item.url)).slice(0, limit);
|
||||
}
|
||||
|
||||
export function normalizeJSONVideos(entries, source = 'PeerTube', limit = 3, dateOptions) {
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error('Invalid video list');
|
||||
return sortAndDedupe(entries.flatMap(entry => {
|
||||
const video = entry?.video ?? entry;
|
||||
if (!video || typeof video !== 'object' || (video.privacy !== undefined && video.privacy?.id !== 1)) return [];
|
||||
// Validate the supplied URL first: UUID metadata must never rescue an unsafe origin/path.
|
||||
const originalURL = validateWatchURL(video.url);
|
||||
if (!originalURL) return [];
|
||||
const url = typeof video.uuid === 'string' && uuidPattern.test(video.uuid)
|
||||
? `${ORIGIN}/videos/watch/${video.uuid.toLowerCase()}` : originalURL;
|
||||
return [{ url, title: video.name ?? video.title, publishedAt: video.publishedAt,
|
||||
thumbnail: video.thumbnailPath, source }];
|
||||
}), limit, dateOptions);
|
||||
}
|
||||
|
||||
function abortError(message = 'Request aborted') { return new DOMException(message, 'AbortError'); }
|
||||
|
||||
/** The timer covers both headers and streamed body, including non-cooperative injected fetches. */
|
||||
export async function fetchText(url, { fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES, shouldFetch = () => true } = {}) {
|
||||
const safe = validateOriginURL(url);
|
||||
if (!safe || !safe.pathname.startsWith('/api/v1/')) throw new Error('Invalid request');
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 || !Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
|
||||
const controller = new AbortController();
|
||||
let reader;
|
||||
let timer;
|
||||
let rejectAbort;
|
||||
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
||||
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
timer = setTimeout(cancel, timeoutMs);
|
||||
const work = async () => {
|
||||
if (signal?.aborted || !shouldFetch()) throw abortError();
|
||||
const response = await fetchImpl(safe.href, { signal: controller.signal, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
||||
headers: { Accept: 'application/json' } });
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (!response.ok || response.redirected || (response.url && response.url !== safe.href)) throw new Error('PeerTube request failed');
|
||||
const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (mime !== 'application/json') throw new Error('Unexpected content type');
|
||||
const length = response.headers.get('content-length');
|
||||
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
|
||||
// Refuse unstreamable bodies rather than allocate an unbounded response.text().
|
||||
if (!response.body?.getReader) throw new Error('Streaming response required');
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let result = '';
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) throw new Error('Response too large');
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return result + decoder.decode();
|
||||
};
|
||||
try { return await Promise.race([work(), aborted]); }
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
controller.abort();
|
||||
if (reader) void reader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJSON(url, dependencies) {
|
||||
return JSON.parse(await fetchText(url, dependencies));
|
||||
}
|
||||
|
||||
export function channelVideosURL(name, { count = 100, start = 0 } = {}) {
|
||||
if (!isChannelName(name)) throw new Error('Invalid channel name');
|
||||
if (!Number.isInteger(count) || count < 1 || count > 100 || !Number.isSafeInteger(start) || start < 0 || start > 1000) throw new Error('Invalid channel pagination');
|
||||
return `${ORIGIN}/api/v1/video-channels/${name}/videos?count=${count}&start=${start}&sort=-publishedAt`;
|
||||
}
|
||||
|
||||
function validateAPIPage(result, count, maxTotal = Number.MAX_SAFE_INTEGER) {
|
||||
if (!result || !Number.isSafeInteger(result.total) || result.total < 0 || result.total > maxTotal ||
|
||||
!Array.isArray(result.data) || result.data.length > count || result.data.length > result.total ||
|
||||
result.data.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))) throw new Error('Invalid API page');
|
||||
}
|
||||
|
||||
export async function fetchChannel(name, source = 'PeerTube', dependencies = {}) {
|
||||
// The server sorts before limiting: even a large channel needs just one latest page.
|
||||
const result = await fetchJSON(channelVideosURL(name, { count: 3 }), dependencies);
|
||||
validateAPIPage(result, 3);
|
||||
if (result.data.length !== Math.min(result.total, 3)) throw new Error('Incomplete API page');
|
||||
return normalizeJSONVideos(result.data, source, 3, dependencies.dateOptions);
|
||||
}
|
||||
|
||||
/** Every page is checked. A changing/inconsistent total is a failure, not a partial library. */
|
||||
export async function fetchPaginated(path, dependencies = {}) {
|
||||
const channelPath = typeof path === 'string' && /^\/api\/v1\/video-channels\/([^/]+)\/videos$/.exec(path);
|
||||
if (!/^\/api\/v1\/accounts\/eric\/(?:video-playlists|video-channels)$/.test(path) &&
|
||||
!(channelPath && isChannelName(channelPath[1])) &&
|
||||
!new RegExp(`^/api/v1/video-playlists/${UUID}/videos$`).test(path)) throw new Error('Invalid API endpoint');
|
||||
const data = [];
|
||||
let total;
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const url = path.startsWith('/api/v1/video-channels/')
|
||||
? channelVideosURL(path.split('/')[4], { start: data.length })
|
||||
: `${ORIGIN}${path}?count=100&start=${data.length}`;
|
||||
const result = await fetchJSON(url, dependencies);
|
||||
validateAPIPage(result, 100, 1000);
|
||||
if (total !== undefined && total !== result.total) throw new Error('API changed during pagination');
|
||||
total = result.total;
|
||||
if (data.length + result.data.length > total) throw new Error('Inconsistent API total');
|
||||
data.push(...result.data);
|
||||
if (data.length === total) return data;
|
||||
if (!result.data.length) throw new Error('Incomplete API page');
|
||||
}
|
||||
throw new Error('API pagination limit exceeded');
|
||||
}
|
||||
|
||||
export function normalizeCollections(channels, playlists) {
|
||||
if (!Array.isArray(channels) || !Array.isArray(playlists) || channels.length > 1000 || playlists.length > 1000) throw new Error('Invalid collections');
|
||||
const unique = values => {
|
||||
const seen = new Set();
|
||||
return values.filter(value => !seen.has(value.key) && seen.add(value.key))
|
||||
.sort((a, b) => a.title.localeCompare(b.title) || a.key.localeCompare(b.key));
|
||||
};
|
||||
return {
|
||||
channels: unique(channels.flatMap(channel => /^[1-9]\d{0,9}$/.test(String(channel?.id)) && isChannelName(channel?.name)
|
||||
? [{ key: `channel:${channel.id}`, type: 'channel', id: channel.name, title: text(channel.displayName ?? channel.name), link: `${ORIGIN}/video-channels/${channel.name}/videos` }] : [])),
|
||||
playlists: unique(playlists.flatMap(playlist => playlist?.privacy?.id === 1 && uuidPattern.test(playlist?.uuid) && typeof playlist.displayName === 'string'
|
||||
? [{ key: `playlist:${playlist.uuid.toLowerCase()}`, type: 'playlist', id: playlist.uuid.toLowerCase(), title: text(playlist.displayName), link: `${ORIGIN}/video-playlists/${playlist.uuid.toLowerCase()}` }] : []))
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverCollections(dependencies = {}) {
|
||||
const [channels, playlists] = await Promise.all([
|
||||
fetchPaginated('/api/v1/accounts/eric/video-channels', dependencies),
|
||||
fetchPaginated('/api/v1/accounts/eric/video-playlists', dependencies)
|
||||
]);
|
||||
return normalizeCollections(channels, playlists);
|
||||
}
|
||||
|
||||
export async function fetchCollection(collection, dependencies = {}) {
|
||||
if (collection?.type === 'channel') return fetchChannel(collection.id, collection.title, dependencies);
|
||||
if (collection?.type !== 'playlist' || !uuidPattern.test(collection.id)) throw new Error('Invalid collection');
|
||||
return normalizeJSONVideos(await fetchPaginated(`/api/v1/video-playlists/${collection.id}/videos`, dependencies), collection.title);
|
||||
}
|
||||
|
||||
export async function loadRegion(names, source, dependencies = {}) {
|
||||
if (!Array.isArray(names) || !names.length || names.length > 20 || names.some(name => !isChannelName(name))) throw new Error('Invalid feed channels');
|
||||
const results = await Promise.allSettled(names.map(name => fetchChannel(name, source, dependencies)));
|
||||
const successes = results.filter(result => result.status === 'fulfilled');
|
||||
return { ok: successes.length > 0, unavailable: results.length - successes.length,
|
||||
items: sortAndDedupe(successes.flatMap(result => result.value), 3) };
|
||||
}
|
||||
|
||||
function element(document, tag, className, content) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (content !== undefined) node.textContent = content;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createCard(document, item) {
|
||||
const url = validateWatchURL(item.url);
|
||||
const publishedAt = normalizeDate(item.publishedAt);
|
||||
if (!url || !publishedAt) throw new Error('Invalid card');
|
||||
const title = text(item.title, 'Untitled video');
|
||||
const card = element(document, 'article', 'project-card media-card');
|
||||
const media = element(document, 'a', 'media-image');
|
||||
media.href = url;
|
||||
media.setAttribute('aria-label', `Watch ${title}`);
|
||||
const thumbnail = validateThumbnailURL(item.thumbnail);
|
||||
if (thumbnail) {
|
||||
const image = element(document, 'img');
|
||||
image.src = thumbnail;
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
image.width = 560;
|
||||
image.height = 315;
|
||||
image.referrerPolicy = 'no-referrer';
|
||||
image.addEventListener('error', () => { image.hidden = true; }, { once: true });
|
||||
media.append(image);
|
||||
}
|
||||
media.append(element(document, 'span', 'media-label', 'Watch ↗'));
|
||||
const copy = element(document, 'div', 'media-copy');
|
||||
const date = element(document, 'time', '', new Date(publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }));
|
||||
date.dateTime = publishedAt;
|
||||
const link = element(document, 'a', 'text-link', 'Watch on PeerTube →');
|
||||
link.href = url;
|
||||
copy.append(element(document, 'p', 'card-kicker', text(item.source, 'PeerTube')), element(document, 'h3', '', title), date, link);
|
||||
card.append(media, copy);
|
||||
return card;
|
||||
}
|
||||
|
||||
export function renderItems(document, container, items) {
|
||||
const cards = items.map(item => createCard(document, item));
|
||||
container.replaceChildren(...(cards.length ? cards : [element(document, 'p', 'feed-empty', 'No public uploads in this collection yet.')]));
|
||||
}
|
||||
|
||||
function checkedStatus(now) { return `Latest uploads · checked ${new Date(now()).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; }
|
||||
|
||||
/** Selection content has its own abort controller AND generation guard; select stays enabled. */
|
||||
export function createLibraryController(document, root, dependencies = {}) {
|
||||
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
||||
const select = root.querySelector('#peertube-collection');
|
||||
const items = root.querySelector('[data-library-items]');
|
||||
const status = root.querySelector('[data-library-status]');
|
||||
const link = root.querySelector('[data-library-link]');
|
||||
const now = dependencies.now ?? Date.now;
|
||||
let collections = new Map();
|
||||
let generation = 0;
|
||||
let contentController;
|
||||
let discoveryController;
|
||||
let discoveryBusy = false;
|
||||
let stopped = false;
|
||||
let contentBusy = false;
|
||||
const selectCollection = async key => {
|
||||
if (stopped || document.hidden) return;
|
||||
const collection = collections.get(key);
|
||||
if (!collection) return;
|
||||
contentController?.abort();
|
||||
contentController = new AbortController();
|
||||
const version = ++generation;
|
||||
contentBusy = true;
|
||||
// Do not show cards from a previous collection under a new collection label.
|
||||
items.replaceChildren();
|
||||
link.href = collection.link;
|
||||
link.hidden = false;
|
||||
link.textContent = collection.type === 'playlist' ? 'Browse playlist on PeerTube →' : 'Browse channel on PeerTube →';
|
||||
status.textContent = `Loading ${collection.title}…`;
|
||||
try {
|
||||
const publications = await (dependencies.fetchCollection ?? fetchCollection)(collection, { ...dependencies, signal: contentController.signal });
|
||||
if (stopped || document.hidden || version !== generation) return;
|
||||
renderItems(document, items, publications);
|
||||
status.textContent = publications.length ? checkedStatus(now) : 'No public uploads in this collection yet.';
|
||||
} catch {
|
||||
if (!stopped && version === generation) status.textContent = 'Live publications unavailable. Browse this collection on PeerTube.';
|
||||
} finally { if (version === generation) contentBusy = false; }
|
||||
};
|
||||
const discover = async () => {
|
||||
if (stopped || document.hidden || discoveryBusy) return;
|
||||
discoveryBusy = true;
|
||||
discoveryController = new AbortController();
|
||||
select.disabled = true;
|
||||
status.textContent = 'Loading channels and playlists…';
|
||||
try {
|
||||
const result = await (dependencies.discoverCollections ?? discoverCollections)({ ...dependencies, signal: discoveryController.signal });
|
||||
if (stopped || document.hidden) return;
|
||||
const previous = select.value;
|
||||
collections = new Map([...result.channels, ...result.playlists].map(collection => [collection.key, collection]));
|
||||
const groups = [];
|
||||
for (const [label, values] of [['Channels', result.channels], ['Playlists', result.playlists]]) {
|
||||
if (!values.length) continue;
|
||||
const group = element(document, 'optgroup');
|
||||
group.label = label;
|
||||
for (const collection of values) {
|
||||
const option = element(document, 'option', '', collection.title);
|
||||
option.value = collection.key;
|
||||
group.append(option);
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
select.replaceChildren(...groups);
|
||||
if (!collections.size) {
|
||||
contentController?.abort();
|
||||
generation++;
|
||||
items.replaceChildren(element(document, 'p', 'feed-empty', 'No public channels or playlists found.'));
|
||||
status.textContent = 'No public channels or playlists found.';
|
||||
link.href = `${ORIGIN}/accounts/eric/videos`;
|
||||
link.textContent = 'Browse all publications';
|
||||
link.hidden = false;
|
||||
return;
|
||||
}
|
||||
select.value = collections.has(previous) ? previous : collections.has(`playlist:${ORIGINALS_UUID}`) ? `playlist:${ORIGINALS_UUID}` : result.playlists[0]?.key ?? result.channels[0].key;
|
||||
select.disabled = false;
|
||||
await selectCollection(select.value);
|
||||
} catch {
|
||||
if (!stopped) {
|
||||
select.disabled = collections.size === 0;
|
||||
status.textContent = 'Live library discovery unavailable. Browse publications on PeerTube.';
|
||||
link.hidden = false;
|
||||
}
|
||||
} finally { discoveryBusy = false; }
|
||||
};
|
||||
const onChange = () => { void selectCollection(select.value); };
|
||||
select.addEventListener('change', onChange);
|
||||
return { discover, selectCollection, refresh: async () => { if (!discoveryBusy && !contentBusy) await discover(); },
|
||||
pause() { generation++; contentBusy = false; contentController?.abort(); discoveryController?.abort(); },
|
||||
stop() { stopped = true; generation++; contentController?.abort(); discoveryController?.abort(); select.removeEventListener('change', onChange); } };
|
||||
}
|
||||
|
||||
/** Returns {ready, refresh, stop, library}; all timers/listeners are removable for tests. */
|
||||
export function initPeerTubeFeeds(document, dependencies = {}) {
|
||||
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const regions = Array.from(document.querySelectorAll('[data-peertube-feed]')).map(root => ({ root, busy: false, controller: null, hasLive: false }));
|
||||
const libraryRoot = document.querySelector('#peertube-library');
|
||||
const library = libraryRoot ? createLibraryController(document, libraryRoot, dependencies) : null;
|
||||
let stopped = false;
|
||||
let lastRefresh = -Infinity;
|
||||
const refreshRegion = async region => {
|
||||
if (region.busy || stopped || document.hidden) return;
|
||||
const status = region.root.querySelector('[data-feed-status]');
|
||||
const items = region.root.querySelector('[data-feed-items]');
|
||||
if (!status || !items) return;
|
||||
region.busy = true;
|
||||
const controller = region.controller = new AbortController();
|
||||
status.textContent = 'Checking latest uploads…';
|
||||
try {
|
||||
const names = (region.root.getAttribute('data-channels') ?? '').split(',').map(name => name.trim());
|
||||
const result = await (dependencies.loadRegion ?? loadRegion)(names, region.root.getAttribute('data-title') ?? 'PeerTube', { ...dependencies, signal: controller.signal });
|
||||
if (stopped || document.hidden || controller.signal.aborted) return;
|
||||
if (!result.ok) { status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; return; }
|
||||
renderItems(document, items, result.items);
|
||||
region.hasLive = true;
|
||||
status.textContent = result.unavailable ? 'Some channels unavailable · showing available uploads.' : result.items.length ? checkedStatus(now) : 'No public uploads in these channels yet.';
|
||||
} catch { if (!stopped && !document.hidden && !controller.signal.aborted) status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; }
|
||||
finally { if (region.controller === controller) region.busy = false; }
|
||||
};
|
||||
const refresh = async ({ force = false } = {}) => {
|
||||
if (stopped || document.hidden || (!force && now() - lastRefresh < REFRESH_MS)) return;
|
||||
lastRefresh = now();
|
||||
await Promise.all([...regions.map(refreshRegion), library?.refresh()]);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (!document.hidden) void refresh();
|
||||
else {
|
||||
for (const region of regions) {
|
||||
if (region.busy) lastRefresh = -Infinity;
|
||||
region.controller?.abort();
|
||||
region.busy = false;
|
||||
}
|
||||
library?.pause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
const interval = (dependencies.setIntervalImpl ?? setInterval)(() => { void refresh(); }, REFRESH_MS);
|
||||
const ready = refresh();
|
||||
return { ready, refresh, library, stop() {
|
||||
stopped = true;
|
||||
(dependencies.clearIntervalImpl ?? clearInterval)(interval);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
for (const region of regions) region.controller?.abort();
|
||||
library?.stop();
|
||||
} };
|
||||
}
|
||||
|
||||
export const boot = initPeerTubeFeeds;
|
||||
if (typeof globalThis.document !== 'undefined') {
|
||||
if (globalThis.document.readyState === 'loading') globalThis.document.addEventListener('DOMContentLoaded', () => boot(globalThis.document), { once: true });
|
||||
else boot(globalThis.document);
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -142,19 +142,23 @@ class ServiceDisplayTests(TestCase):
|
||||
self.assertIn(f'src="https://{host}/', homepage_html)
|
||||
self.assertNotIn("Dex · Shared sign-in", text)
|
||||
projects = homepage_html.split('id="projects"', 1)[1].split("</section>", 1)[0]
|
||||
self.assertIn('id="gitea-projects"', projects)
|
||||
self.assertIn("https://gitea.eom.dev/explore/repos", projects)
|
||||
self.assertIn('data-channels="eric_tech"', projects)
|
||||
gaming = homepage_html.split('id="gaming"', 1)[1].split("</section>", 1)[0]
|
||||
for label in ("Wikideck", "The Button", "Luanti"):
|
||||
self.assertIn(label, projects)
|
||||
self.assertNotIn("My Luanti server", projects)
|
||||
self.assertIn("Season V · Public server", projects)
|
||||
self.assertIn("eleven team islands, markets and an adventure-map spawn", projects)
|
||||
self.assertIn("luanti.eom.dev:30000", projects)
|
||||
self.assertIn(label, gaming)
|
||||
self.assertNotIn("My Luanti server", gaming)
|
||||
self.assertIn("Season V · Public server", gaming)
|
||||
self.assertIn("eleven team islands, markets and an adventure-map spawn", gaming)
|
||||
self.assertIn("luanti.eom.dev:30000", gaming)
|
||||
for destination in (
|
||||
"https://gitea.eom.dev/WikiDeck/wikideck",
|
||||
"https://button.eom.dev/",
|
||||
"https://discourse.eom.dev/t/luanti-season-v/690",
|
||||
):
|
||||
self.assertIn(destination, projects)
|
||||
self.assertNotIn("Browser client source", projects)
|
||||
self.assertIn(destination, gaming)
|
||||
self.assertNotIn("Browser client source", gaming)
|
||||
self.assertNotIn("Broadcast from your own stack", projects)
|
||||
self.assertNotIn("Make patterns, make sound", projects)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<body>
|
||||
<header class="member-nav">
|
||||
<a class="brand" href="/">eom.dev</a>
|
||||
<nav aria-label="Membership navigation"><a href="/">Back to the lab</a><a href="{% url 'members:home' %}">Membership</a></nav>
|
||||
<nav aria-label="Membership navigation"><a href="/">Home</a><a href="{% url 'members:home' %}">Membership</a></nav>
|
||||
</header>
|
||||
<main id="main-content" class="member-main">
|
||||
{% if title %}
|
||||
@@ -21,7 +21,7 @@
|
||||
<h1>{{ title }}</h1>
|
||||
{% if message %}<p>{{ message }}</p>{% endif %}
|
||||
{% if registration %}
|
||||
<p>One account for all of eom.dev. Use the same sign-in to explore the community and its services, without creating a separate account for each one.</p>
|
||||
<p>Your eom.dev account gives you a shared sign-in for the community services included at your membership level. Free accounts include forums, chat, Git hosting, and games; some additional services require a paid tier.</p>
|
||||
<ol>
|
||||
<li>Choose a username and enter your email address below.</li>
|
||||
<li>Check your inbox and follow the confirmation link.</li>
|
||||
@@ -41,9 +41,9 @@
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="member-hero">
|
||||
<p class="eyebrow">Keep your own corner of the internet alive</p>
|
||||
<p class="eyebrow">Music, films, projects & shared worlds</p>
|
||||
<h1>Support the work.<br>Get closer to what’s next.</h1>
|
||||
<p class="member-intro">Join free for community services and games. Choose a paid tier for exclusive content, previews and a closer say in what comes next.</p>
|
||||
<p class="member-intro">Membership supports my music, films, writing, open-source projects, and game servers. Join the community for free, or choose a paid tier to support the work and receive the benefits listed below.</p>
|
||||
{% if account %}
|
||||
<div class="member-panel account-panel">
|
||||
<div><p class="eyebrow">Your eom.dev account · Membership level {{ account.membership_level }}</p><h2>{{ account.uid }}</h2><p>{{ account.email }}</p></div>
|
||||
@@ -53,7 +53,7 @@
|
||||
<form action="{% url 'members:logout' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{% if request.GET.checkout %}<p role="status" class="member-notice">Thanks for supporting the work. Stripe is confirming your subscription. Access appears after payment is verified and the membership worker has synchronized it.</p>{% endif %}
|
||||
{% if request.GET.checkout %}<p role="status" class="member-notice">Thanks for supporting the work. Your membership benefits become available once Stripe confirms payment and your account access updates.</p>{% endif %}
|
||||
{% for subscription in subscriptions %}
|
||||
<p class="member-notice">{% for key, tier in tiers.items %}{% if key == subscription.tier %}{{ tier.label }}{% endif %}{% empty %}Membership{% endfor %} · {{ subscription.status }}{% if subscription.paid_until > now %} · Paid access through {{ subscription.paid_until|date:"M j, Y H:i T" }}{% else %} · No current paid access{% endif %}{% if subscription.cancel_at_period_end %} · Does not renew{% endif %}</p>
|
||||
{% endfor %}
|
||||
@@ -69,36 +69,37 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
<section aria-labelledby="tiers-heading">
|
||||
<div class="section-heading"><p class="eyebrow">Your support, your choice</p><h2 id="tiers-heading">Find your membership</h2><p>Public projects and ordinary community participation stay free. Membership adds a closer connection to the work, not a barrier to joining in.</p></div>
|
||||
<div class="member-panel service-access-panel">
|
||||
<h3>Included services</h3>
|
||||
{% if services %}<ul class="service-access-list">{% for service in services %}<li><div class="service-access-identity">{% if service.url %}<a class="service-access-link" href="{{ service.url }}"><img src="{{ service.favicon }}" alt="" width="32" height="32" loading="lazy" decoding="async"><strong>{{ service.name }}</strong></a>{% else %}<strong>{{ service.name }}</strong>{% endif %}</div><span>{{ service.access }}</span></li>{% endfor %}</ul>{% else %}<p>No additional service access is currently configured.</p>{% endif %}
|
||||
</div>
|
||||
<div class="section-heading"><p class="eyebrow">Your support, your choice</p><h2 id="tiers-heading">Choose how to take part</h2><p>Public recordings, writing, and source code remain available without a paid membership. Your support helps fund creative time, recording, travel, and independent publishing.</p><p>Paid benefits build by level: Bronze adds exclusive Discourse and Matrix content; Silver adds previews when available; Gold adds project-topic polls and occasional feedback sessions. Compare the full benefits below.</p></div>
|
||||
<div class="member-grid">
|
||||
<article class="member-panel tier-card free-tier">
|
||||
<p class="card-kicker">Level 0 · The starting point</p><h3>Open Lab</h3><p class="tier-price">Free <span>/ always</span></p>
|
||||
<p>Your free community account.</p>
|
||||
<p>Join the community without a subscription.</p>
|
||||
<ul><li>Games hosted on eom.dev</li><li>Open Lab-tier giveaways</li></ul>
|
||||
{% if not account %}<a class="button button-secondary" href="{% url 'members:register' %}">Join the community</a>{% else %}<p class="tier-footnote">Your free account is ready.</p>{% endif %}
|
||||
</article>
|
||||
{% for key, tier in tiers.items %}
|
||||
<article class="member-panel tier-card">
|
||||
<p class="card-kicker">Level {{ tier.level }}</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
|
||||
<p>{{ tier.description|default:"Help keep the projects and community running." }}</p>
|
||||
<p>{{ tier.description|default:"Support the independent work and community." }}</p>
|
||||
<ul>{% for benefit in tier.benefits %}<li>{{ benefit }}</li>{% empty %}<li>Support independent projects and the community</li>{% endfor %}<li>Plus all previous benefits</li></ul>
|
||||
{% if account %}<div class="tier-payment-actions"><form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Subscribe with Stripe</button></form>
|
||||
{% if shkeeper_enabled and tier.usd_amount %}<a class="button button-secondary" href="{% url 'members:crypto_checkout' key %}">Pay with crypto</a>{% endif %}</div>{% else %}<a class="button button-primary" href="{% url 'members:register' %}">Create an account to join</a>{% endif %}
|
||||
</article>
|
||||
{% empty %}<div class="member-panel"><h3>Paid memberships are coming soon.</h3><p>You can create your free account now. Paid tiers will appear when their prices and groups are configured.</p></div>{% endfor %}
|
||||
</div>
|
||||
<p class="member-small">The list above shows each service once and the levels that include it. Access updates after directory synchronization; you may need to sign in again to a service. Publicly available content remains subject to each service's own rules.</p>
|
||||
<p class="member-small">Previews are shared when available, and playtests, listening sessions and feedback gatherings are occasional. Membership does not include storage quotas, personal consulting, guaranteed support or promised feature development. Recognition is always optional.</p>
|
||||
<div class="member-panel service-access-panel">
|
||||
<h3>Community services by level</h3>
|
||||
<p>Watching public videos and reading public posts is separate from having an account on those services. This list shows access by level, including channels that are open to everyone.</p>
|
||||
{% if services %}<ul class="service-access-list">{% for service in services %}<li><div class="service-access-identity">{% if service.url %}<a class="service-access-link" href="{{ service.url }}"><img src="{{ service.favicon }}" alt="" width="32" height="32" loading="lazy" decoding="async"><strong>{{ service.name }}</strong></a>{% else %}<strong>{{ service.name }}</strong>{% endif %}</div><span>{{ service.access }}</span></li>{% endfor %}</ul>{% else %}<p>No additional service access is currently configured.</p>{% endif %}
|
||||
</div>
|
||||
<p class="member-small">Service access updates with your membership. You may need to sign in again to a service after it changes. Each service's own rules still apply.</p>
|
||||
<p class="member-small">Previews are shared when available, and playtests, listening sessions and feedback gatherings are occasional. There is no fixed release schedule. Membership does not include storage quotas, personal consulting, managed hosting, guaranteed support or promised feature development. Recognition is always optional.</p>
|
||||
</section>
|
||||
<section class="member-faq" aria-labelledby="membership-faq">
|
||||
<h2 id="membership-faq">Simple, by design.</h2>
|
||||
<details><summary>Do I need a paid membership to play games?</summary><p>No. A free Open Lab (Level 0) account includes access to games hosted on eom.dev, and paid members can play too. Exclusive game servers for higher tiers may be added in the future, but none are currently offered or included as a paid benefit.</p></details>
|
||||
<h2 id="membership-faq">Before you join</h2>
|
||||
<details><summary>Do I need a paid membership to watch, read, or play?</summary><p>No. Public videos, writing, and source code can be explored without a paid membership. A free Open Lab (Level 0) account includes access to games hosted on eom.dev, and paid members can play too. No paid-exclusive game servers are currently included.</p></details>
|
||||
<details><summary>How do membership giveaways work?</summary><p>Giveaways are associated with a membership tier. Higher tiers include giveaway benefits from lower tiers, including Open Lab. Each giveaway's rules will list its availability, dates, prizes and any eligibility restrictions. Membership does not guarantee a prize.</p></details>
|
||||
<details><summary>Who can vote on the platform's direction?</summary><p>Platinum (Level 4) and Diamond (Level 5) members can take part in exclusive polls about the direction of the platform. These are separate from Gold's project-topic polls. Results help inform decisions; they are not binding commitments or guarantees of particular features.</p></details>
|
||||
<details><summary>What do membership polls influence?</summary><p>Gold (Level 3) and above can share preferences about future project topics and experiments. Platinum (Level 4) and Diamond (Level 5) also receive separate polls about the direction of eom.dev itself. Results inform my decisions; they are not binding commitments or guarantees of particular features.</p></details>
|
||||
<details><summary>What happens when a membership ends?</summary><p>Your free eom.dev account stays. Member-only access ends when your paid membership expires. An immediate cancellation removes that access once your membership status updates.</p></details>
|
||||
<details><summary>Where do members connect?</summary><p>On Discourse and Matrix. Ordinary community access is free; Bronze and above also receive exclusive content in those services.</p></details>
|
||||
<details><summary>Can I change or cancel a plan?</summary><p>Use Manage billing after signing in. The billing portal offers the changes enabled for your plan. Cancellation at period end keeps access through the paid expiry date.</p></details>
|
||||
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { realpath as portableRealpath } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
// fs.promises.realpath uses the native API, which fails on some Windows SMB drives.
|
||||
// The portable implementation still resolves every symlink; never fall back to resolve().
|
||||
const realpath = promisify(portableRealpath);
|
||||
|
||||
const PUBLIC_ROOT = fileURLToPath(new URL('../htdocs/', import.meta.url));
|
||||
const INCLUDES = new Map([
|
||||
['<!--#include virtual="/common/header.html" -->', 'common/header.html'],
|
||||
['<!--#include virtual="/common/topnav.html" -->', 'common/topnav.html'],
|
||||
['<!--#include virtual="/common/footer.html" -->', 'common/footer.html'],
|
||||
]);
|
||||
const MIME = new Map([
|
||||
['.html', 'text/html; charset=utf-8'], ['.htm', 'text/html; charset=utf-8'],
|
||||
['.css', 'text/css; charset=utf-8'], ['.js', 'text/javascript; charset=utf-8'],
|
||||
['.mjs', 'text/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'],
|
||||
['.txt', 'text/plain; charset=utf-8'], ['.xml', 'application/xml; charset=utf-8'],
|
||||
['.svg', 'image/svg+xml'], ['.ico', 'image/x-icon'], ['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
||||
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
|
||||
['.woff2', 'font/woff2'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
||||
['.mp3', 'audio/mpeg'], ['.ogg', 'audio/ogg'], ['.wav', 'audio/wav'],
|
||||
['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'],
|
||||
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
|
||||
]);
|
||||
const PORTAL_NOTICE = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex"><title>Local preview only · Membership unavailable</title></head>
|
||||
<body><main><h1>Local preview only</h1><p>The dynamic membership portal is unavailable
|
||||
in this static preview. Registration, sign-in, profiles, and billing require the
|
||||
deployed portal. This preview does not emulate authentication or accept payments.</p>
|
||||
<p><a href="/">Back to the homepage preview</a></p></main></body></html>`;
|
||||
|
||||
function httpError(status) {
|
||||
return Object.assign(new Error('Preview request rejected'), { status });
|
||||
}
|
||||
|
||||
function safeSegments(path) {
|
||||
// Also reject Windows alternate streams, device names and normalization aliases.
|
||||
return !/[\\\x00-\x1f\x7f:<>"|?*#%]/u.test(path)
|
||||
&& path.split('/').every(segment => !segment.startsWith('.')
|
||||
&& !/[. ]$/u.test(segment)
|
||||
&& !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(segment));
|
||||
}
|
||||
|
||||
function requestPath(target) {
|
||||
// Inspect the raw target BEFORE URL parsing can silently normalize ../ or backslashes.
|
||||
if (typeof target !== 'string' || !target.startsWith('/') || target.startsWith('//')) {
|
||||
throw httpError(400);
|
||||
}
|
||||
const raw = target.split('?')[0];
|
||||
if (/%(?:2f|5c)/iu.test(raw)) throw httpError(400);
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(raw); } catch { throw httpError(400); }
|
||||
if (!safeSegments(decoded)) throw httpError(400);
|
||||
return decoded;
|
||||
}
|
||||
|
||||
async function containedFile(root, path) {
|
||||
const candidate = await realpath(resolve(root, path));
|
||||
const inside = relative(root, candidate);
|
||||
if (!inside || isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`)
|
||||
|| !safeSegments(inside.split(sep).join('/'))) throw httpError(403);
|
||||
if (!(await stat(candidate)).isFile()) throw httpError(404);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function assemble(root) {
|
||||
let html = await readFile(await containedFile(root, 'index.html'), 'utf8');
|
||||
// Literal allowlist only: no general SSI parsing, recursive includes, or execution.
|
||||
for (const [directive, path] of INCLUDES) {
|
||||
if (!html.includes(directive)) continue;
|
||||
const snippet = await readFile(await containedFile(root, path), 'utf8');
|
||||
if (/<!--\s*#/u.test(snippet)) throw httpError(500);
|
||||
html = html.replaceAll(directive, () => snippet);
|
||||
}
|
||||
if (/<!--\s*#/u.test(html)) throw httpError(500);
|
||||
return Buffer.from(html);
|
||||
}
|
||||
|
||||
function reply(req, res, status, body, type = 'text/plain; charset=utf-8', extra = {}) {
|
||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': type,
|
||||
'Content-Length': data.length,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Frame-Options': 'DENY',
|
||||
...extra,
|
||||
});
|
||||
res.end(req.method === 'HEAD' ? undefined : data);
|
||||
}
|
||||
|
||||
export function parsePort(value = '8082') {
|
||||
if (!/^\d+$/u.test(String(value))) throw new Error('Port must be numeric (0–65535).');
|
||||
const port = Number(value);
|
||||
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error('Port must be numeric (0–65535).');
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
// publicRoot is a trusted, programmatic fixture seam, never a request or CLI option.
|
||||
export async function createPreviewServer({ publicRoot = PUBLIC_ROOT } = {}) {
|
||||
const root = await realpath(publicRoot);
|
||||
if (!(await stat(root)).isDirectory()) throw new Error('Public root must be a directory.');
|
||||
const server = createServer({ requestTimeout: 10_000, headersTimeout: 5_000 }, async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
reply(req, res, 405, 'Only GET and HEAD are supported.\n', undefined, { Allow: 'GET, HEAD' });
|
||||
return;
|
||||
}
|
||||
// Prevent arbitrary Host/DNS-rebinding origins from accessing local files.
|
||||
if (!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/iu.test(req.headers.host ?? '')) {
|
||||
throw httpError(403);
|
||||
}
|
||||
const path = requestPath(req.url);
|
||||
if (path === '/members' || path.startsWith('/members/')) {
|
||||
reply(req, res, 503, PORTAL_NOTICE, 'text/html; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
const homepage = path === '/' || path === '/index.html';
|
||||
const file = homepage ? 'index.html' : path.slice(1);
|
||||
const body = homepage ? await assemble(root) : await readFile(await containedFile(root, file));
|
||||
reply(req, res, 200, body, MIME.get(extname(file).toLowerCase()) ?? 'application/octet-stream');
|
||||
} catch (error) {
|
||||
const status = error.status ?? (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error.code) ? 404
|
||||
: ['EACCES', 'EPERM', 'ELOOP'].includes(error.code) ? 403 : 500);
|
||||
// Never reflect request data, filesystem paths, or exception details.
|
||||
reply(req, res, status, status === 500 ? 'Preview could not assemble this page.\n'
|
||||
: status === 404 ? 'Not found in the public preview.\n' : 'Preview request rejected.\n');
|
||||
}
|
||||
});
|
||||
server.keepAliveTimeout = 1_000;
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startPreview({ port = 8082, publicRoot = PUBLIC_ROOT } = {}) {
|
||||
port = parsePort(port);
|
||||
const server = await createPreviewServer({ publicRoot });
|
||||
await new Promise((accept, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.removeListener('error', reject);
|
||||
accept();
|
||||
});
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
try {
|
||||
if (process.argv.length > 3) throw new Error('Usage: node scripts/preview.mjs [port]');
|
||||
const server = await startPreview({ port: parsePort(process.argv[2]) });
|
||||
console.log(`Static preview only: http://127.0.0.1:${server.address().port}/ (membership unavailable)`);
|
||||
const stop = () => {
|
||||
server.close();
|
||||
server.closeAllConnections();
|
||||
};
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
} catch (error) {
|
||||
console.error(error.code === 'EADDRINUSE' ? 'Preview port is already in use.'
|
||||
: error.message.startsWith('Port') || error.message.startsWith('Usage') ? error.message
|
||||
: 'Unable to start the local static preview.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Executable
+560
@@ -0,0 +1,560 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
ORIGIN, PAGE_SIZE, MAX_PAGES, MAX_BYTES, MAX_CONTENT_BYTES, REFRESH_MS,
|
||||
validateRepositoryURL, normalizeDate, normalizeActivities, fetchActivityPage,
|
||||
fetchRecentProjects, createCard, initGiteaProjects
|
||||
} from '../htdocs/static/gitea-projects.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-31T12:00:00Z');
|
||||
const HASH = 'b96be7076ecae7fcc6db1cc357feb613196a9bd2';
|
||||
function event(id = 111164, created = '2026-10-30T20:38:17Z', fullName = 'Gaming/the-button', extra = {}) {
|
||||
return { id: 110459 + id, op_type: 'commit_repo', created, is_private: false,
|
||||
act_user: { login: 'eric' }, ref_name: 'refs/heads/main',
|
||||
repo: { id, name: fullName.split('/')[1], full_name: fullName, html_url: `${ORIGIN}/${fullName}`,
|
||||
private: false, description: 'The Button Game', updated_at: '2099-01-01T00:00:00Z' },
|
||||
content: JSON.stringify({ HeadCommit: { Sha1: HASH, Message: 'SSO and turn gate\n',
|
||||
AuthorEmail: 'never-display@example.test' }, Commits: [], Len: 2,
|
||||
CompareURL: 'https://evil.test/compare/tracking' }), ...extra };
|
||||
}
|
||||
const jsonResponse = (value, headers = {}) => new Response(JSON.stringify(value), {
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }
|
||||
});
|
||||
const options = fetchImpl => ({ fetchImpl, now: () => NOW });
|
||||
const fullPage = (entries = []) => [...entries, ...Array(PAGE_SIZE - entries.length).fill(null)];
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// Minimal dependency-free DOM; deliberately throws on unsafe HTML sinks.
|
||||
class Node {
|
||||
constructor(tag = 'div', content = '') {
|
||||
this.localName = tag;
|
||||
this.textContent = content;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.selectors = new Map();
|
||||
}
|
||||
set innerHTML(_) { throw new Error('Unsafe HTML sink'); }
|
||||
set outerHTML(_) { throw new Error('Unsafe HTML sink'); }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
querySelector(selector) { return this.selectors.get(selector) ?? null; }
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
removeEventListener(name, callback) { if (this.listeners.get(name) === callback) this.listeners.delete(name); }
|
||||
dispatch(name) { this.listeners.get(name)?.(); }
|
||||
}
|
||||
class Document extends Node {
|
||||
constructor() { super(); this.hidden = false; }
|
||||
createElement(tag) { return new Node(tag); }
|
||||
}
|
||||
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||
function fixture({ hidden = false } = {}) {
|
||||
const document = new Document();
|
||||
document.hidden = hidden;
|
||||
const root = new Node('section');
|
||||
const items = new Node();
|
||||
items.className = 'project-grid latest-grid';
|
||||
const fallback = new Node('article', 'Selected project');
|
||||
items.append(fallback);
|
||||
const status = new Node('p');
|
||||
root.selectors.set('[data-project-items]', items);
|
||||
root.selectors.set('[data-project-status]', status);
|
||||
document.selectors.set('#gitea-projects', root);
|
||||
const permanentLink = new Node('a', 'Public activity');
|
||||
permanentLink.href = `${ORIGIN}/eric`;
|
||||
document.append(permanentLink, root);
|
||||
const intervals = new Map();
|
||||
const cleared = [];
|
||||
let nextID = 0;
|
||||
const timers = {
|
||||
setIntervalImpl(callback, delay) { const id = ++nextID; intervals.set(id, { callback, delay }); return id; },
|
||||
clearIntervalImpl(id) { cleared.push(id); intervals.delete(id); }
|
||||
};
|
||||
return { document, root, items, fallback, status, permanentLink, timers, intervals, cleared };
|
||||
}
|
||||
|
||||
test('repository URLs require exact raw HTTPS origin and exact owner/name attribution', () => {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/Gaming/the-button`, 'Gaming/the-button'), `${ORIGIN}/Gaming/the-button`);
|
||||
for (const name of ['a/b', 'Org-1/name_2.3', '_org/.repo', `${'a'.repeat(100)}/${'b'.repeat(100)}`]) {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), `${ORIGIN}/${name}`);
|
||||
}
|
||||
for (const value of [null, {}, '/Gaming/the-button', '//gitea.eom.dev/Gaming/the-button',
|
||||
'javascript:alert(1)', 'data:text/html,attack', 'http://gitea.eom.dev/Gaming/the-button',
|
||||
'https://evil.test/Gaming/the-button', 'https://gitea.eom.dev.evil.test/Gaming/the-button',
|
||||
'https://user@gitea.eom.dev/Gaming/the-button', 'https://user:pass@gitea.eom.dev/Gaming/the-button',
|
||||
'https://gitea.eom.dev:443/Gaming/the-button', 'https://gitea.eom.dev:8443/Gaming/the-button',
|
||||
`${ORIGIN}/x/../Gaming/the-button`, `${ORIGIN}/Gaming/%74he-button`, `${ORIGIN}/Gaming/the-button#`,
|
||||
`${ORIGIN}/Gaming/the-button?`, `${ORIGIN}/Gaming/the-button/`, `${ORIGIN}/Gaming/the-button\n`,
|
||||
`${ORIGIN}/Gaming/the-button\\`, ` ${ORIGIN}/Gaming/the-button`, `${ORIGIN}/gaming/the-button`]) {
|
||||
assert.equal(validateRepositoryURL(value, 'Gaming/the-button'), null, String(value));
|
||||
}
|
||||
for (const name of ['', '.', '..', './repo', '../repo', 'Org/.', 'Org/..', 'Org/repo/more',
|
||||
'Org//repo', 'Org/<img>', 'Org/a b', 'Org/a%2fb', 'Org/repo\n', 'Org/répo', `${'a'.repeat(101)}/b`, null, {}]) {
|
||||
assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), null, String(name));
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamps are strict valid activity times, never repo.updated_at', () => {
|
||||
assert.equal(normalizeDate('2026-10-30T20:38:17Z', { now: NOW }), '2026-10-30T20:38:17.000Z');
|
||||
assert.equal(normalizeDate('2026-10-30T22:38:17+02:00', { now: NOW }), '2026-10-30T20:38:17.000Z');
|
||||
assert.equal(normalizeDate('2024-02-29T00:00:00.123Z', { now: NOW }), '2024-02-29T00:00:00.123Z');
|
||||
for (const value of [null, 123, {}, 'not a date', '2026-10-30', 'Oct 30', '2026-02-30T00:00:00Z',
|
||||
'2026-02-29T00:00:00Z', '2026-00-01T00:00:00Z', '2026-13-01T00:00:00Z',
|
||||
'2026-10-30T24:00:00Z', '2026-10-30T00:60:00Z', '2026-10-30T00:00:60Z',
|
||||
'2026-10-30T00:00:00+24:00', '2026-10-30T00:00:00+00:60', '2026-10-30T00:00:00',
|
||||
'2026-10-30T00:00:00Z\n', '2099-01-01T00:00:00Z']) assert.equal(normalizeDate(value, { now: NOW }), null, String(value));
|
||||
assert.equal(normalizeDate('2026-10-30T00:00:00Z', { now: NaN }), null);
|
||||
});
|
||||
|
||||
test('normalization finds personal public pushes across organizations, sorts and deduplicates repo IDs', () => {
|
||||
const entries = [event(1, '2026-10-28T00:00:00Z', 'eric/old'),
|
||||
event(2, '2026-10-29T00:00:00Z', 'Engineering/tool'),
|
||||
event(3, '2026-10-30T00:00:00Z', 'Gaming/game'),
|
||||
event(2, '2026-10-30T01:00:00Z', 'Engineering/tool'),
|
||||
event(4, '2026-10-30T02:00:00Z', 'Community/site')];
|
||||
const result = normalizeActivities(entries, { now: NOW });
|
||||
assert.deepEqual(result.map(item => item.fullName), ['Community/site', 'Engineering/tool', 'Gaming/game']);
|
||||
assert.deepEqual(result.map(item => item.id), [4, 2, 3]);
|
||||
assert.deepEqual(result.map(item => item.created), ['2026-10-30T02:00:00.000Z', '2026-10-30T01:00:00.000Z', '2026-10-30T00:00:00.000Z']);
|
||||
assert.deepEqual(result, normalizeActivities([...entries].reverse(), { now: NOW }));
|
||||
const tied = [event(10), event(9)];
|
||||
assert.deepEqual(normalizeActivities(tied, { now: NOW }), normalizeActivities([...tied].reverse(), { now: NOW }));
|
||||
assert.deepEqual(normalizeActivities([], { now: NOW }), []);
|
||||
for (const entries of [null, {}, Array(PAGE_SIZE * MAX_PAGES + 1).fill(null)]) assert.throws(() => normalizeActivities(entries));
|
||||
});
|
||||
|
||||
test('all privacy, actor, operation, shape, identity and original URL gates fail closed', () => {
|
||||
const good = event();
|
||||
const invalid = [null, [], {},
|
||||
...[true, undefined, null, 0, 'false'].map(is_private => event(1, undefined, undefined, { is_private })),
|
||||
...[true, undefined, null, 0, 'false'].map(privateValue => ({ ...good, repo: { ...good.repo, private: privateValue } })),
|
||||
...['Eric', 'other', '', null].map(login => ({ ...good, act_user: { login } })),
|
||||
...['create_repo', 'push', 'pull_request', null].map(op_type => ({ ...good, op_type })),
|
||||
...[null, [], {}].map(act_user => ({ ...good, act_user })),
|
||||
...[null, [], {}].map(repo => ({ ...good, repo })),
|
||||
...[0, -1, '111164', 1.5, Number.MAX_SAFE_INTEGER + 1, undefined].map(id => ({ ...good, repo: { ...good.repo, id } })),
|
||||
...[0, -1, '1', undefined].map(id => ({ ...good, id })),
|
||||
...[undefined, 'invalid', '2099-01-01T00:00:00Z'].map(created => ({ ...good, created })),
|
||||
{ ...good, repo: { ...good.repo, name: 'mismatch' } },
|
||||
{ ...good, repo: { ...good.repo, html_url: 'https://evil.test/Gaming/the-button' } }];
|
||||
assert.deepEqual(normalizeActivities(invalid, { now: NOW }), []);
|
||||
assert.equal(normalizeActivities([...invalid, good], { now: NOW }).length, 1);
|
||||
});
|
||||
|
||||
test('embedded commit JSON is bounded, first-line text only; hashes are constructed, sensitive metadata discarded', () => {
|
||||
const [project] = normalizeActivities([event()], { now: NOW });
|
||||
assert.equal(project.summary, 'SSO and turn gate');
|
||||
assert.equal(project.commitURL, `${ORIGIN}/Gaming/the-button/commit/${HASH}`);
|
||||
assert.deepEqual(Object.keys(project).sort(), ['commitURL', 'created', 'description', 'fullName', 'id', 'summary', 'url']);
|
||||
assert.doesNotMatch(JSON.stringify(project), /never-display|CompareURL|AuthorEmail|updated_at|ref_name/);
|
||||
for (const content of [undefined, null, {}, 'invalid JSON', 'null', '[]', '{}', '{"HeadCommit":[]}',
|
||||
'x'.repeat(MAX_CONTENT_BYTES + 1), JSON.stringify({ HeadCommit: { Message: 'é'.repeat(MAX_CONTENT_BYTES / 2) } })]) {
|
||||
const [fallback] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW });
|
||||
assert.equal(fallback.summary, 'The Button Game');
|
||||
assert.equal(fallback.commitURL, null);
|
||||
}
|
||||
for (const Sha1 of ['../bad', HASH + '\n', 'javascript:alert(1)', 'a'.repeat(39), 'g'.repeat(40), null]) {
|
||||
const content = JSON.stringify({ HeadCommit: { Sha1, Message: '<img onerror=alert(1)>\r\nprivate second line' } });
|
||||
const [item] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW });
|
||||
assert.equal(item.summary, '<img onerror=alert(1)>');
|
||||
assert.equal(item.commitURL, null);
|
||||
}
|
||||
for (const Sha1 of [HASH.toUpperCase(), 'A'.repeat(64)]) {
|
||||
const [item] = normalizeActivities([event(1, undefined, undefined, { content: JSON.stringify({ HeadCommit: { Sha1 } }) })], { now: NOW });
|
||||
assert.ok(item.commitURL.endsWith(Sha1.toLowerCase()));
|
||||
}
|
||||
const long = event();
|
||||
long.repo.description = 'd'.repeat(500);
|
||||
long.content = JSON.stringify({ HeadCommit: { Message: 's'.repeat(500) + '\nignored' } });
|
||||
const [bounded] = normalizeActivities([long], { now: NOW });
|
||||
assert.equal(bounded.description.length, 240);
|
||||
assert.equal(bounded.summary.length, 200);
|
||||
});
|
||||
|
||||
test('fetch uses only the fixed public activity endpoint, anonymous safe headers and bounded pages', async () => {
|
||||
assert.equal(PAGE_SIZE, 10);
|
||||
assert.equal(MAX_PAGES, 15);
|
||||
assert.equal(PAGE_SIZE * MAX_PAGES, 150);
|
||||
const requests = [];
|
||||
const result = await fetchActivityPage(1, options(async (url, init) => {
|
||||
requests.push({ url, init }); return jsonResponse([event()], { 'X-Total-Count': '1166' });
|
||||
}));
|
||||
assert.equal(result.total, 1166);
|
||||
assert.equal(result.events.length, 1);
|
||||
const [{ url, init }] = requests;
|
||||
assert.equal(url, `${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=1`);
|
||||
assert.equal(init.method, 'GET');
|
||||
assert.equal(init.credentials, 'omit');
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.referrerPolicy, 'no-referrer');
|
||||
assert.equal(init.cache, 'no-store');
|
||||
assert.deepEqual(init.headers, { Accept: 'application/json' });
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
let calls = 0;
|
||||
for (const page of [0, MAX_PAGES + 1, '1', 1.5, -1, null, 'https://evil.test/', `${ORIGIN}/api/v1/users/eric/repos`]) {
|
||||
await assert.rejects(fetchActivityPage(page, options(async () => { calls++; })), /page/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal((await fetchActivityPage(1, options(async () => jsonResponse([])))).total, null);
|
||||
});
|
||||
|
||||
test('fetch rejects status, redirects, offorigin response, MIME, invalid totals and malformed/oversized arrays', async () => {
|
||||
for (const response of [new Response('{}', { status: 503 }),
|
||||
new Response('[]', { headers: { 'Content-Type': 'text/html' } }),
|
||||
new Response('[]'), new Response('[]', { headers: { 'Content-Type': 'application/problem+json' } }),
|
||||
{ ok: true, redirected: true }, { ok: true, url: 'https://evil.test/' },
|
||||
{ ok: true, headers: new Headers({ 'Content-Type': 'application/json' }) },
|
||||
...['-1', '1.5', 'NaN', '', '9007199254740992'].map(total => jsonResponse([], { 'X-Total-Count': total })),
|
||||
...[{}, null, { data: [] }, '[]', Array(PAGE_SIZE + 1).fill(null)].map(value => jsonResponse(value)),
|
||||
new Response('broken json', { headers: { 'Content-Type': 'application/json' } })]) {
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => response)));
|
||||
}
|
||||
// Unknown fields and malformed individual records are tolerated, but never normalized to cards.
|
||||
const result = await fetchRecentProjects(options(async () => jsonResponse([null, [], { irrelevant: 'field' }])));
|
||||
assert.deepEqual(result.items, []);
|
||||
});
|
||||
|
||||
test('streamed response byte limits include UTF-8 and content-length, without response.text allocation', async () => {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({ start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('["ééé"]'));
|
||||
}, cancel() { cancelled = true; } });
|
||||
await assert.rejects(fetchActivityPage(1, { maxBytes: 8, fetchImpl: async () => new Response(stream,
|
||||
{ headers: { 'Content-Type': 'application/json' } }) }), /too large/);
|
||||
assert.equal(cancelled, true);
|
||||
for (const length of [String(MAX_BYTES + 1), '-1', 'not-a-number']) {
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => jsonResponse([], { 'Content-Length': length }))), /too large/);
|
||||
}
|
||||
const invalidUTF8 = new Response(new Uint8Array([0xff]), { headers: { 'Content-Type': 'application/json' } });
|
||||
await assert.rejects(fetchActivityPage(1, options(async () => invalidUTF8)));
|
||||
let calls = 0;
|
||||
for (const bounds of [{ maxBytes: MAX_BYTES + 1 }, { maxBytes: 0 }, { maxBytes: 1.5 },
|
||||
{ timeoutMs: 0 }, { timeoutMs: 10_001 }, { timeoutMs: Infinity }]) {
|
||||
await assert.rejects(fetchActivityPage(1, { ...bounds, fetchImpl: async () => { calls++; } }), /bounds/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('10-second default timeout is bounded; injected short timeout covers fetch and stalled body', async () => {
|
||||
let signal;
|
||||
await assert.rejects(fetchActivityPage(1, { timeoutMs: 5, fetchImpl: async (_, init) => {
|
||||
signal = init.signal; return new Promise(() => {});
|
||||
} }), { name: 'AbortError' });
|
||||
assert.equal(signal.aborted, true);
|
||||
let cancelled = false;
|
||||
await assert.rejects(fetchActivityPage(1, { timeoutMs: 5, fetchImpl: async () => ({ ok: true,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }), body: { getReader: () => ({
|
||||
read: () => new Promise(() => {}), cancel: async () => { cancelled = true; }
|
||||
}) } }) }), { name: 'AbortError' });
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('external cancellation and already-aborted signals prevent delayed fetch/body results', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) });
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('pagination stops after three valid unique projects, not three events or repository owners', async () => {
|
||||
const pages = [];
|
||||
const result = await fetchRecentProjects(options(async url => {
|
||||
pages.push(Number(new URL(url).searchParams.get('page')));
|
||||
return jsonResponse(fullPage([event(1, undefined, 'Gaming/game'), event(2, undefined, 'Engineering/tool'),
|
||||
event(3, undefined, 'Community/site')]), { 'X-Total-Count': '1166' });
|
||||
}));
|
||||
assert.deepEqual(pages, [1]);
|
||||
assert.equal(result.items.length, 3);
|
||||
assert.equal(result.scannedEvents, PAGE_SIZE);
|
||||
assert.equal(result.total, 1166);
|
||||
assert.equal(result.pages, 1);
|
||||
});
|
||||
|
||||
test('multiple pages merge and sort activity.created across owners; invalid/private records do not stop scanning', async () => {
|
||||
const requested = [];
|
||||
const result = await fetchRecentProjects(options(async url => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
requested.push(page);
|
||||
const entries = page === 1 ? fullPage([event(1, '2026-10-28T00:00:00Z', 'Gaming/game'),
|
||||
event(1, '2026-10-29T00:00:00Z', 'Gaming/game'), event(2, undefined, 'Private/secret', { is_private: true })])
|
||||
: [event(1, '2026-10-30T00:00:00Z', 'Gaming/game'), event(3, '2026-10-29T23:00:00Z', 'Engineering/tool'),
|
||||
event(4, '2026-10-29T22:00:00Z', 'Community/site')];
|
||||
return jsonResponse(entries, { 'X-Total-Count': page === 1 ? '1166' : '1167' });
|
||||
}));
|
||||
assert.deepEqual(requested, [1, 2]);
|
||||
assert.deepEqual(result.items.map(item => item.id), [1, 3, 4]);
|
||||
assert.equal(result.scannedEvents, PAGE_SIZE + 3);
|
||||
assert.equal(result.total, 1166);
|
||||
});
|
||||
|
||||
test('bounded page cap and shorter pages terminate regardless of huge, missing or zero total headers', async () => {
|
||||
for (const headers of [{}, { 'X-Total-Count': '999999999' }, { 'X-Total-Count': '0' }]) {
|
||||
let calls = 0;
|
||||
const requested = [];
|
||||
const capped = await fetchRecentProjects(options(async url => {
|
||||
calls++;
|
||||
requested.push(url);
|
||||
return jsonResponse(fullPage([event()]), headers);
|
||||
}));
|
||||
assert.equal(calls, MAX_PAGES);
|
||||
assert.deepEqual(requested, Array.from({ length: MAX_PAGES }, (_, index) =>
|
||||
`${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=${index + 1}`));
|
||||
assert.equal(capped.pages, MAX_PAGES);
|
||||
assert.equal(capped.scannedEvents, PAGE_SIZE * MAX_PAGES);
|
||||
assert.equal(capped.items.length, 1);
|
||||
calls = 0;
|
||||
const short = await fetchRecentProjects(options(async () => { calls++; return jsonResponse([event()], headers); }));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(short.scannedEvents, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('a required later page failure rejects the whole refresh instead of publishing partial cards', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchRecentProjects(options(async () => {
|
||||
if (++calls === 1) return jsonResponse(fullPage([event()]));
|
||||
throw new Error('Later page unavailable');
|
||||
})), /Later page/);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('card DOM uses honest org/name heading, safe plain text, push date and exact repository/commit links', () => {
|
||||
const document = new Document();
|
||||
const sample = event();
|
||||
sample.repo.description = '<script>alert(1)</script>';
|
||||
sample.content = JSON.stringify({ HeadCommit: { Message: '<img onerror=alert(1)>\nnever show this line', Sha1: HASH,
|
||||
AuthorEmail: 'never-display@example.test' } });
|
||||
const [project] = normalizeActivities([sample], { now: NOW });
|
||||
const card = createCard(document, project);
|
||||
const nodes = walk(card);
|
||||
assert.equal(card.localName, 'article');
|
||||
assert.equal(card.className, 'project-card');
|
||||
assert.equal(nodes.find(node => node.localName === 'h3').textContent, 'Gaming/the-button');
|
||||
assert.ok(nodes.some(node => node.textContent === '<script>alert(1)</script>'));
|
||||
assert.ok(nodes.some(node => node.textContent === '<img onerror=alert(1)>'));
|
||||
assert.ok(nodes.every(node => !['script', 'img', 'iframe'].includes(node.localName)));
|
||||
assert.ok(nodes.every(node => !/never-display|never show this line|2099/.test(node.textContent)));
|
||||
const time = nodes.find(node => node.localName === 'time');
|
||||
assert.equal(time.dateTime, project.created);
|
||||
assert.match(time.textContent, /^Public push · Oct 30, 2026$/);
|
||||
const links = nodes.filter(node => node.localName === 'a');
|
||||
assert.deepEqual(links.map(node => node.textContent), ['Explore repository →', 'View this commit']);
|
||||
assert.deepEqual(links.map(node => node.href), [project.url, project.commitURL]);
|
||||
assert.ok(links.every(node => node.className === 'text-link'));
|
||||
assert.ok(links.every(node => node.rel === 'noopener noreferrer' && node.referrerPolicy === 'no-referrer'));
|
||||
});
|
||||
|
||||
test('public card boundary revalidates URLs and dates, limits text and never accepts arbitrary commit links', () => {
|
||||
const document = new Document();
|
||||
const [project] = normalizeActivities([event()], { now: NOW });
|
||||
for (const changes of [{ url: 'https://evil.test/Gaming/the-button' }, { fullName: '../bad' }, { created: 'invalid' }]) {
|
||||
assert.throws(() => createCard(document, { ...project, ...changes }), /card/);
|
||||
}
|
||||
for (const commitURL of ['https://evil.test/commit/' + HASH, project.url + '/compare/' + HASH,
|
||||
project.commitURL + '?tracking=1', project.commitURL + '\n', 'javascript:alert(1)', null]) {
|
||||
const nodes = walk(createCard(document, { ...project, commitURL, description: 'd'.repeat(500), summary: 's'.repeat(500) }));
|
||||
assert.equal(nodes.filter(node => node.localName === 'a').length, 1);
|
||||
assert.equal(nodes.find(node => node.className === 'project-description').textContent.length, 240);
|
||||
assert.equal(nodes.find(node => node.className === 'project-summary').textContent.length, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('init leaves the host grid and permanent activity link intact, renders results and checks every 15 minutes', async t => {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([event()]); }) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children.length, 1);
|
||||
assert.notEqual(f.items.children[0], f.fallback);
|
||||
assert.equal(f.items.className, 'project-grid latest-grid');
|
||||
assert.equal(f.status.getAttribute('role'), 'status');
|
||||
assert.match(f.status.textContent, /^Recent public pushes · Checked/);
|
||||
assert.match(f.status.textContent, /1 recent activity events checked.*not all history/);
|
||||
assert.match(f.status.textContent, /up to 150; not all history/);
|
||||
assert.equal(f.document.children[0], f.permanentLink);
|
||||
const [{ callback, delay }] = [...f.intervals.values()];
|
||||
assert.equal(delay, REFRESH_MS);
|
||||
callback();
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
controller.stop();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.document.listeners.has('visibilitychange'), false);
|
||||
await controller.refresh();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('successful zero results replace static fallback with honest empty message', async t => {
|
||||
const f = fixture();
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => jsonResponse([])) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children.length, 1);
|
||||
assert.equal(f.items.children[0].localName, 'p');
|
||||
assert.equal(f.items.children[0].textContent, 'No recent public pushes found.');
|
||||
assert.match(f.status.textContent, /Recent public pushes · Checked/);
|
||||
});
|
||||
|
||||
test('initial fetch or later pagination failure retains selected fallback; refresh failure retains previous cards', async t => {
|
||||
for (const partial of [false, true]) {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
let succeed = false;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => {
|
||||
calls++;
|
||||
if (succeed) return jsonResponse([event()]);
|
||||
if (partial && calls === 1) return jsonResponse(fullPage([event()]));
|
||||
throw new Error('<script>do not expose response details</script>');
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing selected projects.');
|
||||
succeed = true;
|
||||
await controller.refresh();
|
||||
const previous = f.items.children[0];
|
||||
succeed = false;
|
||||
await controller.refresh();
|
||||
assert.equal(f.items.children[0], previous);
|
||||
assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing previously checked results.');
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden initial page makes no request or timer; visible return retries and starts timer', async t => {
|
||||
const f = fixture({ hidden: true });
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([]); }) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
f.document.hidden = false;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
});
|
||||
|
||||
test('hiding aborts in-flight refresh, pauses timers and blocks delayed swaps; returning retries', async t => {
|
||||
const f = fixture();
|
||||
const late = deferred();
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => {
|
||||
calls++;
|
||||
if (calls === 1) { signal = init.signal; return late.promise; }
|
||||
return jsonResponse([event(2, undefined, 'Engineering/current')]);
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
f.document.hidden = true;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await controller.ready;
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.items.children[0], f.fallback);
|
||||
assert.match(f.status.textContent, /paused.*selected/);
|
||||
f.document.hidden = false;
|
||||
f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
const current = f.items.children[0];
|
||||
assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Engineering/current');
|
||||
late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0], current);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('visible fresh return avoids requests, stale return refreshes using injected time', async t => {
|
||||
const f = fixture();
|
||||
let now = NOW;
|
||||
let calls = 0;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, now: () => now,
|
||||
fetchImpl: async () => { calls++; return jsonResponse([event()]); } });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
const hideAndReturn = () => {
|
||||
f.document.hidden = true; f.document.dispatch('visibilitychange');
|
||||
f.document.hidden = false; f.document.dispatch('visibilitychange');
|
||||
};
|
||||
hideAndReturn();
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.match(f.status.textContent, /Previously checked/);
|
||||
now += REFRESH_MS;
|
||||
hideAndReturn();
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
});
|
||||
|
||||
test('failed or interrupted refresh retries on return even when last success is fresh', async t => {
|
||||
const f = fixture();
|
||||
let calls = 0;
|
||||
const late = deferred();
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => {
|
||||
calls++;
|
||||
return calls === 2 ? late.promise : jsonResponse([event()]);
|
||||
}) });
|
||||
t.after(() => controller.stop());
|
||||
await controller.ready;
|
||||
const pending = controller.refresh();
|
||||
f.document.hidden = true; f.document.dispatch('visibilitychange');
|
||||
await pending;
|
||||
f.document.hidden = false; f.document.dispatch('visibilitychange');
|
||||
await tick();
|
||||
assert.equal(calls, 3);
|
||||
late.resolve(jsonResponse([]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0].localName, 'article');
|
||||
});
|
||||
|
||||
test('new refresh generations and stop both prevent non-cooperative delayed result swaps', async () => {
|
||||
for (const stop of [false, true]) {
|
||||
const f = fixture();
|
||||
const late = deferred();
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => {
|
||||
if (++calls === 1) { signal = init.signal; return late.promise; }
|
||||
return jsonResponse([event(2, undefined, 'Community/current')]);
|
||||
}) });
|
||||
try {
|
||||
if (stop) controller.stop();
|
||||
else await controller.refresh();
|
||||
await controller.ready;
|
||||
assert.equal(signal.aborted, true);
|
||||
const current = f.items.children[0];
|
||||
late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')]));
|
||||
await tick();
|
||||
assert.equal(f.items.children[0], current);
|
||||
if (stop) assert.equal(current, f.fallback);
|
||||
else assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Community/current');
|
||||
} finally { controller.stop(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('absent/incomplete DOM contracts are inert and imports have no browser dependency', async () => {
|
||||
let calls = 0;
|
||||
for (const document of [undefined, new Document(), fixture().root]) {
|
||||
const controller = initGiteaProjects(document, options(async () => { calls++; }));
|
||||
await controller.ready;
|
||||
await controller.refresh();
|
||||
controller.stop();
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
const source = await readFile(new URL('../htdocs/static/gitea-projects.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /\b(?:innerHTML|outerHTML|insertAdjacentHTML|eval|localStorage|sessionStorage)\b|document\.write|peertube-feeds|Creator Launch/);
|
||||
});
|
||||
Executable
+579
@@ -0,0 +1,579 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ORIGIN, ORIGINALS_UUID, MAX_BYTES, REFRESH_MS, validateOriginURL, validateWatchURL,
|
||||
validateThumbnailURL, normalizeDate, sortAndDedupe, normalizeJSONVideos,
|
||||
fetchText, fetchJSON, channelVideosURL, fetchChannel, fetchPaginated, normalizeCollections,
|
||||
discoverCollections, fetchCollection, loadRegion, createCard, createLibraryController, initPeerTubeFeeds
|
||||
} from '../htdocs/static/peertube-feeds.mjs';
|
||||
|
||||
const UUID = '12345678-1234-1234-1234-123456789abc';
|
||||
const video = (id, date = '2026-10-01T12:00:00Z', extra = {}) => ({
|
||||
url: `${ORIGIN}/w/${id}`, title: id, publishedAt: date, source: 'Travel', ...extra
|
||||
});
|
||||
const jsonResponse = value => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } });
|
||||
// Minimal DOM contract for cards, selections and refresh behavior.
|
||||
class Node {
|
||||
constructor(tag = 'div', content = '') {
|
||||
this.localName = tag;
|
||||
this.textContent = content;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.selectors = new Map();
|
||||
this.value = '';
|
||||
}
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
addEventListener(name, callback, options) { this.listeners.set(name, { callback, options }); }
|
||||
removeEventListener(name) { this.listeners.delete(name); }
|
||||
dispatch(name) {
|
||||
const listener = this.listeners.get(name);
|
||||
listener?.callback();
|
||||
if (listener?.options?.once) this.listeners.delete(name);
|
||||
}
|
||||
querySelector(selector) { return this.selectors.get(selector) ?? null; }
|
||||
}
|
||||
class Document extends Node {
|
||||
constructor() { super(); this.hidden = false; this.regions = []; }
|
||||
createElement(tag) { return new Node(tag); }
|
||||
querySelectorAll(selector) { return selector === '[data-peertube-feed]' ? this.regions : []; }
|
||||
}
|
||||
function region(document, channels = 'eric_tech,eric_gaming') {
|
||||
const root = new Node();
|
||||
root.setAttribute('data-channels', channels);
|
||||
root.setAttribute('data-title', 'Workshop');
|
||||
const items = new Node();
|
||||
const fallback = new Node('article', 'Selected recording');
|
||||
items.append(fallback);
|
||||
const status = new Node('p');
|
||||
root.selectors.set('[data-feed-items]', items);
|
||||
root.selectors.set('[data-feed-status]', status);
|
||||
document.regions.push(root);
|
||||
return { root, items, fallback, status };
|
||||
}
|
||||
function library(document) {
|
||||
const root = new Node();
|
||||
const select = new Node('select');
|
||||
select.disabled = true;
|
||||
const items = new Node();
|
||||
const status = new Node('p');
|
||||
const link = new Node('a');
|
||||
link.href = `${ORIGIN}/accounts/eric/videos`;
|
||||
for (const [selector, node] of [['#peertube-collection', select], ['[data-library-items]', items], ['[data-library-status]', status], ['[data-library-link]', link]]) root.selectors.set(selector, node);
|
||||
document.selectors.set('#peertube-library', root);
|
||||
return { root, select, items, status, link };
|
||||
}
|
||||
const timers = { setIntervalImpl: () => 1, clearIntervalImpl: () => {} };
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
test('watch and thumbnail URLs use exact secure origin and narrow paths', () => {
|
||||
assert.equal(validateWatchURL('/w/Ab123'), `${ORIGIN}/w/Ab123`);
|
||||
assert.equal(validateWatchURL(`${ORIGIN}/videos/watch/${UUID}`), `${ORIGIN}/videos/watch/${UUID}`);
|
||||
assert.equal(validateThumbnailURL('/lazy-static/thumbnails/test.webp'), `${ORIGIN}/lazy-static/thumbnails/test.webp`);
|
||||
assert.equal(validateThumbnailURL('/static/thumbnails/ab/test.jpg'), `${ORIGIN}/static/thumbnails/ab/test.jpg`);
|
||||
for (const value of [null, {}, 'javascript:alert(1)', 'data:image/png;base64,x', 'http://peertube.eom.dev/w/a',
|
||||
'https://evil.test/w/a', 'https://peertube.eom.dev.evil.test/w/a', 'https://user@peertube.eom.dev/w/a',
|
||||
'https://peertube.eom.dev:443/w/a', 'https://peertube.eom.dev:8443/w/a', '//peertube.eom.dev/w/a',
|
||||
`${ORIGIN}/x/../w/a`, '/w/../w/a', '/w/%2e%2e/a', '/w/%61', '/w/a#x', '/w/a?x=1',
|
||||
'/w/a\\b', '/w/<script>', '/w/a\n', '/videos/watch/not-a-uuid', '/w/a/b', `${ORIGIN}/w/a/`]) {
|
||||
assert.equal(validateWatchURL(value), null, String(value));
|
||||
}
|
||||
for (const value of ['/static/thumbnails/a.svg', '/lazy-static/thumbnails/../a.jpg', '/static/thumbnails/a.jpg?track=1',
|
||||
'/static/thumbnails/%2f.jpg', '/other/a.jpg', 'https://evil.test/static/thumbnails/a.png']) assert.equal(validateThumbnailURL(value), null, value);
|
||||
assert.equal(validateOriginURL(`${ORIGIN}/api/v1/accounts/eric/video-channels?count=100&start=0`).origin, ORIGIN);
|
||||
});
|
||||
|
||||
test('normalization skips invalid dates, deduplicates and sorts latest, not playlist position', () => {
|
||||
const items = [video('older', '2020-01-01T00:00:00Z'), video('same', '2026-10-02T00:00:00Z'),
|
||||
video('same', '2026-10-03T00:00:00Z'), video('invalid', 'not a date'), video('bad', '2026-02-30T00:00:00Z'),
|
||||
video('newest', '2026-10-04T00:00:00Z')];
|
||||
assert.deepEqual(sortAndDedupe(items).map(item => item.title), ['newest', 'same', 'older']);
|
||||
assert.equal(normalizeDate('2026-02-30'), null);
|
||||
assert.equal(normalizeDate('Mon, 30 Feb 2026 12:00:00 GMT'), null);
|
||||
assert.equal(normalizeDate('Jan 1'), null);
|
||||
assert.equal(normalizeDate('2026-13-01'), null);
|
||||
assert.equal(normalizeDate(1234), null);
|
||||
assert.equal(normalizeDate('2027-01-01', { now: Date.parse('2026-10-01'), maxFutureMs: 60_000 }), null);
|
||||
assert.equal(normalizeDate('Wed, 01 Oct 2025 12:00:00 GMT'), '2025-10-01T12:00:00.000Z');
|
||||
const tied = [video('z'), video('a')];
|
||||
assert.deepEqual(sortAndDedupe(tied), sortAndDedupe([...tied].reverse()));
|
||||
assert.throws(() => sortAndDedupe(Array(1001).fill(video('a'))));
|
||||
assert.throws(() => sortAndDedupe([], -1));
|
||||
const normalized = normalizeJSONVideos([
|
||||
{ video: { url: `${ORIGIN}/w/old`, name: 'Older', publishedAt: '2020-01-01', privacy: { id: 1 } } },
|
||||
{ video: { url: `${ORIGIN}/videos/watch/${UUID}`, uuid: UUID, name: '<img onerror=alert(1)>', publishedAt: '2026-10-02', thumbnailPath: '/static/thumbnails/a.jpg' } },
|
||||
{ video: { url: `${ORIGIN}/w/private`, name: 'Private', publishedAt: '2026-10-03', privacy: { id: 2 } } }
|
||||
], 'Originals');
|
||||
assert.equal(normalized[0].title, '<img onerror=alert(1)>');
|
||||
assert.equal(normalized[0].thumbnail, `${ORIGIN}/static/thumbnails/a.jpg`);
|
||||
assert.equal(normalized[0].source, 'Originals');
|
||||
assert.equal(normalized.length, 2);
|
||||
});
|
||||
|
||||
test('actual channel and nested playlist API shapes validate privacy, URLs and plain metadata', () => {
|
||||
const result = normalizeJSONVideos([
|
||||
{ id: 10, uuid: UUID, shortUUID: 'Short123', url: `/videos/watch/${UUID}`, name: '<b>Title</b>',
|
||||
publishedAt: '2026-10-02T12:00:00Z', thumbnailPath: '/lazy-static/thumbnails/a.jpg',
|
||||
privacy: { id: 1, label: 'Public' }, description: '<script>not rendered</script>' },
|
||||
{ position: 1, startTimestamp: null, video: { url: '/w/Short123', name: 'Short', publishedAt: '2026-10-01T12:00:00Z' } },
|
||||
...[2, 3, 4, '1', null].map(id => ({ url: '/w/private', publishedAt: '2026-10-03', privacy: { id } })),
|
||||
{ url: '/w/malformed', publishedAt: '2026-10-03', privacy: null },
|
||||
{ url: 'https://evil.test/w/attack', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/unsafe', publishedAt: '2026-10-03', thumbnailPath: 'javascript:alert(1)' }
|
||||
], 'Music');
|
||||
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/unsafe`, `${ORIGIN}/videos/watch/${UUID}`, `${ORIGIN}/w/Short123`]);
|
||||
assert.equal(result[0].thumbnail, null);
|
||||
assert.equal(result[1].title, '<b>Title</b>');
|
||||
assert.equal(result[1].thumbnail, `${ORIGIN}/lazy-static/thumbnails/a.jpg`);
|
||||
assert.equal(result[1].source, 'Music');
|
||||
assert.equal('description' in result[1], false);
|
||||
assert.deepEqual(normalizeJSONVideos([]), []);
|
||||
});
|
||||
|
||||
test('JSON UUID identity deduplicates short and long watch URLs only after original URL validation', async () => {
|
||||
const entries = [
|
||||
{ url: '/w/Short123', uuid: UUID.toUpperCase(), name: 'Newest copy', publishedAt: '2026-10-03' },
|
||||
{ video: { url: `/videos/watch/${UUID}`, uuid: UUID, name: 'Older copy', publishedAt: '2026-10-02' } },
|
||||
...['https://evil.test/w/Short123', `${ORIGIN}/w/../w/Short123`, '/w/Short123?track=1',
|
||||
'https://peertube.eom.dev:443/w/Short123', 'javascript:alert(1)', undefined]
|
||||
.map(url => ({ url, uuid: UUID, name: 'Unsafe copy', publishedAt: '2026-10-04' }))
|
||||
];
|
||||
const normalized = normalizeJSONVideos(entries, 'Originals');
|
||||
assert.equal(normalized.length, 1);
|
||||
assert.equal(normalized[0].url, `${ORIGIN}/videos/watch/${UUID}`);
|
||||
assert.equal(normalized[0].title, 'Newest copy');
|
||||
assert.equal(normalized[0].source, 'Originals');
|
||||
assert.deepEqual(normalized, normalizeJSONVideos([...entries].reverse(), 'Originals'));
|
||||
for (const uuid of [undefined, null, {}, 'invalid', `${UUID}/../bad`]) {
|
||||
assert.equal(normalizeJSONVideos([{ url: '/w/Short123', uuid, publishedAt: '2026-10-03' }])[0].url, `${ORIGIN}/w/Short123`);
|
||||
}
|
||||
const merged = await loadRegion(['eric_channel', 'eric_music'], 'Publications', { fetchImpl: async value =>
|
||||
jsonResponse({ total: 1, data: [value.includes('/eric_channel/') ? entries[0] : entries[1]] }) });
|
||||
assert.equal(merged.items.length, 1);
|
||||
assert.equal(merged.items[0].url, `${ORIGIN}/videos/watch/${UUID}`);
|
||||
});
|
||||
|
||||
test('invalid local channel names cannot trigger fetches through any channel entry point', async () => {
|
||||
let calls = 0;
|
||||
const dependencies = { fetchImpl: async () => { calls++; return jsonResponse({ total: 0, data: [] }); } };
|
||||
for (const name of ['2', '../evil', 'eric@host', 'eric/other', 'eric?x=1', 'eric%2fother', 'eric#x', 'eric\n', `a${'b'.repeat(100)}`, '', null]) {
|
||||
await assert.rejects(fetchChannel(name, 'Invalid', dependencies), /channel name/);
|
||||
await assert.rejects(fetchCollection({ type: 'channel', id: name, title: 'Invalid' }, dependencies), /channel name/);
|
||||
await assert.rejects(loadRegion(['eric_tech', name], 'Invalid', dependencies), /feed channels/);
|
||||
if (typeof name === 'string') await assert.rejects(fetchPaginated(`/api/v1/video-channels/${name}/videos`, dependencies), /endpoint/);
|
||||
assert.deepEqual(normalizeCollections([{ id: 2, name }], []).channels, []);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('fetch enforces MIME, status, redirect, omitted credentials and streamed byte cap', async () => {
|
||||
const url = channelVideosURL('eric_channel', { count: 3 });
|
||||
let options;
|
||||
assert.equal(await fetchText(url, { fetchImpl: async (_, init) => { options = init; return jsonResponse({ total: 0, data: [] }); } }), '{"total":0,"data":[]}');
|
||||
assert.equal(options.headers.Accept, 'application/json');
|
||||
assert.equal(options.credentials, 'omit');
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.equal(options.referrerPolicy, 'no-referrer');
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
for (const response of [new Response('<html/>', { headers: { 'Content-Type': 'text/html' } }),
|
||||
new Response('fail', { status: 503, headers: { 'Content-Type': 'application/json' } }),
|
||||
new Response('{}', { headers: { 'Content-Type': 'application/json', 'Content-Length': String(MAX_BYTES + 1) } }),
|
||||
new Response('123456789', { headers: { 'Content-Type': 'application/json' } })]) {
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => response, maxBytes: 8 }));
|
||||
}
|
||||
await assert.rejects(fetchJSON(url, { fetchImpl: async () => new Response('invalid JSON', { headers: { 'Content-Type': 'application/json' } }) }), SyntaxError);
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, redirected: true }) }));
|
||||
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, url: 'https://evil.test/' }) }));
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchText('https://evil.test/', { fetchImpl: async () => { calls++; } }));
|
||||
await assert.rejects(fetchText(`${ORIGIN}/w/notAnAPI`, { fetchImpl: async () => { calls++; } }));
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('timeout and external abort settle even a non-cooperative fetch or hanging reader', async () => {
|
||||
const url = channelVideosURL('eric_channel');
|
||||
let observedSignal;
|
||||
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async (_, init) => { observedSignal = init.signal; return new Promise(() => {}); } }), { name: 'AbortError' });
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
const controller = new AbortController();
|
||||
const pending = fetchText(url, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) });
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
let cancelled = false;
|
||||
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async () => ({ ok: true, headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
body: { getReader: () => ({ read: () => new Promise(() => {}), cancel: async () => { cancelled = true; } }) } }) }), { name: 'AbortError' });
|
||||
assert.equal(cancelled, true);
|
||||
const already = new AbortController();
|
||||
already.abort();
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchText(url, { signal: already.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('channel latest and library selection request exactly three newest videos without pagination', async () => {
|
||||
assert.equal(channelVideosURL('eric_tech'), `${ORIGIN}/api/v1/video-channels/eric_tech/videos?count=100&start=0&sort=-publishedAt`);
|
||||
assert.equal(channelVideosURL('eric_gaming', { start: 100 }), `${ORIGIN}/api/v1/video-channels/eric_gaming/videos?count=100&start=100&sort=-publishedAt`);
|
||||
for (const name of ['eric_channel', 'eric_music', 'a', 'A.name-1_', `a${'b'.repeat(99)}`]) assert.ok(channelVideosURL(name).includes(`/video-channels/${name}/videos`));
|
||||
for (const name of [0, 2, -1, '2', '02', '2/videos', 'eric?count=100', '../eric', 'eric/../other', 'eric%2fother', 'eric#fragment',
|
||||
'eric@host', '//evil.test', '_eric', ' eric', 'eric ', 'eric\\other', 'eric\n', 'eric\r', `a${'b'.repeat(100)}`, '', 1.5, null, {}, undefined]) assert.throws(() => channelVideosURL(name));
|
||||
for (const bounds of [{ count: 101 }, { count: 0 }, { count: '3' }, { start: -1 }, { start: 1.5 }, { start: 1001 }]) assert.throws(() => channelVideosURL('eric_channel', bounds));
|
||||
const requests = [];
|
||||
const dependencies = { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
requests.push(url.href);
|
||||
assert.equal(url.pathname, '/api/v1/video-channels/eric_tech/videos');
|
||||
assert.deepEqual([...url.searchParams], [['count', '3'], ['start', '0'], ['sort', '-publishedAt']]);
|
||||
return jsonResponse({ total: 5000, data: [
|
||||
{ url: `/videos/watch/${UUID}`, name: 'Newest', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/second', name: 'Second', publishedAt: '2026-10-02' },
|
||||
{ url: '/w/third', name: 'Third', publishedAt: '2026-10-01' }
|
||||
] });
|
||||
} };
|
||||
assert.deepEqual((await fetchChannel('eric_tech', 'Travel', dependencies)).map(item => item.title), ['Newest', 'Second', 'Third']);
|
||||
assert.equal(requests.length, 1);
|
||||
const collection = normalizeCollections([{ id: 701, name: 'eric_tech', displayName: 'Travel' }], []).channels[0];
|
||||
assert.equal(collection.key, 'channel:701');
|
||||
const selected = await fetchCollection(collection, dependencies);
|
||||
assert.equal(selected.length, 3);
|
||||
assert.ok(selected.every(item => item.source === 'Travel'));
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('latest channel JSON rejects malformed envelopes and filters nonpublic or unsafe cards', async () => {
|
||||
for (const result of [null, {}, [], { total: '1', data: [] }, { total: -1, data: [] },
|
||||
{ total: 2, data: [] }, { total: 1, data: [null] }, { total: 1, data: [[]] },
|
||||
{ total: 4, data: Array(4).fill(video('a')) }, { total: 0, data: [video('a')] }, { total: 2, data: [video('a')] }]) {
|
||||
await assert.rejects(fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse(result) }));
|
||||
}
|
||||
const result = await fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse({ total: 3, data: [
|
||||
{ url: '/w/public', publishedAt: '2026-10-03', privacy: { id: 1 } },
|
||||
{ url: '/w/unlisted', publishedAt: '2026-10-02', privacy: { id: 2 } },
|
||||
{ url: 'https://evil.test/w/unsafe', publishedAt: '2026-10-01' }
|
||||
] }) });
|
||||
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/public`]);
|
||||
});
|
||||
|
||||
test('optional full channel pagination requests sorted API pages', async () => {
|
||||
const starts = [];
|
||||
const result = await fetchPaginated('/api/v1/video-channels/eric_gaming/videos', { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
assert.equal(url.pathname, '/api/v1/video-channels/eric_gaming/videos');
|
||||
assert.equal(url.searchParams.get('count'), '100');
|
||||
assert.equal(url.searchParams.get('sort'), '-publishedAt');
|
||||
const start = Number(url.searchParams.get('start'));
|
||||
starts.push(start);
|
||||
return jsonResponse({ total: 101, data: Array.from({ length: Math.min(100, 101 - start) }, (_, i) => ({ id: start + i })) });
|
||||
} });
|
||||
assert.deepEqual(starts, [0, 100]);
|
||||
assert.equal(result.length, 101);
|
||||
});
|
||||
|
||||
test('playlist discovery paginates and includes only explicitly public playlists plus new channels', async () => {
|
||||
const requests = [];
|
||||
const result = await discoverCollections({ fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
requests.push(url);
|
||||
if (url.pathname.endsWith('video-channels')) return jsonResponse({ total: 1, data: [{ id: 900, name: 'new_channel', displayName: 'New channel' }] });
|
||||
const start = +url.searchParams.get('start');
|
||||
const all = [
|
||||
...Array.from({ length: 100 }, (_, id) => ({ uuid: UUID, displayName: `Private ${id}`, privacy: { id: 2 } })),
|
||||
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
|
||||
];
|
||||
return jsonResponse({ total: 101, data: all.slice(start, start + 100) });
|
||||
} });
|
||||
assert.equal(result.playlists.length, 1);
|
||||
assert.equal(result.playlists[0].id, ORIGINALS_UUID);
|
||||
assert.equal(result.playlists[0].link, `${ORIGIN}/video-playlists/${ORIGINALS_UUID}`);
|
||||
assert.equal(result.channels[0].id, 'new_channel');
|
||||
assert.equal(result.channels[0].key, 'channel:900');
|
||||
assert.equal(result.channels[0].title, 'New channel');
|
||||
assert.equal(requests.filter(url => url.pathname.endsWith('video-playlists')).length, 2);
|
||||
assert.ok(requests.every(url => url.searchParams.get('count') === '100'));
|
||||
assert.ok(requests.some(url => url.searchParams.get('start') === '100'));
|
||||
const normalized = normalizeCollections([{ id: 2, name: '../evil' }], [
|
||||
{ uuid: UUID, displayName: 'Missing privacy' }, { uuid: 'invalid', displayName: 'Invalid UUID', privacy: { id: 1 } }
|
||||
]);
|
||||
assert.deepEqual(normalized, { channels: [], playlists: [] });
|
||||
});
|
||||
|
||||
test('pagination rejects malformed totals, too-large data, changing totals and incomplete pages', async () => {
|
||||
const path = '/api/v1/accounts/eric/video-playlists';
|
||||
for (const result of [{ total: -1, data: [] }, { total: '0', data: [] }, { total: 1.1, data: [] },
|
||||
{ total: 1001, data: [] }, { total: 0, data: [{}] }, { total: 1, data: [] }, { total: 200, data: Array(101).fill({}) }, { total: 1, data: {} }]) {
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse(result) }));
|
||||
}
|
||||
let pages = 0;
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: ++pages === 1 ? 2 : 3, data: [{}] }) }), /changed/);
|
||||
pages = 0;
|
||||
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => { pages++; return jsonResponse({ total: 11, data: [{}] }); } }), /limit/);
|
||||
assert.equal(pages, 10);
|
||||
await assert.rejects(fetchPaginated('/api/v1/accounts/somebody/video-playlists'), /endpoint/);
|
||||
assert.deepEqual(await fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: 0, data: [] }) }), []);
|
||||
});
|
||||
|
||||
test('playlist items paginate and sort by video publication date, with an exact 1000-entry ceiling', async () => {
|
||||
let pages = 0;
|
||||
const result = await fetchCollection({ type: 'playlist', id: UUID, title: 'Playlist' }, { fetchImpl: async value => {
|
||||
const url = new URL(value);
|
||||
assert.equal(url.pathname, `/api/v1/video-playlists/${UUID}/videos`);
|
||||
assert.equal(url.searchParams.get('count'), '100');
|
||||
assert.equal(url.searchParams.has('sort'), false);
|
||||
pages++;
|
||||
const start = +url.searchParams.get('start');
|
||||
return jsonResponse({ total: 1000, data: Array.from({ length: 100 }, (_, offset) => ({ position: start + offset,
|
||||
video: { url: `${ORIGIN}/w/item${start + offset}`, name: `Item ${start + offset}`,
|
||||
publishedAt: new Date(Date.UTC(2020, 0, 1 + start + offset)).toISOString(), privacy: { id: 1 } } })) });
|
||||
} });
|
||||
assert.equal(pages, 10);
|
||||
assert.deepEqual(result.map(item => item.title), ['Item 999', 'Item 998', 'Item 997']);
|
||||
});
|
||||
|
||||
test('visibility check prevents the next API page from making a hidden-tab network request', async () => {
|
||||
let visible = true;
|
||||
let calls = 0;
|
||||
await assert.rejects(fetchPaginated('/api/v1/accounts/eric/video-playlists', {
|
||||
shouldFetch: () => visible,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
visible = false;
|
||||
return jsonResponse({ total: 2, data: [{}] });
|
||||
}
|
||||
}), { name: 'AbortError' });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('API region merging deduplicates watch URLs, with honest partial/empty/all-failed results', async () => {
|
||||
const dependencies = { fetchImpl: async url => {
|
||||
assert.equal(new URL(url).searchParams.get('count'), '3');
|
||||
assert.equal(new URL(url).searchParams.get('sort'), '-publishedAt');
|
||||
if (url.includes('/eric_tech/')) return jsonResponse({ total: 0, data: [] });
|
||||
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
|
||||
} };
|
||||
const available = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', dependencies);
|
||||
assert.equal(available.ok, true);
|
||||
assert.equal(available.items.length, 1);
|
||||
const partial = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', { ...dependencies, fetchImpl: async url => {
|
||||
if (url.includes('/eric_tech/')) throw new Error('offline');
|
||||
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
|
||||
} });
|
||||
assert.equal(partial.unavailable, 1);
|
||||
assert.equal(partial.items.length, 1);
|
||||
const empty = await loadRegion(['eric_tech'], 'Tech', dependencies);
|
||||
assert.deepEqual(empty, { ok: true, unavailable: 0, items: [] });
|
||||
const failed = await loadRegion(['eric_channel', 'eric_music'], 'Films', { fetchImpl: async () => { throw new Error('offline'); } });
|
||||
assert.deepEqual(failed, { ok: false, unavailable: 2, items: [] });
|
||||
});
|
||||
|
||||
test('safe cards preserve plain title, have accessible same-tab links, and hide only failed image', () => {
|
||||
const doc = new Document();
|
||||
const card = createCard(doc, video('safe', undefined, { title: '<script>alert(1)</script>', thumbnail: '/static/thumbnails/a.jpg' }));
|
||||
assert.equal(card.className, 'project-card media-card');
|
||||
const [media, copy] = card.children;
|
||||
assert.equal(media.getAttribute('aria-label'), 'Watch <script>alert(1)</script>');
|
||||
assert.equal(copy.children[1].localName, 'h3');
|
||||
assert.equal(copy.children[1].textContent, '<script>alert(1)</script>');
|
||||
assert.equal(copy.children[2].dateTime, '2026-10-01T12:00:00.000Z');
|
||||
assert.equal(media.target, undefined);
|
||||
const [image, label] = media.children;
|
||||
assert.equal(image.alt, '');
|
||||
assert.equal(image.loading, 'lazy');
|
||||
assert.equal(image.width, 560);
|
||||
assert.equal(image.height, 315);
|
||||
image.dispatch('error');
|
||||
assert.equal(image.hidden, true);
|
||||
assert.equal(image.listeners.has('error'), false);
|
||||
assert.equal(label.textContent, 'Watch ↗');
|
||||
assert.throws(() => createCard(doc, video('bad', undefined, { url: 'javascript:alert(1)' })));
|
||||
});
|
||||
|
||||
test('feed failure retains fallback; partial success replaces only item children; empty success is honest', async () => {
|
||||
for (const result of [{ ok: false, unavailable: 2, items: [] }, { ok: true, unavailable: 1, items: [video('a')] }, { ok: true, unavailable: 0, items: [] }]) {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const sources = new Node('a', 'Static source link');
|
||||
state.root.append(sources);
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async () => result });
|
||||
await app.ready;
|
||||
if (!result.ok) {
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
assert.match(state.status.textContent, /unavailable/);
|
||||
} else if (result.unavailable) {
|
||||
assert.equal(state.items.children[0].localName, 'article');
|
||||
assert.match(state.status.textContent, /Some channels unavailable/);
|
||||
assert.doesNotMatch(state.status.textContent, /Latest uploads/);
|
||||
} else assert.match(state.items.children[0].textContent, /No public uploads/);
|
||||
assert.equal(state.root.children[0], sources);
|
||||
app.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('library selects Originals, sorts content, keeps select enabled and blocks stale selection responses', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
const collections = normalizeCollections([{ id: 2, name: 'travel', displayName: 'Travel' }], [
|
||||
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
|
||||
]);
|
||||
const slow = deferred();
|
||||
let firstSignal;
|
||||
let calls = 0;
|
||||
const controller = createLibraryController(doc, state.root, {
|
||||
discoverCollections: async () => collections,
|
||||
fetchCollection: async (collection, dependencies) => {
|
||||
calls++;
|
||||
if (calls === 1) { firstSignal = dependencies.signal; return slow.promise; }
|
||||
return [video('travel')];
|
||||
}
|
||||
});
|
||||
const discovery = controller.discover();
|
||||
await Promise.resolve();
|
||||
assert.equal(state.select.value, `playlist:${ORIGINALS_UUID}`);
|
||||
assert.equal(state.select.disabled, false);
|
||||
assert.deepEqual(state.select.children.map(group => group.label), ['Channels', 'Playlists']);
|
||||
await controller.selectCollection('channel:2');
|
||||
assert.equal(firstSignal.aborted, true);
|
||||
slow.resolve([video('stale')]);
|
||||
await discovery;
|
||||
assert.equal(state.items.children[0].children[1].children[1].textContent, 'travel');
|
||||
assert.equal(state.link.href, `${ORIGIN}/video-channels/travel/videos`);
|
||||
controller.stop();
|
||||
assert.equal(state.select.listeners.has('change'), false);
|
||||
});
|
||||
|
||||
test('library failure keeps direct playlist link and discovery failure exposes account fallback', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
const controller = createLibraryController(doc, state.root, {
|
||||
discoverCollections: async () => normalizeCollections([], [{ uuid: UUID, displayName: 'First', privacy: { id: 1 } }]),
|
||||
fetchCollection: async () => { throw new Error('timeout'); }
|
||||
});
|
||||
await controller.discover();
|
||||
assert.equal(state.link.href, `${ORIGIN}/video-playlists/${UUID}`);
|
||||
assert.equal(state.select.disabled, false);
|
||||
assert.match(state.status.textContent, /unavailable/);
|
||||
controller.stop();
|
||||
const failureState = library(new Document());
|
||||
const failed = createLibraryController(doc, failureState.root, { discoverCollections: async () => { throw new Error('offline'); } });
|
||||
await failed.discover();
|
||||
assert.equal(failureState.select.disabled, true);
|
||||
assert.equal(failureState.link.hidden, false);
|
||||
assert.equal(failureState.link.href, `${ORIGIN}/accounts/eric/videos`);
|
||||
failed.stop();
|
||||
});
|
||||
|
||||
test('refresh is visibility gated, minimum interval bounded, disjoint and stoppable', async () => {
|
||||
const doc = new Document();
|
||||
region(doc);
|
||||
doc.hidden = true;
|
||||
let now = 0;
|
||||
let calls = 0;
|
||||
let tick;
|
||||
let cleared;
|
||||
const pending = deferred();
|
||||
const app = initPeerTubeFeeds(doc, { now: () => now,
|
||||
setIntervalImpl: callback => { tick = callback; return 42; }, clearIntervalImpl: handle => { cleared = handle; },
|
||||
loadRegion: async () => { calls++; return pending.promise; }
|
||||
});
|
||||
await app.ready;
|
||||
assert.equal(calls, 0);
|
||||
doc.hidden = false;
|
||||
const running = app.refresh();
|
||||
await app.refresh({ force: true });
|
||||
assert.equal(calls, 1);
|
||||
pending.resolve({ ok: true, unavailable: 0, items: [] });
|
||||
await running;
|
||||
tick();
|
||||
assert.equal(calls, 1);
|
||||
now = REFRESH_MS;
|
||||
doc.hidden = true;
|
||||
tick();
|
||||
assert.equal(calls, 1);
|
||||
doc.hidden = false;
|
||||
doc.dispatch('visibilitychange');
|
||||
assert.equal(calls, 2);
|
||||
app.stop();
|
||||
assert.equal(cleared, 42);
|
||||
assert.equal(doc.listeners.has('visibilitychange'), false);
|
||||
now += REFRESH_MS;
|
||||
tick();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('hiding a document aborts active region requests and never renders late thumbnail cards', async () => {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const pending = deferred();
|
||||
let signal;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async (_ids, _source, dependencies) => {
|
||||
signal = dependencies.signal;
|
||||
return pending.promise;
|
||||
} });
|
||||
doc.hidden = true;
|
||||
doc.dispatch('visibilitychange');
|
||||
assert.equal(signal.aborted, true);
|
||||
pending.resolve({ ok: true, unavailable: 0, items: [video('late', undefined, { thumbnail: '/static/thumbnails/a.jpg' })] });
|
||||
await app.ready;
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
app.stop();
|
||||
});
|
||||
|
||||
test('returning to a visible tab retries interrupted regions immediately and ignores stale responses', async () => {
|
||||
const doc = new Document();
|
||||
const state = region(doc);
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let calls = 0;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, now: () => 0, loadRegion: async names => {
|
||||
assert.deepEqual(names, ['eric_tech', 'eric_gaming']);
|
||||
return ++calls === 1 ? first.promise : second.promise;
|
||||
} });
|
||||
doc.hidden = true;
|
||||
doc.dispatch('visibilitychange');
|
||||
doc.hidden = false;
|
||||
const resumed = app.refresh();
|
||||
assert.equal(calls, 2);
|
||||
first.resolve({ ok: true, unavailable: 0, items: [video('stale')] });
|
||||
await app.ready;
|
||||
assert.equal(state.items.children[0], state.fallback);
|
||||
await app.refresh({ force: true });
|
||||
assert.equal(calls, 2, 'stale completion must not release the new request busy guard');
|
||||
second.resolve({ ok: true, unavailable: 0, items: [video('fresh')] });
|
||||
await resumed;
|
||||
assert.equal(state.items.children[0].children[1].children[1].textContent, 'fresh');
|
||||
app.stop();
|
||||
});
|
||||
|
||||
test('account discovery is reused across selections and refreshed only on the fifteen-minute schedule', async () => {
|
||||
const doc = new Document();
|
||||
const state = library(doc);
|
||||
let now = 0;
|
||||
let discoveries = 0;
|
||||
let selections = 0;
|
||||
const app = initPeerTubeFeeds(doc, { ...timers, now: () => now,
|
||||
discoverCollections: async () => {
|
||||
discoveries++;
|
||||
return normalizeCollections([{ id: 2, name: 'travel' }], [{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }]);
|
||||
},
|
||||
fetchCollection: async () => { selections++; return []; }
|
||||
});
|
||||
await app.ready;
|
||||
await app.library.selectCollection('channel:2');
|
||||
await app.library.selectCollection(`playlist:${ORIGINALS_UUID}`);
|
||||
assert.equal(discoveries, 1);
|
||||
assert.equal(selections, 3);
|
||||
assert.equal(state.select.disabled, false);
|
||||
now = REFRESH_MS - 1;
|
||||
await app.refresh();
|
||||
assert.equal(discoveries, 1);
|
||||
now++;
|
||||
await app.refresh();
|
||||
assert.equal(discoveries, 2);
|
||||
assert.equal(selections, 4);
|
||||
app.stop();
|
||||
});
|
||||
Executable
+431
@@ -0,0 +1,431 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startPreview, parsePort } from '../scripts/preview.mjs';
|
||||
|
||||
const repository = new URL('../', import.meta.url);
|
||||
|
||||
// Small scanner for this site's static markup, not a general-purpose HTML validator.
|
||||
function attributes(tag) {
|
||||
return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)]
|
||||
.map(([, name, double, single]) => [name.toLowerCase(), double ?? single]));
|
||||
}
|
||||
|
||||
function text(html) {
|
||||
return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&')
|
||||
.replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function links(html) {
|
||||
return [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/giu)]
|
||||
.map(([, tag, inner]) => ({ attrs: attributes(tag), inner }));
|
||||
}
|
||||
|
||||
function get(server, path = '/', method = 'GET', headers = {}) {
|
||||
// Raw request paths preserve traversal probes that fetch/URL would normalize away.
|
||||
return new Promise((accept, reject) => {
|
||||
const req = request({ host: '127.0.0.1', port: server.address().port, path,
|
||||
method, headers, agent: false }, res => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => accept({ status: res.statusCode, headers: res.headers,
|
||||
body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out')));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function preview(t, options) {
|
||||
const server = await startPreview({ ...options, port: 0 });
|
||||
t.after(() => new Promise((accept, reject) => {
|
||||
server.close(error => error ? reject(error) : accept());
|
||||
server.closeAllConnections();
|
||||
}));
|
||||
assert.equal(server.address().address, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
|
||||
async function homepage(t) {
|
||||
const response = await get(await preview(t));
|
||||
assert.equal(response.status, 200);
|
||||
return response.body.toString('utf8');
|
||||
}
|
||||
|
||||
test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => {
|
||||
const html = await homepage(t);
|
||||
assert.doesNotMatch(html, /<!--\s*#/u);
|
||||
assert.match(html, /<!doctype html>/iu);
|
||||
assert.match(html, /<html\s+lang="en"/u);
|
||||
assert.equal([...html.matchAll(/<h1\b/giu)].length, 1);
|
||||
assert.equal([...html.matchAll(/<head>/giu)].length, 1);
|
||||
assert.equal([...html.matchAll(/<footer>/giu)].length, 1);
|
||||
assert.match(text(html.match(/<title>([\s\S]*?)<\/title>/iu)?.[1] ?? ''), /Eric.*Meehan/iu);
|
||||
const metas = [...html.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
|
||||
assert.ok(metas.some(meta => meta.get('charset')?.toLowerCase() === 'utf-8'));
|
||||
assert.ok(metas.some(meta => meta.get('name') === 'viewport' && meta.get('content')));
|
||||
for (const name of ['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']) {
|
||||
const meta = metas.find(item => (item.get('name') ?? item.get('property')) === name);
|
||||
assert.ok(meta?.get('content'), `Missing ${name}`);
|
||||
for (const topic of [/music/iu, /films?/iu, /engineering/iu]) assert.match(meta.get('content'), topic);
|
||||
}
|
||||
const canonical = [...html.matchAll(/<link\b[^>]*>/giu)].map(([tag]) => attributes(tag))
|
||||
.find(attrs => attrs.get('rel') === 'canonical');
|
||||
assert.equal(canonical?.get('href'), 'https://www.eom.dev/');
|
||||
for (const [name, expected] of [['og:url', canonical.get('href')], ['og:type', 'website'],
|
||||
['og:site_name', 'eom.dev'], ['twitter:card', 'summary']]) {
|
||||
assert.equal(metas.find(meta => (meta.get('name') ?? meta.get('property')) === name)?.get('content'), expected);
|
||||
}
|
||||
assert.equal(new URL(metas.find(meta => meta.get('property') === 'og:image')?.get('content')).protocol, 'https:');
|
||||
assert.ok(metas.find(meta => meta.get('property') === 'og:image:alt')?.get('content'));
|
||||
const schemas = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/giu)]
|
||||
.filter(([, tag]) => attributes(tag).get('type') === 'application/ld+json')
|
||||
.map(([, , json]) => JSON.parse(json));
|
||||
assert.ok(schemas.length > 0, 'Missing JSON-LD');
|
||||
for (const schema of schemas) {
|
||||
assert.equal(schema['@context'], 'https://schema.org');
|
||||
assert.equal(schema['@type'], 'Person');
|
||||
assert.match(schema.name, /Meehan/u);
|
||||
assert.equal(schema.url, canonical.get('href'));
|
||||
assert.match(schema.jobTitle, /musician/iu);
|
||||
assert.ok(Array.isArray(schema.sameAs) && schema.sameAs.length > 0);
|
||||
for (const url of [schema.url, schema.image, ...schema.sameAs]) {
|
||||
assert.equal(new URL(url).protocol, 'https:');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unique anchors, valid local fragments, and four creative paths ahead of services', async t => {
|
||||
const html = await homepage(t);
|
||||
const ids = [...html.matchAll(/<[a-z][^>]*>/giu)].map(([tag]) => attributes(tag).get('id'))
|
||||
.filter(id => id !== undefined);
|
||||
assert.equal(new Set(ids).size, ids.length, 'Duplicate IDs');
|
||||
assert.ok(ids.every(id => id && !/\s/u.test(id)), 'IDs must be nonempty and whitespace-free');
|
||||
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
||||
for (const href of destinations) {
|
||||
assert.ok(href?.trim(), 'Every link needs a destination');
|
||||
const url = new URL(href.replaceAll('&', '&'), 'https://www.eom.dev/');
|
||||
assert.ok(['https:', 'mailto:'].includes(url.protocol), `Unsafe link: ${href}`);
|
||||
if (url.origin === 'https://www.eom.dev' && url.pathname === '/' && url.hash) {
|
||||
assert.ok(ids.includes(decodeURIComponent(url.hash.slice(1))), `Broken fragment: ${href}`);
|
||||
}
|
||||
}
|
||||
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
||||
assert.ok(destinations.has(`#${id}`), `Missing creative path ${id}`);
|
||||
assert.ok(ids.indexOf(id) >= 0 && ids.indexOf(id) < ids.indexOf('services'), `${id} must precede services`);
|
||||
}
|
||||
const cards = [...html.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/giu)]
|
||||
.filter(([, tag]) => attributes(tag).get('class')?.split(/\s/u).includes('path-card'));
|
||||
assert.equal(cards.length, 4);
|
||||
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
||||
assert.ok(cards.some(([, , card]) => links(card).some(link => link.attrs.get('href') === `#${id}`)));
|
||||
}
|
||||
for (const [, tag] of html.matchAll(/<[a-z]\w*\b([^>]*)>/giu)) {
|
||||
for (const id of (attributes(tag).get('aria-labelledby') ?? '').split(/\s/u).filter(Boolean)) {
|
||||
assert.ok(ids.includes(id), `Broken aria-labelledby: ${id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('featured films and music retain actual video destinations and labeled image links', async t => {
|
||||
const html = await homepage(t);
|
||||
const anchors = links(html);
|
||||
for (const [id, label] of [
|
||||
['b16e17cc-2545-40ad-b9fa-b9200400aff5', /Great Smokey Mountains/iu],
|
||||
['678e7a68-9beb-48b2-b4cc-8cfede773a81', /La Banda Maguey/iu],
|
||||
['f9613754-234e-4053-b194-341154f007c5', /Tidal Waltz/iu],
|
||||
]) {
|
||||
const href = `https://peertube.eom.dev/videos/watch/${id}`;
|
||||
const matching = anchors.filter(link => link.attrs.get('href') === href);
|
||||
assert.ok(matching.length > 0, `Missing featured video ${id}`);
|
||||
const imageLink = matching.find(link => /<img\b/iu.test(link.inner));
|
||||
assert.ok(imageLink, `Video ${id} needs an image link`);
|
||||
assert.match(imageLink.attrs.get('aria-label') ?? text(imageLink.inner), label);
|
||||
}
|
||||
for (const [tag] of html.matchAll(/<img\b[^>]*>/giu)) {
|
||||
const attrs = attributes(tag);
|
||||
assert.ok(attrs.has('alt'), 'Images need alt text, even if decorative');
|
||||
assert.ok(Number(attrs.get('width')) > 0 && Number(attrs.get('height')) > 0);
|
||||
assert.ok(attrs.get('src'));
|
||||
}
|
||||
for (const { attrs, inner } of anchors) {
|
||||
const visible = text(inner.replace(/<[^>]*\baria-hidden="true"[^>]*>[\s\S]*?<\/[^>]+>/giu, ''));
|
||||
const imageLabels = [...inner.matchAll(/<img\b[^>]*>/giu)]
|
||||
.map(([tag]) => attributes(tag).get('alt') ?? '').join('');
|
||||
assert.ok(attrs.get('aria-label')?.trim() || visible || imageLabels.trim(), `Unlabeled link ${attrs.get('href')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('prior game, service, account and donation destinations remain intact', async t => {
|
||||
const html = await homepage(t);
|
||||
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
||||
for (const href of [
|
||||
'https://gitea.eom.dev/WikiDeck/wikideck', 'https://button.eom.dev/',
|
||||
'https://gitea.eom.dev/Gaming/the-button', 'https://discourse.eom.dev/t/luanti-season-v/690',
|
||||
'https://gitea.eom.dev/explore/repos', 'https://discourse.eom.dev/',
|
||||
'https://element.eom.dev/', 'https://gitea.eom.dev/', 'https://mastodon.eom.dev/',
|
||||
'https://peertube.eom.dev/', 'https://owncast.eom.dev/', 'https://discourse.eom.dev/latest',
|
||||
'https://peertube.eom.dev/videos/browse', 'https://mastodon.eom.dev/@eric/',
|
||||
'mailto:eric@eom.dev', '/eric@eom.dev.pub', '/members/', '/members/register/',
|
||||
'https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00', '/xmrig.json',
|
||||
]) assert.ok(destinations.has(href), `Missing preserved destination: ${href}`);
|
||||
assert.match(html, /luanti\.eom\.dev:30000/u);
|
||||
assert.match(html, /48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw/u);
|
||||
});
|
||||
|
||||
test('live publication regions and automatic collection browser are wired to the API module', async t => {
|
||||
const html = await homepage(t);
|
||||
const regions = [...html.matchAll(/<(?:section|div)\b[^>]*\bdata-peertube-feed\b[^>]*>/giu)]
|
||||
.map(([tag]) => attributes(tag));
|
||||
assert.deepEqual(regions.map(region => region.get('data-channels')),
|
||||
['eric_channel', 'eric_music', 'eric_tech', 'eric_gaming']);
|
||||
assert.equal([...html.matchAll(/\bdata-feed-items\b/gu)].length, 4);
|
||||
assert.equal([...html.matchAll(/\bdata-feed-status\b/gu)].length, 4);
|
||||
assert.match(html, /<script type="module" src="\/static\/peertube-feeds\.mjs"><\/script>/u);
|
||||
assert.match(html, /<label for="peertube-collection">/u);
|
||||
assert.match(html, /<select id="peertube-collection" disabled>/u);
|
||||
assert.match(html, /data-library-items/u);
|
||||
assert.match(html, /data-library-status role="status" aria-live="polite"/u);
|
||||
assert.match(html, /<noscript>/u);
|
||||
const response = await get(await preview(t), '/static/peertube-feeds.mjs');
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('gaming is separate from engineering and writing links to Discourse', async t => {
|
||||
const html = await homepage(t);
|
||||
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
|
||||
.map(([, tag, content]) => ({ id: attributes(tag).get('id'), content }));
|
||||
const engineering = sections.find(section => section.id === 'projects').content;
|
||||
const gaming = sections.find(section => section.id === 'gaming').content;
|
||||
assert.match(engineering, /data-channels="eric_tech"/u);
|
||||
assert.doesNotMatch(engineering, /eric_gaming|luanti\.eom\.dev:30000/u);
|
||||
assert.match(gaming, /data-channels="eric_gaming"/u);
|
||||
assert.match(gaming, /luanti\.eom\.dev:30000/u);
|
||||
assert.match(gaming, /https:\/\/button\.eom\.dev\//u);
|
||||
assert.doesNotMatch(engineering, /<h3>Wikideck<\/h3>/u);
|
||||
assert.match(gaming, /<h3>Wikideck<\/h3>/u);
|
||||
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>The Button</h3>'));
|
||||
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>Wikideck</h3>'));
|
||||
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://discourse.eom.dev/' && /writing/iu.test(text(link.inner))));
|
||||
assert.match(html, /href="\/#gaming">Gaming/u);
|
||||
});
|
||||
|
||||
test('my projects follows public cross-organization Gitea activity', async t => {
|
||||
const html = await homepage(t);
|
||||
assert.match(html, /<h2 id="projects-title">My projects\.<\/h2>/u);
|
||||
assert.doesNotMatch(html, /Things I wanted to build/u);
|
||||
assert.match(html, /id="gitea-projects"/u);
|
||||
assert.match(html, /data-project-items/u);
|
||||
assert.match(html, /data-project-status role="status" aria-live="polite"/u);
|
||||
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://gitea.eom.dev/eric?tab=activity'));
|
||||
assert.match(html, /<script type="module" src="\/static\/gitea-projects\.mjs"><\/script>/u);
|
||||
const response = await get(await preview(t), '/static/gitea-projects.mjs');
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('membership templates and routes preserve account and payment actions without emulation', async () => {
|
||||
// Inspect only: no Django rendering, database, authentication, or network calls.
|
||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||
for (const name of ['home', 'register', 'login', 'profile', 'portal', 'logout', 'checkout', 'crypto_checkout']) {
|
||||
assert.match(template, new RegExp(`\\{% url 'members:${name}'`), `Missing portal action ${name}`);
|
||||
}
|
||||
const routes = await readFile(new URL('membership/members/urls.py', repository), 'utf8');
|
||||
for (const route of ['register/', 'login/', 'logout/', 'profile/', 'billing/', 'checkout/<slug:tier>/']) {
|
||||
assert.ok(routes.includes(`path("${route}"`), `Missing account route ${route}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('homepage groups creative sections before the library and membership distinguishes tier benefits', async t => {
|
||||
const html = await homepage(t);
|
||||
const order = ['films', 'music', 'projects', 'gaming', 'peertube-library'];
|
||||
const positions = order.map(id => html.indexOf(`id="${id}"`));
|
||||
assert.ok(positions.every((position, index) => position >= 0 && (!index || position > positions[index - 1])));
|
||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||
assert.match(template, /services included at your membership level/u);
|
||||
assert.match(template, /Bronze adds exclusive Discourse and Matrix content; Silver adds previews when available; Gold adds project-topic polls/u);
|
||||
assert.ok(template.indexOf('class="member-grid"') < template.indexOf('Community services by level'));
|
||||
assert.match(template, /No paid-exclusive game servers are currently included/u);
|
||||
assert.match(template, /There is no fixed release schedule/u);
|
||||
assert.match(template, /personal consulting, managed hosting, guaranteed support/u);
|
||||
assert.doesNotMatch(template, /One account for all of eom\.dev/u);
|
||||
});
|
||||
|
||||
test('creative positioning has no appointments or consulting sales calls', async t => {
|
||||
const html = await homepage(t);
|
||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||
for (const page of [html, template]) {
|
||||
assert.doesNotMatch(page, /apps\/appointments|calendly\.com|cal\.com\//iu);
|
||||
assert.doesNotMatch(text(page), /(?:book|schedule|buy|request)\s+(?:a\s+)?(?:free\s+)?(?:introductory\s+|sales\s+|consulting\s+)?(?:call|consultation)|hire me (?:for|to provide)/iu);
|
||||
}
|
||||
assert.match(text(html), /music/iu);
|
||||
assert.match(text(html), /films/iu);
|
||||
assert.match(text(html), /creative engineering/iu);
|
||||
});
|
||||
|
||||
test('preview serves static MIME, no-store/nosniff and HEAD without a body', async t => {
|
||||
const server = await preview(t);
|
||||
for (const [path, mime] of [
|
||||
['/', 'text/html; charset=utf-8'], ['/index.html', 'text/html; charset=utf-8'],
|
||||
['/static/home.css', 'text/css; charset=utf-8'], ['/static/favicon.svg', 'image/svg+xml'],
|
||||
['/static/profile_2026.jpg', 'image/jpeg'], ['/favicon.ico', 'image/x-icon'],
|
||||
['/xmrig.json', 'application/json; charset=utf-8'], ['/robots.txt', 'text/plain; charset=utf-8'],
|
||||
['/sitemap.xml', 'application/xml; charset=utf-8'],
|
||||
]) {
|
||||
const response = await get(server, path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(response.headers['content-type'], mime, path);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(Number(response.headers['content-length']), response.body.length);
|
||||
const head = await get(server, path, 'HEAD');
|
||||
assert.equal(head.status, response.status);
|
||||
assert.equal(head.headers['content-type'], mime);
|
||||
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
||||
assert.equal(head.body.length, 0);
|
||||
}
|
||||
assert.deepEqual((await get(server, '/?preview=true')).body, (await get(server, '/')).body);
|
||||
});
|
||||
|
||||
test('preview rejects traversal, dotfiles, malformed encodings, private files and unsafe methods', async t => {
|
||||
const server = await preview(t);
|
||||
for (const path of [
|
||||
'/../README.md', '/static/../../membership/config/settings.py', '/%2e%2e/README.md',
|
||||
'/static/%2E%2E/index.html', '/%252e%252e/README.md', '/%2f../README.md',
|
||||
'/static%5c..%5cindex.html', '/static\\..\\index.html', '/.env', '/%2eenv',
|
||||
'/.git/config', '/static/.secret', '/%', '/%GG', '/%C0%AF', '/%00',
|
||||
'/index.html:secret', '/index.html.', '/index.html%20', '/CON', '//README.md',
|
||||
]) {
|
||||
const response = await get(server, path);
|
||||
assert.ok([400, 403].includes(response.status), `${path}: ${response.status}`);
|
||||
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.doesNotMatch(response.body.toString(), /Z:\\|Repositories|Traceback/u);
|
||||
}
|
||||
for (const path of ['/README.md', '/membership/config/settings.py', '/conf/httpd.conf',
|
||||
'/scripts/preview.mjs', '/tests/site.test.mjs', '/static/', '/missing.html']) {
|
||||
assert.equal((await get(server, path)).status, 404, path);
|
||||
assert.equal((await get(server, path, 'HEAD')).body.length, 0);
|
||||
}
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']) {
|
||||
const response = await get(server, '/', method);
|
||||
assert.equal(response.status, 405, method);
|
||||
assert.equal(response.headers.allow, 'GET, HEAD');
|
||||
}
|
||||
assert.equal((await get(server, '/', 'GET', { Host: 'attacker.example' })).status, 403);
|
||||
});
|
||||
|
||||
test('members routes are explicitly preview-only, not authentication or billing emulation', async t => {
|
||||
const server = await preview(t);
|
||||
for (const path of ['/members', '/members/', '/members/register/', '/members/login/',
|
||||
'/members/profile/', '/members/billing/', '/members/checkout/gold/']) {
|
||||
const response = await get(server, path);
|
||||
assert.equal(response.status, 503, path);
|
||||
assert.match(response.body.toString(), /local preview only/iu);
|
||||
assert.match(response.body.toString(), /dynamic membership portal is unavailable/iu);
|
||||
assert.doesNotMatch(response.body.toString(), /<form|<input|csrf|\{%/iu);
|
||||
assert.equal(response.headers['set-cookie'], undefined);
|
||||
const head = await get(server, path, 'HEAD');
|
||||
assert.equal(head.status, 503);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
||||
assert.equal((await get(server, path, 'POST')).status, 405);
|
||||
}
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'www-preview-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const root = join(directory, 'htdocs');
|
||||
await mkdir(join(root, 'common'), { recursive: true });
|
||||
await writeFile(join(directory, 'private.txt'), 'outside-public-root');
|
||||
return { directory, root };
|
||||
}
|
||||
|
||||
test('SSI is literal, nonrecursive and cannot include arbitrary files or execute directives', async t => {
|
||||
const { root } = await fixture(t);
|
||||
for (const name of ['header', 'topnav', 'footer']) {
|
||||
await writeFile(join(root, 'common', `${name}.html`), `<p>${name} $&</p>`);
|
||||
}
|
||||
const server = await preview(t, { publicRoot: root });
|
||||
await writeFile(join(root, 'index.html'), ['header', 'topnav', 'footer']
|
||||
.map(name => `<!--#include virtual="/common/${name}.html" -->`).join('\n'));
|
||||
const response = await get(server);
|
||||
assert.equal(response.status, 200);
|
||||
assert.doesNotMatch(response.body.toString(), /<!--#/u);
|
||||
assert.match(response.body.toString(), /header \$&/u);
|
||||
for (const directive of [
|
||||
'<!--#include virtual="/../private.txt" -->', '<!--#include file="../private.txt" -->',
|
||||
'<!--#exec cmd="echo should-never-execute" -->', '<!--#include virtual="/common/other.html" -->',
|
||||
]) {
|
||||
await writeFile(join(root, 'index.html'), directive);
|
||||
const rejected = await get(server);
|
||||
assert.equal(rejected.status, 500);
|
||||
assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u);
|
||||
}
|
||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
||||
await writeFile(join(root, 'common', 'header.html'), '<!--#include virtual="/common/footer.html" -->');
|
||||
assert.equal((await get(server)).status, 500);
|
||||
});
|
||||
|
||||
test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => {
|
||||
const { directory, root } = await fixture(t);
|
||||
const outside = join(directory, 'outside');
|
||||
await mkdir(outside);
|
||||
await writeFile(join(outside, 'header.html'), 'outside-public-root');
|
||||
try {
|
||||
await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
|
||||
t.skip(`Directory symlinks unavailable on this host (${error.code}).`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const server = await preview(t, { publicRoot: root });
|
||||
assert.equal((await get(server, '/escape-dir/header.html')).status, 403);
|
||||
await rm(join(root, 'common'), { recursive: true });
|
||||
await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
||||
const response = await get(server);
|
||||
assert.equal(response.status, 403);
|
||||
assert.doesNotMatch(response.body.toString(), /outside-public-root/u);
|
||||
});
|
||||
|
||||
test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => {
|
||||
const { directory, root } = await fixture(t);
|
||||
await writeFile(join(root, '.hidden.txt'), 'hidden-target');
|
||||
try {
|
||||
await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file');
|
||||
await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file');
|
||||
await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
|
||||
t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
||||
const server = await preview(t, { publicRoot: root });
|
||||
for (const path of ['/escape.txt', '/alias.txt', '/']) {
|
||||
const response = await get(server, path);
|
||||
assert.equal(response.status, 403, path);
|
||||
assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/u);
|
||||
}
|
||||
});
|
||||
|
||||
test('port parsing defaults to 8082 and accepts only numeric ports in range', () => {
|
||||
assert.equal(parsePort(), 8082);
|
||||
assert.equal(parsePort('0'), 0);
|
||||
assert.equal(parsePort('65535'), 65535);
|
||||
for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) {
|
||||
assert.throws(() => parsePort(value), /Port must be numeric/u);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user