🛂 Add key-based permission system
This commit is contained in:
@ -101,6 +101,9 @@ async function checkLogin(req, res, next) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
req.locals = {
|
||||
session: session.token,
|
||||
};
|
||||
renewSession(session);
|
||||
next();
|
||||
}
|
||||
|
@ -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) {
|
||||
|
95
server/api/permission.js
Normal file
95
server/api/permission.js
Normal file
@ -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);
|
@ -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);
|
||||
|
@ -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?
|
||||
}
|
||||
|
72
src/components/settings/key-card.vue
Normal file
72
src/components/settings/key-card.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { TrashIcon, AlertCircleIcon } from "lucide-vue-next";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const deleteConfirm = ref(false);
|
||||
watch(deleteConfirm, (value) => {
|
||||
if (value) {
|
||||
setTimeout(() => (deleteConfirm.value = false), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
defineProps(["keyData"]);
|
||||
defineEmits(["delete"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="info">
|
||||
<p class="key">{{ keyData.key }}</p>
|
||||
<div class="permissions">
|
||||
<div
|
||||
class="permission"
|
||||
v-for="permission in keyData.permissions"
|
||||
:key="permission"
|
||||
>
|
||||
{{ permission }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button">
|
||||
<TrashIcon v-if="!deleteConfirm" @click="deleteConfirm = true" />
|
||||
<AlertCircleIcon color="red" v-else @click="$emit('delete')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background-color: var(--element-color);
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90%, auto) 50px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
font-family: monospace;
|
||||
font-size: 15px;
|
||||
padding: 15px 10px 0px 10px;
|
||||
}
|
||||
|
||||
.permissions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 5px 10px 0px 10px;
|
||||
user-select: none;
|
||||
max-width: 90%;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.permission {
|
||||
padding: 10px;
|
||||
background-color: var(--element-color-hover);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -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",
|
||||
|
37
src/store.js
37
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();
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
/>
|
||||
<PageCard
|
||||
:name="$t('title.settings.keys')"
|
||||
:icon="KeyRoundIcon"
|
||||
route="settings/keys"
|
||||
/>
|
||||
<PageCard
|
||||
:name="$t('title.settings.about')"
|
||||
:icon="InfoIcon"
|
||||
|
142
src/views/settings/KeySettings.vue
Normal file
142
src/views/settings/KeySettings.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2>{{ $t("settings.heading.keys") }}</h2>
|
||||
<p>{{ $t("settings.text.keys") }}</p>
|
||||
<div class="input">
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="$t('settings.key')"
|
||||
v-model="key"
|
||||
@keyup.enter="applyKey"
|
||||
@keydown="invalidKey = false"
|
||||
/>
|
||||
<div class="button" @click="applyKey">
|
||||
<SendIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalidKey" v-if="invalidKey">
|
||||
<AlertTriangleIcon />
|
||||
<span>{{ $t("settings.invalidKey") }}</span>
|
||||
</div>
|
||||
<div class="list">
|
||||
<KeyCard
|
||||
v-for="key in sessionInfo.appliedKeys"
|
||||
:key="key"
|
||||
:keyData="key"
|
||||
@delete="() => revokeKey(key.key)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0px;
|
||||
}
|
||||
|
||||
.invalidKey {
|
||||
color: red;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0px 0px 15px 5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 10px 0px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 50px;
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input > * {
|
||||
background-color: var(--element-color);
|
||||
border-radius: 7px;
|
||||
padding: 10px;
|
||||
transition: 0.2s background-color;
|
||||
}
|
||||
|
||||
.input > *:hover {
|
||||
background-color: var(--element-color-hover);
|
||||
}
|
||||
|
||||
.input input {
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 15px;
|
||||
font-family: monospace;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user