Files
automata/apps/api/index.ts
2024-03-26 21:12:17 +00:00

275 lines
8.7 KiB
TypeScript

import express from 'express';
import 'dotenv/config';
import { execa } from 'execa';
import validateJsonWebhook from './plugins/validateJsonWebhook.js';
import { FeedbackRequestBody, FeedbackRequestBodySchema, Logs } from './util/types.js';
import cors from 'cors'
import rateLimit from 'express-rate-limit';
import { Webhook } from 'simple-discord-webhooks';
import { codeBlock } from './util/discordCodeBlock.js';
import db, { schema } from 'database/dist/index.js';
import jobs from './jobs.js';
import expressWs from 'express-ws';
import resolvePlugins from './util/resolvePlugins.js';
import { stripIndents } from 'common-tags';
const devMode = process.argv[2] === '--dev';
if (devMode) console.log('You\'re a developer 😎 (sorry for that emoji jumpscare)')
const cwd = process.cwd()
console.log('Setting correct permissions for Github SSH key...')
await execa('chmod', ['-R', '400', '/ssh'], { shell: true })
console.log('Permissions done!')
const { app } = expressWs(express())
app.use(express.json())
app.use(cors())
const feedbackRateLimit = rateLimit({
windowMs: 5 * 60 * 1000,
max: 10,
standardHeaders: 'draft-7',
legacyHeaders: true,
})
app.get('/', (req, res) => {
res.send('hi this is the api what did you even expect')
})
let jobRunning = false
for (const job of jobs) {
switch (job.method) {
case "POST":
app.post(job.route, async (req, res) => {
await expressCode(req, res);
});
break;
case "GET":
app.get(job.route, async (req, res) => {
await expressCode(req, res);
});
break;
}
const expressCode = async (req: express.Request, res: express.Response) => {
const pluginsResult = await resolvePlugins(job.plugins, req, res);
if (pluginsResult.includes(false)) {
return res.status(418).send({ success: false, message: "Plugins didn't pass" });
}
res.send({ success: true, message: "Command is running" });
jobRunning = true;
const parse_payload = (level: 'info' | 'error', payload: any) => ({
timestamp: new Date(),
message: payload.toString(),
level
})
const jobLogs = [] as { step: number, logs: Logs[] }[]
let jobSuccessful = true
try {
for (let i = 0; i < job.steps.length; i++) {
const steps = job.steps[i]!;
const logsToPush = [] as Logs[];
console.log(`Running step ${steps.name}`);
const cmd = execa(
"bash",
[`${cwd}/scripts/${job.stepsMainDir}/${steps.script}`],
{
cwd: steps.cwd.startsWith('repos/') ? `${cwd}/../../${steps.cwd}` : steps.cwd,
shell: true,
env: {
NT_ARGS: JSON.stringify({
...job.cmdArgs,
requestBody: req.body,
})
},
},
)
cmd.stdout!.on('data', (data) => logsToPush.push(parse_payload('info', data.toString().replace(/\n$/, ""))));
cmd.stderr!.on('data', (data) => logsToPush.push(parse_payload('error', data.toString().replace(/\n$/, ""))));
await new Promise((resolve, reject) => {
cmd.once('exit', (code) => {
if (code === 0 || !code) {
console.log(`Step ${steps.name} finished successfully`);
logsToPush.push(parse_payload('info', 'Step finished successfully'));
jobLogs.push({ step: steps.id, logs: logsToPush });
jobSuccessful = true;
resolve('nice');
} else {
console.log(`Step ${steps.name} failed with code ${code}`);
logsToPush.push(parse_payload('error', `Step failed with code ${code}`));
jobLogs.push({ step: steps.id, logs: logsToPush });
jobSuccessful = false;
reject('stop it');
}
});
});
}
} catch {}
jobRunning = false
const markdownText = stripIndents`
# Job ${job.name} finished
## Steps
${jobLogs.map((step) => {
return `### Step ${step.step}: ${job.steps.find(s => s.id === step.step)?.name}\n\`\`\`\n${step.logs.map((log) => {
return `${log.timestamp.toISOString()} - ${log.level.toUpperCase()} | ${log.message}`;
}).join('\n')}\n\`\`\``;
}).join('\n')}
`;
const createSnippet = await fetch(`${process.env.SERN_BIN_ENDPOINT}/api/create`, {
method: 'POST',
headers: {
'Authorization': process.env.SERN_BIN_KEY!,
},
body: JSON.stringify({
fileName: `run-${job.name}-${new Date().toISOString()}.md`,
description: `Logs for ${job.name} job`,
authorId: process.env.SERN_BIN_USER,
lang: "markdown",
code: markdownText
})
}).then(async res => (await res.text()).replaceAll('"', ''))
const dbWrite = (await db.insert(schema.jobsList).values({
name: job.name,
steps: job.steps,
sernbinid: createSnippet
}).returning())[0]
const webhook = new Webhook(new URL(process.env.AUTOMATA_CHANNEL_WEBHOOK!), 'Job Logs (by automata)', 'https://avatars.githubusercontent.com/u/129876409?v=4')
webhook.send(`Job #${dbWrite?.id} ${job.name} finished`, [{
color: jobSuccessful ? 0x00ff00 : 0xff0000,
description: `Job ${job.name} finished with ${jobSuccessful ? 'no errors' : 'errors'}`,
fields: [
{ name: 'Snippet', value: `[Here](https://bin.sern.dev/s/${createSnippet})`, inline: true },
],
}])
}
};
app.post('/wh/updateDocsJson', async (req, res) => {
const validate = validateJsonWebhook(req)
if (!validate) {
res.status(403).send({
success: false,
error: 'Invalid token'
})
return
}
if (req.body.action !== 'released') {
res.send({
success: true,
error: 'Token valid, but ignoring action...'
})
// this line fixed the entire instability of automata
return;
}
const cmd = execa('bash', ['scripts/updateDocsJson.sh', process.env.GHTOKEN!, process.env.EMAIL!], { shell: true })
cmd.stdout!.on('data', (data) => console.log(JSON.stringify(data.toString())))
cmd.stderr!.on('data', (data) => console.log(JSON.stringify(data.toString())))
res.send({
success: true,
message: "command is running"
})
})
app.post('/tutorial/feedback', feedbackRateLimit, async (req, res) => {
const body = req.body as FeedbackRequestBody
// validation of request body
try {
FeedbackRequestBodySchema.parse(body)
} catch {
return res
.status(400)
.send({
successful: false,
error: "You have something missing in your request!",
});
}
if (body.feedback !== "up" && body.feedback !== "down")
return res
.status(400)
.send({
successful: false,
error: "Feedback must be either 'up' or 'down'!",
});
if (!body.route.startsWith('/docs/tutorial'))
return res
.status(400)
.send({
successful: false,
error: "Are you sure you didn't modify this request?",
});
// part where turnstile token gets validated
const turnstileFormData = new URLSearchParams()
turnstileFormData.append('response', body.turnstileToken)
turnstileFormData.append('secret', process.env.TURNSTILE_SECRET_DEV!)
const turnstileResponse = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: turnstileFormData
}).then(res => res.json())
if (!turnstileResponse.success)
return res
.status(403)
.send({
successful: false,
error: "Turnstile verificaion not successful",
});
// actual database recording
const trimmedRoute = body.route.replace('/docs/tutorial', '')
const data = {
id: crypto.randomUUID(),
feedback: body.feedback,
route: trimmedRoute,
inputText: body.inputText,
}
await db.insert(schema.guideFeedback).values(data).execute()
res.send({
successful: true,
message: "Feedback recorded!",
});
// webhook
const webhook = new Webhook(new URL(process.env.DEV_WEBHOOK!), 'Guide Feedback (by automata)', 'https://avatars.githubusercontent.com/u/129876409?v=4')
// const upvoteCount = (await pb.collection('feedback').getFullList({ filter: `feedback = 'up' && route = '${body.route}'` })).length
// const downvoteCount = (await pb.collection('feedback').getFullList({ filter: `feedback = 'down' && route = '${body.route}'` })).length
const upvoteCount = (await (db.query.guideFeedback!.findMany({
where: (guideFeedback, { eq, and }) => and(
eq(guideFeedback.feedback, 'up'),
eq(guideFeedback.route, trimmedRoute)
),
})).execute()).length
const downvoteCount = (await (db.query.guideFeedback!.findMany({
where: (guideFeedback, { eq, and }) => and(
eq(guideFeedback.feedback, 'down'),
eq(guideFeedback.route, trimmedRoute)
),
})).execute()).length
webhook.send(`Feedback recorded for ${body.route}!`, [{
color: body.feedback === 'up' ? 0x00ff00 : 0xff0000,
description: body.inputText ? codeBlock(body.inputText) : undefined,
fields: [
{ name: 'Ratio', value: `${(upvoteCount / (upvoteCount + downvoteCount) * 100).toFixed(2)}%`, inline: true },
{ name: 'Upvotes', value: upvoteCount.toString(), inline: true },
{ name: 'Downvotes', value: downvoteCount.toString(), inline: true },
]
}])
})
app.get('/ping', (req, res) => {
res.send('Pong')
})
const port = Number(process.env.PORT!) || 4000
app.listen(port, '0.0.0.0', () => {
console.log(`Server listening on [::]${port}`)
})