generated from nhcarrigan/template
@@ -1,2 +1,4 @@
|
|||||||
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
|
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
|
||||||
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
|
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
|
||||||
|
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
|
||||||
|
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
|
||||||
+275
-34
@@ -3,9 +3,22 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
/* eslint-disable complexity, max-lines-per-function -- This is a chonky boi script. */
|
/* eslint-disable complexity, max-lines-per-function, max-lines, max-statements -- This is a chonky boi script. */
|
||||||
|
|
||||||
import { password, confirm } from "@inquirer/prompts";
|
/**
|
||||||
|
* OAuth setup (do this once per local machine):
|
||||||
|
* 1. Create a Discord application in the Developer Portal and note the Client ID.
|
||||||
|
* 2. Under OAuth2 → Redirects, add http://127.0.0.1:8721/callback (or supply your own via DISCORD_REDIRECT_URI).
|
||||||
|
* 3. (Optional but recommended) Generate a Client Secret and store it in DISCORD_CLIENT_SECRET.
|
||||||
|
* 4. Export DISCORD_CLIENT_ID (and secret if used) in your shell env before running this script.
|
||||||
|
* 5. Run the script; it will print an authorization URL. Approve the request in your browser and the local OAuth callback will handle the rest.
|
||||||
|
* Using OAuth this way keeps the flow within Discord’s ToS—no user tokens are ever collected or stored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import http from "node:http";
|
||||||
|
import process from "node:process";
|
||||||
|
import { confirm } from "@inquirer/prompts";
|
||||||
|
|
||||||
interface Guild {
|
interface Guild {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -121,45 +134,272 @@ const analyzeGuilds = (guilds: Array<Guild>): void => {
|
|||||||
printReport(stats);
|
printReport(stats);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const defaultRedirectUri = "http://127.0.0.1:8721/callback";
|
||||||
* Fetches the user's guilds and analyses them.
|
const defaultScopes = "identify guilds";
|
||||||
* For safety, we require the user to confirm our terms to continue.
|
const authorizeEndpoint = "https://discord.com/oauth2/authorize";
|
||||||
* Token is provided as an obscured password input to avoid accidental disclosure.
|
const tokenEndpoint = "https://discord.com/api/v10/oauth2/token";
|
||||||
*/
|
|
||||||
async function getGuilds(): Promise<void> {
|
const isRecord = (value: unknown): value is Record<PropertyKey, unknown> => {
|
||||||
console.log(
|
return typeof value === "object" && value !== null;
|
||||||
`WARNING! This script requires your user token. Because of this, you MUST take these into consideration:`,
|
};
|
||||||
|
|
||||||
|
const base64UrlEncode = (buffer: Buffer): string => {
|
||||||
|
const base64 = buffer.toString("base64");
|
||||||
|
return base64.replaceAll("+", "-").replaceAll("/", "_").
|
||||||
|
replace(/[=]+$/u, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCodeVerifier = (): string => {
|
||||||
|
return base64UrlEncode(crypto.randomBytes(64));
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCodeChallenge = (codeVerifier: string): string => {
|
||||||
|
const hash = crypto.createHash("sha256").update(codeVerifier).
|
||||||
|
digest();
|
||||||
|
return base64UrlEncode(hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function waitForOAuthCode(
|
||||||
|
redirectUri: string,
|
||||||
|
expectedState: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const { hostname, port, pathname, protocol } = new URL(redirectUri);
|
||||||
|
if (protocol !== "http:") {
|
||||||
|
throw new Error("Only HTTP redirect URIs are supported for local OAuth.");
|
||||||
|
}
|
||||||
|
let listenPort = 80;
|
||||||
|
if (port !== "") {
|
||||||
|
listenPort = Number.parseInt(port, 10);
|
||||||
|
}
|
||||||
|
let listenHost: string | undefined = hostname;
|
||||||
|
let displayHost = hostname;
|
||||||
|
if (hostname === "") {
|
||||||
|
listenHost = undefined;
|
||||||
|
displayHost = "localhost";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = http.createServer();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
server.close();
|
||||||
|
reject(new Error("OAuth approval timed out. Please try again."));
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
const finish = (
|
||||||
|
result: "resolve" | "reject",
|
||||||
|
value?: string | Error,
|
||||||
|
): void => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
server.close();
|
||||||
|
if (result === "resolve" && typeof value === "string") {
|
||||||
|
resolve(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === "reject" && value instanceof Error) {
|
||||||
|
reject(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPlainText = (
|
||||||
|
response: http.ServerResponse,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
): void => {
|
||||||
|
response.statusCode = status;
|
||||||
|
response.setHeader("Content-Type", "text/plain");
|
||||||
|
response.end(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleOAuthRequest(
|
||||||
|
request: http.IncomingMessage,
|
||||||
|
response: http.ServerResponse,
|
||||||
|
): void {
|
||||||
|
if (request.method !== "GET" || request.url === undefined) {
|
||||||
|
sendPlainText(response, 405, "Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(request.url, redirectUri);
|
||||||
|
if (requestUrl.pathname !== pathname) {
|
||||||
|
sendPlainText(response, 404, "Not Found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingState = requestUrl.searchParams.get("state");
|
||||||
|
const error = requestUrl.searchParams.get("error");
|
||||||
|
const code = requestUrl.searchParams.get("code");
|
||||||
|
|
||||||
|
if (error !== null) {
|
||||||
|
sendPlainText(response, 400, `OAuth Error: ${error}`);
|
||||||
|
finish("reject", new Error(`OAuth error: ${error}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingState !== expectedState || code === null) {
|
||||||
|
sendPlainText(response, 400, "Invalid OAuth response.");
|
||||||
|
finish("reject", new Error("OAuth state mismatch or missing code."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPlainText(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
"Authorization received. You can close this tab.",
|
||||||
);
|
);
|
||||||
|
finish("resolve", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.on("request", handleOAuthRequest);
|
||||||
|
|
||||||
|
server.listen(listenPort, listenHost, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`1. DO NOT SHARE YOUR TOKEN WITH ANYONE. Your token can be used to impersonate your account, and can only be changed by rotating your account password.`,
|
`Waiting for OAuth callback on http://${displayHost}:${listenPort.toString()}${pathname}`,
|
||||||
);
|
);
|
||||||
console.log(
|
|
||||||
`2. THIS SCRIPT IS CONSIDERED SELF BOTTING. Running this is a violation of Discord's Terms of Service. DO SO AT YOUR OWN RISK!`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`3. Naomi Carrigan, NHCarrigan, its associates, and its affiliates are not responsible for any actions taken by you using this script.`,
|
|
||||||
);
|
|
||||||
const confirmed = await confirm({
|
|
||||||
message:
|
|
||||||
"I understand these risks, agree to the terms, and want to continue.",
|
|
||||||
});
|
});
|
||||||
if (!confirmed) {
|
});
|
||||||
throw new Error("User did not confirm the terms.");
|
}
|
||||||
|
|
||||||
|
interface TokenExchangeOptions {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenExchangeOptions {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string | undefined;
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForToken({
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri,
|
||||||
|
}: TokenExchangeOptions): Promise<string> {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("client_id", clientId);
|
||||||
|
body.set("code", code);
|
||||||
|
body.set("code_verifier", codeVerifier);
|
||||||
|
body.set("grant_type", "authorization_code");
|
||||||
|
body.set("redirect_uri", redirectUri);
|
||||||
|
|
||||||
|
if (clientSecret !== undefined && clientSecret !== "") {
|
||||||
|
body.set("client_secret", clientSecret);
|
||||||
}
|
}
|
||||||
const token = await password({
|
|
||||||
mask: true,
|
const headers = new Headers();
|
||||||
message: "Please enter your user token:",
|
headers.set("content-type", "application/x-www-form-urlencoded");
|
||||||
validate: (value) => {
|
|
||||||
if (value === "") {
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
return "Token is required";
|
body: body,
|
||||||
}
|
headers: headers,
|
||||||
return true;
|
method: "POST",
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (token === "") {
|
if (!tokenResponse.ok) {
|
||||||
throw new Error("Missing TOKEN");
|
throw new Error(
|
||||||
|
`OAuth token exchange failed with status ${tokenResponse.status.toString()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenPayload: unknown = await tokenResponse.json();
|
||||||
|
if (!isRecord(tokenPayload)) {
|
||||||
|
throw new TypeError("Token payload is not an object.");
|
||||||
|
}
|
||||||
|
const accessToken = tokenPayload.access_token;
|
||||||
|
if (typeof accessToken !== "string") {
|
||||||
|
throw new TypeError("No access token returned from Discord.");
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOAuthFlow(): Promise<string> {
|
||||||
|
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||||
|
const redirectUri = process.env.DISCORD_REDIRECT_URI ?? defaultRedirectUri;
|
||||||
|
const scopes = process.env.DISCORD_SCOPES ?? defaultScopes;
|
||||||
|
|
||||||
|
if (clientId === undefined || clientId === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find Discord client ID. Please ensure you have followed the steps outlined when you ran this script.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
|
||||||
|
const authUrl = new URL(authorizeEndpoint);
|
||||||
|
authUrl.searchParams.set("client_id", clientId);
|
||||||
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
authUrl.searchParams.set("response_type", "code");
|
||||||
|
authUrl.searchParams.set("scope", scopes);
|
||||||
|
authUrl.searchParams.set("state", state);
|
||||||
|
authUrl.searchParams.set("prompt", "consent");
|
||||||
|
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
console.log("\n====== Discord OAuth ======");
|
||||||
|
console.log("1. Open the following URL in your browser:");
|
||||||
|
console.log(authUrl.toString());
|
||||||
|
console.log("2. Approve access for your application.");
|
||||||
|
console.log(
|
||||||
|
"3. Return here; the script will continue once approval is complete.\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const code = await waitForOAuthCode(redirectUri, state);
|
||||||
|
const tokenRequest: TokenExchangeOptions = {
|
||||||
|
clientId,
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri,
|
||||||
|
};
|
||||||
|
if (clientSecret !== undefined && clientSecret !== "") {
|
||||||
|
tokenRequest.clientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
return await exchangeCodeForToken(tokenRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the user's guilds and analyses them (via OAuth and PKCE).
|
||||||
|
*/
|
||||||
|
async function getGuilds(): Promise<void> {
|
||||||
|
console.log("In order to run this script, you must complete a few steps.");
|
||||||
|
console.log(
|
||||||
|
`1. Create a Discord application in the Developer Portal and note the Client ID.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`2. Under OAuth2 → Redirects, add http://127.0.0.1:8721/callback (or supply your own via DISCORD_REDIRECT_URI).`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`3. (Optional but recommended) Generate a Client Secret and store it in DISCORD_CLIENT_SECRET.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`4. Export DISCORD_CLIENT_ID (and secret if used) in your shell env before running this script.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`5. Run the script; it will print an authorization URL. Approve the request in your browser and the local OAuth callback will handle the rest.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Using OAuth this way keeps the flow within Discord’s ToS—no user tokens are ever collected or stored.`,
|
||||||
|
);
|
||||||
|
const confirmed = await confirm({
|
||||||
|
message: "Have you completed these steps already?",
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log("Please complete the steps and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"Starting OAuth flow to fetch joined servers without exposing user tokens.",
|
||||||
|
);
|
||||||
|
const accessToken = await startOAuthFlow();
|
||||||
console.log("Fetching servers...");
|
console.log("Fetching servers...");
|
||||||
|
|
||||||
// Discord allows max 200 servers per user (with Nitro), so limit=200 catches all.
|
// Discord allows max 200 servers per user (with Nitro), so limit=200 catches all.
|
||||||
@@ -167,7 +407,7 @@ async function getGuilds(): Promise<void> {
|
|||||||
"https://discord.com/api/v10/users/@me/guilds?limit=200",
|
"https://discord.com/api/v10/users/@me/guilds?limit=200",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
authorization: token,
|
authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -186,5 +426,6 @@ async function getGuilds(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await getGuilds();
|
await getGuilds();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
export { getGuilds };
|
export { getGuilds };
|
||||||
|
|||||||
Reference in New Issue
Block a user