mirror of
https://github.com/PCSX2/web-api.git
synced 2026-01-31 01:15:16 +01:00
move over existing code into new repository
This commit is contained in:
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
tsconfig.json
|
||||
14
.eslintrc.json
Normal file
14
.eslintrc.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 13,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {}
|
||||
}
|
||||
24
.github/workflows/build-backend.yml
vendored
Normal file
24
.github/workflows/build-backend.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Build Backend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build App
|
||||
run: npm run build
|
||||
27
.github/workflows/lint-backend.yml
vendored
Normal file
27
.github/workflows/lint-backend.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Linting & Formatting
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check Formatting
|
||||
run: npx prettier --check ./
|
||||
|
||||
- name: Check Linting
|
||||
run: npx eslint ./
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
certs/
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
tsconfig.json
|
||||
0
.prettierrc.json
Normal file
0
.prettierrc.json
Normal file
77
controllers/GithubController.ts
Normal file
77
controllers/GithubController.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ReleaseCache } from "../models/ReleaseCache";
|
||||
import { LogFactory } from "../utils/LogFactory";
|
||||
import { Request, Response } from "express";
|
||||
import crypto from "crypto";
|
||||
|
||||
export class GithubController {
|
||||
private releaseCache: ReleaseCache;
|
||||
private log = new LogFactory("gh-listener").getLogger();
|
||||
private readonly webhookSecret;
|
||||
|
||||
constructor(releaseCache: ReleaseCache) {
|
||||
this.releaseCache = releaseCache;
|
||||
const secret = process.env.GH_WEBHOOK_SECRET;
|
||||
if (secret == undefined) {
|
||||
this.log.error("GH_WEBHOOK_SECRET isn't set. Aborting");
|
||||
throw new Error("GH_WEBHOOK_SECRET isn't set. Aborting");
|
||||
} else {
|
||||
this.webhookSecret = secret;
|
||||
}
|
||||
}
|
||||
|
||||
// in the future, might change it from instead of listing all releases it just uses the content of the webhook to evict the cache
|
||||
// for the foreseeable future though, this is fine
|
||||
webhookHandler(req: Request, resp: Response) {
|
||||
const cid = uuidv4();
|
||||
this.log.info("Received request", req.headers);
|
||||
const ghDigestRaw = req.header("x-hub-signature-256");
|
||||
if (ghDigestRaw == undefined) {
|
||||
resp.send(403);
|
||||
return;
|
||||
}
|
||||
const ghDigest = Buffer.from(ghDigestRaw, "utf8");
|
||||
const digest = Buffer.from(
|
||||
`sha256=${crypto
|
||||
.createHmac("sha256", this.webhookSecret)
|
||||
.update(JSON.stringify(req.body))
|
||||
.digest("hex")}`,
|
||||
"utf8"
|
||||
);
|
||||
if (crypto.timingSafeEqual(digest, ghDigest)) {
|
||||
// Valid webhook from github, proceed
|
||||
const body = req.body;
|
||||
if (
|
||||
"action" in body &&
|
||||
body.action == "published" &&
|
||||
"release" in body &&
|
||||
body.release.draft == true
|
||||
) {
|
||||
// Release event
|
||||
if (
|
||||
"repository" in body &&
|
||||
body.repository.full_name == "PCSX2/pcsx2"
|
||||
) {
|
||||
this.releaseCache.refreshReleaseCache(cid);
|
||||
} else if (
|
||||
"repository" in body &&
|
||||
body.repository.full_name == "PCSX2/archive"
|
||||
) {
|
||||
this.releaseCache.refreshLegacyReleaseCache(cid);
|
||||
}
|
||||
} else if (
|
||||
"action" in body &&
|
||||
body.action == "completed" &&
|
||||
"check_suite" in body &&
|
||||
body.check_suite.status == "completed" &&
|
||||
body.check_suite.conclusion == "success"
|
||||
) {
|
||||
this.releaseCache.refreshPullRequestBuildCache(cid);
|
||||
}
|
||||
} else {
|
||||
resp.send(403);
|
||||
return;
|
||||
}
|
||||
resp.send(204);
|
||||
}
|
||||
}
|
||||
116
controllers/ReleaseCacheControllerV1.ts
Normal file
116
controllers/ReleaseCacheControllerV1.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ReleaseCache } from "../models/ReleaseCache";
|
||||
import { LogFactory } from "../utils/LogFactory";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
export class ReleaseCacheControllerV1 {
|
||||
private releaseCache: ReleaseCache;
|
||||
private log = new LogFactory("release-cache").getLogger();
|
||||
private maxPageSize = 100;
|
||||
|
||||
constructor(releaseCache: ReleaseCache) {
|
||||
this.releaseCache = releaseCache;
|
||||
}
|
||||
|
||||
getLatestReleasesAndPullRequests(req: Request, resp: Response) {
|
||||
const cid = uuidv4();
|
||||
this.log.info("Fetching latest releases");
|
||||
resp.status(200).send(this.releaseCache.getLatestReleases(cid));
|
||||
}
|
||||
|
||||
getStableReleases(req: Request, resp: Response) {
|
||||
const cid = uuidv4();
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const pageSize = Number(req.query.pageSize) || 30;
|
||||
if (offset < 0) {
|
||||
this.log.info("API error occurred - invalid offset", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("Invalid offset value");
|
||||
return;
|
||||
}
|
||||
if (pageSize > this.maxPageSize) {
|
||||
this.log.info("API error occurred - pageSize exceeded", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("pageSize exceeded maximum allowed '100'");
|
||||
return;
|
||||
}
|
||||
this.log.info("Fetching stable releases", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp
|
||||
.status(200)
|
||||
.send(this.releaseCache.getStableReleases(cid, offset, pageSize));
|
||||
}
|
||||
|
||||
getNightlyReleases(req: Request, resp: Response) {
|
||||
const cid = uuidv4();
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const pageSize = Number(req.query.pageSize) || 30;
|
||||
if (offset < 0) {
|
||||
this.log.info("API error occurred - invalid offset", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("Invalid offset value");
|
||||
return;
|
||||
}
|
||||
if (pageSize > this.maxPageSize) {
|
||||
this.log.info("API error occurred - pageSize exceeded", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("pageSize exceeded maximum allowed '100'");
|
||||
return;
|
||||
}
|
||||
this.log.info("Fetching nightly releases", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp
|
||||
.status(200)
|
||||
.send(this.releaseCache.getNightlyReleases(cid, offset, pageSize));
|
||||
}
|
||||
|
||||
getPullRequests(req: Request, resp: Response) {
|
||||
const cid = uuidv4();
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const pageSize = Number(req.query.pageSize) || 30;
|
||||
if (offset < 0) {
|
||||
this.log.info("API error occurred - invalid offset", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("Invalid offset value");
|
||||
return;
|
||||
}
|
||||
if (pageSize > this.maxPageSize) {
|
||||
this.log.info("API error occurred - pageSize exceeded", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp.status(400).send("pageSize exceeded maximum allowed '100'");
|
||||
return;
|
||||
}
|
||||
this.log.info("Fetching current pull requests", {
|
||||
cid: cid,
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
resp
|
||||
.status(200)
|
||||
.send(this.releaseCache.getPullRequestBuilds(cid, offset, pageSize));
|
||||
}
|
||||
}
|
||||
91
index.ts
Normal file
91
index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { ReleaseCache } from "./models/ReleaseCache";
|
||||
import { exit } from "process";
|
||||
import { LogFactory } from "./utils/LogFactory";
|
||||
import { RoutesV1 } from "./routes/RoutesV1";
|
||||
import fs from "fs";
|
||||
import https from "https";
|
||||
|
||||
const log = new LogFactory("app").getLogger();
|
||||
|
||||
const devEnv = process.env.NODE_ENV !== "production";
|
||||
|
||||
const ghWebhookSecret = process.env.GH_WEBHOOK_SECRET;
|
||||
if (ghWebhookSecret == undefined) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const corsOptions = {
|
||||
origin: devEnv ? "http://localhost:8080" : process.env.CORS_FRONTEND_URL,
|
||||
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
const app = express();
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
|
||||
// Enable if you're behind a reverse proxy (Heroku, Bluemix, AWS ELB, Nginx, etc)
|
||||
// see https://expressjs.com/en/guide/behind-proxies.html
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1 minutes
|
||||
max: 30, // limit each IP to 30 requests per minute
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
|
||||
onLimitReached: function (req: any, res: any, options: any) {
|
||||
log.warn("rate limit hit", {
|
||||
ip: req.ip,
|
||||
url: req.url,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// apply to all requests
|
||||
app.use(limiter);
|
||||
|
||||
const releaseCache = new ReleaseCache();
|
||||
|
||||
(async function () {
|
||||
const cid = uuidv4();
|
||||
log.info("Initializing Server Cache", { cid: cid });
|
||||
await releaseCache.refreshReleaseCache(cid);
|
||||
await releaseCache.refreshPullRequestBuildCache(cid);
|
||||
// build up legacy releases in the background
|
||||
// releaseCache.refreshLegacyReleaseCache(cid);
|
||||
log.info("Initializing Server Cache", { cid: cid });
|
||||
})();
|
||||
|
||||
// Init Routes
|
||||
const v1Router = new RoutesV1(releaseCache);
|
||||
app.use("/v1", v1Router.router);
|
||||
|
||||
// Default Route
|
||||
app.use(function (req, res) {
|
||||
log.warn("invalid route accessed", {
|
||||
url: req.originalUrl,
|
||||
});
|
||||
res.send(404);
|
||||
});
|
||||
|
||||
if (!devEnv) {
|
||||
const key = fs.readFileSync(__dirname + "/../certs/ssl.key");
|
||||
const cert = fs.readFileSync(__dirname + "/../certs/ssl.crt");
|
||||
const sslOptions = { key: key, cert: cert };
|
||||
const httpsServer = https.createServer(sslOptions, app);
|
||||
httpsServer.listen(Number(process.env.PORT), async () => {
|
||||
log.info("Cache Initialized, Serving...", {
|
||||
port: Number(process.env.PORT),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
app.listen(Number(process.env.PORT), async () => {
|
||||
log.info("Cache Initialized, Serving...", {
|
||||
port: Number(process.env.PORT),
|
||||
});
|
||||
});
|
||||
}
|
||||
517
models/ReleaseCache.ts
Normal file
517
models/ReleaseCache.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import { retry } from "@octokit/plugin-retry";
|
||||
import striptags from "striptags";
|
||||
import * as path from "path";
|
||||
import { LogFactory } from "../utils/LogFactory";
|
||||
|
||||
enum ReleaseType {
|
||||
Stable = 1,
|
||||
Nightly,
|
||||
PullRequest,
|
||||
}
|
||||
|
||||
enum ReleasePlatform {
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
MacOS = "MacOS",
|
||||
}
|
||||
|
||||
class ReleaseAsset {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly displayName: string,
|
||||
readonly additionalTags: string[], // things like 32bit, AppImage, distro names, etc
|
||||
readonly downloadCount: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class Release {
|
||||
constructor(
|
||||
readonly version: string,
|
||||
readonly url: string,
|
||||
readonly semverMajor: number,
|
||||
readonly semverMinor: number,
|
||||
readonly semverPatch: number,
|
||||
readonly description: string | undefined | null,
|
||||
readonly assets: Record<ReleasePlatform, ReleaseAsset[]>,
|
||||
readonly type: ReleaseType,
|
||||
readonly prerelease: boolean,
|
||||
readonly createdAt: Date,
|
||||
readonly publishedAt: Date | undefined | null
|
||||
) {}
|
||||
}
|
||||
|
||||
class PullRequest {
|
||||
constructor(
|
||||
readonly number: number,
|
||||
readonly link: string,
|
||||
readonly githubUser: string,
|
||||
readonly updatedAt: Date,
|
||||
readonly body: string,
|
||||
readonly title: string,
|
||||
readonly additions: number,
|
||||
readonly deletions: number
|
||||
) {}
|
||||
}
|
||||
|
||||
Octokit.plugin(throttling);
|
||||
Octokit.plugin(retry);
|
||||
|
||||
const log = new LogFactory("release-cache").getLogger();
|
||||
|
||||
const semverRegex = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GH_TOKEN,
|
||||
userAgent: "PCSX2/PCSX2.github.io",
|
||||
log: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
debug: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
info: () => {},
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter: any, options: any) => {
|
||||
log.warn(
|
||||
`Request quota exhausted for request ${options.method} ${options.url}`
|
||||
);
|
||||
|
||||
// Retry twice after hitting a rate limit error, then give up
|
||||
if (options.request.retryCount <= 2) {
|
||||
log.warn(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onAbuseLimit: (retryAfter: any, options: any) => {
|
||||
// does not retry, only logs a warning
|
||||
log.warn(`Abuse detected for request ${options.method} ${options.url}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// NOTE - Depends on asset naming convention:
|
||||
// pcsx2-<version>-windows-<arch>-<additional tags>.whatever
|
||||
// In the case of macOS:
|
||||
// pcsx2-<version>-macOS-<macOS version (ie. Mojave)>-<additional tags>.whatever
|
||||
// In the case of linux:
|
||||
// pcsx2-<version>-linux-<distro OR appimage>-<arch>-<additional tags>.whatever
|
||||
function gatherReleaseAssets(
|
||||
release: any,
|
||||
legacy: boolean
|
||||
): Record<ReleasePlatform, ReleaseAsset[]> {
|
||||
const assets: Record<ReleasePlatform, ReleaseAsset[]> = {
|
||||
Windows: [],
|
||||
Linux: [],
|
||||
MacOS: [],
|
||||
};
|
||||
|
||||
if (!("assets" in release)) {
|
||||
return assets;
|
||||
}
|
||||
|
||||
// All legacy builds are only windows 32 bit, we'll find it to confirm
|
||||
if (legacy) {
|
||||
for (let i = 0; i < release.assets.length; i++) {
|
||||
const asset = release.assets[i];
|
||||
if (asset.name.includes("windows")) {
|
||||
assets.Windows.push(
|
||||
new ReleaseAsset(
|
||||
asset.browser_download_url,
|
||||
`Windows 32bit`,
|
||||
[],
|
||||
asset.download_count
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
for (let i = 0; i < release.assets.length; i++) {
|
||||
const asset = release.assets[i];
|
||||
const assetComponents = path.parse(asset.name).name.split("-");
|
||||
if (assetComponents.length < 4) {
|
||||
log.warn("invalid release asset naming", {
|
||||
isLegacy: legacy,
|
||||
semver: release.tag_name,
|
||||
assetName: asset.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const platform = assetComponents[2].toLowerCase();
|
||||
if (platform == "windows") {
|
||||
const arch = assetComponents[3];
|
||||
const additionalTags = assetComponents.slice(4);
|
||||
assets.Windows.push(
|
||||
new ReleaseAsset(
|
||||
asset.browser_download_url,
|
||||
`Windows ${arch}`,
|
||||
additionalTags,
|
||||
asset.download_count
|
||||
)
|
||||
);
|
||||
} else if (assetComponents[2].toLowerCase() == "linux") {
|
||||
const distroOrAppImage = assetComponents[3];
|
||||
const additionalTags = assetComponents.slice(4);
|
||||
assets.Linux.push(
|
||||
new ReleaseAsset(
|
||||
asset.browser_download_url,
|
||||
`Linux ${distroOrAppImage}`,
|
||||
additionalTags,
|
||||
asset.download_count
|
||||
)
|
||||
);
|
||||
} else if (assetComponents[2].toLowerCase() == "macos") {
|
||||
const osxVersion = assetComponents[3];
|
||||
const additionalTags = assetComponents.slice(4);
|
||||
assets.MacOS.push(
|
||||
new ReleaseAsset(
|
||||
asset.browser_download_url,
|
||||
`MacOS ${osxVersion}`,
|
||||
additionalTags,
|
||||
asset.download_count
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
export class ReleaseCache {
|
||||
private stableReleases: Release[] = [];
|
||||
private combinedNightlyReleases: Release[] = [];
|
||||
private nightlyReleases: Release[] = [];
|
||||
private legacyNightlyReleases: Release[] = [];
|
||||
private pullRequestBuilds: PullRequest[] = [];
|
||||
|
||||
private initialized: boolean;
|
||||
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
public isInitialized(cid: string): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
public async refreshReleaseCache(cid: string): Promise<void> {
|
||||
log.info("refreshing main release cache", { cid: cid, cacheType: "main" });
|
||||
const releases = await octokit.paginate(octokit.rest.repos.listReleases, {
|
||||
owner: "PCSX2",
|
||||
repo: "pcsx2",
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const newStableReleases: Release[] = [];
|
||||
const newNightlyReleases: Release[] = [];
|
||||
for (let i = 0; i < releases.length; i++) {
|
||||
const release = releases[i];
|
||||
if (release.draft) {
|
||||
continue;
|
||||
}
|
||||
const releaseAssets = gatherReleaseAssets(release, false);
|
||||
const semverGroups = release.tag_name.match(semverRegex);
|
||||
if (semverGroups != null && semverGroups.length == 4) {
|
||||
const newRelease = new Release(
|
||||
release.tag_name,
|
||||
release.html_url,
|
||||
Number(semverGroups[1]),
|
||||
Number(semverGroups[2]),
|
||||
Number(semverGroups[3]),
|
||||
release.body == undefined || release.body == null
|
||||
? release.body
|
||||
: striptags(release.body),
|
||||
releaseAssets,
|
||||
release.prerelease ? ReleaseType.Nightly : ReleaseType.Stable,
|
||||
release.prerelease,
|
||||
new Date(release.created_at),
|
||||
release.published_at == null
|
||||
? undefined
|
||||
: new Date(release.published_at)
|
||||
);
|
||||
if (newRelease.type == ReleaseType.Nightly) {
|
||||
newNightlyReleases.push(newRelease);
|
||||
} else {
|
||||
newStableReleases.push(newRelease);
|
||||
}
|
||||
} else {
|
||||
log.warn("invalid semantic version", {
|
||||
cid: cid,
|
||||
cacheType: "main",
|
||||
semver: release.tag_name,
|
||||
matches: semverGroups,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.stableReleases = newStableReleases;
|
||||
// Releases returned from github are not sorted by semantic version, but by published date -- this ensures consistency
|
||||
this.stableReleases.sort(
|
||||
(a, b) =>
|
||||
b.semverMajor - a.semverMajor ||
|
||||
b.semverMinor - a.semverMinor ||
|
||||
b.semverPatch - a.semverPatch
|
||||
);
|
||||
this.nightlyReleases = newNightlyReleases;
|
||||
this.nightlyReleases.sort(
|
||||
(a, b) =>
|
||||
b.semverMajor - a.semverMajor ||
|
||||
b.semverMinor - a.semverMinor ||
|
||||
b.semverPatch - a.semverPatch
|
||||
);
|
||||
this.combinedNightlyReleases = this.nightlyReleases.concat(
|
||||
this.legacyNightlyReleases
|
||||
);
|
||||
log.info("main release cache refreshed", { cid: cid, cacheType: "main" });
|
||||
}
|
||||
|
||||
public async refreshLegacyReleaseCache(cid: string): Promise<void> {
|
||||
log.info("refreshing legacy release cache", {
|
||||
cid: cid,
|
||||
cacheType: "legacy",
|
||||
});
|
||||
// First pull down the legacy releases, these are OLD nightlys
|
||||
const legacyReleases = await octokit.paginate(
|
||||
octokit.rest.repos.listReleases,
|
||||
{
|
||||
owner: "PCSX2",
|
||||
repo: "archive",
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const newLegacyReleases: Release[] = [];
|
||||
for (let i = 0; i < legacyReleases.length; i++) {
|
||||
const release = legacyReleases[i];
|
||||
if (release.draft) {
|
||||
continue;
|
||||
}
|
||||
const releaseAssets = gatherReleaseAssets(release, true);
|
||||
const semverGroups = release.tag_name.match(semverRegex);
|
||||
if (semverGroups != null && semverGroups.length == 4) {
|
||||
newLegacyReleases.push(
|
||||
new Release(
|
||||
release.tag_name,
|
||||
release.html_url,
|
||||
Number(semverGroups[1]),
|
||||
Number(semverGroups[2]),
|
||||
Number(semverGroups[3]),
|
||||
release.body == undefined || release.body == null
|
||||
? release.body
|
||||
: striptags(release.body),
|
||||
releaseAssets,
|
||||
ReleaseType.Nightly,
|
||||
release.prerelease,
|
||||
new Date(release.created_at),
|
||||
release.published_at == null
|
||||
? undefined
|
||||
: new Date(release.published_at)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
log.warn("invalid semantic version", {
|
||||
cid: cid,
|
||||
cacheType: "main",
|
||||
semver: release.tag_name,
|
||||
matches: semverGroups,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.legacyNightlyReleases = newLegacyReleases;
|
||||
this.legacyNightlyReleases.sort(
|
||||
(a, b) =>
|
||||
b.semverMajor - a.semverMajor ||
|
||||
b.semverMinor - a.semverMinor ||
|
||||
b.semverPatch - a.semverPatch
|
||||
);
|
||||
this.combinedNightlyReleases = this.nightlyReleases.concat(
|
||||
this.legacyNightlyReleases
|
||||
);
|
||||
log.info("legacy release cache refreshed", {
|
||||
cid: cid,
|
||||
cacheType: "legacy",
|
||||
});
|
||||
}
|
||||
|
||||
private async grabPullRequestInfo(cursor: string | null): Promise<any> {
|
||||
const response: any = await octokit.graphql(
|
||||
`
|
||||
fragment pr on PullRequest {
|
||||
number
|
||||
author {
|
||||
login
|
||||
}
|
||||
updatedAt
|
||||
body
|
||||
title
|
||||
additions
|
||||
deletions
|
||||
isDraft
|
||||
permalink
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query ($owner: String!, $repo: String!, $states: [PullRequestState!], $baseRefName: String, $headRefName: String, $orderField: IssueOrderField = UPDATED_AT, $orderDirection: OrderDirection = DESC, $perPage: Int!, $endCursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(states: $states, orderBy: {field: $orderField, direction: $orderDirection}, baseRefName: $baseRefName, headRefName: $headRefName, first: $perPage, after: $endCursor) {
|
||||
nodes {
|
||||
...pr
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
owner: "PCSX2",
|
||||
repo: "pcsx2",
|
||||
states: "OPEN",
|
||||
baseRefName: "master",
|
||||
perPage: 100,
|
||||
endCursor: cursor,
|
||||
}
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async refreshPullRequestBuildCache(cid: string): Promise<void> {
|
||||
log.info("refreshing main release cache", {
|
||||
cid: cid,
|
||||
cacheType: "pullRequests",
|
||||
});
|
||||
|
||||
try {
|
||||
let paginate = true;
|
||||
let cursor: string | null = null;
|
||||
const newPullRequestCache: PullRequest[] = [];
|
||||
while (paginate) {
|
||||
const resp: any = await this.grabPullRequestInfo(cursor);
|
||||
if (resp.repository.pullRequests.pageInfo.hasNextPage) {
|
||||
cursor = resp.repository.pullRequests.pageInfo.endCursor;
|
||||
} else {
|
||||
paginate = false;
|
||||
}
|
||||
for (let i = 0; i < resp.repository.pullRequests.nodes.length; i++) {
|
||||
// We only care about non-draft / successfully building PRs
|
||||
const pr = resp.repository.pullRequests.nodes[i];
|
||||
if (pr.isDraft) {
|
||||
continue;
|
||||
}
|
||||
if (pr.commits.nodes[0].commit.statusCheckRollup.state == "SUCCESS") {
|
||||
newPullRequestCache.push(
|
||||
new PullRequest(
|
||||
pr.number,
|
||||
pr.permalink,
|
||||
pr.author.login,
|
||||
new Date(pr.updatedAt),
|
||||
pr.body == undefined || pr.body == null
|
||||
? pr.body
|
||||
: striptags(pr.body),
|
||||
pr.title == undefined || pr.title == null
|
||||
? pr.title
|
||||
: striptags(pr.title),
|
||||
pr.additions,
|
||||
pr.deletions
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pullRequestBuilds = newPullRequestCache;
|
||||
log.info("finished refreshing main release cache", {
|
||||
cid: cid,
|
||||
cacheType: "pullRequests",
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("error occurred when refreshing main release cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the first page of each release type in a single response
|
||||
public getLatestReleases(cid: string) {
|
||||
return {
|
||||
stableReleases: this.getStableReleases(cid, 0, 30),
|
||||
nightlyReleases: this.getNightlyReleases(cid, 0, 30),
|
||||
pullRequestBuilds: this.getPullRequestBuilds(cid, 0, 30),
|
||||
};
|
||||
}
|
||||
|
||||
public getStableReleases(cid: string, offset: number, pageSize: number) {
|
||||
if (offset >= this.stableReleases.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ret = [];
|
||||
for (
|
||||
let i = 0;
|
||||
i < pageSize && i + offset < this.stableReleases.length;
|
||||
i++
|
||||
) {
|
||||
ret.push(this.stableReleases[i + offset]);
|
||||
}
|
||||
|
||||
return {
|
||||
data: ret,
|
||||
pageInfo: {
|
||||
total: this.stableReleases.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public getNightlyReleases(cid: string, offset: number, pageSize: number) {
|
||||
if (offset >= this.combinedNightlyReleases.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ret = [];
|
||||
for (
|
||||
let i = 0;
|
||||
i < pageSize && i + offset < this.combinedNightlyReleases.length;
|
||||
i++
|
||||
) {
|
||||
ret.push(this.combinedNightlyReleases[i + offset]);
|
||||
}
|
||||
|
||||
return {
|
||||
data: ret,
|
||||
pageInfo: {
|
||||
total: this.combinedNightlyReleases.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public getPullRequestBuilds(cid: string, offset: number, pageSize: number) {
|
||||
if (offset >= this.pullRequestBuilds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ret = [];
|
||||
for (
|
||||
let i = 0;
|
||||
i < pageSize && i + offset < this.pullRequestBuilds.length;
|
||||
i++
|
||||
) {
|
||||
ret.push(this.pullRequestBuilds[i + offset]);
|
||||
}
|
||||
|
||||
return {
|
||||
data: ret,
|
||||
pageInfo: {
|
||||
total: this.pullRequestBuilds.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
6344
package-lock.json
generated
Normal file
6344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "pcsx2-webapi",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"serve": "ts-node -r dotenv/config index.ts",
|
||||
"build": "tsc -p .",
|
||||
"start": "node -r dotenv/config ./dist/index.js dotenv_config_path=./.env",
|
||||
"format": "npx prettier --write .",
|
||||
"lint": "npx eslint ./"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@octokit/graphql": "^4.8.0",
|
||||
"@octokit/plugin-retry": "^3.0.9",
|
||||
"@octokit/plugin-throttling": "^3.5.2",
|
||||
"@octokit/rest": "^18.11.4",
|
||||
"@octokit/types": "^6.31.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^5.4.0",
|
||||
"striptags": "^3.2.0",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"winston-loki": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^16.10.2",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.3.1",
|
||||
"@typescript-eslint/parser": "^5.3.1",
|
||||
"eslint": "^8.2.0",
|
||||
"prettier": "2.4.1",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript": "^4.4.3"
|
||||
}
|
||||
}
|
||||
46
routes/RoutesV1.ts
Normal file
46
routes/RoutesV1.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from "express";
|
||||
import { GithubController } from "../controllers/GithubController";
|
||||
import { ReleaseCacheControllerV1 } from "../controllers/ReleaseCacheControllerV1";
|
||||
import { ReleaseCache } from "../models/ReleaseCache";
|
||||
|
||||
export class RoutesV1 {
|
||||
router: express.Router;
|
||||
private githubController: GithubController;
|
||||
private releaseCacheControllerV1: ReleaseCacheControllerV1;
|
||||
|
||||
constructor(releaseCache: ReleaseCache) {
|
||||
this.router = express.Router();
|
||||
this.githubController = new GithubController(releaseCache);
|
||||
this.releaseCacheControllerV1 = new ReleaseCacheControllerV1(releaseCache);
|
||||
|
||||
// Init Routes
|
||||
this.router
|
||||
.route("/latestReleasesAndPullRequests")
|
||||
.get((req, resp) =>
|
||||
this.releaseCacheControllerV1.getLatestReleasesAndPullRequests(
|
||||
req,
|
||||
resp
|
||||
)
|
||||
);
|
||||
this.router
|
||||
.route("/stableReleases")
|
||||
.get((req, resp) =>
|
||||
this.releaseCacheControllerV1.getStableReleases(req, resp)
|
||||
);
|
||||
this.router
|
||||
.route("/nightlyReleases")
|
||||
.get((req, resp) =>
|
||||
this.releaseCacheControllerV1.getNightlyReleases(req, resp)
|
||||
);
|
||||
this.router
|
||||
.route("/pullRequests")
|
||||
.get((req, resp) =>
|
||||
this.releaseCacheControllerV1.getPullRequests(req, resp)
|
||||
);
|
||||
|
||||
// Other Routes
|
||||
this.router
|
||||
.route("/github-webhook")
|
||||
.post((req, resp) => this.githubController.webhookHandler(req, resp));
|
||||
}
|
||||
}
|
||||
100
tsconfig.json
Normal file
100
tsconfig.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files */
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
37
utils/LogFactory.ts
Normal file
37
utils/LogFactory.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import LokiTransport from "winston-loki";
|
||||
import winston from "winston";
|
||||
|
||||
export class LogFactory {
|
||||
private devEnv = process.env.NODE_ENV !== "production";
|
||||
private log: winston.Logger;
|
||||
|
||||
constructor(scope: string) {
|
||||
this.log = winston.createLogger({
|
||||
defaultMeta: { service: "pcsx2-api", scope: scope },
|
||||
});
|
||||
this.log.add(
|
||||
new winston.transports.Console({
|
||||
format: winston.format.simple(),
|
||||
})
|
||||
);
|
||||
if (!this.devEnv) {
|
||||
console.log("Piping logs to Grafana as well");
|
||||
const lokiTransport = new LokiTransport({
|
||||
host: `https://logs-prod-us-central1.grafana.net`,
|
||||
batching: true,
|
||||
basicAuth: `${process.env.GRAFANA_LOKI_USER}:${process.env.GRAFANA_LOKI_PASS}`,
|
||||
labels: { app: "pcsx2-backend", env: this.devEnv ? "dev" : "prod" },
|
||||
// remove color from log level label - loki really doesn't like it
|
||||
format: winston.format.uncolorize({
|
||||
message: false,
|
||||
raw: false,
|
||||
}),
|
||||
});
|
||||
this.log.add(lokiTransport);
|
||||
}
|
||||
}
|
||||
|
||||
public getLogger(): winston.Logger {
|
||||
return this.log;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user