Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 2 additions & 2 deletions src/application/core/src/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class Message extends Base {
await this.state.database.guildRecordOneDay.upsertAll(query);
}

public async includeInviteLink(content: string) {
public async includeInviteLink(content: string): Promise<string[]> {
const { inviteLinks, normalUrls } = await findUrls(content);

for (const url of normalUrls) {
Expand Down Expand Up @@ -96,6 +96,6 @@ export class Message extends Base {
}
}

return inviteLinks;
return Array.from(new Set(inviteLinks));
}
}
12 changes: 9 additions & 3 deletions src/domain/service/src/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ describe("url.ts", () => {
// normal urls
"https://google.com",
"https://www.youtube.com/watch?v=AICOs2mNTrw",

// duplicate
"https://discord.com/invite/foo",
"https://discord.com/invite/foo",
"https://example.com",
"https://example.com",
].join("\n"),
);

Expand Down Expand Up @@ -55,17 +61,17 @@ describe("url.ts", () => {
"https://discord.com/invite/fuga",
"http://\\canary.diScordApP.com\\google.com⁂⌘∮/..\\/invite\\/youtube.com‖∠∇\\../twitter.com⁑∋〻\\../\\../fuga",
"https://\\canary.diScordApP.com\\google.com⁂⌘∮/..\\/invite\\/youtube.com‖∠∇\\../twitter.com⁑∋〻\\../\\../fuga",

"https://discord.com/invite/foo",
]);

expect(urls.normalUrls).toEqual([
"https://discord.com/",
"https://discord.com/terms",
"https://discord.com/terms/terms-of-service-april-2024",
"https://discord.com/",
"https://discord.com/terms",
"https://discord.com/terms/terms-of-service-april-2024",
"https://google.com",
"https://www.youtube.com/watch?v=AICOs2mNTrw",
"https://example.com",
]);
});
});
16 changes: 8 additions & 8 deletions src/domain/service/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ export type FindUrls = {

export const URL_REGEXP = /https?:\/\/[\w!?/+\-_~=;.,*&@#$%()'[\]]+/im;

export const URL_REGEXP_DISCORD_GG = /(?:https?:\/\/)?discord\.gg\/[a-zA-Z0-9_-]+/im;
export const URL_REGEXP_DISCORD_GG = /(?:(https?|discord):\/\/)?discord\.gg\/[a-zA-Z0-9_-]+/im;

export const URL_REGEXP_DISCORD_COM =
/(?:https?:\/\/)?\S*(?:discord\.com|discordapp\.com)\S*invite\S*[a-zA-Z0-9_-]+/im;
/(?:(https?|discord):\/\/)?\S*(?:discord\.com|discordapp\.com)\S*invite\S*[a-zA-Z0-9_-]+/im;

export function findUrlsSync(content: string): FindUrls {
const inviteLinks: string[] = [];
const normalUrls: string[] = [];
const inviteLinks = new Set<string>();
const normalUrls = new Set<string>();
const lines = content.split("\n");

for (const line of lines) {
Expand All @@ -38,16 +38,16 @@ export function findUrlsSync(content: string): FindUrls {
const isUrl = new RegExp(URL_REGEXP.source, "im").test(url);

if (isInviteLink) {
inviteLinks.push(url);
inviteLinks.add(url);
} else if (isUrl) {
normalUrls.push(url);
normalUrls.add(url);
}
}
}

return {
inviteLinks,
normalUrls,
inviteLinks: Array.from(inviteLinks),
normalUrls: Array.from(normalUrls),
};
}

Expand Down
119 changes: 116 additions & 3 deletions src/infrastructure/http/src/invite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@ import type { MockedFunction } from "vitest";

import { LocalAddressError } from "./Error/LocalAddressError";
import { RedirectError } from "./Error/RedirectError";
import { DISCORD_INVITE_LINK_START, isInviteLink, isUsedCf } from "./invite";
import { DISCORD_DOMAINS, DISCORD_INVITE_LINK_START, INVITE_PROTOCOL, isInviteLink, isUsedCf } from "./invite";
import { safeFetch } from "./safefetch";

const safeFetchMock = safeFetch as MockedFunction<typeof safeFetch>;

function fakeResponse(responseUrl: string, status = 200, body = ""): Response {
function fakeResponse(
responseUrl: string,
status = 200,
body = "",
extraHeaders: Record<string, string> = {},
): Response {
const obj = {
url: responseUrl,
status,
headers: new Headers(extraHeaders),
clone() {
return fakeResponse(responseUrl, status, body);
return fakeResponse(responseUrl, status, body, extraHeaders);
},
async text() {
return body;
Expand Down Expand Up @@ -145,3 +151,110 @@ describe("isInviteLink", () => {
expect((callInit?.headers as Record<string, string>)?.["User-Agent"]).toBe("Mozilla/5.0");
});
});

// ─── DISCORD_INVITE_LINK_START equivalence ───────────────────────────────────
//
// Verifies that the URL-parsing approach (isDiscordInviteLink) detects exactly
// the same URLs as the old DISCORD_INVITE_LINK_START.some(v => url.startsWith(v))
// approach, for both the final response URL and the Location response header.

describe("isInviteLink — DISCORD_INVITE_LINK_START equivalence", () => {
beforeEach(() => {
vi.clearAllMocks();
});

// ── resUrl path ─────────────────────────────────────────────────────────────

describe("resUrl", () => {
it.each(DISCORD_INVITE_LINK_START)(
"content: true — %s + invite code",
async (prefix) => {
safeFetchMock.mockResolvedValueOnce(fakeResponse(`${prefix}abc123`));
const result = await isInviteLink("https://shortener.example.com/xyz");
expect(result).not.toBeInstanceOf(Error);
expect((result as { content: boolean }).content).toBe(true);
},
);

it.each([
// no trailing slash → pathname stays /invite and does not match /invite/
"https://discord.com/invite",
"https://ptb.discord.com/invite",
"https://canary.discord.com/invite",
"discord://discord.com/invite",
// non-invite path
"https://discord.com/channels/123456/789012",
"https://discord.com/",
// subdomain spoofing
"https://evil.discord.com/invite/abc123",
// domain spoofing
"https://discord.com.evil.com/invite/abc123",
// unrelated domain
"https://example.com/invite/abc123",
])("content: false — %s", async (nonInviteUrl) => {
safeFetchMock.mockResolvedValueOnce(fakeResponse(nonInviteUrl));
const result = await isInviteLink("https://shortener.example.com/xyz");
expect(result).not.toBeInstanceOf(Error);
expect((result as { content: boolean }).content).toBe(false);
});
});

// ── location header path ────────────────────────────────────────────────────

describe("location header", () => {
it.each(DISCORD_INVITE_LINK_START)(
"content: true — Location: %s + invite code",
async (prefix) => {
safeFetchMock.mockResolvedValueOnce(
fakeResponse("https://example.com/", 200, "", { location: `${prefix}abc123` }),
);
const result = await isInviteLink("https://shortener.example.com/xyz");
expect(result).not.toBeInstanceOf(Error);
expect((result as { content: boolean }).content).toBe(true);
},
);

it.each([
"https://discord.com/invite",
"https://evil.discord.com/invite/abc123",
"https://discord.com.evil.com/invite/abc123",
"https://example.com/invite/abc123",
])("content: false — Location: %s", async (location) => {
safeFetchMock.mockResolvedValueOnce(
fakeResponse("https://example.com/", 200, "", { location }),
);
const result = await isInviteLink("https://shortener.example.com/xyz");
expect(result).not.toBeInstanceOf(Error);
expect((result as { content: boolean }).content).toBe(false);
});

it("content: false when Location header is absent", async () => {
safeFetchMock.mockResolvedValueOnce(fakeResponse("https://example.com/page"));
const result = await isInviteLink("https://shortener.example.com/xyz");
expect(result).not.toBeInstanceOf(Error);
expect((result as { content: boolean }).content).toBe(false);
});
});

// ── INVITE_PROTOCOL / DISCORD_DOMAINS exports ────────────────────────────────
// Verifies that all protocols and hosts present in the legacy DISCORD_INVITE_LINK_START
// are covered by their respective constants without omission.

it("INVITE_PROTOCOL covers all protocols used in DISCORD_INVITE_LINK_START", () => {
const usedProtocols = new Set(
DISCORD_INVITE_LINK_START.map((url) => new URL(url).protocol),
);
for (const protocol of usedProtocols) {
expect(INVITE_PROTOCOL).toContain(protocol);
}
});

it("DISCORD_DOMAINS covers all hosts used in DISCORD_INVITE_LINK_START", () => {
const usedHosts = new Set(
DISCORD_INVITE_LINK_START.map((url) => new URL(url).hostname),
);
for (const host of usedHosts) {
expect(DISCORD_DOMAINS).toContain(host);
}
});
});
42 changes: 36 additions & 6 deletions src/infrastructure/http/src/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,29 @@ export const DISCORD_INVITE_LINK_START = [
"https://discord.com/invite/",
"https://ptb.discord.com/invite/",
"https://canary.discord.com/invite/",
"discord://discord.com/invite/",
"discord://ptb.discord.com/invite/",
"discord://canary.discord.com/invite/",
];

export const DISCORD_DOMAINS = ["discord.com", "ptb.discord.com", "canary.discord.com"];

export const INVITE_PROTOCOL = ["discord:", "http:", "https:"];

async function isDiscordInviteLink(url: string | URL) {
const parsedUrl = URL.parse(url);

if (parsedUrl === null) {
return false;
}

return (
INVITE_PROTOCOL.includes(parsedUrl.protocol) &&
DISCORD_DOMAINS.includes(parsedUrl.host) &&
parsedUrl.pathname.startsWith("/invite/")
);
}

// this function is fucking shit.
// I will fix it someday.
export async function isUsedCf(res: Response) {
Expand All @@ -39,21 +60,30 @@ export async function isUsedCf(res: Response) {
export async function isInviteLink(
url: string,
): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError> {
const response = await safeFetch(url as SafeUrl, {
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0",
const response = await safeFetch(
url as SafeUrl,
{
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0",
},
},
{
detectDiscordProtocol: true,
},
});
);

if (response instanceof Error) {
return response;
}

const resUrl = response.url;
const location = response.headers.get("location");

return {
content: DISCORD_INVITE_LINK_START.some((value) => resUrl.startsWith(value)),
content:
(await isDiscordInviteLink(resUrl)) ||
(location !== null && (await isDiscordInviteLink(location))),
isUsedCf: await isUsedCf(response),
};
}
10 changes: 7 additions & 3 deletions src/infrastructure/http/src/safefetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("./url", () => ({
isLocalUrl: vi.fn(),
}));
vi.mock("./url", async (importOriginal) => {
const actual = await importOriginal<typeof import("./url")>();
return {
...actual,
isLocalUrl: vi.fn(),
};
});
vi.mock("./size", () => ({
isValidSize: vi.fn(),
}));
Expand Down
13 changes: 11 additions & 2 deletions src/infrastructure/http/src/safefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { DEFAULT_MAX_REDIRECT } from "./redirect";
import type { SafeUrl } from "./safeurl";
import { isValidSize } from "./size";
import { DEFAULT_TIMEOUT, DISCORD_TIMEOUT } from "./timeout";
import { isLocalUrl } from "./url";
import { isHttpProtocol, isLocalUrl } from "./url";

export type SafeFetchOptions = {
detectDiscordProtocol?: boolean;
};

export const DISCORD_DOMAINS = ["discord.com", "discordapp.com", "discord.gg"];

Expand All @@ -28,6 +32,7 @@ export async function safeFetchForDiscord(
export async function safeFetch(
input: SafeUrl,
init?: RequestInit,
options?: SafeFetchOptions,
): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError> {
let reqUrl: string = input;
let currentInit = init;
Expand Down Expand Up @@ -61,7 +66,11 @@ export async function safeFetch(
return new HeaderError(`${location} is invalid.`);
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
if (options?.detectDiscordProtocol && url.protocol === "discord://") {
return response;
Comment on lines +69 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
bun -e 'console.log(new URL("discord://discord.com/invite/abc123").protocol)'
# Expect: discord:

Repository: ThunLights/distopia

Length of output: 198


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- src/infrastructure/http/src/safefetch.ts (relevant lines) ---\n'
sed -n '1,140p' src/infrastructure/http/src/safefetch.ts

printf '\n--- URL.protocol behavior in Node ---\n'
node - <<'JS'
const u = new URL('discord://discord.com/invite/abc123');
console.log(u.protocol);
JS

Repository: ThunLights/distopia

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/infrastructure/http/src/safefetch.ts (relevant lines) ---'
sed -n '1,140p' src/infrastructure/http/src/safefetch.ts

echo
echo '--- URL.protocol behavior ---'
node - <<'JS'
const urls = [
  'discord://discord.com/invite/abc123',
  'https://example.com/path',
  'mailto:test@example.com',
];
for (const raw of urls) {
  const u = new URL(raw);
  console.log(raw, '=>', u.protocol);
}
JS

Repository: ThunLights/distopia

Length of output: 3365


Compare against discord: in the Discord redirect branch

URL.protocol returns discord: for discord://..., so this condition never matches and Discord invite redirects still fall through to HeaderError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/infrastructure/http/src/safefetch.ts` around lines 69 - 70, The Discord
redirect check in safefetch’s response handling is comparing against the wrong
URL protocol string, so the invite redirect branch never triggers. Update the
condition in the detectDiscordProtocol path to match the actual protocol value
returned by URL.protocol for discord invite URLs, and keep the change localized
to the response handling logic that returns early before HeaderError is raised.

}

if (!(await isHttpProtocol(url))) {
return new HeaderError(`${url.protocol} is not allowed.`);
}

Expand Down
6 changes: 6 additions & 0 deletions src/infrastructure/http/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,9 @@ export async function isLocalUrl(url: string): Promise<boolean> {

return await isLocalHostname(hostname);
}

export async function isHttpProtocol(url: string | URL): Promise<boolean> {
const protocol = URL.parse(url)?.protocol;

return protocol === "http:" || protocol === "https:";
}
Loading