From 18efd30a263ec0d79a26a82cbd8c90c9f81056b7 Mon Sep 17 00:00:00 2001 From: Minteck Date: Mon, 28 Nov 2022 17:14:38 +0100 Subject: Open source --- src/core/API.ts | 216 +++++++++++++++++++++++++++++++++++++++++++++ src/core/Authentication.ts | 93 +++++++++++++++++++ src/core/Autoreport.ts | 74 ++++++++++++++++ src/core/AutoreportBase.ts | 10 +++ src/core/Notification.ts | 41 +++++++++ src/index.ts | 32 +++++++ src/types/Report.ts | 68 ++++++++++++++ src/types/UUID.ts | 7 ++ 8 files changed, 541 insertions(+) create mode 100644 src/core/API.ts create mode 100644 src/core/Authentication.ts create mode 100644 src/core/Autoreport.ts create mode 100644 src/core/AutoreportBase.ts create mode 100644 src/core/Notification.ts create mode 100644 src/index.ts create mode 100644 src/types/Report.ts create mode 100644 src/types/UUID.ts (limited to 'src') diff --git a/src/core/API.ts b/src/core/API.ts new file mode 100644 index 0000000..bb0fab3 --- /dev/null +++ b/src/core/API.ts @@ -0,0 +1,216 @@ +import AutoreportBase from "./AutoreportBase"; +import Authentication from "./Authentication"; +import {Report, ReportError, ReportResponse, ReportSeverity, SystemInformation, SystemProcess} from "../types/Report"; +import {readFileSync, writeFileSync} from "node:fs"; +import UUID from "../types/UUID"; +import Notification from "./Notification"; + +class APIEndpoint extends AutoreportBase {} + +export class ReportEndpoint extends APIEndpoint { + private static reports: Report[] = JSON.parse(readFileSync("./data/reports.json").toString()); + + public static refresh(req, res) { + ReportEndpoint.reports = JSON.parse(readFileSync("./data/reports.json").toString()); + return res.status(200).json({ + code: 200, + message: "OK." + }); + } + + public static post(req, res) { + if ([null, undefined, ""].includes(req.body.service) || + [null, undefined, ""].includes(req.body.time) || + [null, undefined, ""].includes(req.body.severity) || + [null, undefined, "", {}].includes(req.body.error) || + [null, undefined, "", {}].includes(req.body.systemInfo)) { + return res.status(400).json({ + code: 400, + message: "Report information is missing." + }); + } + + if ([null, undefined, ""].includes(req.body.error.message) || + [null, undefined, ""].includes(req.body.error.stacktrace)) { + return res.status(400).json({ + code: 400, + message: "Error information is missing." + }); + } + + if ([null, undefined, ""].includes(req.body.systemInfo.pid) || + [null, undefined, ""].includes(req.body.systemInfo.user) || + [null, undefined, ""].includes(req.body.systemInfo.executable) || + [null, undefined, ""].includes(req.body.systemInfo.memoryUsed) || + [null, undefined, ""].includes(req.body.systemInfo.cpuTimes) || + [null, undefined, ""].includes(req.body.systemInfo.uptime) || + [null, undefined, ""].includes(req.body.systemInfo.systemUptime) || + [null, undefined, ""].includes(req.body.systemInfo.os)) { + return res.status(400).json({ + code: 400, + message: "System information is missing." + }); + } + + let severity = ReportSeverity.Medium; + + switch (req.body.severity.toString().toLowerCase()) { + case "low": + case "0": + severity = ReportSeverity.Low; + break; + case "medium": + case "1": + severity = ReportSeverity.Medium; + break; + case "high": + case "2": + severity = ReportSeverity.High; + break; + case "critical": + case "3": + severity = ReportSeverity.Critical; + break; + case "fatal": + case "4": + severity = ReportSeverity.Fatal; + break; + default: + severity = ReportSeverity.Medium; + break; + } + + let reportError: ReportError = { + message: req.body.error.message, + stacktrace: req.body.error.stacktrace, + logs: req.body.error.logs ?? null, + potentialFix: null + } + + let systemInfo: SystemInformation = req.body.systemInfo; + + let report: Report = { + id: new UUID(), + service: req.body.service, + time: new Date(req.body.time), + severity, + response: ReportResponse.None, + error: reportError, + systemInfo + } + + ReportEndpoint.reports.push(report); + writeFileSync(AutoreportBase.getRoot() + "/data/reports.json", JSON.stringify(ReportEndpoint.reports)); + + let notification = new Notification(report); + notification.send().then(() => { + res.status(201).json({ + code: 201, + message: "Created." + }); + }); + } + + public static get(req, res) { + if (!Authentication.checkAuthentication(req)) return res.redirect("/oauth2/start"); + + if([null, undefined, ""].includes(req.query.id)) { + return res.status(400).json({ + code: 400, + message: "An ID must be provided." + }); + } + + if(!ReportEndpoint.reports.some(report => report.id === req.query.id)) { + return res.status(404).json({ + code: 404, + message: "Report with that ID does not exist." + }); + } + + res.status(200).json(ReportEndpoint.reports.find(report => report.id === req.query.id)); + } + + public static getMany(req, res) { + if (!Authentication.checkAuthentication(req)) return res.redirect("/oauth2/start"); + + let page = parseInt(req.query.page ?? 0); + let size = parseInt(req.query.size ?? 10); + + if (isNaN(page)) { + return res.status(400).json({ + code: 400, + message: "Page must be a number." + }); + } + if (isNaN(size)) { + return res.status(400).json({ + code: 400, + message: "Size must be a number." + }); + } + + // task: split the array into chunks of `size` + // good luck <3 + let reportsRequested = ReportEndpoint.reports.slice((size * page), size); + + res.status(200).json({ + reports: reportsRequested, + page: page, + pageCount: Math.ceil(ReportEndpoint.reports.length / size) + }); + } + + public static patch(req, res) { + if (!Authentication.checkAuthentication(req)) return res.redirect("/oauth2/start"); + + if ([null, undefined, ""].includes(req.query.id) || + [null, undefined, ""].includes(req.query.response)) { + return res.status(400).json({ + code: 400, + message: "Report infomation is missing." + }); + } + + if(!ReportEndpoint.reports.some(report => report.id === req.query.id)) { + return res.status(404).json({ + code: 404, + message: "That report does not exist." + }); + } + + let reportIndex = ReportEndpoint.reports.findIndex(report => report.id === req.query.id); + + let response; + + switch (req.query.response.toString().toLowerCase()) { + case "none": + case "0": + response = ReportResponse.None; + break; + case "acknowledged": + case "1": + response = ReportResponse.Acknowledged; + break; + case "ignored": + case "2": + response = ReportResponse.Ignored; + break; + case "stfu": + case "3": + response = ReportResponse.STFU; + break; + default: + response = ReportResponse.None; + break; + } + + ReportEndpoint.reports[reportIndex].response = response; + writeFileSync(AutoreportBase.getRoot() + "/data/reports.json", JSON.stringify(ReportEndpoint.reports)); + + res.status(200).json({ + code: 200, + message: "Updated." + }); + } +} \ No newline at end of file diff --git a/src/core/Authentication.ts b/src/core/Authentication.ts new file mode 100644 index 0000000..f298bdf --- /dev/null +++ b/src/core/Authentication.ts @@ -0,0 +1,93 @@ +import AutoreportBase from "./AutoreportBase"; +import axios from "axios"; +import { readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "crypto"; + +export default class Authentication extends AutoreportBase { + public static getToken(token) { + let tokens = JSON.parse(readFileSync(AutoreportBase.getRoot() + "/data/tokens.json").toString()); + + if (Object.keys(tokens).includes(token) && new Date(tokens[token].date).getTime() - new Date().getTime() <= 31000000) { + return tokens[token].info; + } else { + return false; + } + } + + private static saveToken(userInfo) { + let tokens = JSON.parse(readFileSync(AutoreportBase.getRoot() + "/data/tokens.json").toString()); + let token = randomBytes(64).toString("base64url"); + + tokens[token] = { + date: new Date().getTime(), + info: userInfo + }; + writeFileSync(AutoreportBase.getRoot() + "/data/tokens.json", JSON.stringify(tokens)); + + return token; + } + + public static startFlow(req, res) { + res.redirect(`${AutoreportBase.config.authentication.server}/api/rest/oauth2/auth?client_id=${AutoreportBase.config.authentication.id}&response_type=code&redirect_uri=${AutoreportBase.config.authentication.redirect}&scope=Hub&request_credentials=default&access_type=offline`); + } + + public static checkAuthentication(req) { + let _cookies = req.headers.cookie ?? ""; + let _tokens = _cookies.split(";").map(i => i.trim().split("=")).filter(i => i[0] === "AutoreportToken"); + let __tokens = _tokens[0] ?? []; + let token = __tokens[1] ?? null; + + return !(!token || !this.getToken(token)); + } + + public static async callback(req, res) { + if (!req.query.code) { + res.redirect("/"); + } + + let token = (await axios.post(`${AutoreportBase.config.authentication.server}/api/rest/oauth2/token`, `grant_type=authorization_code&redirect_uri=${encodeURIComponent(AutoreportBase.config.authentication.redirect)}&code=${req.query.code}`, { + headers: { + 'Authorization': `Basic ${Buffer.from(`${AutoreportBase.config.authentication.id}:${AutoreportBase.config.authentication.secret}`).toString("base64")}`, + 'Accept': "application/json", + 'Content-Type': "application/x-www-form-urlencoded" + } + })).data.access_token; + + let userInfo = (await axios.get(`${AutoreportBase.config.authentication.server}/api/rest/users/me`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': "application/json" + } + })).data; + + let userToken = Authentication.saveToken(userInfo); + res.cookie('AutoreportToken', userToken, { maxAge: 31000000, httpOnly: true }); + res.redirect("/"); + } + + public static testEndpoint(req, res) { + if (Authentication.checkAuthentication(req)) { + res.send("Authenticated"); + } else { + res.send("NOT authenticated"); + } + } + + public static protectedAPI(req, res, next) { + if ([null, undefined, ""].includes(req.get("authorization"))) { + return res.status(401).json({ + code: 401, + message: "Please provide an Authorization header." + }); + } + + if (req.get("authorization") !== AutoreportBase.config.api.token) { + return res.status(403).json( { + code: 403, + message: "You do not have permission to use this endpoint." + }); + } + + next(); + } +} \ No newline at end of file diff --git a/src/core/Autoreport.ts b/src/core/Autoreport.ts new file mode 100644 index 0000000..756c19f --- /dev/null +++ b/src/core/Autoreport.ts @@ -0,0 +1,74 @@ +import AutoreportBase from "./AutoreportBase"; +import express from "express"; +import Authentication from "./Authentication"; +import {Report, ReportError, ReportResponse, ReportSeverity} from "../types/Report"; +import {readFileSync} from "node:fs"; +import UUID from "../types/UUID"; +import * as API from "./API"; + +export default class Autoreport extends AutoreportBase { + constructor() { + const app = express(); + + app.use(express.static(AutoreportBase.getRoot() + "/assets")); + app.use(express.json()); + app.set('view engine', 'ejs'); + + app.get("/", (req, res) => { + if (!Authentication.checkAuthentication(req)) return res.redirect("/oauth2/start"); + + let reports: Report[] = JSON.parse(readFileSync("./data/reports.json").toString()) + + res.render("index", { reports }); + }); + + app.get("/oauth2/start", (req, res) => { + Authentication.startFlow(req, res); + }); + + app.get("/oauth2/callback", (req, res) => { + Authentication.callback(req, res); + }); + + app.post("/api/reports/refresh", (req, res) => { + API.ReportEndpoint.refresh(req, res); + }) + + // API methods (public) + app.get("/api/report", (req, res) => { + API.ReportEndpoint.get(req, res); + }); + + app.get("/api/reports", (req, res) => { + API.ReportEndpoint.getMany(req, res); + }); + + // API methods (private, need privateauth.equestria.dev authentication) + app.patch("/api/report", (req, res) => { + API.ReportEndpoint.patch(req, res); + }); + + // API methods (private, need token authentication) + app.post("/api/report", Authentication.protectedAPI, (req, res) => { + API.ReportEndpoint.post(req, res); + }); + + app.get("/oauth2/test", (req, res) => { + Authentication.testEndpoint(req, res); + }) + + app.listen(34512); + + // To setup port forwarding: + // - Ctrl+Shift+K/Cmd+Shift+K + // - "Forward port" + // - "34512:34512" + // - You can now access it from http://localhost:34512 + + console.log("Listening!"); + console.log(" - Public URL: http://localhost:34512"); + console.log(" - OAuth2 test: http://localhost:34512/oauth2/start"); + + super(); + } +} \ No newline at end of file diff --git a/src/core/AutoreportBase.ts b/src/core/AutoreportBase.ts new file mode 100644 index 0000000..1129d35 --- /dev/null +++ b/src/core/AutoreportBase.ts @@ -0,0 +1,10 @@ +import YAML from "yaml"; +import { readFileSync } from "node:fs"; + +export default class AutoreportBase { + public static getRoot() { + return __dirname + "/../../"; + } + + public static config = YAML.parse(readFileSync(AutoreportBase.getRoot() + "/config.yml").toString()); +} \ No newline at end of file diff --git a/src/core/Notification.ts b/src/core/Notification.ts new file mode 100644 index 0000000..0ab838d --- /dev/null +++ b/src/core/Notification.ts @@ -0,0 +1,41 @@ +import AutoreportBase from "./AutoreportBase"; +import {Report, ReportSeverity} from "../types/Report"; + +export default class Notification extends AutoreportBase { + service: string; + report: Report; + + constructor(report: Report) { + super(); + this.service = report.service; + this.report = report; + } + + public async send() { + let message: string; + + switch (this.report.severity) { + case ReportSeverity.Low: message = "Service " + this.service + " has encountered a minor error"; break; + case ReportSeverity.Medium: message = "Service " + this.service + " has encountered an error"; break; + case ReportSeverity.High: message = "Service " + this.service + " has encountered a major error"; break; + case ReportSeverity.Critical: message = "Service " + this.service + " has encountered a critical error"; break; + case ReportSeverity.Fatal: message = "Service " + this.service + " has encountered a fatal error"; break; + } + + await fetch("https://" + AutoreportBase.config.notifications.server, { + method: "POST", + body: JSON.stringify({ + topic: AutoreportBase.config.notifications.topic, + message, + title: "A service encountered an error", + tags: [ "crash", "service:" + this.service ], + priority: 3, + actions: [{ "action": "view", "label": "Open report", "url": AutoreportBase.config.base + "/#/report/" + this.report.id }] + }), + headers: { + "Authorization": "Basic " + Buffer.from(AutoreportBase.config.notifications.user + ":" + AutoreportBase.config.notifications.password).toString("base64"), + "Content-Type": "application/json" + } + }) + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b925d2f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,32 @@ +import Autoreport from "./core/Autoreport"; + +new Autoreport(); + +// you need to add topic "crashes" to ntfy (make sure you select use another server) +// mhm! +// done +// so when we'll test it it should work +// mhm! + + +// don't we need ejs? +// probably, installing it +// epic hehe +// installed + +// is there a module you use to interface with the notifications? +// it's just HTTP requests +// okay, so here's the requirements +// express, superagent, why not axios instead? +// sure hehe +// installed all that +// epic, server time +// oh wait we need dotenv to store the api token in .env +// why not just use a config file +// don't want to push the config file to the repo +// add it to .gitignore +// which i currently cannot see +// because it doesn't exist -c- +// oh +// trying to not have a coughing fit while programming rn +// .c. \ No newline at end of file diff --git a/src/types/Report.ts b/src/types/Report.ts new file mode 100644 index 0000000..c4ce342 --- /dev/null +++ b/src/types/Report.ts @@ -0,0 +1,68 @@ +import AutoreportBase from "../core/AutoreportBase"; +import UUID from "./UUID"; + +export interface Report extends AutoreportBase { + id: UUID; + service: string; + time: Date; + severity: ReportSeverity; + response: ReportResponse; + error: ReportError; + systemInfo: SystemInformation; +} + +export interface ReportError extends AutoreportBase { + message: string; + stacktrace: string; + logs?: string; + potentialFix: string|null; +} + +export interface SystemInformation extends SystemProcess { + systemUptime: number; + os: string; +} + +export interface SystemProcess extends AutoreportBase { + pid: number; + user: string; + executable: string; + memoryUsed: number; + cpuTime: number; + uptime: number; +} + +export enum ReportSeverity { + // Low - something went wrong where it shouldn't, but it's not that bad + // Medium - something went wrong where it shouldn't, should be looked into + // High - something went wrong where it shouldn't, must be looked into + // Critical - something went wrong where it really shouldn't of, must be looked into right that moment + // Fatal - something went so wrong that the service will not recover without help, must be looked into right that moment + + Low, + Medium, + High, + Critical, + Fatal +} + +export enum ReportResponse { + // None - not been responded to yet + // Acknowledged - report has been acknowledged + // Ignored - report has been ignored + // STFU - "Shut The Fuck Up", we're already aware of this please stop telling us + + None, + Acknowledged, + Ignored, + STFU +} + +export enum ProcessState { + Stopped, + Running, + Starting, + Idle, + Blocked, + Stopping +} \ No newline at end of file diff --git a/src/types/UUID.ts b/src/types/UUID.ts new file mode 100644 index 0000000..4a6c1eb --- /dev/null +++ b/src/types/UUID.ts @@ -0,0 +1,7 @@ +import uuid from "uuid-v4"; + +export default class UUID extends String { + constructor() { + super(uuid()); + } +} \ No newline at end of file -- cgit