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
66 changes: 63 additions & 3 deletions packages/core/src/net/adapters/tsbb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ interface Forum {
slug: string;
name?: string;
description?: string;
/** A container, not a destination. Nothing is posted into a category. */
kind?: string;
/**
* Whether the caller may start a topic here, from tsbb 0.5.1 on. Older
* boards omit it, and `undefined` has to mean "cannot tell" rather than
* "no": treating a silent board as forbidding everything would refuse a
* login that works.
*/
canPost?: boolean;
locked?: boolean;
}

interface DevicePoll {
Expand Down Expand Up @@ -75,9 +85,18 @@ const splitForums = (raw: string | undefined): string[] =>
.map((slug) => slug.trim().replace(/^\/?f\//, "").replace(/^\/+|\/+$/g, ""))
.filter(Boolean);

/** The forums a board publishes. Reading them needs no token. */
export async function listForums(instance: string): Promise<Forum[]> {
const result = await getJson<{ forums?: Forum[] } | Forum[]>(`${normalizeInstance(instance)}/api/v1/forums`);
/**
* The forums a board publishes.
*
* Reading the list needs no token, but `canPost` is answered for whoever asks:
* without a token that is a guest, who may post nowhere. Pass the token when
* the answer is meant to be about the member.
*/
export async function listForums(instance: string, token?: string): Promise<Forum[]> {
const result = await getJson<{ forums?: Forum[] } | Forum[]>(
`${normalizeInstance(instance)}/api/v1/forums`,
token ? { headers: { authorization: `Bearer ${token}` } } : {},
);
const forums = Array.isArray(result) ? result : (result.forums ?? []);
return forums.filter((forum) => typeof forum?.slug === "string");
}
Expand Down Expand Up @@ -180,6 +199,17 @@ export const tsbb: Network = {
`${new URL(instance).host} has no forum ${unknown.join(", ")}. It has: ${[...known].join(", ")}`,
);
}
// A category holds forums rather than topics, and that is true of
// everybody, so it can be refused before anyone approves anything.
const categories = chosen.filter(
(slug) => available.find((forum) => forum.slug === slug)?.kind === "category",
);
if (categories.length) {
throw new Error(
`${categories.join(", ")} ${categories.length === 1 ? "is a category" : "are categories"} on ` +
`${new URL(instance).host}, not a forum. Topics go in the forums underneath.`,
);
}
}
if (chosen.length > 1) {
ctx.report(`Posts will cycle through ${chosen.join(" → ")}, one forum per post.`);
Expand Down Expand Up @@ -231,6 +261,36 @@ export const tsbb: Network = {
});
const username = me.username ?? me.name ?? "member";

/*
* Now that there is a token, ask again as the member.
*
* The list read before the device flow answered for a guest, and a guest
* may post nowhere, so it could not be used to check this. From tsbb 0.5.1
* a forum says whether the caller may start a topic in it; a feed-only or
* locked forum is dropped here with a line saying so, rather than being
* discovered by a 403 on the first announcement. An older board omits the
* field, and "cannot tell" must not be read as "no".
*/
if (chosen.length) {
const asMember = await listForums(instance, token).catch(() => [] as Forum[]);
const refused = chosen.filter(
(slug) => asMember.find((forum) => forum.slug === slug)?.canPost === false,
);
if (refused.length) {
const keep = chosen.filter((slug) => !refused.includes(slug));
if (!keep.length) {
throw new Error(
`${username} cannot start topics in ${refused.join(", ")} on ${new URL(instance).host}: ` +
"locked, reply-only, or above this member's rank. Pick a forum that takes topics.",
);
}
ctx.report(
`${refused.join(", ")} takes replies only, or is locked, so it is left out. Posting to ${keep.join(", ")}.`,
);
chosen = keep;
}
}

return {
handle: `${username}@${new URL(instance).host}`,
displayName: index.board?.name ? `${username} on ${index.board.name}` : username,
Expand Down
122 changes: 122 additions & 0 deletions packages/core/test/tsbb-login-forums.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* What login does with the board's answer about where a member may post.
*
* The whole device flow is stubbed here, because the interesting part is the
* two forum-list reads around it: one as a guest before anyone approves a code,
* and one as the member afterwards. `canPost` from the first is worthless — a
* guest may post nowhere — and reading it as a refusal would reject a login
* that works.
*/
import { test, expect, afterEach } from "bun:test";
import { tsbb } from "../src/net/adapters/tsbb.ts";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

interface BoardForum {
slug: string;
kind?: string;
canPost?: boolean;
}

/** A board whose forum list answers differently with and without a token. */
function stubBoard(guest: BoardForum[], member: BoardForum[]): { asked: string[] } {
const asked: string[] = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(typeof input === "object" && "url" in input ? input.url : input);
const headers = new Headers(
(typeof input === "object" && "headers" in input ? (input as Request).headers : init?.headers) ?? {},
);
const authed = headers.get("authorization") !== null;
asked.push(`${authed ? "member" : "guest"} ${new URL(url).pathname}`);

const body = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });

if (url.endsWith("/api/v1")) return body({ api: "tsbb", board: { name: "Test Board" } });
if (url.endsWith("/api/v1/forums")) return body({ forums: authed ? member : guest });
if (url.endsWith("/api/v1/device/start")) {
return body({ userCode: "AAAA-BBBB", deviceCode: "dc", verifyUrl: "https://board.test/link", interval: 0 });
}
if (url.endsWith("/api/v1/device/poll")) return body({ status: "approved", token: "tsbb_member" });
if (url.endsWith("/api/v1/me")) return body({ username: "member" });
throw new Error(`unexpected request: ${url}`);
}) as unknown as typeof fetch;
return { asked };
}

const ctx = () => {
const lines: string[] = [];
return {
lines,
report: (line: string) => lines.push(line),
openUrl: async () => {},
};
};

test("a forum the member cannot post in is left out, with a line saying why", async () => {
// The live case: `news` is fed by a blog and takes replies only. As a guest
// every canPost is false, which is exactly why the guest list cannot decide.
stubBoard(
[{ slug: "app-showcase", canPost: false }, { slug: "news", canPost: false }],
[{ slug: "app-showcase", canPost: true }, { slug: "news", canPost: false }],
);
const context = ctx();

const account = await tsbb.login(
{ instance: "https://board.test", forum: "app-showcase,news" },
context as never,
);

expect(account.meta.forum).toBe("app-showcase");
expect(context.lines.some((line) => /news.*replies only|left out/i.test(line))).toBe(true);
});

test("an older board that omits canPost keeps every forum it was given", async () => {
// "Cannot tell" must not read as "no", or myna would refuse a working login
// against every board older than tsbb 0.5.1.
stubBoard([{ slug: "general" }, { slug: "news" }], [{ slug: "general" }, { slug: "news" }]);

const account = await tsbb.login(
{ instance: "https://board.test", forum: "general,news" },
ctx() as never,
);

expect(account.meta.forum).toBe("general,news");
});

test("when nothing the member asked for takes topics, login says so", async () => {
stubBoard([{ slug: "news", canPost: false }], [{ slug: "news", canPost: false }]);

await expect(
tsbb.login({ instance: "https://board.test", forum: "news" }, ctx() as never),
).rejects.toThrow(/cannot start topics in news/);
});

test("a category is refused before anyone approves a code", async () => {
const board = stubBoard(
[{ slug: "community", kind: "category" }, { slug: "general", kind: "forum" }],
[{ slug: "community", kind: "category" }, { slug: "general", kind: "forum" }],
);

await expect(
tsbb.login({ instance: "https://board.test", forum: "community" }, ctx() as never),
).rejects.toThrow(/is a category/);

// Being a category is true of everybody, so this costs no device approval.
expect(board.asked.some((call) => call.includes("device/start"))).toBe(false);
});

test("the member list is read with the token, not without it", async () => {
const board = stubBoard(
[{ slug: "general", canPost: false }],
[{ slug: "general", canPost: true }],
);

await tsbb.login({ instance: "https://board.test", forum: "general" }, ctx() as never);

expect(board.asked).toContain("guest /api/v1/forums");
expect(board.asked).toContain("member /api/v1/forums");
});