Skip to content
Snippets Groups Projects
Verified Commit 5fc730b5 authored by Jonas Zohren's avatar Jonas Zohren :speech_balloon:
Browse files

Move to SvelteKit

parent ea60c14d
Branches migrate-to-oauth
No related tags found
No related merge requests found
Showing
with 619 additions and 153 deletions
<html>
<head>
<title>Protokoll-Generator FS Informatik TU Dortmund</title>
<style>
body {
margin: 1rem;
}
input {
margin-top: 0.5rem;
}
h1 {
font-size: larger;
}
</style>
</head>
<body>
<h1>Protokoll-Generator FS Informatik TU Dortmund</h1>
<ul>
<li>
Alte FSR-Protokolle:
<a href="https://oh14.de/protokolle">oh14.de/protokolle</a>.
</li>
<li>
Offene Protokoll-Merge-Requests:
<a
href="https://gitlab.fachschaften.org/tudo-fsinfo/fsr/sitzungen/-/merge_requests"
>
Siehe GitLab
</a>
</li>
</ul>
<form action="template" method="GET">
<label for="number">Sitzungsnummer:</label>
<br />
<input
type="text"
name="number"
value=""
placeholder="Sitzungsnummer eingeben"
/><br />
<input
type="submit"
name="generateTranscript"
value="Protokoll generieren"
/>
</form>
</body>
</html>
const Router = require("koa-router");
const fsp = require("fs").promises;
const { generateTranscript } = require("./generate_transcript.js");
const router = new Router();
// Temlate erzeugen
router.get("/template", async (ctx, next) => {
ctx.assert(
ctx.query.number,
400,
'Required query parameter "number" missing.'
);
const num = ctx.query.number;
const template = await generateTranscript(num);
ctx.body = template;
});
// Serve formular page
router.get("/", async (ctx, next) => {
const index = await fsp.readFile("public/index.html", { encoding: "utf8" });
ctx.body = index;
});
module.exports = router;
screenshot.png

145 KiB

require("dotenv").config();
let Sentry = null;
if (process.env.SENTRY_DSN) {
Sentry = require("@sentry/node");
Sentry.init({
dsn: process.env.SENTRY_DSN,
});
console.log("Successfully connected to Sentry for error tracking.");
} else {
// Add No-OPs for Sentry methods
Sentry = {
captureException: () => {},
captureMessage: () => {},
};
console.log("Not using Sentry for error tracking.");
}
// Check if all required envs are there
const { checkAllEnvsExist } = require("./check_envs");
const errorMessages = checkAllEnvsExist([
{
name: "GITLAB_TOKEN",
aliases: [],
description: "A valid token with sufficient access rights for GitLab",
whereToGetIt:
"https://gitlab.fachschaften.org/-/profile/personal_access_tokens",
},
{
name: "ZAMMAD_TOKEN",
aliases: [],
description: "A valid token with sufficient access rights for Zammad",
whereToGetIt: "https://zammad.oh14.de/#profile/token_access",
},
]);
if (errorMessages.length > 0) {
console.error(errorMessages.join("\n"));
console.error(
"\nThe README.md file has some information about how to use this program." +
"If you think you followed these instructions but still don't know what went wrong, feel free to ask for help."
);
process.exit(1);
}
// Make process killable via Ctrl+C if run via docker
process.on("SIGINT", function () {
process.exit();
});
async function main() {
// Test if user is authenticated against GitLab
const { hasUserGitlabFSRAccess } = require("./generate_transcript");
if (!(await hasUserGitlabFSRAccess())) {
console.error("Could not access FSR resource on GitLab.");
console.error(
"└ Your access token may be invalid / expired or does not have enough permission "
);
console.error("└ Please check the README.md");
process.exit(1);
} else {
console.debug("Successfully tested GitLab access to FSR ressources...");
}
const Koa = require("koa");
const app = new Koa();
const PORT = process.env.PORT || 3000;
const router = require("./router.js");
app.use(router.routes()).use(router.allowedMethods());
console.log("\nStarting server on port", PORT, "...");
console.log("Visit http://localhost:3000/ to generate a transcript\n");
app.listen(PORT);
}
main().then("finished...");
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="water.min.css" />
<title>FSR-Protokoll-Pad-Generator</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
/**
* Complete the second step of the oAuth2 authentication by requesting a usable token from GitLab
* @param {string} callbackCode
* @param {fetch} fetch
* @returns {Promise<[string, number]>}
*/
export async function completeGitLabAuth(callbackCode, fetch) {
const tokenUrl = new URL(env.GITLAB_BASE_URL + '/oauth/token').href;
const clientId = env.GITLAB_APP_ID;
const clientSecret = env.GITLAB_APP_SECRET;
const redirectUrl = new URL(publicEnv.PUBLIC_BASE_URL + '/auth/gitlab/callback').href;
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: callbackCode,
redirect_uri: redirectUrl,
client_id: clientId,
client_secret: clientSecret
});
const rawRes = await fetch(tokenUrl, {
method: 'POST',
body: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const res = await rawRes.json();
return [res.access_token, res.expires_in];
}
import { env as privateEnv } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
// Useful: https://darutk.medium.com/diagrams-and-movies-of-all-the-oauth-2-0-flows-194f3c3ade85
/**
* Complete the second step of the oAuth2 authentication by requesting a usable token from Zammad
* @param {string} callbackCode
* @param {fetch} fetch
* @returns {Promise<[string, number]>}
*/
export async function completeZammadAuth(callbackCode, fetch) {
const tokenUrl = new URL(privateEnv.ZAMMAD_BASE_URL + '/oauth/token').href;
const clientId = privateEnv.ZAMMAD_APP_ID;
const clientSecret = privateEnv.ZAMMAD_APP_SECRET;
const redirectUrl = new URL(publicEnv.PUBLIC_BASE_URL + '/auth/zammad/callback').href;
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: callbackCode,
redirect_uri: redirectUrl,
client_id: clientId,
client_secret: clientSecret
});
const rawRes = await fetch(tokenUrl, {
method: 'POST',
body: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const res = await rawRes.json();
/**
* Looks like this:
*
* {
* access_token: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
* token_type: 'Bearer',
* expires_in: 7200,
* refresh_token: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
* scope: 'full',
* created_at: 123456789
* }
*/
return [res.access_token, res.expires_in];
}
import { env } from '$env/dynamic/private';
import { Gitlab } from '@gitbeaker/rest';
export class GitLabAccessor {
/**
* @param {string} gitlabUrl Base URL for GitLab Instance
* @param {string} oauthToken User's oAuth2 API token
*/
constructor(gitlabUrl, oauthToken) {
this._apiClient = new Gitlab({
host: gitlabUrl,
oauthToken: oauthToken,
queryTimeout: 5 * 1000 // 5 seconds
});
}
/**
* Checks if the current user has enough permissions to access issues in the given project
* @param {number} projectId
* @returns {Promise<boolean>}
*/
async checkIssueAccess(projectId) {
try {
const res = await this._apiClient.Issues.all({
projectId: projectId,
perPage: 1
});
return Array.isArray(res);
} catch (e) {
return false;
}
}
async getCurrentUsersName() {
const currentUser = await this._apiClient.Users.showCurrentUser();
return currentUser.name;
}
async getFSRMembers() {
const members = await this._apiClient.GroupMembers.all(env.GITLAB_FSR_GROUP_ID);
return members.map((m) => m.name).sort();
}
async getToDos() {
const issues = await this._apiClient.Issues.all({
projectId: env.GITLAB_TODO_PROJECT_ID,
state: 'opened',
labels: 'To-Do',
perPage: 100
});
const todos = issues.map((t) => {
return {
title: t.title,
/** @type {string} */
assignees: t.assignees?.map((a) => a.name).join(', ') ?? '',
issue: t.iid,
issueUrl: t.web_url
};
});
return todos;
}
async getTOPs() {
const issues = await this._apiClient.Issues.all({
projectId: env.GITLAB_TOP_PROJECT_ID,
state: 'opened',
perPage: 100,
sort: 'asc'
});
const tops = issues
// Filter for TOPs
.filter((i) => i?.labels?.some((l) => l === 'TOP' || l === 'Fin-TOP'))
.map((i) => ({
isFin: i?.labels?.some((l) => l === 'Fin-TOP') ?? false,
finMarker: i?.labels?.some((l) => l === 'Fin-TOP') ?? false ? ' .fin' : '',
title: i.title,
origin: i?.assignees?.[0]?.name ?? i.author.name,
hasComments: i.user_notes_count > 0,
/** @type {{author: string; text: string; two_padded_text: string}[]} */
comments: [],
description: i.description,
issue: i.iid,
issueUrl: i.web_url
}));
return tops;
}
async getReports() {
const issues = await this._apiClient.Issues.all({
projectId: env.GITLAB_TOP_PROJECT_ID,
state: 'opened',
perPage: 100,
sort: 'asc'
});
const reports = issues
// Filter for Reports
.filter((i) => i?.labels?.some((l) => l === 'Bericht'))
.map((i) => ({
title: i.title,
origin: i?.assignees?.[0]?.name ?? i.author.name,
hasComments: i.user_notes_count > 0,
/** @type {{author: string; text: string; two_padded_text: string}[]} */
comments: [],
description: i.description,
two_padded_description: i.description.replaceAll(/\n/gm, '\n '),
issue: i.iid,
issueUrl: i.web_url
}));
return reports;
}
/** @param {number} issue_id */
async getTOPNotes(issue_id) {
const notes = await this._apiClient.IssueNotes.all(env.GITLAB_TOP_PROJECT_ID, issue_id, {
perPage: 100,
sort: 'asc'
});
return notes
.filter((n) => !n.system) // Filter out non-comment notes
.map((n) => ({
author: n.author.name,
text: n.body,
two_padded_text: n.body.replaceAll(/\n/gm, '\n ')
}));
}
}
import template from '../../template.mustache.md?raw';
import Mustache from 'mustache';
import { getTodayAsIsoString, getTodayGermanDate } from './utils';
/**
* Gather data and return the pre-filled pad content
* @param {import('./zammad').ZammadAccessor} zammadClient
* @param {import('./gitlab').GitLabAccessor} gitlabClient
* @param {number} number
* @returns {Promise<string>}
*/
export async function createPadContent(zammadClient, gitlabClient, number) {
const topsProm = gitlabClient.getTOPs();
const mailsProm = zammadClient.getRelevantMails();
const fsrMembersProm = gitlabClient.getFSRMembers();
const todosProm = gitlabClient.getToDos();
const reportsProm = gitlabClient.getReports();
const authorProm = gitlabClient.getCurrentUsersName();
const tops = await topsProm;
for (let top of tops) {
top.comments = await gitlabClient.getTOPNotes(top.issue);
}
const view = {
number: number,
present: await fsrMembersProm,
absent: ['TO_BE_FILLED_IN_OR_REMOVED'],
guests: ['TO_BE_FILLED_IN_OR_REMOVED'],
isoDate: getTodayAsIsoString(),
germanDate: getTodayGermanDate(),
tops: tops,
reports: await reportsProm,
todos: await todosProm,
mails: await mailsProm,
head: 'TO_BE_FILLED_IN',
author: await authorProm
};
return Mustache.render(template, view) + '\n';
}
/**
* Today's date in the format DD.MM.YYYY
* @returns `${number}.${number}.${number}`
*/
export function getTodayGermanDate() {
const today = new Date();
const dateStrgs = {
y: today.getFullYear(),
m: ('' + (today.getMonth() + 1)).padStart(2, '0'),
d: ('' + today.getDate()).padStart(2, '0')
};
const dateStr = `${dateStrgs.d}.${dateStrgs.m}.${dateStrgs.y}`;
return dateStr;
}
/**
* Today's date in the format YYYY-MM-DD
* @returns `${number}-${number}-${number}`
*/
export function getTodayAsIsoString() {
const today = new Date();
const dateStrgs = {
y: today.getFullYear(),
m: ('' + (today.getMonth() + 1)).padStart(2, '0'),
d: ('' + today.getDate()).padStart(2, '0')
};
const dateStr = `${dateStrgs.y}-${dateStrgs.m}-${dateStrgs.d}`;
return dateStr;
}
import { env } from '$env/dynamic/private';
export class ZammadAccessor {
/**
* @param {string} apitoken oAuth2 API token of the user
*/
constructor(apitoken) {
this._zammadToken = apitoken;
}
/**
* @param {string} url
*/
async apiCall(url) {
const rawRes = await fetch(url, {
headers: {
Authorization: 'Bearer ' + this._zammadToken,
Accept: 'application/json'
}
});
return await rawRes.json();
}
async getRelevantMails() {
const url = new URL(
env.ZAMMAD_BASE_URL +
'/api/v1/search?query=%23' +
env.ZAMMAD_MARKER_TAG +
'&sort_by=id&order_by=asc'
).href;
/** @type {{result: {type: string, id: string}[], assets: any[]}} */
const searchResults = await this.apiCall(url);
const ticketSearchResults = searchResults.result.filter((e) => {
return e.type === 'Ticket';
});
const tickets = [];
for (const searchResult of ticketSearchResults) {
const ticketUrl = new URL(env.ZAMMAD_BASE_URL + '/api/v1/tickets/' + searchResult.id).href;
/** @type {{customer_id: string, state_id: number, title: string, id: string}} */
const ticketResponse = await this.apiCall(ticketUrl);
const ticket = ticketResponse;
const userUrl = new URL(env.ZAMMAD_BASE_URL + '/api/v1/users/' + ticket.customer_id).href;
/** @type {{lastname: string | null, firstname: string}} */
const userResponse = await this.apiCall(userUrl);
const user = userResponse;
tickets.push({
from: user.firstname + (user.lastname ? ' ' + user.lastname : ''),
subject: ticket.title,
ticketId: ticket.id,
url: new URL(env.ZAMMAD_BASE_URL + '/#ticket/zoom/' + ticket.id).href
});
}
return tickets;
}
}
<script>
import { env as publicEnv } from '$env/dynamic/public';
// @ts-ignore
import { PUBLIC_BUILD_INFO, PUBLIC_URL_SOURCE_CODE } from '$env/static/public';
</script>
<slot />
<footer style="margin-top: 6rem; text-align: center">
<p>
<a href={PUBLIC_URL_SOURCE_CODE} target="_blank" rel="noreferrer"> Source Code </a>
{#if publicEnv.PUBLIC_URL_PRIVACY?.length > 0}
<a href={publicEnv.PUBLIC_URL_PRIVACY} target="_blank" rel="noreferrer noopener">
Datenschutz
</a>
{/if}
{#if publicEnv.PUBLIC_URL_IMPRINT?.length > 0}
<a href={publicEnv.PUBLIC_URL_IMPRINT} target="_blank" rel="noreferrer noopener">
Impressum
</a>
{/if}
</p>
<p>
Made with <a href="watercss.kognise.dev/">Water.css</a>,
<a href="https://kit.svelte.dev">SvelteKit</a>
</p>
{#if typeof PUBLIC_BUILD_INFO === 'string' && PUBLIC_BUILD_INFO.length > 1}
<p>
{PUBLIC_BUILD_INFO}
</p>
{/if}
</footer>
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
const zammadToken = cookies.get('zammadToken');
const gitlabToken = cookies.get('gitlabToken');
const sessionIdToken = cookies.get('started_using_unix');
if (typeof sessionIdToken !== 'string') {
cookies.set('started_using_unix', Math.floor(Date.now()).toString());
}
return {
zammadSignedIn: typeof zammadToken === 'string' && zammadToken.length > 5,
gitlabSignedIn: typeof gitlabToken === 'string' && gitlabToken.length > 5
};
}
<script>
import { goto } from '$app/navigation';
/** @type {import('./$types').PageServerData} */
export let data;
async function onZammadLoginClick() {
await goto('/auth/zammad/login');
}
async function onGitLabLoginClick() {
await goto('/auth/gitlab/login');
}
async function onGenerateClick() {
await goto('/template');
}
</script>
<h1>Protokoll-Pad-Generator</h1>
<p>
Als FSR-Protokoll-Mensch kannst du hier ein HedgeDoc-Pad für die nächste -FSR-Sitzung vorbereiten
lassen.
</p>
<fieldset>
<legend>1. Bei GitLab anmelden</legend>
<p>Du musst bei GitLab angemeldet sein, um auf die folgenden Infos zuzugreifen:</p>
<ul>
<li>Deinen Anzeige-Namen als „Protkoll-Autor:in“</li>
<li>
<abbr title="Tagesordnungspunkt">TOP</abbr>-Issues und FSR-Mitgliederliste im
<a href="https://gitlab.fachschaften.org/tudo-fsinfo/fsr/sitzungen"> Sitzungen-Projekt </a>
</li>
<li>
To-Do-Issues im
<a href="https://gitlab.fachschaften.org/tudo-fsinfo/fsr/meta"> FSR-Meta-Projekt </a>
</li>
</ul>
<p>
{#if data?.gitlabSignedIn}
<div class="success-button">
<img class="icon" src="./gitlab-logo-500.svg" alt="Zammad-Logo" />
<span> Du hast dich erfolgreich bei GitLab angemeldet</span>
</div>
{:else}
<button class="icon-button" on:click={onGitLabLoginClick}>
<img class="icon" src="./gitlab-logo-500.svg" alt="GitLab-Logo" />
<span>Bei GitLab anmelden</span>
</button>
{/if}
</p>
</fieldset>
<fieldset>
<legend>2. Bei Zammad anmelden</legend>
<p>Du musst bei Zammad angemeldet sein, um auf die folgenden Infos zuzugreifen:</p>
<ul>
<li>
Tickets mit dem tag
<a href="https://zammad.oh14.de/#ticket/view/sitzungsrelevant">
<code>#sitzungsrelevant</code>
</a>
</li>
</ul>
<p>
{#if data?.zammadSignedIn}
<div class="success-button">
<img class="icon" src="./zammad_logo_70x61.png" alt="Zammad-Logo" />
<span> Du hast dich erfolgreich bei Zammad angemeldet</span>
</div>
{:else}
<button class="icon-button" on:click={onZammadLoginClick}>
<img class="icon" src="./zammad_logo_70x61.png" alt="Zammad-Logo" />
<span>Bei Zammad anmelden</span>
</button>
{/if}
</p>
</fieldset>
<fieldset>
<legend>3. Protokoll-Pad generieren</legend>
{#if data?.zammadSignedIn && data?.gitlabSignedIn}
<p>
Du kannst nun eine Protokoll-Vorlage generieren und es dann in ein neues <a
href="https://md.fachschaften.org/auth/oauth2">HedgeDoc-Pad</a
> kopieren.
</p>
<p>
Sende den Hedgedoc-Pad-Link dann an alle FSR-Menschen auf der Sitzung, damit diese mitlesen
können.
</p>
<p>
<button class="icon-button" on:click={onGenerateClick}> 📝 Vorlage generieren </button>
</p>
{:else}
<p>
<i>Bitte logge dich bei GitLab und Zammad ein, um fortzufahren...</i>
</p>
{/if}
</fieldset>
<style>
div.success-button {
display: inline;
padding: 0.5rem;
padding-inline: 1.2rem;
margin-block: 2rem;
border: 1px solid lightgray;
background-color: rgb(156, 201, 156);
border-radius: 0.4rem;
}
@media (prefers-color-scheme: dark) {
div.success-button {
background-color: rgb(0, 80, 0);
}
}
button.icon-button {
padding-inline: 1.2rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
align-content: center;
}
img.icon {
height: 1.1rem;
aspect-ratio: 1 / 1;
}
</style>
import { completeGitLabAuth } from '$lib/oauth/gitlab';
import { redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ url, fetch, cookies }) {
const callbackCode = url.searchParams.get('code');
if (typeof callbackCode !== 'string') {
throw 'Invalid, needed code';
}
const [apiToken, expires_in] = await completeGitLabAuth(callbackCode, fetch);
cookies.set('gitlabToken', apiToken, {
secure: true,
path: '/',
maxAge: expires_in
});
throw redirect(302, `/`);
}
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import { redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export function load({ cookies }) {
const authUrl = new URL(env.GITLAB_BASE_URL + '/oauth/authorize').href;
const clientId = env.GITLAB_APP_ID;
const redirectUrl = new URL(publicEnv.PUBLIC_BASE_URL + '/auth/gitlab/callback').href;
const urlParams = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUrl,
state: cookies.get('started_using_unix') ?? '424242',
scope: 'read_api'
//client_secret: clientSecret,
});
throw redirect(302, `${authUrl}?${urlParams.toString()}`);
}
import { completeZammadAuth } from '$lib/oauth/zammad';
import { redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ url, fetch, cookies }) {
const callbackCode = url.searchParams.get('code');
if (typeof callbackCode !== 'string') {
throw 'Invalid, needed code';
}
const [apiToken, expires_in] = await completeZammadAuth(callbackCode, fetch);
cookies.set('zammadToken', apiToken, {
secure: true,
path: '/',
maxAge: expires_in
});
throw redirect(302, `/`);
}
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
/** @type {import('./$types').PageServerLoad} */
export function load({ cookies }) {
const authUrl = new URL(env.ZAMMAD_BASE_URL + '/oauth/authorize').href;
const clientId = env.ZAMMAD_APP_ID;
const redirectUrl = new URL(publicEnv.PUBLIC_BASE_URL + '/auth/zammad/callback').href;
const urlParams = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUrl,
state: cookies.get('started_using_unix') ?? '424242'
//client_secret: clientSecret,
});
throw redirect(302, `${authUrl}?${urlParams.toString()}`);
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment