feat: add multilingual support so Naomi can use Python too

This commit is contained in:
2026-01-23 15:32:02 -08:00
parent 38e7f15d93
commit c0ad74367a
52 changed files with 1305 additions and 46 deletions
@@ -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!");
+47
View File
@@ -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!");
+46
View File
@@ -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;
};
+41
View File
@@ -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!");
+525
View File
@@ -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",
});
}
+673
View File
@@ -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 Discords 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("&", "&amp;").
replaceAll("<", "&lt;").
replaceAll(">", "&gt;").
replaceAll("\"", "&quot;").
replaceAll("'", "&#39;");
};
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 Discords 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`,
);
}
+240
View File
@@ -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...`);
}
}
+124
View File
@@ -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()}`);
+149
View File
@@ -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!");
+101
View File
@@ -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()}`);
+13
View File
@@ -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"`);
+188
View File
@@ -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 };
+37
View File
@@ -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 };
+216
View File
@@ -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 };
+46
View 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!");
+217
View File
@@ -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.`,
);
+192
View File
@@ -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).`,
);
+68
View File
@@ -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}`);
+433
View File
@@ -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}`);
+35
View File
@@ -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;
}
};
+165
View File
@@ -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);
+77
View File
@@ -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];
};
+89
View File
@@ -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;
}
};
+16
View File
@@ -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);
});
};