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
12 changes: 7 additions & 5 deletions src/app/invite-only/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
/**
* Invite-only screen
*
* Where everyone who is not an admin lands while bittorrented.com is invite only
* Where signed-out visitors land while bittorrented.com is invite only
* (src/lib/site-offline.ts). Signed out: what BitTorrented is (InviteLanding), then sign up
* with an invite, or sign in. Signed in:
* the site is not open yet, and here are your invites (5 a month, unused ones carry over).
* with an invite, or sign in. Signed in: the site is yours, and here are your invites
* (5 a month, unused ones carry over).
*
* "Create invite" opens a <dialog> for the invite's limits: an expiry date and/or a
* number of uses, or neither, in which case it works forever. Each allowed use costs
Expand All @@ -15,6 +15,7 @@

import { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { InviteLanding } from '@/components/home/invite-landing';
import { MainLayout } from '@/components/layout';
Expand Down Expand Up @@ -199,8 +200,9 @@ export default function InviteOnlyPage(): React.ReactElement {
{state.kind === 'member' ? (
<div className="space-y-6">
<p className="text-center text-text-secondary">
You&apos;re in. The site isn&apos;t open yet; your account is kept and you&apos;ll hear from us when it
is. Meanwhile you can invite people.
You&apos;re in. BitTorrented is live and invite only, so new members need an invite from someone
already here. <Link href="/" className="text-accent-primary hover:underline">Go to BitTorrented</Link>,
or invite people below.
</p>

<section className="rounded-lg border border-border-default bg-bg-secondary p-5">
Expand Down
28 changes: 20 additions & 8 deletions src/lib/admin-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock('@/lib/admin', () => ({
checkUserAdmin: vi.fn(async (id: string) => ({ isAdmin: id === 'admin-1', source: id === 'admin-1' ? 'user_profiles' : null })),
}));

const { accessTokenOf, isAdminRequest, isSignInPath } = await import('./admin-gate');
const { accessTokenOf, isAdminRequest, isSignInPath, requestAccess } = await import('./admin-gate');
const { proxy } = await import('@/proxy');

const cookies = (token?: string) => ({
Expand Down Expand Up @@ -56,6 +56,14 @@ describe('admin gate', () => {
expect(await isAdminRequest(cookies(), null)).toBe(false);
});

it('counts any verified account as a member, and only checkUserAdmin as an admin', async () => {
stubSupabase();
expect(await requestAccess(cookies('admin-token'), null)).toEqual({ member: true, admin: true });
expect(await requestAccess(cookies('member-token'), null)).toEqual({ member: true, admin: false });
expect(await requestAccess(cookies('forged-token'), null)).toEqual({ member: false, admin: false });
expect(await requestAccess(cookies(), null)).toEqual({ member: false, admin: false });
});

it('fails closed when Supabase cannot be reached', async () => {
vi.stubEnv('SUPABASE_URL', 'https://db.test');
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'anon');
Expand Down Expand Up @@ -85,13 +93,17 @@ describe('admin gate', () => {
expect(res.headers.get('set-cookie')).toContain('sb-auth-token='); // the rotated tokens are kept
});

it('through the gate: invite-only screen for a paying member, the site for an admin', async () => {
it('through the gate: the site for a member (legal mode applies), every route for an admin', async () => {
stubSupabase();
const as = (token: string, path: string) =>
proxy(new NextRequest(`https://bittorrented.com${path}`, { headers: { authorization: `Bearer ${token}` } }));
const member = await as('member-token', '/account');
expect(member.status).toBe(307);
expect(member.headers.get('location')).toMatch(/\/invite-only$/);
expect(member.status).not.toBe(503);
expect(member.headers.get('location') ?? '').not.toContain('/invite-only');
expect((await as('member-token', '/dht')).status).toBe(410); // legal mode still applies to members
const stranger = await as('forged-token', '/account');
expect(stranger.status).toBe(307);
expect(stranger.headers.get('location')).toMatch(/\/invite-only$/);
for (const p of ['/dht', '/torrents', '/']) {
const r = await as('admin-token', p);
expect([503, 410], p).not.toContain(r.status); // admins see every route
Expand Down Expand Up @@ -123,12 +135,12 @@ describe('admin gate: cast tokens (a Chromecast has no cookie)', () => {
expect(await isAdminCastRequest(SEGMENT)).toBe(false);
});

it('gets an admin cast past the invite-only notice, and nobody else', async () => {
it('gets a member cast past the invite-only gate, and no one without a valid token', async () => {
const crkey = 'Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Safari/537.36 CrKey/1.56.500000';
const call = async (url: string) =>
proxy(new NextRequest(url, { headers: { 'user-agent': crkey, 'x-forwarded-for': '24.1.2.3' } }));
expect((await call(`${SEGMENT}&ct=${await token('admin-1')}`)).status).not.toBe(503);
expect((await call(`${SEGMENT}&ct=${await token('member-1')}`)).status).toBe(503);
expect((await call(SEGMENT)).status).toBe(503);
expect((await call(`${SEGMENT}&ct=${await token('admin-1')}`)).status).not.toBe(401);
expect((await call(`${SEGMENT}&ct=${await token('member-1')}`)).status).toBe(410); // in, but legal mode
expect((await call(SEGMENT)).status).toBe(401);
});
});
91 changes: 59 additions & 32 deletions src/lib/admin-gate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Admins only: while bittorrented.com is offline, an admin account is the one way in.
* Who is asking: a signed-in member, and whether that member is an admin.
*
* Anthony, 2026-10-05: "it only serves is_admin accounts from now on" and "no way for anyone
* to access the data even if they pay". A paid membership is not enough; only an account
* that checkUserAdmin accepts (user_profiles.is_admin, or the legacy admin_users table)
* gets past the offline notice. Legal mode still applies to admins.
* The site is invite only (site-offline.ts): any verified account gets in, and since an
* account can only be created with an invite, that is the invitation check. Anthony,
* 2026-10-12: "its all live its just invite only". Admins (checkUserAdmin:
* user_profiles.is_admin, or the legacy admin_users table) are also exempt from legal mode.
*
* The session is verified by Supabase (/auth/v1/user), not by decoding the cookie ourselves,
* so a forged or expired token is refused. Every failure (no cookie, bad token, Supabase
Expand All @@ -18,7 +18,21 @@ const VERIFY_TIMEOUT_MS = 3000;
/** How long one verified answer is reused for the same access token. */
const CACHE_MS = 60_000;

const cache = new Map<string, { admin: boolean; until: number }>();
export interface Access {
/** A real, verified account. */
member: boolean;
admin: boolean;
}

const NOBODY: Access = { member: false, admin: false };

const cache = new Map<string, Access & { until: number }>();

function remember(key: string, access: Access, until = Date.now() + CACHE_MS): Access {
if (cache.size > 1000) cache.clear();
cache.set(key, { ...access, until });
return access;
}

/** Sign-in pages stay reachable so an admin can log in. Signup is open only with an invite (site-offline.ts). */
const SIGN_IN = ['/login', '/forgot-password', '/reset-password', '/api/auth'];
Expand Down Expand Up @@ -58,61 +72,74 @@ async function verifiedUserId(token: string): Promise<string | null> {
return typeof user.id === 'string' ? user.id : null;
}

export async function isAdminRequest(
export async function requestAccess(
cookies: CookieReader,
authorization: string | null,
check: (userId: string) => Promise<{ isAdmin: boolean }> = checkUserAdmin
): Promise<boolean> {
): Promise<Access> {
const token = accessTokenOf(cookies, authorization);
if (!token) return false;
if (!token) return NOBODY;
const hit = cache.get(token);
if (hit && hit.until > Date.now()) return hit.admin;
let admin = false;
if (hit && hit.until > Date.now()) return { member: hit.member, admin: hit.admin };
let access = NOBODY;
try {
const userId = await verifiedUserId(token);
admin = userId ? (await check(userId)).isAdmin : false;
if (userId) access = { member: true, admin: (await check(userId)).isAdmin };
} catch {
admin = false;
access = NOBODY;
}
if (cache.size > 1000) cache.clear();
cache.set(token, { admin, until: Date.now() + CACHE_MS });
return admin;
return remember(token, access);
}

export async function isAdminRequest(
cookies: CookieReader,
authorization: string | null,
check: (userId: string) => Promise<{ isAdmin: boolean }> = checkUserAdmin
): Promise<boolean> {
return (await requestAccess(cookies, authorization, check)).admin;
}

/**
* A Chromecast fetching what an admin cast: no cookie, only the signed `ct`
* A Chromecast fetching what a member cast: no cookie, only the signed `ct`
* (src/lib/cast/token.ts), and only on the media routes a cast token opens.
* The token's user must be an admin by the same check as a session; it fails
* closed the same way.
* The token's user counts as a member, and as an admin by the same check as a
* session; it fails closed the same way.
*/
export async function isAdminCastRequest(
export async function castAccess(
requestUrl: string,
check: (userId: string) => Promise<{ isAdmin: boolean }> = checkUserAdmin
): Promise<boolean> {
): Promise<Access> {
const { CAST_TOKEN_PARAM, isCastablePath, verifyCastToken } = await import('@/lib/cast/token');
let url: URL;
try {
url = new URL(requestUrl);
} catch {
return false;
return NOBODY;
}
if (!isCastablePath(url.pathname)) return false;
if (!isCastablePath(url.pathname)) return NOBODY;
const token = url.searchParams.get(CAST_TOKEN_PARAM);
if (!token) return false;
if (!token) return NOBODY;
const key = `cast:${token}`;
const hit = cache.get(key);
if (hit && hit.until > Date.now()) return hit.admin;
let admin = false;
if (hit && hit.until > Date.now()) return { member: hit.member, admin: hit.admin };
let access = NOBODY;
// Never remember a yes past the token's own expiry.
let until = Date.now() + CACHE_MS;
try {
const claims = await verifyCastToken(token);
admin = claims ? (await check(claims.userId)).isAdmin : false;
if (claims) until = Math.min(until, claims.expiresAt * 1000);
if (claims) {
access = { member: true, admin: (await check(claims.userId)).isAdmin };
until = Math.min(until, claims.expiresAt * 1000);
}
} catch {
admin = false;
access = NOBODY;
}
if (cache.size > 1000) cache.clear();
cache.set(key, { admin, until });
return admin;
return remember(key, access, until);
}

export async function isAdminCastRequest(
requestUrl: string,
check: (userId: string) => Promise<{ isAdmin: boolean }> = checkUserAdmin
): Promise<boolean> {
return (await castAccess(requestUrl, check)).admin;
}
4 changes: 2 additions & 2 deletions src/lib/site-offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ describe('bittorrented.com offline, invite only', () => {
expect(openWhileInviteOnly('/api/invitesx')).toBe(false);
});

it('answers the rest of the API with JSON 503s', async () => {
it('answers the rest of the API with JSON 401s for signed-out callers', async () => {
for (const path of ['/api/search', '/api/seedbox/add', '/api/iptv/subscription', '/api/public/datasets']) {
const res = await get(path);
expect(res.status).toBe(503);
expect(res.status).toBe(401);
expect((await res.json()).error).toMatch(/invite only/);
}
});
Expand Down
15 changes: 9 additions & 6 deletions src/lib/site-offline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
*
* Invite only (Anthony, 2026-10-06): instead of a bare notice, a visitor who is not an
* admin is sent to /invite-only. Signing up is open again, but only with an unused
* invite code (src/lib/invites.ts; the database refuses any other signup), and a new
* account still sees /invite-only rather than the site. Members create invites there.
* invite code (src/lib/invites.ts; the database refuses any other signup).
*
* Live for members (Anthony, 2026-10-12: "its all live its just invite only"): any
* signed-in account gets the site; only signed-out visitors are sent to /invite-only.
* Members create invites there.
*/

export const SITE_OFFLINE = true;
Expand All @@ -38,15 +41,15 @@ export function openWhileInviteOnly(pathname: string): boolean {
}

/**
* Pages go to the invite-only screen; the API answers 503 so clients and search
* engines treat it as temporary.
* Pages go to the invite-only screen; the API answers 401, since signing in is
* what lets a caller through.
*/
export function offlineResponse(pathname: string, requestUrl: string | URL): Response {
const headers = { 'cache-control': 'no-store' };
if (pathname.startsWith('/api/')) {
return Response.json(
{ error: 'BitTorrented is invite only and not open yet.' },
{ status: 503, headers: { ...headers, 'retry-after': '86400' } }
{ error: 'BitTorrented is invite only. Sign in, or sign up with an invite.' },
{ status: 401, headers }
);
}
// Absolute: Next.js answers 500 "Invalid URL" for a relative Location from the proxy.
Expand Down
28 changes: 16 additions & 12 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { meter } from '@/lib/throttle';
import { CAST_TOKEN_PARAM, isCastablePath, verifyCastToken } from '@/lib/cast/token';
import { SITE_OFFLINE, offlineResponse, openWhileInviteOnly, stillServed } from '@/lib/site-offline';
import { LEGAL_MODE, goneResponse, legallyServed } from '@/lib/legal-mode';
import { isAdminCastRequest, isAdminRequest, isSignInPath } from '@/lib/admin-gate';
import { type Access, castAccess, isSignInPath, requestAccess } from '@/lib/admin-gate';

// =============================================================================
// Rate Limiting (in-memory sliding window)
Expand Down Expand Up @@ -550,27 +550,31 @@ export async function proxy(request: NextRequest): Promise<Response> {
const session = await refreshSession(request);
const asNext = (res: Response) => withSession(res instanceof NextResponse ? res : new NextResponse(res.body, res), session);

// Whether this is an admin, asked at most once per request and only when it matters.
// The refreshed cookie, when there is one, holds the token to check.
// Whether this is a member, and an admin, asked at most once per request and only when it
// matters. The refreshed cookie, when there is one, holds the token to check.
const cookies = session?.value
? { get: (name: string) => (name === 'sb-auth-token' ? { value: session.value } : request.cookies.get(name)) }
: request.cookies;
let admin: boolean | undefined;
// A Chromecast casting for an admin carries no cookie, only a signed cast token.
const isAdmin = async () =>
(admin ??=
(await isAdminRequest(cookies, request.headers.get('authorization'))) ||
(await isAdminCastRequest(request.url)));
let access: Access | undefined;
// A Chromecast casting for a member carries no cookie, only a signed cast token.
const whoIs = async (): Promise<Access> => {
if (access) return access;
const viaSession = await requestAccess(cookies, request.headers.get('authorization'));
access = viaSession.member ? viaSession : await castAccess(request.url);
return access;
};
const isAdmin = async () => (await whoIs()).admin;

// --- 0a. The site is offline and invite only (see src/lib/site-offline.ts) ---
// Admins only (src/lib/admin-gate.ts): everyone else, paying or not, is sent to the
// invite-only screen, where they can sign up with an invite or manage their own.
// Members only (src/lib/admin-gate.ts): an account needs an invite, so any signed-in
// member gets the site. Signed-out visitors are sent to the invite-only screen, where
// they can sign up with an invite or sign in.
if (
SITE_OFFLINE &&
!stillServed(request.nextUrl.pathname) &&
!isSignInPath(request.nextUrl.pathname) &&
!openWhileInviteOnly(request.nextUrl.pathname) &&
!(await isAdmin())
!(await whoIs()).member
) {
return asNext(offlineResponse(request.nextUrl.pathname, request.url));
}
Expand Down
Loading