generated from nhcarrigan/template
feat: add multilingual support so Naomi can use Python too
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,27 @@
|
||||
.PHONY: help install build lint test clean
|
||||
|
||||
help:
|
||||
@echo "TypeScript project commands:"
|
||||
@echo " make install - Install dependencies"
|
||||
@echo " make build - Build TypeScript (type check)"
|
||||
@echo " make lint - Run ESLint"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
install:
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
build:
|
||||
pnpm exec tsc --noEmit
|
||||
|
||||
lint:
|
||||
pnpm exec eslint src --max-warnings 0
|
||||
|
||||
test:
|
||||
@echo "No tests configured yet"
|
||||
@exit 0
|
||||
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
rm -rf dist
|
||||
rm -f *.tsbuildinfo
|
||||
@@ -0,0 +1,12 @@
|
||||
import NaomisConfig from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [
|
||||
...NaomisConfig,
|
||||
{
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "op run --env-file=prod.env --no-masking -- tsx"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.940.0",
|
||||
"@inquirer/prompts": "7.8.6",
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@types/cli-progress": "3.11.6",
|
||||
"@types/node": "24.3.0",
|
||||
"cli-progress": "3.12.0",
|
||||
"eslint": "9.34.0",
|
||||
"open": "11.0.0",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
Generated
+5970
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { readFile, appendFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getLanguages } from "./utils/getLanguages.js";
|
||||
import type { String } from "./interfaces/string.js";
|
||||
|
||||
const projectId = process.env.CROWDIN_PROJECT_ID;
|
||||
const apiUrl = process.env.CROWDIN_API_URL;
|
||||
const token = process.env.CROWDIN_TOKEN;
|
||||
|
||||
if (
|
||||
projectId === undefined
|
||||
|| projectId === ""
|
||||
|| apiUrl === undefined
|
||||
|| apiUrl === ""
|
||||
|| token === undefined
|
||||
|| token === ""
|
||||
) {
|
||||
throw new Error(`Project ID or API URL is missing! Did you run this script with 'op run'?`);
|
||||
}
|
||||
|
||||
const logPath
|
||||
= join(import.meta.dirname, "..", "..", "data", "crowdin-strings-hidden.txt");
|
||||
|
||||
const languages = await getLanguages(projectId, apiUrl, token);
|
||||
console.log(`Found ${languages.length.toString()} active languages.`);
|
||||
const rawStrings = await readFile(
|
||||
join(import.meta.dirname, "..", "..", "data", "crowdin-strings.json"),
|
||||
"utf-8",
|
||||
);
|
||||
const strings: Array<String["data"][0]["data"]> = JSON.parse(rawStrings);
|
||||
console.log(`Found ${strings.length.toString()} strings.`);
|
||||
|
||||
const log = await readFile(
|
||||
logPath,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const processedIds = log.split("\n");
|
||||
|
||||
const unprocessedStrings = strings.filter((string) => {
|
||||
return !processedIds.includes(string.id.toString());
|
||||
});
|
||||
|
||||
const hidden = unprocessedStrings.filter((string) => {
|
||||
return string.isHidden;
|
||||
});
|
||||
|
||||
console.log(`Processing ${hidden.length.toString()} hidden strings.`);
|
||||
|
||||
for (const string of hidden) {
|
||||
console.log(`Deleting translations for ${string.id.toString()}`);
|
||||
await Promise.all(languages.map(async(language) => {
|
||||
await fetch(`${apiUrl}/projects/${projectId}/translations`, {
|
||||
body: JSON.stringify({
|
||||
languageId: language,
|
||||
stringId: string.id,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${token}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- header.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
}));
|
||||
await appendFile(logPath, `${string.id.toString()}\n`);
|
||||
}
|
||||
|
||||
console.log("Completed!");
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export interface File {
|
||||
data: Array<{
|
||||
data: {
|
||||
id: number;
|
||||
projectId: number;
|
||||
branchId: number;
|
||||
directoryId: number;
|
||||
name: string;
|
||||
title: string;
|
||||
context: string;
|
||||
type: string;
|
||||
path: string;
|
||||
status: string;
|
||||
revisionId: number;
|
||||
priority: string;
|
||||
importOptions: {
|
||||
importTranslations: boolean;
|
||||
firstLineContainsHeader: boolean;
|
||||
contentSegmentation: boolean;
|
||||
customSegmentation: boolean;
|
||||
scheme: {
|
||||
identifier: number;
|
||||
sourcePhrase: number;
|
||||
en: number;
|
||||
de: number;
|
||||
};
|
||||
};
|
||||
exportOptions: {
|
||||
exportPattern: string;
|
||||
};
|
||||
excludedTargetLanguages: Array<string>;
|
||||
parserVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}>;
|
||||
pagination: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export interface PreTranslation {
|
||||
data: {
|
||||
identifier: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
attributes: {
|
||||
languageIds: Array<string>;
|
||||
fileIds: Array<number>;
|
||||
method: string;
|
||||
autoApproveOption: string;
|
||||
duplicateTranslations: boolean;
|
||||
skipApprovedTranslations: boolean;
|
||||
translateUntranslatedOnly: boolean;
|
||||
translateWithPerfectMatchOnly: boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export interface Project {
|
||||
data: {
|
||||
id: number;
|
||||
type: number;
|
||||
userId: number;
|
||||
sourceLanguageId: string;
|
||||
targetLanguageIds: Array<string>;
|
||||
languageAccessPolicy: string;
|
||||
name: string;
|
||||
cname: string;
|
||||
identifier: string;
|
||||
description: string;
|
||||
visibility: string;
|
||||
logo: string;
|
||||
publicDownloads: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivity: string;
|
||||
sourceLanguage: {
|
||||
id: string;
|
||||
name: string;
|
||||
editorCode: string;
|
||||
twoLettersCode: string;
|
||||
threeLettersCode: string;
|
||||
locale: string;
|
||||
androidCode: string;
|
||||
osxCode: string;
|
||||
osxLocale: string;
|
||||
pluralCategoryNames: Array<string>;
|
||||
pluralRules: string;
|
||||
pluralExamples: Array<string>;
|
||||
textDirection: string;
|
||||
dialectOf: string;
|
||||
};
|
||||
targetLanguages: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
editorCode: string;
|
||||
twoLettersCode: string;
|
||||
threeLettersCode: string;
|
||||
locale: string;
|
||||
androidCode: string;
|
||||
osxCode: string;
|
||||
osxLocale: string;
|
||||
pluralCategoryNames: Array<string>;
|
||||
pluralRules: string;
|
||||
pluralExamples: Array<string>;
|
||||
textDirection: string;
|
||||
dialectOf: string;
|
||||
}>;
|
||||
webUrl: string;
|
||||
savingsReportSettingsTemplateId: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export interface String {
|
||||
data: Array<{
|
||||
data: {
|
||||
id: number;
|
||||
projectId: number;
|
||||
branchId: number;
|
||||
identifier: string;
|
||||
text: string;
|
||||
type: string;
|
||||
context: string;
|
||||
maxLength: number;
|
||||
isHidden: boolean;
|
||||
isDuplicate: boolean;
|
||||
masterStringId: number;
|
||||
hasPlurals: boolean;
|
||||
isIcu: boolean;
|
||||
labelIds: Array<number>;
|
||||
webUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
fileId: number;
|
||||
directoryId: number;
|
||||
revision: number;
|
||||
};
|
||||
}>;
|
||||
pagination: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { sleep } from "../utils/sleep.js";
|
||||
import { getFiles } from "./utils/getFiles.js";
|
||||
import { getLanguages } from "./utils/getLanguages.js";
|
||||
import type { PreTranslation } from "./interfaces/preTranslation.js";
|
||||
|
||||
const projectId = process.env.CROWDIN_PROJECT_ID;
|
||||
const apiUrl = process.env.CROWDIN_API_URL;
|
||||
const token = process.env.CROWDIN_TOKEN;
|
||||
|
||||
if (
|
||||
projectId === undefined
|
||||
|| projectId === ""
|
||||
|| apiUrl === undefined
|
||||
|| apiUrl === ""
|
||||
|| token === undefined
|
||||
|| token === ""
|
||||
) {
|
||||
throw new Error(`Project ID or API URL is missing! Did you run this script with 'op run'?`);
|
||||
}
|
||||
|
||||
const languages = await getLanguages(projectId, apiUrl, token);
|
||||
console.log(`Found ${languages.length.toString()} active languages.`);
|
||||
const files = await getFiles(projectId, apiUrl, token);
|
||||
console.log(`Found ${files.length.toString()} files.`);
|
||||
|
||||
const url = `${apiUrl}/projects/${projectId}/pre-translations`;
|
||||
|
||||
const request = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
autoApproveOption: "perfectMatchOnly",
|
||||
fileIds: files,
|
||||
languageIds: languages,
|
||||
method: "tm",
|
||||
skipApprovedTranslations: true,
|
||||
translateWithPerfectMatchOnly: true,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${token}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- header.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const response: PreTranslation = await request.json();
|
||||
|
||||
const { identifier } = response.data;
|
||||
console.log(`Pre-translation ${identifier} started! Will report progress every 5 seconds.`);
|
||||
|
||||
let { progress } = response.data;
|
||||
const progressUrl = `${apiUrl}/projects/${projectId}/pre-translations/${identifier}`;
|
||||
|
||||
while (progress < 100) {
|
||||
await sleep(5000);
|
||||
const progressRequest = await fetch(progressUrl, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const progressResult: PreTranslation = await progressRequest.json();
|
||||
const { progress: updatedProgress } = progressResult.data;
|
||||
progress = updatedProgress;
|
||||
console.log(`Progress: ${progress.toString()}%`);
|
||||
}
|
||||
console.log("Pretranslation complete!");
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { File } from "../interfaces/file.js";
|
||||
|
||||
/**
|
||||
* Gets a list of all files from a project.
|
||||
* @param projectId - The project ID (a numeric string).
|
||||
* @param apiUrl - The base URL for the API.
|
||||
* @param token - The API key.
|
||||
* @returns An array of language IDs as strings.
|
||||
*/
|
||||
export const getFiles
|
||||
= async(
|
||||
projectId: string,
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
): Promise<Array<File["data"][0]>> => {
|
||||
const url = `${apiUrl}/projects/${projectId}/files?limit=500`;
|
||||
let offset = 0;
|
||||
const files: Array<File["data"][0]> = [];
|
||||
|
||||
console.log(`Requesting files ${offset.toString()} to ${(offset + 500).toString()}`);
|
||||
let request = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
let response: File = await request.json();
|
||||
files.push(...response.data);
|
||||
while (response.data.length >= 500) {
|
||||
offset = offset + 500;
|
||||
console.log(`Requesting files ${offset.toString()} to ${(offset + 500).toString()}`);
|
||||
request = await fetch(`${url}&offset=${offset.toString()}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
response = await request.json();
|
||||
files.push(...response.data);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { Project } from "../interfaces/project.js";
|
||||
|
||||
/**
|
||||
* Fetches a project from Crowdin and returns the list of target language IDs.
|
||||
* @param projectId - The project ID (a numeric string).
|
||||
* @param apiUrl - The base URL for the API.
|
||||
* @param token - The API key.
|
||||
* @returns An array of language IDs as strings.
|
||||
*/
|
||||
export const getLanguages
|
||||
= async(
|
||||
projectId: string,
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
): Promise<Array<string>> => {
|
||||
const url = `${apiUrl}/projects/${projectId}`;
|
||||
const request = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const response: Project = await request.json();
|
||||
const ids = response.data.targetLanguageIds;
|
||||
return ids;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { String } from "../interfaces/string.js";
|
||||
|
||||
/**
|
||||
* Gets a list of all strings from a project.
|
||||
* @param projectId - The project ID (a numeric string).
|
||||
* @param apiUrl - The base URL for the API.
|
||||
* @param token - The API key.
|
||||
* @returns An array of string objects.
|
||||
*/
|
||||
export const getStrings
|
||||
= async(
|
||||
projectId: string,
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
): Promise<Array<String["data"][0]["data"]>> => {
|
||||
const url = `${apiUrl}/projects/${projectId}/strings?limit=500`;
|
||||
let offset = 0;
|
||||
const strings: Array<String["data"][0]["data"]> = [];
|
||||
|
||||
console.log(`Requesting strings ${offset.toString()} to ${(offset + 500).toString()}`);
|
||||
let request = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
let response: String = await request.json();
|
||||
strings.push(...response.data.map((datum) => {
|
||||
return datum.data;
|
||||
}));
|
||||
while (response.data.length >= 500) {
|
||||
offset = offset + 500;
|
||||
console.log(`Requesting strings ${offset.toString()} to ${(offset + 500).toString()}`);
|
||||
request = await fetch(`${url}&offset=${offset.toString()}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
response = await request.json();
|
||||
strings.push(...response.data.map((datum) => {
|
||||
return datum.data;
|
||||
}));
|
||||
}
|
||||
return strings;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getFiles } from "./utils/getFiles.js";
|
||||
import { getStrings } from "./utils/getStrings.js";
|
||||
|
||||
const projectId = process.env.CROWDIN_PROJECT_ID;
|
||||
const apiUrl = process.env.CROWDIN_API_URL;
|
||||
const token = process.env.CROWDIN_TOKEN;
|
||||
|
||||
if (
|
||||
projectId === undefined
|
||||
|| projectId === ""
|
||||
|| apiUrl === undefined
|
||||
|| apiUrl === ""
|
||||
|| token === undefined
|
||||
|| token === ""
|
||||
) {
|
||||
throw new Error(`Project ID or API URL is missing! Did you run this script with 'op run'?`);
|
||||
}
|
||||
|
||||
const files = await getFiles(projectId, apiUrl, token);
|
||||
const strings = await getStrings(projectId, apiUrl, token);
|
||||
|
||||
await writeFile(
|
||||
join(import.meta.dirname, "..", "..", "data", "crowdin-files.json"),
|
||||
JSON.stringify(files, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await writeFile(
|
||||
join(import.meta.dirname, "..", "..", "data", "crowdin-strings.json"),
|
||||
JSON.stringify(strings, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
console.log("Loaded files and strings!");
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Too big strings */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API */
|
||||
/* eslint-disable max-lines -- Justification above */
|
||||
|
||||
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||
|
||||
const data: Array<{ title: string; speaker: string }> = [
|
||||
{
|
||||
speaker: "Abbey Perini",
|
||||
title: "Slots, Slots, Slots, Everybody!",
|
||||
},
|
||||
{
|
||||
speaker: "Vishnu Ramineni",
|
||||
title: "Building Inclusive Web Experiences: Web Accessibility in ReactJS",
|
||||
},
|
||||
{
|
||||
speaker: "Francisco Zenteno",
|
||||
title: "Effective Testing Strategies for Optimization Models in Java",
|
||||
},
|
||||
{
|
||||
speaker: "Manisha Ponugoti",
|
||||
title: "Legacy to Cloud: Transforming Insurance Systems with .NET Microservices & Azure",
|
||||
},
|
||||
{
|
||||
speaker: "Arjan Dedgjonaj",
|
||||
title: "Mutation Testing",
|
||||
},
|
||||
{
|
||||
speaker: "Kierra Dotson",
|
||||
title: "Beyond The Hype: Leading the Charge in Responsible AgentOps for Business Transformation",
|
||||
},
|
||||
{
|
||||
speaker: "Chris DeMars",
|
||||
title: "Know Your JS",
|
||||
},
|
||||
{
|
||||
speaker: "Kaleb Garner",
|
||||
title: "Need for Speed: How Modern JavaScript Frameworks Are Solving Web Performance",
|
||||
},
|
||||
{
|
||||
speaker: "Faye X",
|
||||
title: "Break into full stack from Java",
|
||||
},
|
||||
{
|
||||
speaker: "Tim Corey",
|
||||
title: "Modernize Your .NET Skills",
|
||||
},
|
||||
{
|
||||
speaker: "Ramasankar Molleti",
|
||||
title: "Convergence of AI, Cybersecurity, and Cloud Technologies: Shaping the Future of Enterprise IT",
|
||||
},
|
||||
{
|
||||
speaker: "Sireesha Chilakamarri",
|
||||
title: "TBA",
|
||||
},
|
||||
{},
|
||||
{
|
||||
speaker: "Lawrence Lockhart",
|
||||
title: "You Don't Know JSON: How to Grow From Syntax to Superpowers",
|
||||
},
|
||||
{
|
||||
speaker: "Hermes Frangoudis",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Josh Long",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Caleb Jenkins",
|
||||
title: "10 Reasons your Software Sucks! (and what to do about it)",
|
||||
},
|
||||
{
|
||||
speaker: "Leah Thompson",
|
||||
title: "Using React with InertiaJS - Making front end development FAST🚀",
|
||||
},
|
||||
{
|
||||
speaker: "Katarinya Hughes",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Nnenna Ndukwe",
|
||||
title: "Building with Confidence: Mastering Feature Flags in React Applications",
|
||||
},
|
||||
{
|
||||
speaker: "Dennis Ivy",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Aly Ibrahim",
|
||||
title: "FinOps and Cloud Cost Optimization",
|
||||
},
|
||||
{
|
||||
speaker: "Ken Versaw",
|
||||
title: "C# Past, Present, and Future",
|
||||
},
|
||||
{
|
||||
speaker: "Jonathan Perry",
|
||||
title: "Strategies for Mitigating Performance Interference in Cloud-Native Systems",
|
||||
},
|
||||
{
|
||||
speaker: "Alexander Crettenand",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "David Parry",
|
||||
title: "AI Assisted Coding: Navigating the Strengths, Challenges, and Future of Coding Assistants",
|
||||
},
|
||||
{
|
||||
speaker: "Andrew MacLean",
|
||||
title: "Goodbye, World; Hello, Battlesnake!",
|
||||
},
|
||||
{
|
||||
speaker: "Sergey Kryvets",
|
||||
title: "Making You a Better Speaker with Spring AI",
|
||||
},
|
||||
{
|
||||
speaker: "Nick Cosentino",
|
||||
title: "plugin architectures in C#",
|
||||
},
|
||||
{
|
||||
speaker: "Mike Arsenault",
|
||||
title: "Stop Hitting Yourself: How to Prevent Common Attacks to your Application",
|
||||
},
|
||||
{
|
||||
speaker: "Tracy Lee",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Shaurya Agrawal",
|
||||
title: "Integrating Microsoft Fabric and Databricks for Modern Data Analytics",
|
||||
},
|
||||
{
|
||||
speaker: "Swizec Teller",
|
||||
title: "Abusing React Server Components as a tool for incremental rewrites",
|
||||
},
|
||||
{
|
||||
speaker: "Prathap Raghavan",
|
||||
title: "Spring Boot Microservices Architecture: A Deep Dive",
|
||||
},
|
||||
{
|
||||
speaker: "Matthew Hess",
|
||||
title: "Wait, What? C# Async-Await Explained",
|
||||
},
|
||||
{
|
||||
speaker: "Cedric Clyburn",
|
||||
title: "Structuring the Unstructured: Advanced Document Parsing for AI Workflows",
|
||||
},
|
||||
{
|
||||
speaker: "Michael Leitz",
|
||||
title: "The First Principles, Ep 1: Computers Run Code, Humans Read Code",
|
||||
},
|
||||
{
|
||||
speaker: "Nick Taylor",
|
||||
title: "OAuth Gets You In, Zero Trust Keeps You Safe",
|
||||
},
|
||||
{
|
||||
speaker: "Allen Helton",
|
||||
title: "Designing APIs Both Humans and AI Love",
|
||||
},
|
||||
{
|
||||
speaker: "Nate Custer",
|
||||
title: "Leveraging Quality Intelligence and AI to Improve the Quality of Java Applications",
|
||||
},
|
||||
{
|
||||
speaker: "Boston Cartwright",
|
||||
title: "Beyond Frontend and Backend: Defining the AI Layer",
|
||||
},
|
||||
{
|
||||
speaker: "Rob Ocel",
|
||||
title: "The Superpower of Intentional Architecture",
|
||||
},
|
||||
{
|
||||
speaker: "Jeremy Barger",
|
||||
title: "Split-Second Leadership: Mastering Emotions Before They Master You",
|
||||
},
|
||||
{
|
||||
speaker: "Rishab Kumar",
|
||||
title: "Breaking Bad: Cooking APIs with AWS Serverless Chemistry",
|
||||
},
|
||||
{
|
||||
speaker: "Michael Brown",
|
||||
title: "Hypervelocity Engineering: The End of Slow Dev",
|
||||
},
|
||||
{
|
||||
speaker: "Ted M. Young",
|
||||
title: "Testable Architecture: Keep 'em Separated",
|
||||
},
|
||||
{
|
||||
speaker: "Elder Yzunia",
|
||||
title: "Doing the Right Thing: Web Accessibility Tools for the Modern Dev",
|
||||
},
|
||||
{
|
||||
speaker: "David Strickland",
|
||||
title: "On Prem to Cloud Native",
|
||||
},
|
||||
{
|
||||
speaker: "John Crighton",
|
||||
title: "Cloud Enabled Event-Driven Architecture for Healthcare SaaS Solutions",
|
||||
},
|
||||
{
|
||||
speaker: "Pradeepkumar Palanisamy",
|
||||
title: "Killing Hardcoded Secrets: Secure Test Automation with Vault and Cloud-Native Secrets Management",
|
||||
},
|
||||
{
|
||||
speaker: "Dona Maria Jose",
|
||||
title: "From Vision to Impact: Leading Zero to One Projects in the Workplace",
|
||||
},
|
||||
{
|
||||
speaker: "Tanasin Vivitvorn",
|
||||
title: "Boost Java Application Efficiency on the Cloud and Slash Costs with GraalVM and Spring AOT",
|
||||
},
|
||||
{
|
||||
speaker: "JJ Asghar",
|
||||
title: "Open Source AI and Granite",
|
||||
},
|
||||
{
|
||||
speaker: "Jerry Reghunadh",
|
||||
title: "The Modern Full-Stack: Owning Your Deployment Pipeline",
|
||||
},
|
||||
{
|
||||
speaker: "James Q. Quick",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Amanda Martin",
|
||||
title: "Powering AI with your APIs: A Enterprise Architecture for MCP",
|
||||
},
|
||||
{
|
||||
speaker: "Diana Pham",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Trey Clark",
|
||||
title: "Local to Live: Deploying your App to the Cloud",
|
||||
},
|
||||
{
|
||||
speaker: "Borko Djurkovic",
|
||||
title: "Automating Secure OIDC-Based Cross-Cloud Authentication",
|
||||
},
|
||||
{
|
||||
speaker: "Alex Merced",
|
||||
title: "Unifying Data for Data Applications with the Data Lakehouse",
|
||||
},
|
||||
{
|
||||
speaker: "Reece Iriye",
|
||||
title: "Cloud vs. Control: Saving (or Sinking) a Business by Deploying Global Applications w/ Local Hardware",
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
speaker: "Jasmine Vo",
|
||||
title: "Blueprint for Architecting a Scalable Component Library",
|
||||
},
|
||||
{
|
||||
speaker: "Sachin Gupta",
|
||||
title: "From Concept to Code: Domain-Driven Design for .NET Business Applications",
|
||||
},
|
||||
{
|
||||
speaker: "Dan Vega",
|
||||
title: "Building AI-Enabled Spring Applications with Model Context Protocol (MCP)",
|
||||
},
|
||||
{
|
||||
speaker: "Akshay Mittal",
|
||||
title: "AI-Powered DevOps in Cloud App Modernization: Automating Deployments, Monitoring, and Resilience",
|
||||
},
|
||||
{
|
||||
speaker: "Justin Biard",
|
||||
title: "Manage Complexity with Terraform: Tips and Tricks from the Trenches",
|
||||
},
|
||||
{
|
||||
speaker: "Stacy Haven",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Rizel Scarlett",
|
||||
title: "MCP-UI: Where Intent Becomes Interface",
|
||||
},
|
||||
{
|
||||
speaker: "Chris Griffing",
|
||||
title: "Migrating 600k+ Lines of Code from Flow to TypeScript",
|
||||
},
|
||||
{
|
||||
speaker: "Srikanth Maru",
|
||||
title: "Building Production-Ready Data Pipelines for AI/ML with Java and Apache Beam",
|
||||
},
|
||||
{
|
||||
speaker: "Israa Taha",
|
||||
title: "ADRs: Because Context Isn't Just for LLMs",
|
||||
},
|
||||
{
|
||||
speaker: "Andrea Griffiths",
|
||||
title: "The AI Teammate Is Here — Now What?",
|
||||
},
|
||||
{
|
||||
speaker: "Marshall Burns",
|
||||
title: "Embedded Leadership: A Guid to Leading from Within",
|
||||
},
|
||||
{},
|
||||
{
|
||||
speaker: "Patrick Hulce",
|
||||
title: "Video AI in Your Browser with WebGPU",
|
||||
},
|
||||
{
|
||||
speaker: "Anupama Pathirage",
|
||||
title: "Ballerina in Action: Open-Source Innovation for Cloud-Native Integration",
|
||||
},
|
||||
{
|
||||
speaker: "Soujanya Vummannagari",
|
||||
title: "AI-Enhanced Strangler Pattern: Cognitive Legacy System Transformation",
|
||||
},
|
||||
{
|
||||
speaker: "Sarah Shook",
|
||||
title: "Bring Your Applications to Life with 3D CSS Animations",
|
||||
},
|
||||
{
|
||||
speaker: "Shafik Quoraishee",
|
||||
title: "Building a Handwriting Recognition System for the New York Times Crossword",
|
||||
},
|
||||
{
|
||||
speaker: "Victor Lyuboslavsky",
|
||||
title: "Radical Transparency: Leading Engineering with Openness Inside and Out",
|
||||
},
|
||||
{
|
||||
speaker: "Jacob Orshalick",
|
||||
title: "Agentic Fundamentals: How language models and code can communicate",
|
||||
},
|
||||
{
|
||||
speaker: "Bas Steins",
|
||||
title: "Beyond IaC: Bring your infrastructure to your app with sst",
|
||||
},
|
||||
{
|
||||
speaker: "Andrew Smit",
|
||||
title: "unit testing your API",
|
||||
},
|
||||
{
|
||||
speaker: "Naveen Chatlapalli",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Vasanth Mudavatu",
|
||||
title: "Commit Secure AI: Embedding Security in Code, Pipelines, and Production",
|
||||
},
|
||||
{
|
||||
speaker: "Samantha St-Louis",
|
||||
title: "Dynamic AI Architectures: Building Scalable, Real-World Solutions in the Cloud",
|
||||
},
|
||||
{
|
||||
speaker: "Charles Wood",
|
||||
title: "Your First JavaScript AI Agent",
|
||||
},
|
||||
{
|
||||
speaker: "Jacob Daddario",
|
||||
title: "JavaScript for the Rest of Us",
|
||||
},
|
||||
{
|
||||
speaker: "Suren Konathala",
|
||||
title: "Harness Java's Unmatched Power with JAQ Stack: Accelerate Web Development for Enterprise-Scale",
|
||||
},
|
||||
{
|
||||
speaker: "Ron Dagdag",
|
||||
title: "Predicting the future (of equipment) using ML.NET",
|
||||
},
|
||||
{
|
||||
speaker: "Wayne Jones",
|
||||
title: "Building Accessible Components: Best Practices and Business Buy-In",
|
||||
},
|
||||
{
|
||||
speaker: "Raja Krishna",
|
||||
title: "AI-Powered Code Reviews with Node and Vercel AI SDK",
|
||||
},
|
||||
{
|
||||
speaker: "Bob Fornal",
|
||||
title: "Writing Testable Code",
|
||||
},
|
||||
{
|
||||
speaker: "Jeremy Morgan",
|
||||
title: "Leveling Up: Building Retro 2D Games with JavaScript and Phaser",
|
||||
},
|
||||
{
|
||||
speaker: "Joseph Evans",
|
||||
title: "Write Once, Run Confidently: A Guide to Building Production-Ready Apps with Spring Boot",
|
||||
},
|
||||
{
|
||||
speaker: "Bobby Davis, Jr.",
|
||||
title: "Enterprise-Ready Minimal APIs in .NET: Scaling Simplicity",
|
||||
},
|
||||
{
|
||||
speaker: "Kevin Griffin",
|
||||
title: "Crazy Web Performance with Azure Static Web Apps",
|
||||
},
|
||||
{
|
||||
speaker: "Angela Cortes",
|
||||
title: "The QA spectrum",
|
||||
},
|
||||
{
|
||||
speaker: "Alvaro Montoro",
|
||||
title: "Playing with the Gamepad API: Using Game Controllers with JS",
|
||||
},
|
||||
{
|
||||
speaker: "Brian Morrison",
|
||||
title: "Demystifying deployments as a service",
|
||||
},
|
||||
{
|
||||
speaker: "Vishal Reddy",
|
||||
title: "Kotlin and Compose WASM for the UI challenged",
|
||||
},
|
||||
{
|
||||
speaker: "Alex Johnson",
|
||||
title: "From React to Angular: Making the Transition a Breeze",
|
||||
},
|
||||
{
|
||||
speaker: "Nick Guzmán",
|
||||
title: "Securing and Scaling AWS with Terraform, Vault, and Veeam: A Practical Guide",
|
||||
},
|
||||
{
|
||||
speaker: "Danielle Larregui",
|
||||
title: "Mastering Large Language Models: Prompt Engineering for Precision and Performance",
|
||||
},
|
||||
{
|
||||
speaker: "Mark Thompson",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Eleazar Hernandez",
|
||||
title: "WAI-ARIA in React components: guidance and considerations",
|
||||
},
|
||||
{
|
||||
speaker: "Satyanarayana Purella",
|
||||
title: "Architecting Financial Systems for the Future: Microservices and Event-Driven Paradigms at Scale",
|
||||
},
|
||||
{
|
||||
speaker: "Bree Hall",
|
||||
title: "Code, But Make It Cute",
|
||||
},
|
||||
{
|
||||
speaker: "Naveen Srikanth Pasupuleti",
|
||||
title: "K8s-Native ML in Healthcare: Production Spark, AWS & Container Orchestration at Scale",
|
||||
},
|
||||
{
|
||||
speaker: "Avindra Fernando",
|
||||
title: "The Hidden Struggles of React Server Components",
|
||||
},
|
||||
{
|
||||
speaker: "Mike Chen",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Adam Rackis",
|
||||
title: "Serving content on the web: from PHP to RSC—and beyond",
|
||||
},
|
||||
{
|
||||
speaker: "Robert Groves",
|
||||
title: "Creative Coding 101: Create Algorithmic Art with Processing",
|
||||
},
|
||||
{
|
||||
speaker: "Jeremy Miller",
|
||||
title: "Simplifying Our Code with Vertical Slice Architecture",
|
||||
},
|
||||
{
|
||||
speaker: "Paul Chin Jr.",
|
||||
title: "Data Gone in 60 Seconds: A Serverless ETL Heist",
|
||||
},
|
||||
{
|
||||
speaker: "Anitha Dakamarri",
|
||||
title: "SBOM adopatability in open source software scanning",
|
||||
},
|
||||
{
|
||||
speaker: "Arfi Siddik Mollashaik",
|
||||
title: "Designing Secure, Compliant, and Scalable Test Data Pipelines for Enterprise DevOps",
|
||||
},
|
||||
{
|
||||
speaker: "Julian Macagno",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Suresh A M",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "David Edoh-Bedi",
|
||||
title: "TBA",
|
||||
},
|
||||
{
|
||||
speaker: "Arun Prasad Jaganathan",
|
||||
title: "How not to build a Startup",
|
||||
},
|
||||
{
|
||||
speaker: "Claire Bourdon",
|
||||
title: "Little Bobby Tables: Understanding Data Validation and Sanitization",
|
||||
},
|
||||
].filter((item): item is { title: string; speaker: string } => {
|
||||
return Boolean(item.title) && Boolean(item.speaker);
|
||||
});
|
||||
|
||||
const token = process.env.DISCORD_TOKEN;
|
||||
|
||||
if (token === undefined) {
|
||||
throw new Error("Missing DISCORD_TOKEN");
|
||||
}
|
||||
|
||||
for (const talk of data) {
|
||||
await backoffAndRetry("https://discord.com/api/v10/channels/1417221194536714393/threads", {
|
||||
body: JSON.stringify({
|
||||
auto_archive_duration: 1440,
|
||||
message: {
|
||||
content: `Please use this thread to discuss anything related to this talk, ask questions, or share insights. Let's keep the conversation respectful and on-topic. Enjoy the session!`,
|
||||
},
|
||||
name: `${talk.title} - ${talk.speaker}`,
|
||||
type: 11,
|
||||
}),
|
||||
headers: {
|
||||
"Authorization": `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable complexity, max-lines-per-function, max-lines, max-statements -- This is a chonky boi script. */
|
||||
|
||||
/**
|
||||
* 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";
|
||||
import open from "open";
|
||||
|
||||
interface Guild {
|
||||
name?: string;
|
||||
permissions?: string;
|
||||
features?: Array<string>;
|
||||
owner?: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
admin: Array<string>;
|
||||
community: Array<string>;
|
||||
discoverable: Array<string>;
|
||||
moderating: Array<string>;
|
||||
owned: Array<string>;
|
||||
partnered: Array<string>;
|
||||
total: number;
|
||||
verified: Array<string>;
|
||||
}
|
||||
|
||||
// Permission Flags (BigInt)
|
||||
const permissions = {
|
||||
administrator: 0x00_00_00_00_00_00_00_08n,
|
||||
banMembers: 0x00_00_00_00_00_00_00_04n,
|
||||
kickMembers: 0x00_00_00_00_00_00_00_02n,
|
||||
manageGuild: 0x00_00_00_00_00_00_00_20n,
|
||||
manageMessages: 0x00_00_00_00_00_00_20_00n,
|
||||
moderateMembers: 0x00_00_01_00_00_00_00_00n,
|
||||
};
|
||||
|
||||
const printList = (title: string, list: Array<string>, icon: string): void => {
|
||||
console.log(`${icon} ${title}: ${list.length.toString()}`);
|
||||
if (list.length > 0) {
|
||||
for (const name of list) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
console.log("\n");
|
||||
};
|
||||
|
||||
const printReport = (stats: Stats): void => {
|
||||
console.log("\n====== DISCORD SERVER BREAKDOWN ======");
|
||||
console.log(`Total Servers Joined: ${stats.total.toString()}\n`);
|
||||
|
||||
printList("Owned Servers", stats.owned, "👑");
|
||||
printList("Admin (Non-Owned)", stats.admin, "⚙️ ");
|
||||
printList("Moderating (Non-Admin/Non-Owned)", stats.moderating, "🛡️ ");
|
||||
printList("Partnered Servers", stats.partnered, "🤝");
|
||||
printList("Verified Servers", stats.verified, "✅");
|
||||
printList("Community/Public Servers", stats.community, "🌍");
|
||||
printList("Discovery Enabled Servers", stats.discoverable, "🔍");
|
||||
|
||||
console.log(`======================================\n`);
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string): string => {
|
||||
return value.
|
||||
replaceAll("&", "&").
|
||||
replaceAll("<", "<").
|
||||
replaceAll(">", ">").
|
||||
replaceAll("\"", """).
|
||||
replaceAll("'", "'");
|
||||
};
|
||||
|
||||
const buildDashboardHtml = (stats: Stats): string => {
|
||||
const tiles: Array<{ items: Array<string>; label: string; value: number }> = [
|
||||
{ items: stats.owned, label: "You Own", value: stats.owned.length },
|
||||
{ items: stats.admin, label: "You Admin", value: stats.admin.length },
|
||||
{
|
||||
items: stats.moderating,
|
||||
label: "You Moderate",
|
||||
value: stats.moderating.length,
|
||||
},
|
||||
{
|
||||
items: stats.community,
|
||||
label: "Community Enabled",
|
||||
value: stats.community.length,
|
||||
},
|
||||
{
|
||||
items: stats.discoverable,
|
||||
label: "Discovery Enabled",
|
||||
value: stats.discoverable.length,
|
||||
},
|
||||
{
|
||||
items: stats.partnered,
|
||||
label: "Partnered",
|
||||
value: stats.partnered.length,
|
||||
},
|
||||
{ items: stats.verified, label: "Verified", value: stats.verified.length },
|
||||
];
|
||||
|
||||
const cards = tiles.map((tile) => {
|
||||
const listItems
|
||||
= tile.items.length === 0
|
||||
? "<li class=\"empty\">No servers in this category.</li>"
|
||||
: tile.items.map((name) => {
|
||||
return `<li>${escapeHtml(name)}</li>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="card">
|
||||
<p class="label">${escapeHtml(tile.label)}</p>
|
||||
<p class="count">${tile.value.toString()}</p>
|
||||
<ul class="server-list">
|
||||
${listItems}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Discord Server Counter</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root {
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #0f0f16;
|
||||
color: #111;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #ff4ecd, #7873f5, #4ac7fa, #43e97b);
|
||||
}
|
||||
.wrapper {
|
||||
width: min(1100px, 100%);
|
||||
border-radius: 1.5rem;
|
||||
padding: 0.25rem;
|
||||
background: linear-gradient(120deg, #ff4ecd, #b388ff, #4ac7fa, #43e97b);
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
.content {
|
||||
background: #fff;
|
||||
border-radius: 1.25rem;
|
||||
padding: 2rem 2.5rem 3rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3vw, 2.5rem);
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.total {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.total span {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.total strong {
|
||||
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||
color: #4c1d95;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.card {
|
||||
background: #f9fafb;
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.label {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.count {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
.server-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
.server-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
.server-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.server-list .empty {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
border-bottom: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
<h1>Server Counter</h1>
|
||||
<p class="subtitle">View counts of servers you are in along with quick insights.</p>
|
||||
<div class="total">
|
||||
<span>You are in</span>
|
||||
<strong>${stats.total.toString()} servers</strong>
|
||||
</div>
|
||||
<div class="grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
const serveStatsDashboard = async(stats: Stats): Promise<void> => {
|
||||
const html = buildDashboardHtml(stats);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.method !== "GET") {
|
||||
response.statusCode = 405;
|
||||
response.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.end(html);
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address !== null && typeof address === "object") {
|
||||
const url = `http://${address.address}:${address.port.toString()}/`;
|
||||
console.log(`Opening dashboard at ${url}`);
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
console.log(
|
||||
`Failed to open browser automatically. Please visit ${url} manually.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const checkForPermission = (perms: bigint, permission: bigint): boolean => {
|
||||
// eslint-disable-next-line no-bitwise -- Since Discord uses bit flags...
|
||||
return (perms & permission) === permission;
|
||||
};
|
||||
|
||||
const analyzeGuilds = (guilds: Array<Guild>): Stats => {
|
||||
// Arrays to store names instead of just counts
|
||||
const stats: Stats = {
|
||||
admin: [],
|
||||
community: [],
|
||||
discoverable: [],
|
||||
moderating: [],
|
||||
owned: [],
|
||||
partnered: [],
|
||||
total: guilds.length,
|
||||
verified: [],
|
||||
};
|
||||
|
||||
for (const guild of guilds) {
|
||||
const perms = BigInt(guild.permissions ?? 0);
|
||||
const features = guild.features ?? [];
|
||||
const { name, owner, id } = guild;
|
||||
|
||||
// 1. Ownership
|
||||
if (owner === true) {
|
||||
stats.owned.push(name ?? "Unknown");
|
||||
}
|
||||
|
||||
const isAdmin = checkForPermission(perms, permissions.administrator);
|
||||
const hasModeratorPermissions
|
||||
= isAdmin
|
||||
|| checkForPermission(perms, permissions.manageGuild)
|
||||
|| checkForPermission(perms, permissions.banMembers)
|
||||
|| checkForPermission(perms, permissions.kickMembers)
|
||||
|| checkForPermission(perms, permissions.moderateMembers);
|
||||
|
||||
if (isAdmin && owner !== true) {
|
||||
stats.admin.push(name ?? id);
|
||||
} else if (hasModeratorPermissions && owner !== true && !isAdmin) {
|
||||
stats.moderating.push(name ?? id);
|
||||
}
|
||||
|
||||
// 3. Partnered
|
||||
if (features.includes("PARTNERED")) {
|
||||
stats.partnered.push(name ?? id);
|
||||
}
|
||||
|
||||
// 4. Verified
|
||||
if (features.includes("VERIFIED")) {
|
||||
stats.verified.push(name ?? id);
|
||||
}
|
||||
|
||||
/*
|
||||
* 5. Community / Public
|
||||
* "COMMUNITY" feature enables public facing screens (Welcome Screen, Rules, etc)
|
||||
* "DISCOVERABLE" means it appears in Server Discovery
|
||||
*/
|
||||
if (features.includes("COMMUNITY")) {
|
||||
stats.community.push(name ?? id);
|
||||
}
|
||||
if (features.includes("DISCOVERABLE")) {
|
||||
stats.discoverable.push(name ?? id);
|
||||
}
|
||||
}
|
||||
|
||||
printReport(stats);
|
||||
return stats;
|
||||
};
|
||||
|
||||
const defaultRedirectUri = "http://127.0.0.1:8721/callback";
|
||||
const defaultScopes = "identify guilds";
|
||||
const authorizeEndpoint = "https://discord.com/oauth2/authorize";
|
||||
const tokenEndpoint = "https://discord.com/api/v10/oauth2/token";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<PropertyKey, unknown> => {
|
||||
return typeof value === "object" && value !== null;
|
||||
};
|
||||
|
||||
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(
|
||||
`Waiting for OAuth callback on http://${displayHost}:${listenPort.toString()}${pathname}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 headers = new Headers();
|
||||
headers.set("content-type", "application/x-www-form-urlencoded");
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
body: body,
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
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 (we will try to automatically open it for you):`,
|
||||
);
|
||||
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",
|
||||
);
|
||||
|
||||
void open(authUrl.toString(), { wait: false }).catch(() => {
|
||||
console.log(
|
||||
"Failed to open browser. Please open the following URL in your browser:",
|
||||
);
|
||||
console.log(authUrl.toString());
|
||||
});
|
||||
|
||||
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...");
|
||||
|
||||
// Discord allows max 200 servers per user (with Nitro), so limit=200 catches all.
|
||||
const response = await fetch(
|
||||
"https://discord.com/api/v10/users/@me/guilds?limit=200",
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.error("Error: Invalid Token. Please check your TOKEN.");
|
||||
} else {
|
||||
console.error(`Error: API returned status ${response.status.toString()}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const guilds: Array<Guild> = await response.json();
|
||||
const stats = analyzeGuilds(guilds);
|
||||
await serveStatsDashboard(stats);
|
||||
}
|
||||
|
||||
await getGuilds();
|
||||
process.exit(0);
|
||||
|
||||
export { getGuilds };
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||
import type { CategoryGet, CategoryList } from "../interfaces/discourse.js";
|
||||
|
||||
const discourseUrl = process.env.DISCOURSE_URL;
|
||||
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||
|
||||
if (
|
||||
discourseUrl === undefined
|
||||
|| discourseApiKey === undefined
|
||||
|| discourseApiUsername === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const categoryResponse = await backoffAndRetry<CategoryList>(
|
||||
`${discourseUrl}/categories.json`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (categoryResponse === null) {
|
||||
throw new Error("Failed to get categories");
|
||||
}
|
||||
const categories = categoryResponse;
|
||||
|
||||
// Collect all categories including subcategories
|
||||
const allCategories: Array<{ id: number; name: string }> = [];
|
||||
const processedIds = new Set<number>();
|
||||
|
||||
// Process top-level categories and their subcategories
|
||||
for (const category of categories.category_list.categories) {
|
||||
// Add top-level category if not already processed
|
||||
if (!processedIds.has(category.id)) {
|
||||
allCategories.push({ id: category.id, name: category.name });
|
||||
processedIds.add(category.id);
|
||||
}
|
||||
|
||||
if (!Array.isArray(category.subcategory_ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add subcategories if they exist
|
||||
for (const subcategoryId of category.subcategory_ids) {
|
||||
if (!processedIds.has(subcategoryId)) {
|
||||
const subcategoryRequest = await backoffAndRetry<CategoryGet>(
|
||||
`${discourseUrl}/c/${subcategoryId.toString()}/show.json`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (subcategoryRequest === null) {
|
||||
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||
continue;
|
||||
}
|
||||
if (subcategoryRequest.category?.name === undefined) {
|
||||
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||
console.log(Object.keys(subcategoryRequest));
|
||||
continue;
|
||||
}
|
||||
allCategories.push({
|
||||
id: subcategoryId,
|
||||
name: subcategoryRequest.category.name,
|
||||
});
|
||||
processedIds.add(subcategoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update all categories (including subcategories)
|
||||
for (const category of allCategories) {
|
||||
const { id, name } = category;
|
||||
console.log(`Updating category ${id.toString()}: ${name}`);
|
||||
const updateRequest = await backoffAndRetry<CategoryGet>(
|
||||
`${discourseUrl}/categories/${id.toString()}`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||
auto_close_based_on_last_post: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||
auto_close_hours: 672,
|
||||
name: name,
|
||||
}),
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
if (updateRequest === null) {
|
||||
console.error(`Failed to update category ${id.toString()}`);
|
||||
continue;
|
||||
}
|
||||
if (updateRequest.category?.auto_close_hours === undefined) {
|
||||
console.error(`Failed to update category ${id.toString()}`);
|
||||
console.error(JSON.stringify(updateRequest, null, 2));
|
||||
continue;
|
||||
}
|
||||
const { auto_close_hours: returnedAutoCloseHours } = updateRequest.category;
|
||||
console.log(
|
||||
`Updated category ${id.toString()}: ${name} to auto_close after ${returnedAutoCloseHours.toString()} hours`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||
import { sleep } from "../utils/sleep.js";
|
||||
import type { Topic, TopicList } from "../interfaces/discourse.js";
|
||||
|
||||
const discourseUrl = process.env.DISCOURSE_URL;
|
||||
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||
|
||||
if (
|
||||
discourseUrl === undefined
|
||||
|| discourseApiKey === undefined
|
||||
|| discourseApiUsername === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const apiHeaders = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a single page of topics from Discourse.
|
||||
* @param page - The page number to fetch.
|
||||
* @returns The topics from that page, or null if the request failed.
|
||||
*/
|
||||
const fetchTopicsPage = async(page: number): Promise<Array<Topic> | null> => {
|
||||
const url = `${discourseUrl}/latest.json?page=${page.toString()}`;
|
||||
console.log(`Fetching topics page ${page.toString()}...`);
|
||||
|
||||
const response = await backoffAndRetry<TopicList>(
|
||||
url,
|
||||
{
|
||||
headers: apiHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
if (response === null) {
|
||||
console.error(`Failed to fetch page ${page.toString()}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { topic_list: topicList } = response;
|
||||
const { topics } = topicList;
|
||||
console.log(
|
||||
`Page ${page.toString()}: Received ${topics.length.toString()} topics`,
|
||||
);
|
||||
|
||||
return topics;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the last activity date for a topic.
|
||||
* @param topic - The topic to get the activity date for.
|
||||
* @returns The date when the topic was last active.
|
||||
*/
|
||||
const getLastActivityDate = (topic: Topic): Date => {
|
||||
if (topic.last_posted_at !== null) {
|
||||
return new Date(topic.last_posted_at);
|
||||
}
|
||||
|
||||
if (topic.bumped_at !== null) {
|
||||
return new Date(topic.bumped_at);
|
||||
}
|
||||
|
||||
return new Date(topic.created_at);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches open topics from Discourse until we hit topics older than 6 months.
|
||||
* The /latest.json endpoint returns topics in a nested structure, so we need
|
||||
* to handle it differently than standard paginated endpoints.
|
||||
* @param sixMonthsAgo - The date cutoff for stopping pagination (6 months ago).
|
||||
* @returns Open topics that are newer than 6 months old.
|
||||
*/
|
||||
const fetchAllOpenTopics = async(sixMonthsAgo: Date): Promise<Array<Topic>> => {
|
||||
const allTopics: Array<Topic> = [];
|
||||
let page = 0;
|
||||
let topics: Array<Topic> | null = await fetchTopicsPage(page);
|
||||
|
||||
while (topics !== null && topics.length > 0) {
|
||||
let foundOldTopic = false;
|
||||
|
||||
for (const topic of topics) {
|
||||
// Skip closed topics
|
||||
if (topic.closed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastActivityDate = getLastActivityDate(topic);
|
||||
|
||||
// If we hit a topic older than 6 months, stop fetching
|
||||
if (lastActivityDate < sixMonthsAgo) {
|
||||
foundOldTopic = true;
|
||||
console.log(
|
||||
`Found topic older than 6 months (${lastActivityDate.toISOString()}), stopping pagination.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
allTopics.push(topic);
|
||||
}
|
||||
|
||||
if (foundOldTopic) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Continue to next page
|
||||
page = page + 1;
|
||||
topics = await fetchTopicsPage(page);
|
||||
}
|
||||
|
||||
console.log(`Total open topics fetched: ${allTopics.length.toString()}`);
|
||||
return allTopics;
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes a topic by ID.
|
||||
* @param topicId - The ID of the topic to close.
|
||||
* @param topicTitle - The title of the topic (for logging).
|
||||
* @returns Whether the topic was successfully closed.
|
||||
*/
|
||||
const closeTopic = async(
|
||||
topicId: number,
|
||||
topicTitle: string,
|
||||
): Promise<boolean> => {
|
||||
const url = `${discourseUrl}/t/${topicId.toString()}/status`;
|
||||
console.log(`Closing topic ${topicId.toString()}: ${topicTitle}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
status: "closed",
|
||||
}),
|
||||
headers: {
|
||||
...apiHeaders,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.log("Rate limited, waiting 5 seconds...");
|
||||
await sleep(5000);
|
||||
return await closeTopic(topicId, topicTitle);
|
||||
}
|
||||
console.error(
|
||||
`Failed to close topic ${topicId.toString()}: ${response.status.toString()} ${response.statusText}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Successfully closed topic ${topicId.toString()}: ${topicTitle}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Main execution
|
||||
const daysInactive = 28;
|
||||
|
||||
/**
|
||||
* Approximately 6 months in days.
|
||||
*/
|
||||
const daysSixMonths = 180;
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
|
||||
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setDate(sixMonthsAgo.getDate() - daysSixMonths);
|
||||
|
||||
console.log(`Fetching open topics (stopping at topics older than 6 months)...`);
|
||||
console.log(
|
||||
`Will close topics inactive for ${daysInactive.toString()}+ days but less than ${daysSixMonths.toString()} days old`,
|
||||
);
|
||||
console.log(
|
||||
`Cutoff date: ${cutoffDate.toISOString()} (topics inactive for ${daysInactive.toString()} days)`,
|
||||
);
|
||||
console.log(
|
||||
`Stop fetching at: ${sixMonthsAgo.toISOString()} (6 months ago)`,
|
||||
);
|
||||
|
||||
const openTopics = await fetchAllOpenTopics(sixMonthsAgo);
|
||||
|
||||
console.log(`\nChecking ${openTopics.length.toString()} open topics for inactivity...`);
|
||||
|
||||
const topicsToClose: Array<Topic> = [];
|
||||
|
||||
for (const topic of openTopics) {
|
||||
const lastActivityDate = getLastActivityDate(topic);
|
||||
|
||||
/**
|
||||
* Only close topics that are inactive for 28+ days but less than 6 months old.
|
||||
*/
|
||||
if (lastActivityDate < cutoffDate && lastActivityDate >= sixMonthsAgo) {
|
||||
topicsToClose.push(topic);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${topicsToClose.length.toString()} topics to close\n`);
|
||||
|
||||
if (topicsToClose.length === 0) {
|
||||
console.log("No topics need to be closed.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Close topics with rate limiting
|
||||
let closedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const topic of topicsToClose) {
|
||||
const success = await closeTopic(topic.id, topic.title);
|
||||
if (success) {
|
||||
closedCount = closedCount + 1;
|
||||
} else {
|
||||
failedCount = failedCount + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a small delay between requests to respect rate limits.
|
||||
* (backoffAndRetry handles 429s, but we want to avoid hitting them).
|
||||
*/
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
console.log(`\nCompleted:`);
|
||||
console.log(` Closed: ${closedCount.toString()}`);
|
||||
console.log(` Failed: ${failedCount.toString()}`);
|
||||
console.log(` Total: ${topicsToClose.length.toString()}`);
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { input } from "@inquirer/prompts";
|
||||
import { paginatedFetch } from "../utils/paginatedFetch.js";
|
||||
import type { File, Repository } from "../interfaces/gitea.js";
|
||||
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
if (giteaToken === undefined) {
|
||||
throw new Error("GITEA_TOKEN is not set");
|
||||
}
|
||||
|
||||
const giteaUrl = "https://git.nhcarrigan.com";
|
||||
|
||||
/**
|
||||
* Will be something like "/.gitea/workflows/security.yml".
|
||||
*/
|
||||
const deletePath = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the PATH to delete the file from, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'",
|
||||
});
|
||||
if (deletePath === "") {
|
||||
throw new Error("Delete path is not set");
|
||||
}
|
||||
|
||||
const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ];
|
||||
|
||||
for (const org of orgs) {
|
||||
const repos = await paginatedFetch<Array<Repository>>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } });
|
||||
|
||||
for (const repo of repos) {
|
||||
console.log(`Checking if file exists in ${org}/${repo.name}`);
|
||||
const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${deletePath}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${giteaToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
if (fileResponse.ok) {
|
||||
console.log(`File exists in ${org}/${repo.name}, deleting...`);
|
||||
const fileData: File = await fileResponse.json();
|
||||
console.log(`Deleting ${deletePath} from ${org}/${repo.name}`);
|
||||
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${deletePath}`, {
|
||||
body: JSON.stringify({
|
||||
branch: repo.default_branch,
|
||||
message: `feat: automated delete of ${deletePath}`,
|
||||
sha: fileData.sha,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${giteaToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to delete ${deletePath} from ${org}/${repo.name}: ${response.statusText}`);
|
||||
console.error(await response.text());
|
||||
continue;
|
||||
}
|
||||
console.log(`Deleted ${deletePath} from ${org}/${repo.name}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`File does not exist in ${org}/${repo.name}, skipping...`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { input } from "@inquirer/prompts";
|
||||
import { paginatedFetch } from "../utils/paginatedFetch.js";
|
||||
import type { File, Repository } from "../interfaces/gitea.js";
|
||||
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
if (giteaToken === undefined) {
|
||||
throw new Error("GITEA_TOKEN is not set");
|
||||
}
|
||||
|
||||
const giteaUrl = "https://git.nhcarrigan.com";
|
||||
|
||||
/**
|
||||
* Will be something like "actions.yml" or "gitea/actions.yml".
|
||||
*/
|
||||
const fileName = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'actions.yml' or 'gitea/actions.yml'",
|
||||
});
|
||||
if (fileName === "") {
|
||||
throw new Error("File name is not set");
|
||||
}
|
||||
|
||||
const file = await readFile(
|
||||
join(import.meta.dirname, "..", "..", "data", fileName),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/**
|
||||
* Will be something like "/.gitea/workflows/security.yml".
|
||||
*/
|
||||
const uploadPath = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'",
|
||||
});
|
||||
if (uploadPath === "") {
|
||||
throw new Error("Upload path is not set");
|
||||
}
|
||||
|
||||
const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ];
|
||||
|
||||
let totalReposProcessed = 0;
|
||||
let totalReposSucceeded = 0;
|
||||
let totalReposFailed = 0;
|
||||
|
||||
for (const org of orgs) {
|
||||
console.log(`\n=== Fetching repositories for org: ${org} ===`);
|
||||
const repos = await paginatedFetch<Array<Repository>>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } });
|
||||
console.log(`Found ${repos.length.toString()} repositories in ${org}`);
|
||||
|
||||
for (const repo of repos) {
|
||||
totalReposProcessed = totalReposProcessed + 1;
|
||||
console.log(`Checking if file exists in ${org}/${repo.name}`);
|
||||
const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${giteaToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
if (fileResponse.ok) {
|
||||
console.log(`File already exists in ${org}/${repo.name}`);
|
||||
const fileData: File = await fileResponse.json();
|
||||
console.log(`Updating ${fileName} in ${org}/${repo.name}`);
|
||||
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
body: JSON.stringify({
|
||||
branch: repo.default_branch,
|
||||
content: Buffer.from(file).toString("base64"),
|
||||
message: `feat: automated upload of ${uploadPath}`,
|
||||
sha: fileData.sha,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${giteaToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) {
|
||||
totalReposFailed = totalReposFailed + 1;
|
||||
console.error(`Failed to update ${fileName} in ${org}/${repo.name}: ${response.statusText}`);
|
||||
console.error(await response.text());
|
||||
continue;
|
||||
}
|
||||
totalReposSucceeded = totalReposSucceeded + 1;
|
||||
console.log(`Updated ${fileName} in ${org}/${repo.name}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`Uploading ${fileName} to ${org}/${repo.name}`);
|
||||
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
body: JSON.stringify({
|
||||
branch: repo.default_branch,
|
||||
content: Buffer.from(file).toString("base64"),
|
||||
message: `feat: automated upload of ${uploadPath}`,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${giteaToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
totalReposFailed = totalReposFailed + 1;
|
||||
console.error(`Failed to upload ${fileName} to ${org}/${repo.name}: ${response.statusText}`);
|
||||
console.error(await response.text());
|
||||
continue;
|
||||
}
|
||||
totalReposSucceeded = totalReposSucceeded + 1;
|
||||
console.log(`Uploaded ${fileName} to ${org}/${repo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Summary ===`);
|
||||
console.log(`Total repositories processed: ${totalReposProcessed.toString()}`);
|
||||
console.log(`Successfully uploaded/updated: ${totalReposSucceeded.toString()}`);
|
||||
console.log(`Failed: ${totalReposFailed.toString()}`);
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { input, select } from "@inquirer/prompts";
|
||||
import { paginatedFetch } from "../utils/paginatedFetch.js";
|
||||
import type { File, Repository } from "../interfaces/gitea.js";
|
||||
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
if (giteaToken === undefined) {
|
||||
throw new Error("GITEA_TOKEN is not set");
|
||||
}
|
||||
|
||||
const giteaUrl = "https://git.nhcarrigan.com";
|
||||
|
||||
/**
|
||||
* Will be something like "actions.yml" or "gitea/actions.yml".
|
||||
*/
|
||||
const fileName = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'actions.yml' or 'gitea/actions.yml'",
|
||||
});
|
||||
if (fileName === "") {
|
||||
throw new Error("File name is not set");
|
||||
}
|
||||
|
||||
const file = await readFile(
|
||||
join(import.meta.dirname, "..", "..", "data", fileName),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/**
|
||||
* Will be something like ".gitea/workflows/security.yml".
|
||||
*/
|
||||
const uploadPath = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'",
|
||||
});
|
||||
if (uploadPath === "") {
|
||||
throw new Error("Upload path is not set");
|
||||
}
|
||||
|
||||
/**
|
||||
* The file path to check for in each repo to determine if upload should proceed.
|
||||
* Will be something like ".gitea/workflows/ci.yml" or "package.json".
|
||||
*/
|
||||
const conditionFilePath = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the file path to check for in each repo (condition file). WITHOUT leading slash. Example: '.gitea/workflows/ci.yml' or 'package.json'",
|
||||
});
|
||||
if (conditionFilePath === "") {
|
||||
throw new Error("Condition file path is not set");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to upload when the condition file exists or doesn't exist.
|
||||
*/
|
||||
const uploadCondition = await select({
|
||||
choices: [
|
||||
{ name: "Upload if condition file EXISTS", value: "exists" },
|
||||
{ name: "Upload if condition file DOES NOT EXIST", value: "not_exists" },
|
||||
],
|
||||
message: "When should the upload proceed?",
|
||||
});
|
||||
|
||||
const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ];
|
||||
|
||||
let totalReposProcessed = 0;
|
||||
let totalReposSucceeded = 0;
|
||||
let totalReposFailed = 0;
|
||||
let totalReposSkipped = 0;
|
||||
|
||||
for (const org of orgs) {
|
||||
console.log(`\n=== Fetching repositories for org: ${org} ===`);
|
||||
const repos = await paginatedFetch<Array<Repository>>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } });
|
||||
console.log(`Found ${repos.length.toString()} repositories in ${org}`);
|
||||
|
||||
for (const repo of repos) {
|
||||
totalReposProcessed = totalReposProcessed + 1;
|
||||
console.log(`Checking condition file in ${org}/${repo.name}`);
|
||||
|
||||
// Check if condition file exists
|
||||
const conditionFileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${conditionFilePath}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${giteaToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const conditionFileExists = conditionFileResponse.ok;
|
||||
const shouldUpload = uploadCondition === "exists"
|
||||
? conditionFileExists
|
||||
: !conditionFileExists;
|
||||
|
||||
if (!shouldUpload) {
|
||||
totalReposSkipped = totalReposSkipped + 1;
|
||||
console.log(`Skipping ${org}/${repo.name} (condition file ${conditionFileExists
|
||||
? "exists"
|
||||
: "does not exist"}, but upload condition is "${uploadCondition}")`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Condition met for ${org}/${repo.name}, proceeding with upload`);
|
||||
console.log(`Checking if upload file exists in ${org}/${repo.name}`);
|
||||
const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${giteaToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
if (fileResponse.ok) {
|
||||
console.log(`File already exists in ${org}/${repo.name}`);
|
||||
const fileData: File = await fileResponse.json();
|
||||
console.log(`Updating ${fileName} in ${org}/${repo.name}`);
|
||||
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
body: JSON.stringify({
|
||||
branch: repo.default_branch,
|
||||
content: Buffer.from(file).toString("base64"),
|
||||
message: `feat: automated upload of ${uploadPath}`,
|
||||
sha: fileData.sha,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${giteaToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) {
|
||||
totalReposFailed = totalReposFailed + 1;
|
||||
console.error(`Failed to update ${fileName} in ${org}/${repo.name}: ${response.statusText}`);
|
||||
console.error(await response.text());
|
||||
continue;
|
||||
}
|
||||
totalReposSucceeded = totalReposSucceeded + 1;
|
||||
console.log(`Updated ${fileName} in ${org}/${repo.name}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`Uploading ${fileName} to ${org}/${repo.name}`);
|
||||
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||
body: JSON.stringify({
|
||||
branch: repo.default_branch,
|
||||
content: Buffer.from(file).toString("base64"),
|
||||
message: `feat: automated upload of ${uploadPath}`,
|
||||
}),
|
||||
headers: {
|
||||
"authorization": `Bearer ${giteaToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
totalReposFailed = totalReposFailed + 1;
|
||||
console.error(`Failed to upload ${fileName} to ${org}/${repo.name}: ${response.statusText}`);
|
||||
console.error(await response.text());
|
||||
continue;
|
||||
}
|
||||
totalReposSucceeded = totalReposSucceeded + 1;
|
||||
console.log(`Uploaded ${fileName} to ${org}/${repo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Summary ===`);
|
||||
console.log(`Total repositories processed: ${totalReposProcessed.toString()}`);
|
||||
console.log(`Successfully uploaded/updated: ${totalReposSucceeded.toString()}`);
|
||||
console.log(`Failed: ${totalReposFailed.toString()}`);
|
||||
console.log(`Skipped (condition not met): ${totalReposSkipped.toString()}`);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { writeFile, appendFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { serialiseJsonOrError } from "../utils/serialiseJsonOrError.js";
|
||||
|
||||
if (process.env.GITHUB_TOKEN === undefined) {
|
||||
throw new Error("Missing Github Token - did you run this with `op`?");
|
||||
}
|
||||
|
||||
const resultPath = join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"data",
|
||||
"npm-vulnerabilities.txt",
|
||||
);
|
||||
|
||||
const orgsToCheck = [
|
||||
"freecodecamp",
|
||||
];
|
||||
|
||||
const vulnerablePackages: Array<{ name: string; version: string }> = [
|
||||
{ name: "ansi-regex", version: "6.2.1" },
|
||||
{ name: "ansi-styles", version: "6.2.2" },
|
||||
{ name: "backslash", version: "0.2.1" },
|
||||
{ name: "chalk-template", version: "1.1.1" },
|
||||
{ name: "chalk", version: "5.6.1" },
|
||||
{ name: "color-convert", version: "3.1.1" },
|
||||
{ name: "color-name", version: "2.0.1" },
|
||||
{ name: "color-string", version: "2.1.1" },
|
||||
{ name: "color", version: "5.0.1" },
|
||||
{ name: "debug", version: "4.4.2" },
|
||||
{ name: "has-ansi", version: "6.0.1" },
|
||||
{ name: "is-arrayish", version: "0.3.3" },
|
||||
{ name: "simple-swizzle", version: "0.2.3" },
|
||||
{ name: "slice-ansi", version: "7.1.1" },
|
||||
{ name: "strip-ansi", version: "7.1.1" },
|
||||
{ name: "supports-color", version: "10.2.1" },
|
||||
{ name: "supports-hyperlinks", version: "4.1.1" },
|
||||
{ name: "wrap-ansi", version: "9.0.1" },
|
||||
];
|
||||
|
||||
const gh = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
const repositories: Array<{ name: string; owner: string }>
|
||||
= [];
|
||||
|
||||
await writeFile(resultPath, "", "utf-8");
|
||||
|
||||
for (const org of orgsToCheck) {
|
||||
let page = 1;
|
||||
console.log(`Fetching ${org} repositories page ${page.toString()}`);
|
||||
let repos = await gh.repos.listForOrg(
|
||||
{
|
||||
org: org,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK signature.
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
repositories.push(...repos.data.map((repo) => {
|
||||
return { name: repo.name, owner: org };
|
||||
}));
|
||||
while (repos.data.length >= 100) {
|
||||
page = page + 1;
|
||||
console.log(`Fetching ${org} repositories page ${page.toString()}`);
|
||||
repos = await gh.repos.listForOrg({
|
||||
org: org,
|
||||
page: page,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- SDK signature.
|
||||
per_page: 100,
|
||||
});
|
||||
repositories.push(...repos.data.map((repo) => {
|
||||
return { name: repo.name, owner: org };
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${repositories.length.toString()} repositories in ${orgsToCheck.length.toString()} orgs.`);
|
||||
|
||||
for (const repo of repositories) {
|
||||
const fileRequest = await gh.repos.getContent({
|
||||
owner: repo.owner,
|
||||
path: "package.json",
|
||||
repo: repo.name,
|
||||
}).catch(() => {
|
||||
return null;
|
||||
});
|
||||
if (!fileRequest) {
|
||||
console.log(`Package.json not found in ${repo.owner}/${repo.name}`);
|
||||
continue;
|
||||
}
|
||||
const file = fileRequest.data;
|
||||
if (!("type" in file) || file.type !== "file") {
|
||||
console.log(`Package.json found but is not file.`);
|
||||
continue;
|
||||
}
|
||||
const { content } = file;
|
||||
const parsed = Buffer.from(content, "base64").toString();
|
||||
const serialised: {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
} | null = serialiseJsonOrError(parsed);
|
||||
if (!serialised) {
|
||||
console.log(`Failed to serialise ${parsed}`);
|
||||
continue;
|
||||
}
|
||||
const deps: Record<string, string> = {};
|
||||
if (serialised.dependencies) {
|
||||
Object.assign(deps, serialised.dependencies);
|
||||
}
|
||||
if (serialised.devDependencies) {
|
||||
Object.assign(deps, serialised.devDependencies);
|
||||
}
|
||||
|
||||
console.log(`Auditing packages in ${repo.owner}/${repo.name}...`);
|
||||
|
||||
for (const dep of vulnerablePackages) {
|
||||
if (!(dep.name in deps)) {
|
||||
continue;
|
||||
}
|
||||
if (dep.version !== deps[dep.name]) {
|
||||
console.log(
|
||||
`Found ${dep.name}: ${dep.version} but it was not the vulnerable ${String(deps[dep.name])} version.`,
|
||||
);
|
||||
await appendFile(
|
||||
resultPath,
|
||||
`${repo.owner}/${repo.name}: Found ${dep.name} but ${String(deps[dep.name])} is not the vulnerable ${dep.version} version.\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
console.log(
|
||||
`FOUND VULNERABLE ${dep.name}: ${dep.version} IN ${repo.owner}/${repo.name}!!!!`,
|
||||
);
|
||||
await appendFile(
|
||||
resultPath,
|
||||
`!! FOUND VULNERALBE ${dep.name}: ${dep.version} IN ${repo.owner}/${repo.name} !!\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("All done!");
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
if (process.env.GITHUB_TOKEN === undefined) {
|
||||
throw new Error("Missing Github Token - did you run this with `op`?");
|
||||
}
|
||||
|
||||
/**
|
||||
* Change this to the desired organization name.
|
||||
*/
|
||||
const orgName = "freeCodeCamp-Alpha-and-Omega";
|
||||
|
||||
const gh = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
const storiesDirectory = join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"data",
|
||||
"stories",
|
||||
);
|
||||
|
||||
// Read all markdown files from the stories directory
|
||||
const files = await readdir(storiesDirectory);
|
||||
const markdownFiles = files.filter((file) => {
|
||||
return file.endsWith(".md");
|
||||
});
|
||||
|
||||
console.log(`Found ${markdownFiles.length.toString()} story files to process.`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
// Parse filename: format is "{repo-name}-{issue-number}.md"
|
||||
const filenameRegex = /^(?<repoName>.+)-(?<issueNumber>\d+)\.md$/u;
|
||||
const match = filenameRegex.exec(file);
|
||||
const groups = match?.groups;
|
||||
if (!groups) {
|
||||
console.error(`Skipping ${file}: filename format not recognized (expected: repo-name-issue-number.md)`);
|
||||
errorCount = errorCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { issueNumber: issueNumberString, repoName } = groups;
|
||||
if (
|
||||
repoName === undefined
|
||||
|| issueNumberString === undefined
|
||||
|| repoName === ""
|
||||
|| issueNumberString === ""
|
||||
) {
|
||||
console.error(`Skipping ${file}: failed to extract repo name or issue number`);
|
||||
errorCount = errorCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const issueNumber = Number.parseInt(issueNumberString, 10);
|
||||
|
||||
if (Number.isNaN(issueNumber)) {
|
||||
console.error(`Skipping ${file}: invalid issue number ${issueNumberString}`);
|
||||
errorCount = errorCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const filePath = join(storiesDirectory, file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
|
||||
try {
|
||||
console.log(`Updating issue #${issueNumber.toString()} in ${orgName}/${repoName}...`);
|
||||
|
||||
await gh.rest.issues.update({
|
||||
body: content,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API parameter name.
|
||||
issue_number: issueNumber,
|
||||
owner: orgName,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
console.log(`✓ Successfully updated issue #${issueNumber.toString()} in ${orgName}/${repoName}`);
|
||||
successCount = successCount + 1;
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to update issue #${issueNumber.toString()} in ${orgName}/${repoName}:`, error);
|
||||
errorCount = errorCount + 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Summary ===`);
|
||||
console.log(`Total files processed: ${markdownFiles.length.toString()}`);
|
||||
console.log(`Successful updates: ${successCount.toString()}`);
|
||||
console.log(`Errors: ${errorCount.toString()}`);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
console.log(`Hello there~!
|
||||
|
||||
It looks like you may be trying to run this tool like a typical project. But it is not!
|
||||
|
||||
Instead of running "pnpm start", you should identify the script you want to run and use "tsx src/path/to/script.js".
|
||||
|
||||
Or, if your script requires environment variables, run "op run --env-file=.env --no-masking -- tsx src/path/to/script.js"`);
|
||||
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- The names of the properties match the API responses. */
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface CategoryList {
|
||||
category_list: {
|
||||
can_create_category: boolean;
|
||||
can_create_topic: boolean;
|
||||
categories: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
text_color: string;
|
||||
style_type: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
topic_count: number;
|
||||
post_count: number;
|
||||
position: number;
|
||||
description: string;
|
||||
description_text: string;
|
||||
description_excerpt: string;
|
||||
topic_url: string;
|
||||
read_restricted: boolean;
|
||||
permission: number;
|
||||
notification_level: number;
|
||||
can_edit: boolean;
|
||||
topic_template: string;
|
||||
has_children: boolean;
|
||||
subcategory_count: number;
|
||||
sort_order: string;
|
||||
sort_ascending: string;
|
||||
show_subcategory_list: boolean;
|
||||
num_featured_topics: number;
|
||||
default_view: string;
|
||||
subcategory_list_style: string;
|
||||
default_top_period: string;
|
||||
default_list_filter: string;
|
||||
minimum_required_tags: number;
|
||||
navigate_to_first_post_after_read: boolean;
|
||||
topics_day: number;
|
||||
topics_week: number;
|
||||
topics_month: number;
|
||||
topics_year: number;
|
||||
topics_all_time: number;
|
||||
is_uncategorized: boolean;
|
||||
subcategory_ids: Array<number>;
|
||||
subcategory_list: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
uploaded_logo: string;
|
||||
uploaded_logo_dark: string;
|
||||
uploaded_background: string;
|
||||
uploaded_background_dark: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoryGet {
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
text_color: string;
|
||||
style_type: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
topic_count: number;
|
||||
post_count: number;
|
||||
position: number;
|
||||
description: string;
|
||||
description_text: string;
|
||||
description_excerpt: string;
|
||||
topic_url: string;
|
||||
read_restricted: boolean;
|
||||
permission: number;
|
||||
notification_level: number;
|
||||
can_edit: boolean;
|
||||
topic_template: string;
|
||||
form_template_ids: Array<unknown>;
|
||||
has_children: boolean;
|
||||
subcategory_count: number;
|
||||
sort_order: string;
|
||||
sort_ascending: string;
|
||||
show_subcategory_list: boolean;
|
||||
num_featured_topics: number;
|
||||
default_view: string;
|
||||
subcategory_list_style: string;
|
||||
default_top_period: string;
|
||||
default_list_filter: string;
|
||||
minimum_required_tags: number;
|
||||
navigate_to_first_post_after_read: boolean;
|
||||
custom_fields: Record<string, unknown>;
|
||||
allowed_tags: Array<unknown>;
|
||||
allowed_tag_groups: Array<unknown>;
|
||||
allow_global_tags: boolean;
|
||||
required_tag_groups: Array<{
|
||||
name: string;
|
||||
min_count: number;
|
||||
}>;
|
||||
category_setting: {
|
||||
auto_bump_cooldown_days: number;
|
||||
num_auto_bump_daily: number;
|
||||
require_reply_approval: boolean;
|
||||
require_topic_approval: boolean;
|
||||
};
|
||||
category_localizations: Array<unknown>;
|
||||
read_only_banner: string;
|
||||
available_groups: Array<unknown>;
|
||||
auto_close_hours: string;
|
||||
auto_close_based_on_last_post: boolean;
|
||||
allow_unlimited_owner_edits_on_first_post: boolean;
|
||||
default_slow_mode_seconds: string;
|
||||
group_permissions: Array<{
|
||||
permission_type: number;
|
||||
group_name: string;
|
||||
group_id: number;
|
||||
}>;
|
||||
email_in: string;
|
||||
email_in_allow_strangers: boolean;
|
||||
mailinglist_mirror: boolean;
|
||||
all_topics_wiki: boolean;
|
||||
can_delete: boolean;
|
||||
allow_badges: boolean;
|
||||
topic_featured_link_allowed: boolean;
|
||||
search_priority: number;
|
||||
uploaded_logo: string;
|
||||
uploaded_logo_dark: string;
|
||||
uploaded_background: string;
|
||||
uploaded_background_dark: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
id: number;
|
||||
title: string;
|
||||
fancy_title: string;
|
||||
slug: string;
|
||||
posts_count: number;
|
||||
reply_count: number;
|
||||
highest_post_number: number;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
last_posted_at: string | null;
|
||||
bumped: boolean;
|
||||
bumped_at: string | null;
|
||||
archetype: string;
|
||||
unseen: boolean;
|
||||
pinned: boolean;
|
||||
unpinned: boolean | null;
|
||||
visible: boolean;
|
||||
closed: boolean;
|
||||
archived: boolean;
|
||||
bookmarked: boolean | null;
|
||||
liked: boolean | null;
|
||||
tags: Array<string>;
|
||||
tags_descriptions: Record<string, string>;
|
||||
like_count: number;
|
||||
views: number;
|
||||
category_id: number;
|
||||
featured_link: string | null;
|
||||
has_accepted_answer: boolean;
|
||||
posters: Array<{
|
||||
extras: string | null;
|
||||
description: string;
|
||||
user_id: number;
|
||||
primary_group_id: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TopicList {
|
||||
topic_list: {
|
||||
can_create_topic: boolean;
|
||||
draft: string | null;
|
||||
draft_key: string;
|
||||
draft_sequence: number;
|
||||
per_page: number;
|
||||
topics: Array<Topic>;
|
||||
};
|
||||
}
|
||||
|
||||
export type { CategoryList, CategoryGet, Topic, TopicList };
|
||||
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- interface properties match API responses. */
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Finding extends Record<string, unknown> {
|
||||
id: number;
|
||||
title: string;
|
||||
severity: "Critical" | "High" | "Medium" | "Low" | "Info";
|
||||
active: boolean;
|
||||
verified: boolean;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
interface DojoResponse {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: Array<Finding>;
|
||||
}
|
||||
|
||||
interface ProjectStats {
|
||||
Critical: number;
|
||||
High: number;
|
||||
Medium: number;
|
||||
Low: number;
|
||||
Info: number;
|
||||
}
|
||||
|
||||
export type { Product, Finding, DojoResponse, ProjectStats };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- This is a Gitea interface. The responses from the API are not something we can control. */
|
||||
|
||||
interface Repository extends Record<string, unknown> {
|
||||
allow_fast_forward_only_merge: boolean;
|
||||
allow_merge_commits: boolean;
|
||||
allow_rebase: boolean;
|
||||
allow_rebase_explicit: boolean;
|
||||
allow_rebase_update: boolean;
|
||||
allow_squash_merge: boolean;
|
||||
archived: boolean;
|
||||
archived_at: string;
|
||||
avatar_url: string;
|
||||
clone_url: string;
|
||||
created_at: string;
|
||||
default_allow_maintainer_edit: boolean;
|
||||
default_branch: string;
|
||||
default_delete_branch_after_merge: boolean;
|
||||
default_merge_style: string;
|
||||
description: string;
|
||||
empty: boolean;
|
||||
external_tracker: {
|
||||
external_tracker_format: string;
|
||||
external_tracker_regexp_pattern: string;
|
||||
external_tracker_style: string;
|
||||
external_tracker_url: string;
|
||||
};
|
||||
external_wiki: {
|
||||
external_wiki_url: string;
|
||||
};
|
||||
fork: boolean;
|
||||
forks_count: number;
|
||||
full_name: string;
|
||||
has_actions: boolean;
|
||||
has_issues: boolean;
|
||||
has_packages: boolean;
|
||||
has_projects: boolean;
|
||||
has_pull_requests: boolean;
|
||||
has_releases: boolean;
|
||||
has_wiki: boolean;
|
||||
html_url: string;
|
||||
id: number;
|
||||
ignore_whitespace_conflicts: boolean;
|
||||
internal: boolean;
|
||||
internal_tracker: {
|
||||
allow_only_contributors_to_track_time: boolean;
|
||||
enable_issue_dependencies: boolean;
|
||||
enable_time_tracker: boolean;
|
||||
};
|
||||
language: string;
|
||||
languages_url: string;
|
||||
licenses: Array<string>;
|
||||
link: string;
|
||||
mirror: boolean;
|
||||
mirror_interval: string;
|
||||
mirror_updated: string;
|
||||
name: string;
|
||||
object_format_name: string;
|
||||
open_issues_count: number;
|
||||
open_pr_counter: number;
|
||||
original_url: string;
|
||||
owner: {
|
||||
active: boolean;
|
||||
avatar_url: string;
|
||||
created: string;
|
||||
description: string;
|
||||
email: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
id: number;
|
||||
is_admin: boolean;
|
||||
language: string;
|
||||
last_login: string;
|
||||
location: string;
|
||||
login: string;
|
||||
login_name: string;
|
||||
prohibit_login: boolean;
|
||||
restricted: boolean;
|
||||
source_id: number;
|
||||
starred_repos_count: number;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
parent: string;
|
||||
permissions: {
|
||||
admin: boolean;
|
||||
pull: boolean;
|
||||
push: boolean;
|
||||
};
|
||||
private: boolean;
|
||||
projects_mode: string;
|
||||
release_counter: number;
|
||||
repo_transfer: {
|
||||
doer: {
|
||||
active: boolean;
|
||||
avatar_url: string;
|
||||
created: string;
|
||||
description: string;
|
||||
email: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
id: number;
|
||||
is_admin: boolean;
|
||||
language: string;
|
||||
last_login: string;
|
||||
location: string;
|
||||
login: string;
|
||||
login_name: string;
|
||||
prohibit_login: boolean;
|
||||
restricted: boolean;
|
||||
source_id: number;
|
||||
starred_repos_count: number;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
recipient: {
|
||||
active: boolean;
|
||||
avatar_url: string;
|
||||
created: string;
|
||||
description: string;
|
||||
email: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
id: number;
|
||||
is_admin: boolean;
|
||||
language: string;
|
||||
last_login: string;
|
||||
location: string;
|
||||
login: string;
|
||||
login_name: string;
|
||||
prohibit_login: boolean;
|
||||
restricted: boolean;
|
||||
source_id: number;
|
||||
starred_repos_count: number;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
teams: Array<{
|
||||
can_create_org_repo: boolean;
|
||||
description: string;
|
||||
id: number;
|
||||
includes_all_repositories: boolean;
|
||||
name: string;
|
||||
organization: {
|
||||
avatar_url: string;
|
||||
description: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
id: number;
|
||||
location: string;
|
||||
name: string;
|
||||
repo_admin_change_team_access: boolean;
|
||||
username: string;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
permission: string;
|
||||
units: Array<string>;
|
||||
units_map: {
|
||||
"repo.code": string;
|
||||
"repo.ext_issues": string;
|
||||
"repo.ext_wiki": string;
|
||||
"repo.issues": string;
|
||||
"repo.projects": string;
|
||||
"repo.pulls": string;
|
||||
"repo.releases": string;
|
||||
"repo.wiki": string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
size: number;
|
||||
ssh_url: string;
|
||||
stars_count: number;
|
||||
template: boolean;
|
||||
topics: Array<string>;
|
||||
updated_at: string;
|
||||
url: string;
|
||||
watchers_count: number;
|
||||
website: string;
|
||||
}
|
||||
|
||||
interface File {
|
||||
_links: {
|
||||
git: string;
|
||||
html: string;
|
||||
self: string;
|
||||
};
|
||||
content: string;
|
||||
download_url: string;
|
||||
encoding: string;
|
||||
git_url: string;
|
||||
html_url: string;
|
||||
last_author_date: string;
|
||||
last_commit_sha: string;
|
||||
last_committer_date: string;
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
submodule_git_url: string;
|
||||
target: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type { Repository, File };
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { SingleBar, Presets } from "cli-progress";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const directory = "/home/naomi/down";
|
||||
const cover = "/home/naomi/neuro.png";
|
||||
|
||||
const files = await readdir(directory);
|
||||
|
||||
const bar = new SingleBar({}, Presets.shades_classic);
|
||||
|
||||
console.log(`Found ${files.length.toString()} files in ${directory}`);
|
||||
|
||||
bar.start(files.length, 0);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".mp3")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await execAsync(
|
||||
`eyeD3 --add-image="${cover}":FRONT_COVER "${directory}/${file}"`,
|
||||
);
|
||||
|
||||
const title
|
||||
= /[""](?<title>.*)[""]/.exec(file)?.groups?.title
|
||||
?? file.replace(".mp3", "");
|
||||
const artist = "Neuro-sama";
|
||||
|
||||
await execAsync(
|
||||
`id3v2 "${directory}/${file}" --TIT2 "${title}" --TPE1 "${artist}"`,
|
||||
);
|
||||
bar.increment();
|
||||
}
|
||||
|
||||
bar.stop();
|
||||
console.log("Done!");
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { confirm } from "@inquirer/prompts";
|
||||
import { SingleBar, Presets } from "cli-progress";
|
||||
import { getMimeType } from "../utils/mimeType.js";
|
||||
|
||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
||||
}
|
||||
|
||||
const dataDirectory = join(import.meta.dirname, "..", "..", "data");
|
||||
|
||||
/**
|
||||
* Recursively gets all files in a directory.
|
||||
* @param directory - The directory to scan.
|
||||
* @param baseDirectory - The base directory for relative paths.
|
||||
* @returns An array of file paths relative to baseDirectory.
|
||||
*/
|
||||
const getAllFiles = async(
|
||||
directory: string,
|
||||
baseDirectory: string,
|
||||
): Promise<Array<string>> => {
|
||||
const files: Array<string> = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(directory, entry.name);
|
||||
const relativePath = relative(baseDirectory, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath, baseDirectory);
|
||||
files.push(...subFiles);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a record.
|
||||
* @param value - The value to check.
|
||||
* @returns Whether the value is a record.
|
||||
*/
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a tree node into a string representation.
|
||||
* @param node - The tree node to format.
|
||||
* @param prefix - The prefix for the current level.
|
||||
* @param _isLast - Whether this is the last entry (unused but kept for API consistency).
|
||||
* @returns The formatted tree string.
|
||||
*/
|
||||
const formatTree = (
|
||||
node: Record<string, unknown>,
|
||||
prefix = "",
|
||||
_isLast = true,
|
||||
): string => {
|
||||
const entries = Object.entries(node).sort(([ a ], [ b ]) => {
|
||||
const aIsDirectory = typeof node[a] === "object" && node[a] !== null;
|
||||
const bIsDirectory = typeof node[b] === "object" && node[b] !== null;
|
||||
|
||||
// Directories come first
|
||||
if (aIsDirectory && !bIsDirectory) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsDirectory && bIsDirectory) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
let result = "";
|
||||
|
||||
for (let index = 0; index < entries.length; index = index + 1) {
|
||||
const entry = entries[index];
|
||||
if (entry === undefined) {
|
||||
continue;
|
||||
}
|
||||
const [ name, value ] = entry;
|
||||
const isLastEntry = index === entries.length - 1;
|
||||
const connector = isLastEntry
|
||||
? "└── "
|
||||
: "├── ";
|
||||
const nextPrefix = isLastEntry
|
||||
? " "
|
||||
: "│ ";
|
||||
|
||||
result = `${result}${prefix}${connector}${name}\n`;
|
||||
|
||||
if (isRecord(value)) {
|
||||
const subTree = formatTree(
|
||||
value,
|
||||
`${prefix}${nextPrefix}`,
|
||||
isLastEntry,
|
||||
);
|
||||
result = `${result}${subTree}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a tree structure from file paths.
|
||||
* @param files - Array of relative file paths.
|
||||
* @returns A tree structure as a string.
|
||||
*/
|
||||
const buildFileTree = (files: Array<string>): string => {
|
||||
const tree: Record<string, unknown> = {};
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.split("/");
|
||||
let current = tree;
|
||||
|
||||
for (let index = 0; index < parts.length; index = index + 1) {
|
||||
const part = parts[index];
|
||||
if (part === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (index === parts.length - 1) {
|
||||
// Last part is a file
|
||||
current[part] = null;
|
||||
} else {
|
||||
// It's a directory
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
const currentValue = current[part];
|
||||
if (isRecord(currentValue)) {
|
||||
current = currentValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatTree(tree);
|
||||
};
|
||||
|
||||
const files = await getAllFiles(dataDirectory, dataDirectory);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No files found in the data directory.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${files.length.toString()} file(s) to upload:\n`);
|
||||
console.log(buildFileTree(files));
|
||||
console.log(`\nTotal: ${files.length.toString()} file(s)\n`);
|
||||
|
||||
const shouldProceed = await confirm({
|
||||
default: false,
|
||||
message: "Do you want to proceed with uploading all these files?",
|
||||
});
|
||||
|
||||
if (!shouldProceed) {
|
||||
console.log("Upload cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint: "https://hel1.your-objectstorage.com",
|
||||
region: "hel1",
|
||||
});
|
||||
|
||||
const bar = new SingleBar({}, Presets.shades_classic);
|
||||
bar.start(files.length, 0);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(dataDirectory, file);
|
||||
const fileContent = await readFile(filePath);
|
||||
const contentType = getMimeType(file);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Body: fileContent,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Bucket: "nhcarrigan",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
ContentType: contentType,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Key: file,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
successCount = successCount + 1;
|
||||
} catch (error) {
|
||||
console.error(`\nError uploading ${file}:`, error);
|
||||
errorCount = errorCount + 1;
|
||||
}
|
||||
|
||||
bar.increment();
|
||||
}
|
||||
|
||||
bar.stop();
|
||||
|
||||
console.log(
|
||||
`\nUpload complete! ${successCount.toString()} succeeded, ${errorCount.toString()} failed.`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
type ListObjectsV2CommandOutput,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { confirm } from "@inquirer/prompts";
|
||||
import { getMimeType } from "../utils/mimeType.js";
|
||||
|
||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||
throw new Error(
|
||||
"AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint: "https://hel1.your-objectstorage.com",
|
||||
region: "hel1",
|
||||
});
|
||||
|
||||
const bucket = "nhcarrigan";
|
||||
|
||||
/**
|
||||
* Lists all objects in the S3 bucket recursively.
|
||||
* @returns An array of object keys.
|
||||
*/
|
||||
const listAllObjects = async(): Promise<Array<string>> => {
|
||||
const objects: Array<string> = [];
|
||||
let continuationToken: string | null = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Bucket: bucket,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
ContinuationToken: continuationToken ?? undefined,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion -- AWS SDK type inference issue
|
||||
const response = await s3.send(command) as ListObjectsV2CommandOutput;
|
||||
|
||||
if (response.Contents !== undefined) {
|
||||
for (const object of response.Contents) {
|
||||
if (object.Key !== undefined) {
|
||||
objects.push(object.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken ?? null;
|
||||
} while (continuationToken !== null);
|
||||
|
||||
return objects;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the content type of an object.
|
||||
* @param key - The S3 object key to check.
|
||||
* @returns The content type, or undefined if not found.
|
||||
*/
|
||||
const getObjectContentType = async(
|
||||
key: string,
|
||||
): Promise<string | undefined> => {
|
||||
const command = new HeadObjectCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Bucket: bucket,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
return response.ContentType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the content type of an object.
|
||||
* @param key - The S3 object key to update.
|
||||
* @param contentType - The new content type to set.
|
||||
*/
|
||||
const updateObjectContentType = async(
|
||||
key: string,
|
||||
contentType: string,
|
||||
): Promise<void> => {
|
||||
const command = new CopyObjectCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Bucket: bucket,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
ContentType: contentType,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
CopySource: `${bucket}/${key}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Key: key,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
MetadataDirective: "REPLACE",
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
};
|
||||
|
||||
console.log("Listing all objects in S3 bucket...");
|
||||
const allObjects = await listAllObjects();
|
||||
console.log(`Found ${allObjects.length.toString()} object(s) to check.\n`);
|
||||
|
||||
let correctedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const objectKey of allObjects) {
|
||||
// Skip directory markers (keys ending with /)
|
||||
if (objectKey.endsWith("/")) {
|
||||
console.log(`Skipping ${objectKey} (directory marker)`);
|
||||
skippedCount = skippedCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-useless-assignment -- What?
|
||||
let currentContentType: string | null = null;
|
||||
try {
|
||||
currentContentType = await getObjectContentType(objectKey) ?? null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
console.error(
|
||||
`Error getting content type for ${objectKey}: ${errorMessage}`,
|
||||
);
|
||||
errorCount = errorCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedContentType = getMimeType(objectKey);
|
||||
|
||||
// Skip if we don't know the expected type
|
||||
if (expectedContentType === undefined) {
|
||||
console.log(`Skipping ${objectKey} (unknown file type)`);
|
||||
skippedCount = skippedCount + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if content type needs correction
|
||||
const needsCorrection = currentContentType === null
|
||||
|| currentContentType === "application/octet-stream"
|
||||
|| currentContentType !== expectedContentType;
|
||||
|
||||
if (needsCorrection) {
|
||||
const message = `\nFile: ${objectKey}\nCurrent type: ${
|
||||
currentContentType ?? "undefined"
|
||||
}\nProposed type: ${expectedContentType}\n\nUpdate this file's content type?`;
|
||||
|
||||
const shouldUpdate = await confirm({
|
||||
default: true,
|
||||
message: message,
|
||||
});
|
||||
|
||||
if (shouldUpdate) {
|
||||
try {
|
||||
await updateObjectContentType(objectKey, expectedContentType);
|
||||
console.log(`✓ Updated ${objectKey}`);
|
||||
correctedCount = correctedCount + 1;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
console.error(
|
||||
`Error updating ${objectKey}: ${errorMessage}`,
|
||||
);
|
||||
errorCount = errorCount + 1;
|
||||
}
|
||||
} else {
|
||||
console.log(`✗ Skipped ${objectKey}`);
|
||||
skippedCount = skippedCount + 1;
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${objectKey} is already correct: ${currentContentType ?? "undefined"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nComplete! ${correctedCount.toString()} file(s) corrected, ${
|
||||
skippedCount.toString()
|
||||
} file(s) skipped, ${errorCount.toString()} error(s).`,
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { input } from "@inquirer/prompts";
|
||||
import { getMimeType } from "../utils/mimeType.js";
|
||||
|
||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
||||
}
|
||||
|
||||
const fileName = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'naomi.png' or 'img/naomi.png'",
|
||||
});
|
||||
if (fileName === "") {
|
||||
throw new Error("File name is not set");
|
||||
}
|
||||
|
||||
const file = await readFile(
|
||||
join(import.meta.dirname, "..", "..", "data", fileName),
|
||||
);
|
||||
|
||||
const uploadPath = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: 'img/naomi.png' or 'naomi.png':",
|
||||
});
|
||||
if (uploadPath === "") {
|
||||
throw new Error("Upload path is not set");
|
||||
}
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint: "https://hel1.your-objectstorage.com",
|
||||
region: "hel1",
|
||||
});
|
||||
|
||||
const contentType = getMimeType(uploadPath);
|
||||
|
||||
if (contentType === undefined) {
|
||||
console.warn(
|
||||
`Warning: Unknown file type for ${uploadPath}. Content type will not be set.`,
|
||||
);
|
||||
}
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Body: file,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Bucket: "nhcarrigan",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
ContentType: contentType,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Key: uploadPath,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
console.log(`Uploaded ${fileName} to ${uploadPath}`);
|
||||
@@ -0,0 +1,433 @@
|
||||
/* eslint-disable max-lines -- Necessary for all of the HTML templating. */
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
DojoResponse,
|
||||
Finding,
|
||||
ProjectStats,
|
||||
} from "../interfaces/dojo.js";
|
||||
|
||||
// --- Configuration ---
|
||||
const dojoUrl = "https://security.nhcarrigan.com";
|
||||
const dojoToken = process.env.DOJO_TOKEN ?? "YOUR_API_TOKEN_HERE";
|
||||
const outputFile = join(process.cwd(), "data", "public_security_report.html");
|
||||
|
||||
// --- Logic ---
|
||||
|
||||
console.log(`🔍 Fetching data from ${dojoUrl}...`);
|
||||
|
||||
const headers = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"Authorization": `Token ${dojoToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch active and verified findings only.
|
||||
* Handles pagination by following the 'next' URL until all pages are fetched.
|
||||
*/
|
||||
const allFindings: Array<Finding> = [];
|
||||
let nextUrl: string | null = `${dojoUrl}/api/v2/findings/?active=true&verified=true&limit=1000`;
|
||||
let pageCount = 0;
|
||||
|
||||
while (nextUrl !== null) {
|
||||
pageCount = pageCount + 1;
|
||||
console.log(`📄 Fetching findings page ${pageCount.toString()}...`);
|
||||
|
||||
const response = await fetch(nextUrl, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status.toString()} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: DojoResponse = await response.json();
|
||||
allFindings.push(...data.results);
|
||||
|
||||
console.log(` Retrieved ${data.results.length.toString()} findings (total: ${allFindings.length.toString()})`);
|
||||
|
||||
nextUrl = data.next;
|
||||
}
|
||||
|
||||
console.log(`✅ Retrieved ${allFindings.length.toString()} active findings across ${pageCount.toString()} page(s).`);
|
||||
|
||||
/**
|
||||
* Build a map of finding ID to product name by fetching products.
|
||||
* Products include a findings_list array with finding IDs.
|
||||
*/
|
||||
console.log("🔍 Building finding-to-product lookup map...");
|
||||
|
||||
// Fetch all products (paginated)
|
||||
const findingToProductMap = new Map<number, string>();
|
||||
let nextProductUrl: string | null = `${dojoUrl}/api/v2/products/?limit=1000`;
|
||||
let productPageCount = 0;
|
||||
|
||||
while (nextProductUrl !== null) {
|
||||
productPageCount = productPageCount + 1;
|
||||
console.log(`📄 Fetching products page ${productPageCount.toString()}...`);
|
||||
|
||||
const productResponse = await fetch(nextProductUrl, { headers });
|
||||
|
||||
if (!productResponse.ok) {
|
||||
throw new Error(`API Error: ${productResponse.status.toString()} - ${productResponse.statusText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response structure.
|
||||
const productData = await productResponse.json() as {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API uses snake_case.
|
||||
findings_list: Array<number>;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Debug: Log first product structure
|
||||
if (productPageCount === 1 && productData.results.length > 0) {
|
||||
console.log(` 🔍 Sample product structure:`, JSON.stringify(productData.results[0], null, 2));
|
||||
}
|
||||
|
||||
// Map each finding ID to its product name
|
||||
for (const product of productData.results) {
|
||||
const { findings_list: findingsList, name } = product;
|
||||
for (const findingId of findingsList) {
|
||||
findingToProductMap.set(findingId, name);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Processed ${productData.results.length.toString()} products (total mappings: ${findingToProductMap.size.toString()})`);
|
||||
|
||||
nextProductUrl = productData.next;
|
||||
}
|
||||
|
||||
console.log(`✅ Built lookup map with ${findingToProductMap.size.toString()} finding-to-product mappings across ${productPageCount.toString()} page(s).`);
|
||||
|
||||
const data = allFindings;
|
||||
|
||||
/**
|
||||
* Extracts the product name from a finding.
|
||||
* Tries finding.product.name first, then looks up via finding ID.
|
||||
* @param finding - The finding to extract the product name from.
|
||||
* @param productMap - Map of finding ID to product name.
|
||||
* @returns The product name, or undefined if not found.
|
||||
*/
|
||||
const getProductName = (
|
||||
finding: Finding,
|
||||
productMap: Map<number, string>,
|
||||
): string | undefined => {
|
||||
// Try direct product field first
|
||||
const productName = finding.product?.name;
|
||||
if (productName !== undefined && productName.length > 0) {
|
||||
return productName;
|
||||
}
|
||||
|
||||
// Look up via finding ID
|
||||
return productMap.get(finding.id);
|
||||
};
|
||||
|
||||
// Aggregate Data
|
||||
const stats: Record<string, ProjectStats> = {};
|
||||
|
||||
for (const finding of data) {
|
||||
const projectName = getProductName(finding, findingToProductMap);
|
||||
|
||||
// Skip findings without a product assigned
|
||||
if (projectName === undefined || projectName.length === 0) {
|
||||
console.warn(`⚠️ Skipping finding ${finding.id.toString()} - no product found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { severity } = finding;
|
||||
|
||||
// Initialize project entry if missing
|
||||
if (!stats[projectName]) {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- matches the API response.
|
||||
stats[projectName] = { Critical: 0, High: 0, Info: 0, Low: 0, Medium: 0 };
|
||||
}
|
||||
|
||||
const projectStats = stats[projectName];
|
||||
|
||||
// Increment count if severity matches our tracking keys
|
||||
if (severity in projectStats) {
|
||||
projectStats[severity] = projectStats[severity] + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a project name by removing org prefix and converting to title case.
|
||||
* @param projectName - The project name (e.g., "nhcarrigan/website-headers").
|
||||
* @returns Formatted name (e.g., "Website Headers").
|
||||
*/
|
||||
const formatProjectName = (projectName: string): string => {
|
||||
// Remove org prefix (everything before and including the last "/")
|
||||
const parts = projectName.split("/");
|
||||
const nameOnly = parts.at(-1) ?? projectName;
|
||||
|
||||
// Replace hyphens with spaces and title case each word
|
||||
return nameOnly.
|
||||
split("-").
|
||||
map((word) => {
|
||||
if (word.length === 0) {
|
||||
return word;
|
||||
}
|
||||
|
||||
const [ firstChar, ...rest ] = word;
|
||||
if (firstChar === undefined) {
|
||||
return word;
|
||||
}
|
||||
|
||||
return firstChar.toUpperCase() + rest.join("").toLowerCase();
|
||||
}).
|
||||
join(" ");
|
||||
};
|
||||
|
||||
// Generate HTML - sort alphabetically by formatted name
|
||||
const htmlRows = Object.entries(stats).
|
||||
sort(([ projectA ], [ projectB ]) => {
|
||||
const formattedA = formatProjectName(projectA);
|
||||
const formattedB = formatProjectName(projectB);
|
||||
return formattedA.localeCompare(formattedB);
|
||||
}).
|
||||
map(([ project, counts ]) => {
|
||||
const critClass = counts.Critical > 0
|
||||
? "crit"
|
||||
: "info";
|
||||
const highClass = counts.High > 0
|
||||
? "high"
|
||||
: "info";
|
||||
const mediumClass = counts.Medium > 0
|
||||
? "medium"
|
||||
: "info";
|
||||
const lowClass = counts.Low > 0
|
||||
? "low"
|
||||
: "info";
|
||||
|
||||
const formattedProject = formatProjectName(project);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${formattedProject}</strong></td>
|
||||
<td class="${critClass}">${counts.Critical.toString()}</td>
|
||||
<td class="${highClass}">${counts.High.toString()}</td>
|
||||
<td class="${mediumClass}">${counts.Medium.toString()}</td>
|
||||
<td class="${lowClass}">${counts.Low.toString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).
|
||||
join("");
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Public Security Transparency Report</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Security Transparency Dashboard</h1>
|
||||
<div class="meta">
|
||||
Status: <strong>Active Monitoring</strong> |
|
||||
Generated: ${new Date().toISOString().
|
||||
split("T")[0] ?? "Unknown"} |
|
||||
Source: Automated Pipeline
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p>This report provides an aggregated view of the current security posture across our public projects. We practice continuous remediation; findings listed here are actively being addressed.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project Scope</th>
|
||||
<th>Critical</th>
|
||||
<th>High</th>
|
||||
<th>Medium</th>
|
||||
<th>Low</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${htmlRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.nhcarrigan.com/headers/index.js"></script>
|
||||
<style>
|
||||
/* Complement global styles - use existing CSS variables */
|
||||
:root {
|
||||
--crit-light: #d32f2f;
|
||||
--high-light: #f57c00;
|
||||
--medium-light: rgb(255, 251, 0);
|
||||
--low-light: rgb(36, 200, 3);
|
||||
--info-light: rgb(146, 146, 146);
|
||||
/* Dark mode colors - lighter versions for dark background */
|
||||
--crit-dark: #ff6b6b;
|
||||
--high-dark: #ffa94d;
|
||||
--medium-dark: #ffeb3b;
|
||||
--low-dark: #81c784;
|
||||
--info-dark: #b0b0b0;
|
||||
/* Hover colors - for when background is var(--foreground) */
|
||||
/* Light mode hover: background is #8F2447 (dark pink) */
|
||||
--crit-hover-light: #ff9999;
|
||||
--high-hover-light: #ffb366;
|
||||
--medium-hover-light: #fff266;
|
||||
--low-hover-light: #99d699;
|
||||
--info-hover-light: #d0d0d0;
|
||||
/* Dark mode hover: background is #E1F6F9 (light cyan) */
|
||||
--crit-hover-dark: #b71c1c;
|
||||
--high-hover-dark: #e65100;
|
||||
--medium-hover-dark: #f57f17;
|
||||
--low-hover-dark: #1b5e20;
|
||||
--info-hover-dark: #424242;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--foreground);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.meta {
|
||||
color: var(--foreground);
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.crit {
|
||||
color: var(--crit-light);
|
||||
}
|
||||
.high {
|
||||
color: var(--high-light);
|
||||
}
|
||||
.medium {
|
||||
color: var(--medium-light);
|
||||
}
|
||||
.low {
|
||||
color: var(--low-light);
|
||||
}
|
||||
.info {
|
||||
color: var(--info-light);
|
||||
}
|
||||
/* Dark mode severity colors */
|
||||
.is-dark .crit {
|
||||
color: var(--crit-dark);
|
||||
}
|
||||
.is-dark .high {
|
||||
color: var(--high-dark);
|
||||
}
|
||||
.is-dark .medium {
|
||||
color: var(--medium-dark);
|
||||
}
|
||||
.is-dark .low {
|
||||
color: var(--low-dark);
|
||||
}
|
||||
.is-dark .info {
|
||||
color: var(--info-dark);
|
||||
}
|
||||
/* Hover colors for severity classes */
|
||||
tr:hover .crit {
|
||||
color: var(--crit-hover-light);
|
||||
}
|
||||
tr:hover .high {
|
||||
color: var(--high-hover-light);
|
||||
}
|
||||
tr:hover .medium {
|
||||
color: var(--medium-hover-light);
|
||||
}
|
||||
tr:hover .low {
|
||||
color: var(--low-hover-light);
|
||||
}
|
||||
tr:hover .info {
|
||||
color: var(--info-hover-light);
|
||||
}
|
||||
/* Dark mode hover colors */
|
||||
.is-dark tr:hover .crit {
|
||||
color: var(--crit-hover-dark);
|
||||
}
|
||||
.is-dark tr:hover .high {
|
||||
color: var(--high-hover-dark);
|
||||
}
|
||||
.is-dark tr:hover .medium {
|
||||
color: var(--medium-hover-dark);
|
||||
}
|
||||
.is-dark tr:hover .low {
|
||||
color: var(--low-hover-dark);
|
||||
}
|
||||
.is-dark tr:hover .info {
|
||||
color: var(--info-hover-dark);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--foreground);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
th {
|
||||
background-color: var(--foreground);
|
||||
color: var(--background);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 1;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
tr:hover td:not(.crit, .high, .medium, .low, .info) {
|
||||
color: var(--background);
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Dark mode support */
|
||||
.is-dark table {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
.is-dark th {
|
||||
background-color: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
.is-dark tr:hover {
|
||||
background-color: var(--foreground);
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Write to Disk
|
||||
await writeFile(outputFile, htmlContent, "utf-8");
|
||||
|
||||
console.log(`🚀 Report generated successfully: ${outputFile}`);
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { sleep } from "./sleep.js";
|
||||
|
||||
/**
|
||||
* Wraps the native fetch method in logic to back off
|
||||
* and retry on 429 errors.
|
||||
* @type {T} - The type of the response.
|
||||
* @param url - The URL to fetch.
|
||||
* @param options - The fetch options.
|
||||
* @returns The response, or null on error.
|
||||
*/
|
||||
export const backoffAndRetry
|
||||
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
await sleep(5000);
|
||||
return await backoffAndRetry(url, options);
|
||||
}
|
||||
throw new Error(`Request failed with status ${response.status.toString()}`);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { select } from "@inquirer/prompts";
|
||||
import { execSync } from "child_process";
|
||||
import { readdirSync, statSync, existsSync } from "fs";
|
||||
import { join, relative } from "path";
|
||||
|
||||
interface ScriptOption {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const getTypeScriptCategories = (): string[] => {
|
||||
const srcPath = join(__dirname, "..");
|
||||
const entries = readdirSync(srcPath);
|
||||
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
const fullPath = join(srcPath, entry);
|
||||
return statSync(fullPath).isDirectory() && entry !== "utils" && entry !== "interfaces";
|
||||
})
|
||||
.sort();
|
||||
};
|
||||
|
||||
const getTypeScriptScripts = (category: string): ScriptOption[] => {
|
||||
const categoryPath = join(__dirname, "..", category);
|
||||
const scripts: ScriptOption[] = [];
|
||||
|
||||
const walkDirectory = (dir: string) => {
|
||||
const entries = readdirSync(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkDirectory(fullPath);
|
||||
} else if (entry.endsWith(".ts") && entry !== "index.ts") {
|
||||
const relativePath = relative(join(__dirname, ".."), fullPath);
|
||||
scripts.push({
|
||||
name: entry.replace(".ts", ""),
|
||||
value: relativePath,
|
||||
description: relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walkDirectory(categoryPath);
|
||||
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const getPythonCategories = (): string[] => {
|
||||
const pythonPath = join(__dirname, "../../../../python");
|
||||
const entries = readdirSync(pythonPath);
|
||||
|
||||
const categories = entries
|
||||
.filter((entry) => {
|
||||
const fullPath = join(pythonPath, entry);
|
||||
return statSync(fullPath).isDirectory() &&
|
||||
!entry.startsWith(".") &&
|
||||
entry !== "__pycache__";
|
||||
})
|
||||
.sort();
|
||||
|
||||
// Also check for scripts in the root
|
||||
const hasRootScripts = entries.some(entry => entry.endsWith(".py"));
|
||||
if (hasRootScripts) {
|
||||
categories.unshift("(root)");
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const getPythonScripts = (category: string): ScriptOption[] => {
|
||||
const pythonPath = join(__dirname, "../../../../python");
|
||||
const searchPath = category === "(root)" ? pythonPath : join(pythonPath, category);
|
||||
|
||||
const scripts: ScriptOption[] = [];
|
||||
const entries = readdirSync(searchPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith(".py") && !entry.startsWith("__")) {
|
||||
const relativePath = category === "(root)" ? entry : join(category, entry);
|
||||
scripts.push({
|
||||
name: entry.replace(".py", ""),
|
||||
value: relativePath,
|
||||
description: relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
console.log("🌸 Welcome to Ephemere Script Runner! 💖\n");
|
||||
|
||||
// Select language
|
||||
const language = await select({
|
||||
message: "Which language would you like to run?",
|
||||
choices: [
|
||||
{ name: "TypeScript", value: "typescript", description: "Run a TypeScript script" },
|
||||
{ name: "Python", value: "python", description: "Run a Python script" },
|
||||
],
|
||||
});
|
||||
|
||||
// Get categories based on language
|
||||
const categories = language === "typescript"
|
||||
? getTypeScriptCategories()
|
||||
: getPythonCategories();
|
||||
|
||||
if (categories.length === 0) {
|
||||
console.error(`No categories found for ${language}!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Select category
|
||||
const category = await select({
|
||||
message: "Which category?",
|
||||
choices: categories.map(cat => ({
|
||||
name: cat === "(root)" ? "Root Directory" : cat.charAt(0).toUpperCase() + cat.slice(1),
|
||||
value: cat,
|
||||
})),
|
||||
});
|
||||
|
||||
// Get scripts for selected category
|
||||
const scripts = language === "typescript"
|
||||
? getTypeScriptScripts(category)
|
||||
: getPythonScripts(category);
|
||||
|
||||
if (scripts.length === 0) {
|
||||
console.error(`No scripts found in ${category}!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Select script
|
||||
const script = await select({
|
||||
message: "Which script would you like to run?",
|
||||
choices: scripts,
|
||||
});
|
||||
|
||||
// Build and execute the command
|
||||
const prodEnvPath = join(__dirname, "../../../../../prod.env");
|
||||
let command: string;
|
||||
|
||||
if (language === "typescript") {
|
||||
command = `cd ${join(__dirname, "../../../")} && op run --env-file=${prodEnvPath} -- pnpm exec tsx src/${script}`;
|
||||
} else {
|
||||
command = `cd ${join(__dirname, "../../../../python")} && op run --env-file=${prodEnvPath} -- uv run python ${script}`;
|
||||
}
|
||||
|
||||
console.log(`\n✨ Running: ${script}\n`);
|
||||
|
||||
try {
|
||||
execSync(command, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("\n❌ Script execution failed!");
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { extname } from "node:path";
|
||||
|
||||
/**
|
||||
* MIME type mapping for file extensions.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- File extensions */
|
||||
/* eslint-disable stylistic/key-spacing -- Alignment for readability */
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".7z": "application/x-7z-compressed",
|
||||
".aac": "audio/aac",
|
||||
".avi": "video/x-msvideo",
|
||||
".bmp": "image/bmp",
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".doc": "application/msword",
|
||||
".docx":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".flac": "audio/flac",
|
||||
".gif": "image/gif",
|
||||
".gz": "application/gzip",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".md": "text/markdown",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".otf": "font/otf",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx":
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".svg": "image/svg+xml",
|
||||
".tar": "application/x-tar",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain",
|
||||
".wav": "audio/wav",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xml": "application/xml",
|
||||
".zip": "application/zip",
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- File extensions */
|
||||
/* eslint-enable stylistic/key-spacing -- Alignment for readability */
|
||||
|
||||
/**
|
||||
* Gets the MIME type for a file based on its extension.
|
||||
* @param filePath - The file name or path.
|
||||
* @returns The MIME type, or undefined if unknown.
|
||||
*/
|
||||
export const getMimeType = (filePath: string): string | undefined => {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
return mimeTypes[extension];
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches a paginated resource from a URL. Automatically handles pagination,
|
||||
* and returns the complete data as an array.
|
||||
* @type {Array<Record<string, unknown>>} - The type of data returned from the API endpoint. This should be an array of objects.
|
||||
* @param url - The URL to fetch.
|
||||
* @param limit - The number of items to fetch per page.
|
||||
* @param options - The standard fetch options object.
|
||||
* @returns The complete data as type T.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements -- We're doing some complex logic here.
|
||||
export const paginatedFetch = async <T extends Array<Record<string, unknown>>>(
|
||||
url: string,
|
||||
limit: number,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
let page = 1;
|
||||
let offset = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||
const data: T = [] as unknown as T;
|
||||
|
||||
// First page
|
||||
const firstUrl = `${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`;
|
||||
console.log(`Fetching page ${page.toString()} (offset ${offset.toString()}, limit ${limit.toString()})...`);
|
||||
let request = await fetch(firstUrl, options);
|
||||
|
||||
if (!request.ok) {
|
||||
throw new Error(`Failed to fetch ${firstUrl}: ${request.status.toString()} ${request.statusText}`);
|
||||
}
|
||||
|
||||
let response: T = await request.json();
|
||||
|
||||
// Check if response is actually an array
|
||||
if (!Array.isArray(response)) {
|
||||
console.error(
|
||||
"API response is not an array:",
|
||||
typeof response,
|
||||
Object.keys(response),
|
||||
);
|
||||
const errorMessage
|
||||
= `Expected array response but got ${typeof response}. `
|
||||
+ `Response keys: ${Object.keys(response).join(", ")}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
console.log(`Page ${page.toString()}: Received ${response.length.toString()} items`);
|
||||
data.push(...response);
|
||||
|
||||
/**
|
||||
* Continue paginating while we get items back.
|
||||
* Keep fetching until we get an empty array (0 items), which means we've reached the end.
|
||||
*/
|
||||
while (response.length > 0) {
|
||||
page = page + 1;
|
||||
offset = offset + limit;
|
||||
const pageUrl = `${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`;
|
||||
console.log(`Fetching page ${page.toString()} (offset ${offset.toString()}, limit ${limit.toString()})...`);
|
||||
request = await fetch(pageUrl, options);
|
||||
|
||||
if (!request.ok) {
|
||||
console.error(`Failed to fetch page ${page.toString()}: ${request.status.toString()} ${request.statusText}`);
|
||||
break;
|
||||
}
|
||||
|
||||
response = await request.json();
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
console.error(`Page ${page.toString()} response is not an array:`, typeof response);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Page ${page.toString()}: Received ${response.length.toString()} items`);
|
||||
|
||||
// If we get an empty array, we've reached the end
|
||||
if (response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
data.push(...response);
|
||||
}
|
||||
|
||||
console.log(`Total items fetched: ${data.length.toString()}`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attempts to serialise a string as JSON. Includes error handling if the
|
||||
* string is not serialisable.
|
||||
* @param text -- The text to serialise.
|
||||
* @returns The serialised object, or null on error.
|
||||
*/
|
||||
export const serialiseJsonOrError
|
||||
= (text: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const object = JSON.parse(text);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- we know this is an object.
|
||||
return object;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Uses async promises to pause exection for the specified time.
|
||||
* @param ms - The number of milliseconds to pause for.
|
||||
* @returns The promise.
|
||||
*/
|
||||
export const sleep = async(ms: number): Promise<Promise<void>> => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./prod"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user