diff --git a/server/api/auth.js b/server/api/auth.js index 1ea6ab5..9e3dccc 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -101,6 +101,9 @@ async function checkLogin(req, res, next) { res.sendStatus(401); return; } + req.locals = { + session: session.token, + }; renewSession(session); next(); } diff --git a/server/api/index.js b/server/api/index.js index c483dec..295e6ce 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -1,6 +1,60 @@ import Prisma from "@prisma/client"; const prisma = new Prisma.PrismaClient(); +import { + applyKey, + hasPermission, + listPermissions, + revokeKey, +} from "./permission.js"; + +// Get info API endpoint (/api/info) +// Returns information about the requesting session +export async function getInfo(req, res) { + const session = await prisma.session.findUnique({ + where: { + token: req.locals.session, + }, + include: { + appliedKeys: { + select: { + key: true, + permissions: true, + validUntil: true, + }, + }, + }, + }); + + res.send({ + authenticated: true, + appliedKeys: session.appliedKeys, + permissions: await listPermissions(session.token), + }); +} + +// Put and Delete key API endpoints (/api/key) +// Applies or revokes a key from the requesting user's session +export async function putKey(req, res) { + if (await applyKey(req.locals.session, req.query.key)) { + res.status(200).send(); + } else { + res.status(400).send({ + success: false, + error: "invalid_key", + message: "This key does not exist", + }); + } +} + +export async function deleteKey(req, res) { + if (await revokeKey(req.locals.session, req.query.key)) { + res.status(200).send(); + } else { + res.status(400).send(); + } +} + // Get timetable API endpoint (/api/timetable) // Returns timetable data for requested class if available export async function getTimetable(req, res) { diff --git a/server/api/permission.js b/server/api/permission.js new file mode 100644 index 0000000..f62486f --- /dev/null +++ b/server/api/permission.js @@ -0,0 +1,95 @@ +import Prisma from "@prisma/client"; +import { log } from "../logs.js"; +const prisma = new Prisma.PrismaClient(); + +export async function listPermissions(sessionToken) { + const session = await prisma.session.findUnique({ + where: { + token: sessionToken, + }, + include: { + appliedKeys: "true", + }, + }); + if (!session) return []; + + const perms = []; + for (const key of session.appliedKeys) { + if (key.validUntil && new Date() > key.validUntil) continue; + for (const perm of key.permissions) { + perms.push(perm); + } + } + return perms; +} + +export async function hasPermission(sessionToken, permission, forValue) { + let hasPermission = false; + for (const perm of await listPermissions(sessionToken)) { + if (perm == permission) hasPermission = true; + else if (perm == permission + ":" + forValue) hasPermission = true; + } + return hasPermission; +} + +export async function applyKey(sessionToken, key) { + if (!key) return false; + const foundKey = await prisma.key.findUnique({ + where: { + key, + }, + }); + if (!foundKey) return false; + + await prisma.session.update({ + where: { + token: sessionToken, + }, + data: { + appliedKeys: { + connect: { + key: foundKey.key, + }, + }, + }, + }); + return true; +} + +export async function revokeKey(sessionToken, key) { + if (!key) return false; + + await prisma.session.update({ + where: { + token: sessionToken, + }, + data: { + appliedKeys: { + disconnect: { + key: key, + }, + }, + }, + }); + return true; +} + +// Clean up expired keys every hour +setInterval(async () => { + const keys = await prisma.key.findMany(); + for (const key of keys) { + if (key.validUntil && key.validUntil < new Date()) { + log( + "API / Permissions", + `Removed expired key: ${key.key}; Permissions: ${key.permissions.join( + ", " + )}` + ); + await prisma.key.delete({ + where: { + key: key.key, + }, + }); + } + } +}, 1000 * 60 * 60); diff --git a/server/index.js b/server/index.js index 1c55900..525eb64 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,9 @@ import { getSubstitutions, getHistory, getClasses, + getInfo, + putKey, + deleteKey, } from "./api/index.js"; import auth from "./api/auth.js"; import { Parser } from "./parser/index.js"; @@ -30,6 +33,7 @@ const port = process.env.PORT || 3000; app.use(cors()); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); +app.use(express.json()); // Initialize the Parser and set it to update the // substitution plan at the specified update interval @@ -55,6 +59,9 @@ app.get("/api/check", (_req, res) => { }); // Register API endpoints +app.get("/api/info", getInfo); +app.put("/api/key", putKey); +app.delete("/api/key", deleteKey); app.get("/api/timetable", getTimetable); app.get("/api/substitutions", getSubstitutions); app.get("/api/history", getHistory); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 26e3a9e..0487caf 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -73,7 +73,18 @@ model Time { } model Session { - token String @id @unique @default(uuid()) - createdAt DateTime @default(now()) - validUntil DateTime + token String @id @unique @default(uuid()) + createdAt DateTime @default(now()) + validUntil DateTime + appliedKeys Key[] +} + +model Key { + key String @id @unique @default(uuid()) + createdAt DateTime? @default(now()) + validUntil DateTime? + permissions String[] + notes String? + Session Session? @relation(fields: [sessionToken], references: [token]) + sessionToken String? } diff --git a/src/components/settings/key-card.vue b/src/components/settings/key-card.vue new file mode 100644 index 0000000..9e17ed5 --- /dev/null +++ b/src/components/settings/key-card.vue @@ -0,0 +1,72 @@ + + + + + + {{ keyData.key }} + + + {{ permission }} + + + + + + + + + + + diff --git a/src/router/index.js b/src/router/index.js index 5bcfb66..7d38a54 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -12,6 +12,7 @@ import TimetableSettings from "@/views/settings/TimetableSettings.vue"; import TimetableEditor from "@/views/settings/TimetableEditor.vue"; import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue"; import AppearanceSettings from "@/views/settings/AppearanceSettings.vue"; +import KeySettings from "@/views/settings/KeySettings.vue"; import AboutPage from "@/views/settings/AboutPage.vue"; const router = createRouter({ @@ -70,6 +71,11 @@ const router = createRouter({ name: "title.settings.appearance", component: AppearanceSettings, }, + { + path: "keys", + name: "title.settings.keys", + component: KeySettings, + }, { path: "about", name: "title.settings.about", diff --git a/src/store.js b/src/store.js index 0c046e6..466a879 100644 --- a/src/store.js +++ b/src/store.js @@ -72,6 +72,7 @@ export const timetable = computed(() => { localTimetable || remoteTimetable || { trusted: true, source: "", data: [] } ); }); +export const sessionInfo = ref({}); export const timetables = ref([]); export const times = ref([]); export const substitutions = ref({}); @@ -81,7 +82,7 @@ export const classList = ref([]); /* API functions */ // Set the `VITE_API_ENDPOINT` env variable when // building the frontend to use an external api server -const baseUrl = import.meta.env.VITE_API_ENDPOINT || "/api"; +export const baseUrl = import.meta.env.VITE_API_ENDPOINT || "/api"; export async function fetchData(days, partial) { if (!timetable.value.data || Object.keys(classList.value).length == 0) { @@ -96,23 +97,12 @@ export async function fetchData(days, partial) { // Check if the API server is reachable // and the user is authenticated - try { - const checkResponse = await fetch(`${baseUrl}/check`); - if (checkResponse.status == 401) { - shouldLogin.value = true; - return; - } else if (checkResponse.status != 200) { - loadingFailed.value = true; - loadingProgress.value = 1; - console.log("Other error while fetching data: " + checkResponse.status); - return; - } - } catch { + if (!(await fetchSessionInfo())) { loadingFailed.value = true; loadingProgress.value = 1; - console.log("Error while fetching data: No internet connection!"); return; } + loadingProgress.value = step++ / steps; if (!partial) { @@ -137,6 +127,25 @@ watch(selectedDate, () => fetchData(getNextAndPrevDay(selectedDate.value), true) ); +export async function fetchSessionInfo() { + try { + const checkResponse = await fetch(`${baseUrl}/info`); + if (checkResponse.status == 401) { + shouldLogin.value = true; + return false; + } else if (checkResponse.status != 200) { + console.log("Other error while fetching data: " + checkResponse.status); + return false; + } else { + sessionInfo.value = await checkResponse.json(); + } + } catch { + console.log("Error while fetching data: No internet connection!"); + return false; + } + return true; +} + export async function fetchClassList() { const classListResponse = await fetch(`${baseUrl}/classes`); const classListData = await classListResponse.json(); diff --git a/src/strings.js b/src/strings.js index a3f0b6c..68c6696 100644 --- a/src/strings.js +++ b/src/strings.js @@ -13,6 +13,7 @@ export const strings = { timetable: "Manage Timetables", groups: "Timetable Groups", appearance: "Appearance", + keys: "Manage Keys", about: "About", }, }, @@ -25,6 +26,7 @@ export const strings = { language: "Language", about: "About", theme: "Theme", + keys: "Manage Keys", }, text: { filtering: @@ -38,12 +40,15 @@ export const strings = { "This Tool queries and parses the latest substitution-plan data every minute. The correctness of the data can in no way be guaranteed, so please check the data against the official plan if something seems wrong! Due to the format of the plan files, it it sometimes not easily possible to extract the correct data, so the plan displayed here may not be correct.", theme: "Select a Theme to change the colors of the app. The 'Auto' option selects a theme based on your system preferences.", + keys: "Keys are used to give you special permissions, for example editing a timetable. You can enter keys that you received here.", }, other: "Other", back: "Back", none: "None", version: "Version", source: "Source", + key: "Key", + invalidKey: "This key does not exist!", theme: { auto: "Auto", dark: "Dark", @@ -130,6 +135,7 @@ export const strings = { timetable: "Stundenpläne Verwalten", groups: "Stundenplan-Gruppen", appearance: "Aussehen", + keys: "Schlüssel Verwalten", about: "Über", }, }, @@ -142,6 +148,7 @@ export const strings = { language: "Sprache", about: "Über diese Anwendung", theme: "Farbschema", + keys: "Schlüssel Verwalten", }, text: { filtering: @@ -155,12 +162,15 @@ export const strings = { "Diese Anwendung fragt jede Minute die neusten Vertretungsplan-Daten an und verarbeitet sie, um sie dir schöner und besser lesbar anzuzeigen. Die Richtigkeit dieser Daten kann nicht garantiert werden, da es manchmal kompliziert ist, die richtigen Daten zu extrahieren. Wenn etwas nicht richtig aussieht, überprüfe es bitte auf dem offiziellen Plan!", theme: "Wähle ein Farbschema aus, um die Farben dieser Anwendung anzupassen. Die Option 'Automatisch' wählt ein Farbschema basierend auf den Einstellungen deines Systems aus.", + keys: "Schlüssel können dir erweiterte rechte geben, zum Beispiel zum editieren eines Stundenplans. Wenn du einen Schlüssel bekommen hast, kannst du ihn hier eingeben.", }, other: "Andere", back: "Zurück", none: "Keine", version: "Version", source: "Quelle", + key: "Schlüssel", + invalidKey: "Dieser Schlüssel existiert nicht!", theme: { auto: "Automatisch", dark: "Dunkel", diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 4ba3e0a..12b0f79 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -6,6 +6,7 @@ import { CalendarIcon, CopyCheckIcon, PaletteIcon, + KeyRoundIcon, InfoIcon, ChevronLeft, } from "lucide-vue-next"; @@ -35,6 +36,11 @@ import { :icon="PaletteIcon" route="settings/appearance" /> + +import { + baseUrl, + sessionInfo, + fetchSessionInfo, + loading, + loadingProgress, +} from "@/store"; +import KeyCard from "@/components/settings/key-card.vue"; +import { ref } from "vue"; +import { AlertTriangleIcon, SendIcon } from "lucide-vue-next"; + +const key = ref(); +const invalidKey = ref(false); + +async function applyKey() { + loadingProgress.value = 0.1; + loading.value = true; + const result = await fetch( + baseUrl + "/key?key=" + encodeURIComponent(key.value), + { + method: "put", + } + ); + loadingProgress.value = 0.5; + if (result.status == 400) { + invalidKey.value = true; + } else if (result.status != 200) { + alert("Error while applying key!"); + console.warn(result.body); + } else { + await fetchSessionInfo(); + } + loadingProgress.value = 1; + loading.value = false; +} + +async function revokeKey(key) { + loadingProgress.value = 0.1; + loading.value = true; + const result = await fetch(baseUrl + "/key?key=" + encodeURIComponent(key), { + method: "delete", + }); + loadingProgress.value = 0.5; + if (result.status != 200) { + alert("Error while revoking key!"); + console.warn(result.body); + } else { + await fetchSessionInfo(); + } + loadingProgress.value = 1; + loading.value = false; +} + + + + {{ $t("settings.heading.keys") }} + {{ $t("settings.text.keys") }} + + + + + + + + + {{ $t("settings.invalidKey") }} + + + revokeKey(key.key)" + /> + + + +
{{ keyData.key }}
{{ $t("settings.text.keys") }}