331 lines
11 KiB
JavaScript

const axios = require("axios");
const fs = require("fs");
const path = require("path");
const { pipeline } = require("stream/promises");
const cliProgress = require("cli-progress");
const cheerio = require("cheerio");
const SftpClient = require("ssh2-sftp-client");
const crypto = require("crypto");
// --- Config & Secrets ---
const CONFIG_PATH = "config.json";
const SECRET_PATH = "secret.key";
// Load or create encryption key
let secret;
if (!fs.existsSync(SECRET_PATH)) {
secret = crypto.randomBytes(32);
fs.writeFileSync(SECRET_PATH, secret.toString("hex"));
console.log("🔐 Generated and saved a new secret key.");
} else {
secret = Buffer.from(fs.readFileSync(SECRET_PATH, "utf-8"), "hex");
}
function encryptPassword(password) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", secret, iv);
const encrypted = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
return {
iv: iv.toString("hex"),
password: encrypted.toString("hex"),
};
}
function decryptPassword(encrypted) {
const decipher = crypto.createDecipheriv(
"aes-256-cbc",
secret,
Buffer.from(encrypted.iv, "hex")
);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encrypted.password, "hex")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
// Load config and auto-convert plaintext SFTP password
let config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
const sftpConfig = config.sftp || { enabled: false };
if (sftpConfig.password && !sftpConfig.encryptedPassword) {
sftpConfig.encryptedPassword = encryptPassword(sftpConfig.password);
delete sftpConfig.password;
config.sftp = sftpConfig;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
console.log("🔐 Encrypted SFTP password saved to config.json.");
}
// --- Utilities ---
const ensureDir = (dir) => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
};
const DOWNLOAD_PATH = config.global.downloadPath || "downloads";
const isDownloaded = (filename) => fs.existsSync(path.join(DOWNLOAD_PATH, filename));
const downloadJar = async (url, name) => {
if (isDownloaded(name)) {
console.log(`🟡 Skipped (already exists): ${name}`);
return;
}
const USER_AGENT = config.global?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
const response = await axios.get(url, {
responseType: "stream",
headers: {
"User-Agent":
USER_AGENT,
},
});
const total = parseInt(response.headers["content-length"] || "0", 10);
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
bar.start(total, 0);
response.data.on("data", (chunk) => bar.increment(chunk.length));
response.data.on("end", () => bar.stop());
const filePath = path.join(DOWNLOAD_PATH, name);
await pipeline(response.data, fs.createWriteStream(filePath));
console.log(`✔️ Saved: ${name}`);
};
// --- Source handlers ---
const handleJenkins = async (url) => {
const html = await axios.get(url).then((res) => res.data);
const $ = cheerio.load(html);
const links = $("a[href$='.jar']").map((_, el) => $(el).attr("href")).get();
if (!links.length) throw new Error("No .jar files found on page");
const base = new URL(url);
const preferred = ["paper", "spigot", "bukkit"];
const skip = ["javadoc", "sources", "cli", "bootstrap", "mojangapi", "nashorn", "remapper"];
const essentialsOK = ["EssentialsX", "EssentialsXChat", "EssentialsXSpawn", "EssentialsXGeoIP"];
const valid = links
.map((href) => {
const fileName = path.basename(href);
const lower = fileName.toLowerCase();
if (skip.some((term) => lower.includes(term))) return null;
if (fileName.startsWith("EssentialsX")) {
const base = fileName.split("-")[0];
if (!essentialsOK.includes(base)) return null;
}
return { href, fileName: lower };
})
.filter(Boolean);
for (const tag of preferred) {
const pick = valid.find((f) => f.fileName.includes(tag));
if (pick) {
const finalURL = new URL(pick.href, base).href;
await downloadJar(finalURL, path.basename(pick.href));
return;
}
}
// Fallback: all valid jars
for (const f of valid) {
const finalURL = new URL(f.href, base).href;
await downloadJar(finalURL, path.basename(f.href));
}
};
const handleGitHub = async (url) => {
const match = url.match(/github\.com\/([^/]+\/[^/]+)/);
if (!match) throw new Error("Invalid GitHub URL format");
const repo = match[1];
const apiURL = `https://api.github.com/repos/${repo}/releases/latest`;
const { data } = await axios.get(apiURL);
const preferred = data.assets.find((a) => a.name === "GeyserSkinManager-Spigot.jar");
const fallback = data.assets.find((a) => a.name.endsWith(".jar"));
const chosen = preferred || fallback;
if (!chosen) throw new Error("No .jar assets found");
await downloadJar(chosen.browser_download_url, chosen.name);
};
const handleModrinth = async (url) => {
const match = url.match(/modrinth\.com\/plugin\/([^/]+)/);
if (!match) throw new Error("Invalid Modrinth URL format");
const project = match[1];
const versions = await axios
.get(`https://api.modrinth.com/v2/project/${project}/version`)
.then((res) => res.data);
const latest = versions[0];
const file = latest.files.find((f) => f.filename.endsWith(".jar"));
if (!file) throw new Error("No .jar file in latest version");
await downloadJar(file.url, file.filename);
};
const handleDirect = async (url) => {
let name = path.basename(url.split("?")[0]);
if (url.includes("download.geysermc.org")) {
name = "floodgate-spigot.jar";
} else {
try {
const head = await axios.head(url);
const disp = head.headers["content-disposition"];
const match = disp?.match(/filename[^=]*=(?:UTF-8'')?["']?([^"';]+)/i);
if (match) name = decodeURIComponent(match[1]);
} catch {}
}
name = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "");
await downloadJar(url, name);
};
// --- Handle PaperMC ---
const handlePaperMC = async (url) => {
const versionMatch = url.match(/papermc.io\/download\/(.*?)(?:\/|$)/);
if (!versionMatch) throw new Error("Invalid PaperMC URL format");
const version = versionMatch[1];
const apiURL = `https://api.papermc.io/v2/projects/paper/versions/${version}/builds`;
const builds = await axios.get(apiURL).then((res) => res.data.builds);
const latestBuild = builds[0];
const downloadURL = latestBuild.downloads.application.url;
await downloadJar(downloadURL, `paper-${version}-${latestBuild.build}.jar`);
};
// --- Handle dev.bukkit.org ---
const handleBukkit = async (url) => {
if (!url.includes("dev.bukkit.org")) {
throw new Error("Not a dev.bukkit.org URL");
}
const html = await axios.get(url).then((res) => res.data);
const $ = cheerio.load(html);
let projectName = $("span.overflow-tip").text().trim() || "unknown-project";
projectName = projectName
.replace(/[<>:"/\\|?*\x00-\x1F()™©®]/g, "")
.replace(/[^\w.-]/g, "_");
const downloadLink = $("a.button.alt.fa-icon-download[href*='/files/latest']")
.attr("href");
if (!downloadLink) {
throw new Error("No 'Download Latest File' link found on dev.bukkit.org page");
}
const fullDownloadLink = `https://dev.bukkit.org${downloadLink}`;
console.log(`🔗 Found download link: ${fullDownloadLink}`);
let filename = `${projectName}.jar`;
try {
const head = await axios.head(fullDownloadLink);
const disp = head.headers["content-disposition"];
const match = disp?.match(/filename[^=]*=(?:UTF-8'')?["']?([^"';]+)/i);
if (match) filename = decodeURIComponent(match[1]);
} catch (err) {
console.log("Could not retrieve filename from headers, using project name.");
}
await downloadJar(fullDownloadLink, filename);
};
// --- Upload to SFTP ---
const uploadToSFTP = async () => {
if (!sftpConfig.enabled) {
console.log("📦 SFTP is disabled in config.");
return;
}
const sftp = new SftpClient();
const remote = sftpConfig.remotePath || "/";
const localFiles = fs.readdirSync(DOWNLOAD_PATH).filter(f => f.endsWith(".jar"));
const connectOptions = {
host: sftpConfig.host,
port: sftpConfig.port || 22,
username: sftpConfig.username,
};
if (sftpConfig.privateKeyPath && fs.existsSync(sftpConfig.privateKeyPath)) {
connectOptions.privateKey = fs.readFileSync(sftpConfig.privateKeyPath);
} else if (sftpConfig.encryptedPassword) {
connectOptions.password = decryptPassword(sftpConfig.encryptedPassword);
} else {
throw new Error("Missing SFTP password or private key.");
}
const extractBaseName = (filename) => {
return filename.replace(/[-_.](v?\d.*)?\.jar$/, "").trim();
};
try {
await sftp.connect(connectOptions);
const remoteFiles = await sftp.list(remote);
const remoteJars = remoteFiles.filter(f => f.name.endsWith(".jar"));
for (const localFile of localFiles) {
const baseName = extractBaseName(localFile);
const toDelete = remoteJars.filter(remoteFile =>
extractBaseName(remoteFile.name) === baseName
);
for (const file of toDelete) {
const fullPath = path.posix.join(remote, file.name);
await sftp.delete(fullPath);
console.log(`🗑️ Deleted remote: ${file.name}`);
}
// 🚀 Upload new files
const localPath = path.join(DOWNLOAD_PATH, localFile);
const remotePath = path.posix.join(remote, localFile);
await sftp.fastPut(localPath, remotePath);
console.log(`⬆️ Uploaded: ${localFile}`);
}
} catch (err) {
console.error("❌ SFTP Error:", err.message);
} finally {
sftp.end();
}
};
// --- Main ---
(async () => {
ensureDir(DOWNLOAD_PATH);
const existingFiles = fs.readdirSync(DOWNLOAD_PATH).filter(f => f.endsWith(".jar"));
for (const file of existingFiles) {
const filePath = path.join(DOWNLOAD_PATH, file);
fs.unlinkSync(filePath);
console.log(`🗑️ Deleted local file: ${file}`);
}
for (const url of config.urls) {
console.log(`\n📥 ${url}`);
try {
if (url.includes("github.com")) {
await handleGitHub(url);
} else if (url.includes("modrinth.com")) {
await handleModrinth(url);
} else if (url.includes("papermc.io")) {
await handlePaperMC(url);
} else if (url.includes("dev.bukkit.org")) {
await handleBukkit(url);
} else if (url.includes("/job/")) {
await handleJenkins(url);
} else if (url.endsWith(".jar") || url.includes("download.geysermc.org")) {
await handleDirect(url);
} else {
console.warn("⚠️ Skipping unknown URL format.");
}
} catch (err) {
console.error(`❌ Failed: ${err.message}`);
}
}
await uploadToSFTP();
})();