Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions server/e2e/crews.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,27 @@ const dee = await signIn('dee');
check('an unknown crew is a 404 page, not an error', nope.status === 404);
}

{
const backOf = async (path, referer) => {
const res = await req(path, referer ? { headers: { referer } } : {});
const m = /<a class="back" href="([^"]*)">← ([^<]*)<\/a>/.exec(await res.text());
return { href: m?.[1].replace(/&amp;/g, '&'), label: m?.[2], vary: res.headers.get('vary') };
};
const p = `/@${ada.handle}`;
const plain = await backOf(p);
check('a profile opened directly offers a way back to the leaderboard', plain.href === '/leaderboard' && plain.label === 'leaderboard', JSON.stringify(plain));
const fromMonth = await backOf(p, `${BASE}/leaderboard?window=month`);
check('from the leaderboard, back keeps the tab you were on', fromMonth.href === '/leaderboard?window=month' && fromMonth.vary?.includes('referer'), JSON.stringify(fromMonth));
const fromCrew = await backOf(p, `${BASE}/crew/${slug}`);
check('from a crew, back returns to that crew', fromCrew.href === `/crew/${slug}` && fromCrew.label === 'back to the crew', JSON.stringify(fromCrew));
const fromElsewhere = await backOf(p, 'https://evil.example/leaderboard');
check('a link from another site never becomes the back link', fromElsewhere.href === '/leaderboard', JSON.stringify(fromElsewhere));
const crewPlain = await backOf(`/crew/${slug}`);
const crewFromProfile = await backOf(`/crew/${slug}`, `${BASE}${p}`);
check('a crew page goes back to crews, or to the profile you came from',
crewPlain.href === '/crews' && crewFromProfile.href === p && crewFromProfile.label === `@${ada.handle}`, JSON.stringify([crewPlain, crewFromProfile]));
}

github.close();
console.log(failed === 0 ? '\nall passed' : `\n${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
10 changes: 6 additions & 4 deletions server/src/routes/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
import {
COPY_SCRIPT, MESSAGES, renderConfirm, renderCreate, renderCrew, renderCrewBoard, renderCrewMissing, renderInvite, renderInviteGone,
} from '../views/crews.js';
import { renderNotice, type NavContext } from '../views/layout.js';
import { backFrom, renderNotice, type NavContext } from '../views/layout.js';
import { currentViewer, pageCacheControl, signInEnabled, webOrigin, type Viewer } from '../web-session.js';
import { sameOrigin } from './web-auth.js';

const CSP = "default-src 'self'; img-src https://avatars.githubusercontent.com; style-src 'unsafe-inline'; base-uri 'self'; form-action 'self'";

function page(body: string, viewer: Viewer | null, status = 200): Response {
function page(body: string, viewer: Viewer | null, status = 200, byReferer = false): Response {
const cache = pageCacheControl(viewer);
return html(body, {
status,
headers: {
...pageCacheControl(viewer),
...cache,
...(byReferer ? { vary: 'cookie, referer' } : {}),
'content-security-policy': CSP,
'referrer-policy': 'strict-origin-when-cross-origin',
'x-content-type-options': 'nosniff',
Expand Down Expand Up @@ -108,7 +110,7 @@ export async function crewHtml(request: Request, env: Env, slug: string): Promis
viewer ? isMember(env, crew.id, viewer.githubId) : Promise.resolve(false),
]);
const me = { member, owner: member && viewer?.githubId === crew.ownerGithubId };
return page(renderCrew(crew, members, window, windowStart(window), new Date(), rank, nav, me, knownMessage(url), webOrigin(env)), viewer);
return page(renderCrew(crew, members, window, windowStart(window), new Date(), rank, nav, me, knownMessage(url), webOrigin(env), backFrom(request, { href: '/crews', label: 'crews' })), viewer, 200, true);
}

export async function crewJoinPost(request: Request, env: Env, slug: string): Promise<Response> {
Expand Down
8 changes: 5 additions & 3 deletions server/src/routes/profile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Env } from '../env.js';
import { html } from '../http.js';
import { currentViewer, pageCacheControl, signInEnabled } from '../web-session.js';
import { backFrom } from '../views/layout.js';
import { renderProfile, renderProfileNotFound } from '../views/profile.js';
import { crewsOf } from '../crews.js';
import { dayKey, startOfCalendarMonth, startOfCalendarWeek } from '../window.js';
Expand Down Expand Up @@ -156,11 +157,12 @@ const PAGE_HEADERS = {
export async function profileHtml(request: Request, env: Env, handle: string): Promise<Response> {
const [data, viewer] = await Promise.all([buildProfile(env, handle), currentViewer(request, env)]);
const nav = { viewer, signInEnabled: signInEnabled(env), path: new URL(request.url).pathname };
const headers = { ...PAGE_HEADERS, ...pageCacheControl(viewer) };
const headers = { ...PAGE_HEADERS, ...pageCacheControl(viewer), 'vary': 'cookie, referer' };
const back = backFrom(request, { href: '/leaderboard', label: 'leaderboard' });
if (!data) {
return html(renderProfileNotFound(handle, nav), { status: 404, headers });
return html(renderProfileNotFound(handle, nav, back), { status: 404, headers });
}
// A missing crews table must not take the profile down with it.
const crews = await crewsOf(env, data.githubId).catch(() => []);
return html(renderProfile(data, new Date(), nav, crews), { headers });
return html(renderProfile(data, new Date(), nav, crews, back), { headers });
}
6 changes: 4 additions & 2 deletions server/src/views/crews.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { BoardEntry, Crew, CrewWindow, MemberEntry } from '../crews.js';
import { MAX_CREWS_PER_USER, MAX_MEMBERS, MIN_MEMBERS_TO_RANK, NAME_MAX } from '../crews.js';
import { NAV_STYLE, escapeHtml, renderNav, type NavContext, type NavPlace } from './layout.js';
import { BACK_STYLE, NAV_STYLE, escapeHtml, renderBack, renderNav, type BackLink, type NavContext, type NavPlace } from './layout.js';
import { heatmapCells, relativeTime, windowRange } from './leaderboard.js';

const WINDOW_LABEL: Record<CrewWindow, string> = { week: 'this week', month: 'this month', all: 'all time' };
Expand Down Expand Up @@ -120,6 +120,7 @@ const STYLE = `
.empty { text-align: center; color: #666; padding: 32px 12px; }
footer { color: #444; font-size: 11px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #141414; }
${NAV_STYLE}
${BACK_STYLE}
@media (max-width: 600px) {
body { padding: 20px 12px; }
td, th { padding-left: 4px; padding-right: 4px; }
Expand Down Expand Up @@ -212,7 +213,7 @@ export interface CrewViewerState {
export function renderCrew(
crew: Crew, members: MemberEntry[], window: CrewWindow, start: Date | null, now: Date,
rank: { rank: number; above: BoardEntry | null; count: number } | null,
nav: NavContext, me: CrewViewerState, message: string | null, origin: string,
nav: NavContext, me: CrewViewerState, message: string | null, origin: string, back: BackLink,
): string {
const ships = members.reduce((a, m) => a + m.ships, 0);
const shipping = members.filter((m) => m.current && m.ships > 0).length;
Expand Down Expand Up @@ -275,6 +276,7 @@ export function renderCrew(
<div class="tools"><div class="row"><a class="btn btn-danger" href="${base}/confirm?action=leave">leave crew</a></div></div>` : '';

return page(`${crew.name} · vibetime crews`, 'crews', nav, `
${renderBack(back)}
${note(message)}
<div class="head">
<div class="who" style="gap:14px">${monogram(crew.name, true)}<div>
Expand Down
33 changes: 33 additions & 0 deletions server/src/views/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,39 @@ export interface NavContext {
path: string;
}

export interface BackLink {
href: string;
label: string;
}

// Pages people reach by clicking through get a way back to where they were.
// Only this site's own pages count; anything else falls back to the fallback.
export function backFrom(request: Request, fallback: BackLink): BackLink {
const referer = request.headers.get('referer');
if (!referer) return fallback;
let from: URL;
try { from = new URL(referer); } catch { return fallback; }
const here = new URL(request.url);
if (from.origin !== here.origin || from.pathname === here.pathname) return fallback;
const href = `${from.pathname}${from.search}`;
if (!/^[\x21-\x7e]+$/.test(href) || href.length > 300) return fallback;
if (from.pathname === '/leaderboard') return { href, label: 'leaderboard' };
if (from.pathname === '/crews') return { href, label: 'crews' };
if (/^\/crew\/[a-z0-9-]+$/.test(from.pathname)) return { href, label: 'back to the crew' };
const handle = /^\/@([A-Za-z0-9-]+)$/.exec(from.pathname);
if (handle) return { href, label: `@${handle[1]}` };
return fallback;
}

export const BACK_STYLE = `
.back { color: #666; font-size: 12px; display: inline-block; margin: -8px 0 20px; text-decoration: none; }
.back:hover { color: #a78bfa; }
`;

export function renderBack(back: BackLink): string {
return `<a class="back" href="${escapeHtml(back.href)}">← ${escapeHtml(back.label)}</a>`;
}

export const NAV_STYLE = `
.nav { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid #1a1a1a; }
.nav .brand { color: #a78bfa; font-weight: 600; letter-spacing: 0.5px; white-space: nowrap; text-decoration: none; }
Expand Down
9 changes: 6 additions & 3 deletions server/src/views/profile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ProfileData, ProfileHeatmapDay } from '../routes/profile.js';
import { NAV_STYLE, escapeHtml, renderNav, type NavContext } from './layout.js';
import { BACK_STYLE, NAV_STYLE, escapeHtml, renderBack, renderNav, type BackLink, type NavContext } from './layout.js';
import type { Membership } from '../crews.js';

function relativeTime(iso: string, now: Date): string {
Expand Down Expand Up @@ -70,14 +70,15 @@ const SHARED_STYLE = `
.chip:hover { border-color: #a78bfa; color: #e5e5e5; }
.chip-mono { width: 20px; height: 20px; border-radius: 999px; background: #a78bfa; color: #0a0a0a; font-size: 9px; font-weight: 700; display: inline-grid; place-items: center; }
${NAV_STYLE}
${BACK_STYLE}
@media (max-width: 480px) {
body { padding: 20px 12px; }
.stats { grid-template-columns: 1fr; }
.who img, .avatar-fallback { width: 40px; height: 40px; }
}
`;

export function renderProfile(data: ProfileData, updatedAt: Date, nav: NavContext, crews: Membership[]): string {
export function renderProfile(data: ProfileData, updatedAt: Date, nav: NavContext, crews: Membership[], back: BackLink): string {
const crewChips = crews.length === 0 ? '' : `<div class="chips">${crews.map((c) =>
`<a class="chip" href="/crew/${escapeHtml(c.slug)}"><span class="chip-mono" aria-hidden="true">${escapeHtml(c.name.split(/\s+/).map((w) => [...w][0] ?? '').join('').slice(0, 2).toUpperCase())}</span>${escapeHtml(c.name)}</a>`).join('')}</div>`;
const handle = escapeHtml(data.handle);
Expand Down Expand Up @@ -112,6 +113,7 @@ export function renderProfile(data: ProfileData, updatedAt: Date, nav: NavContex
<body>
<main>
${renderNav(null, nav)}
${renderBack(back)}
<header>
<div class="who">
${avatar}
Expand All @@ -137,7 +139,7 @@ export function renderProfile(data: ProfileData, updatedAt: Date, nav: NavContex
</html>`;
}

export function renderProfileNotFound(handle: string, nav: NavContext): string {
export function renderProfileNotFound(handle: string, nav: NavContext, back: BackLink): string {
const safe = escapeHtml(handle);
return `<!doctype html>
<html lang="en">
Expand All @@ -155,6 +157,7 @@ export function renderProfileNotFound(handle: string, nav: NavContext): string {
<body>
<main>
${renderNav(null, nav)}
${renderBack(back)}
<div class="missing">
<h1>@${safe}</h1>
<p>no vibetime profile for this handle.</p>
Expand Down
Loading