Compare commits

..

11 commits

Author SHA1 Message Date
1826529bc8 Mark /api/events endpoint as uncachable
All checks were successful
/ build (push) Successful in 1m27s
/ deploy (push) Has been skipped
The content of the events stream is generated live in response to
changes done in the system as they occur.  It therefore can't be
meaningfully cached or stored in any way.  Mark the response as
uncacheable to prevent potential problems with caches.
2025-09-21 23:54:12 +02:00
a32a49b281 Add UI for setting cancelled status
All checks were successful
/ build (push) Successful in 1m35s
/ deploy (push) Successful in 16s
Add indications in event cards, event slot cards and the timetable for
an event or event slot being cancelled by striking it through and
dimming the text colour.  And a checkbox in the event and event slot
list to edit the cancelled status.  And a diff entry for the cancelled
status on events and event slots.
2025-09-21 23:15:10 +02:00
7314b26c77 Fix tab visuals in Firefox
The rendering of the tabs would not include the spacer in Firefox for
if the width was not set for some reason.
2025-09-21 23:13:26 +02:00
5d4cdb7b83 Fix session-expired event causing events to stop
If a session-expired event is hit that does not match the current
session then it should be ignored and event processing continue without
it.  This was incorrectly implemented and instead event processing
would stop when any session-expired event was hit, not just the ones
matching the current session.

Fix logic to properly ignore session-expired events when it doesn't
match the current session.
2025-09-21 22:15:11 +02:00
80cec71308 Use sync access for the temp JSON file database
All checks were successful
/ build (push) Successful in 1m37s
/ deploy (push) Has been skipped
Replace all async reads and writes to the JSON database with the sync
reads and writes to prevent a data corruption race condition where two
requests are processed at the same time and write to the same file, or
one reads while the other writes causing read of partially written data.
2025-09-20 23:04:16 +02:00
f9d188b2ba Remove unused <style> blocks
All checks were successful
/ build (push) Successful in 1m38s
/ deploy (push) Successful in 16s
2025-09-20 20:44:17 +02:00
0083696343 Fix unscoped CSS leaking out
The missing scoped attribute cause h2 headers to no longer have the
expected top margin.  Fix by adding the intended scope attribute.
2025-09-20 20:43:11 +02:00
753da6d3d4 Refactor to persist and reliably deliver events
Store events that are to be broadcasted in the database, and fetch
events to serve in the /api/event stream to the client from the
database.  This ensures that events are not lost if the operation to
open the stream takes longer than usual, or the client was not connected
at the time the event was broadcast.

To ensure no events are lost in the transition from server generating
the page to the client hydrating and establishing a connection with the
event stream, the /api/last-event-id endpoint is first queried on the
server before any other entities is fetched from the database.  The
client then passes this id when establishing the event stream, and
receives all events greater than that id.
2025-09-20 20:36:37 +02:00
0a0eb43d78 Treat description fields as markdown
All checks were successful
/ build (push) Successful in 1m36s
/ deploy (push) Has been skipped
Support basic formatting in the display of the description fields to
locations, events and shifts by rendering them as Markdown using the
micromark library.
2025-09-16 20:54:36 +02:00
6d93e99858 Fix edits to notice field not being shown in diff 2025-09-16 20:46:11 +02:00
400bb7bfe9 Add .flow class for spacing custom elements
Add .flow class for when vertical spacing between elements is desired in
the same way paragraphs are vertically spaced apart.
2025-09-16 20:33:47 +02:00
50 changed files with 804 additions and 284 deletions

View file

@ -11,7 +11,10 @@
import "~/assets/global.css";
const event = useRequestEvent();
const sessionStore = useSessionStore();
await callOnce("fetch-session", async () => {
const eventsStore = useEventsStore();
const nuxtApp = useNuxtApp();
await callOnce("fetch-globals", async () => {
await sessionStore.fetch(event);
await nuxtApp.runWithContext(eventsStore.fetchLastEventId);
})
</script>

View file

@ -76,7 +76,7 @@ label>* {
margin-inline-start: 0.5rem;
}
:is(p, form, fieldset, pre, ul) + :is(p, form, fieldset, pre, ul) {
:is(p, form, fieldset, pre, ul, .flow) + :is(p, form, fieldset, pre, ul, .flow) {
margin-block-start: 0.5rem;
}

View file

@ -3,7 +3,10 @@
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section class="event">
<section
class="event"
:class="{ cancelled: event.cancelled }"
>
<h3>{{ event.name }}</h3>
<p v-if=event.host>
Host: {{ event.host }}
@ -16,7 +19,11 @@
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event.description ?? "No description provided" }}</p>
<div
v-if="descriptionHtml"
class="flow"
v-html="descriptionHtml"
/>
<p v-if="event.interested">
{{ event.interested }} interested
</p>
@ -32,7 +39,12 @@
<h4>Timeslots</h4>
<ul>
<li v-for="slot in event.slots.values()" :key="slot.id">
<li
v-for="slot in event.slots.values()"
:key="slot.id"
class="slot"
:class="{ cancelled: slot.cancelled }"
>
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
<button
v-if="accountStore.valid && event.slots.size > 1"
@ -56,12 +68,19 @@
</template>
<script lang="ts" setup>
import { micromark } from 'micromark';
import { DateTime } from '~/shared/utils/luxon';
defineProps<{
const props = defineProps<{
event: ClientScheduleEvent
}>()
const descriptionHtml = computed(() => {
if (props.event.description) {
return micromark(props.event.description);
}
});
const accountStore = useAccountStore();
const usersStore = useUsersStore();
@ -80,6 +99,10 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
padding: 0.5rem;
border-radius: 0.5rem;
}
.event.cancelled>*, .slot.cancelled {
text-decoration: line-through;
color: grey;
}
.event h3 {
margin: 0;
}
@ -87,7 +110,9 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
margin-block-start: 0.5rem;
}
.notice {
.event .notice {
text-decoration: none;
color: CanvasText;
display: flex;
width: fit-content;
gap: 0.5rem;

View file

@ -3,7 +3,10 @@
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section class="eventSlot">
<section
class="eventSlot"
:class="{ cancelled: slot.cancelled || event?.cancelled }"
>
<hgroup>
<h3>{{ event?.name }}</h3>
<p>
@ -21,7 +24,11 @@
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event?.description ?? "No description provided" }}</p>
<div
v-if="descriptionHtml !== undefined"
class="flow"
v-html="descriptionHtml"
/>
<p v-if="locations.length">
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
</p>
@ -40,12 +47,20 @@
</template>
<script lang="ts" setup>
import { micromark } from 'micromark';
import { DateTime } from '~/shared/utils/luxon';
const props = defineProps<{
event?: ClientScheduleEvent,
slot: ClientScheduleEventSlot,
}>()
const descriptionHtml = computed(() => {
if (props.event?.description)
return micromark(props.event.description);
return undefined;
});
const scheduleStore = useSchedulesStore();
const schedule = scheduleStore.activeSchedule;
@ -63,6 +78,10 @@ function formatTime(time: DateTime) {
padding: 0.5rem;
border-radius: 0.5rem;
}
.eventSlot.cancelled>* {
text-decoration: line-through;
color: grey;
}
.eventSlot h3 {
margin: 0;
}
@ -70,7 +89,9 @@ function formatTime(time: DateTime) {
margin-block-start: 0.5rem;
}
.notice {
.eventSlot .notice {
text-decoration: none;
color: CanvasText;
display: flex;
width: fit-content;
gap: 0.5rem;

View file

@ -5,7 +5,11 @@
<template>
<section class="shift">
<h3>{{ shift.name }}</h3>
<p class="preWrap">{{ shift.description ?? "No description provided" }}</p>
<div
v-if="descriptionHtml"
class="flow"
v-html="descriptionHtml"
/>
<h4>Timeslots</h4>
<ul>
@ -21,12 +25,19 @@
</template>
<script lang="ts" setup>
import { micromark } from 'micromark';
import { DateTime } from '~/shared/utils/luxon';
defineProps<{
const props = defineProps<{
shift: ClientScheduleShift
}>()
const descriptionHtml = computed(() => {
if (props.shift.description) {
return micromark(props.shift.description);
}
});
const usersStore = useUsersStore();
function formatTime(time: DateTime) {

View file

@ -61,7 +61,7 @@ const shifts = computed(() => {
});
</script>
<style>
<style scoped>
h2 {
margin-block-start: 0.2rem;
}

View file

@ -29,6 +29,18 @@
:after='event.crew ? "No" : "Yes"'
:state
/>
<DiffFieldString
title="Cancelled"
:before='event.serverCancelled ? "Yes" : "No"'
:after='event.cancelled ? "Yes" : "No"'
:state
/>
<DiffFieldString
title="Notice"
:before="event.serverNotice"
:after="event.notice"
:state
/>
<DiffFieldString
title="Description"
:before="event.serverDescription"

View file

@ -24,6 +24,12 @@
:entities="schedule.locations"
:state
/>
<DiffFieldString
title="Cancelled"
:before='slot.serverCancelled ? "Yes" : "No"'
:after='slot.cancelled ? "Yes" : "No"'
:state
/>
<DiffFieldSetEntityId
title="Assigned"
:before="slot.serverAssigned"

View file

@ -35,7 +35,3 @@ async function logIn() {
}
}
</script>
<style>
</style>

View file

@ -12,6 +12,7 @@
<th>duration</th>
<th>event</th>
<th>location</th>
<th title="cancelled">c</th>
<th>assigned</th>
<th v-if="edit"></th>
</tr>
@ -50,6 +51,7 @@
/>
</td>
<td></td>
<td></td>
<td>
Add at
<button
@ -97,6 +99,11 @@
v-model="es.slot.locationIds"
/>
</td>
<td>
<input
type="checkbox"
v-model="es.slot.cancelled">
</td>
<td>
<SelectMultiEntity
:entities="usersStore.users"
@ -149,6 +156,7 @@
/>
</td>
<td></td>
<td></td>
<td colspan="2">
<button
type="button"
@ -177,6 +185,7 @@
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.event?.name }}</td>
<td></td>
<td>{{ es.slot.cancelled ? "x" : undefined }}</td>
<td><AssignedCrew :modelValue="es.slot.assigned" :edit="false" /></td>
</template>
</tr>

View file

@ -14,6 +14,7 @@
<th>notice</th>
<th>description</th>
<th>p</th>
<th title="cancelled">c</th>
<th>s</th>
<th v-if="edit"></th>
</tr>
@ -69,6 +70,13 @@
@change="event.crew = !event.crew"
>
</td>
<td>
<input
type="checkbox"
:disabled="!canEdit(event)"
v-model="event.cancelled"
>
</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
<td>
<button
@ -123,6 +131,7 @@
>
</td>
<td></td>
<td></td>
<td>
<button
v-if="eventExists(newEventName)"

View file

@ -61,7 +61,3 @@
useEventSource();
const usersStore = useUsersStore();
</script>
<style>
</style>

View file

@ -55,7 +55,6 @@ nav {
}
.tab {
display: flex;
flex-wrap: wrap;
}
.tab.active {
padding-block-start: 1px;
@ -65,6 +64,7 @@ nav {
}
.tab .spacer {
flex: 1 0 0.75rem;
width: 0.75rem;
}
.tab .flap,
.tab .spacer {

View file

@ -102,7 +102,11 @@
v-for="cell, index in row"
:key="index"
:colSpan="cell.span"
:class='{"event": cell.slot, "crew": cell.event?.crew }'
:class="{
event: cell.slot,
crew: cell.event?.crew,
cancelled: cell.event?.cancelled || cell.slot?.cancelled,
}"
:title="cell.event?.name"
>
{{ cell.event?.notice ? "⚠️" : undefined }}
@ -791,4 +795,9 @@ tr.hours>th + th.dayShift div {
.event.crew {
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
}
.event.cancelled {
color: color-mix(in oklab, CanvasText, rgb(127, 127, 127) 80%);
text-decoration: line-through;
}
</style>

View file

@ -2,12 +2,12 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ApiEvent } from "~/shared/types/api";
import type { ApiEvent, ApiEventStreamMessage } from "~/shared/types/api";
interface AppEventMap {
"open": Event,
"message": MessageEvent<string>,
"update": MessageEvent<ApiEvent>,
"message": MessageEvent<ApiEventStreamMessage>,
"event": MessageEvent<ApiEvent>,
"error": Event,
"close": Event,
}
@ -18,12 +18,11 @@ class AppEventSource extends EventTarget {
#forwardEvent(type: string) {
this.#source!.addEventListener(type, event => {
if (type === "open" || type === "message" || type === "error") {
console.log("AppEventSource", event.type, event.data);
console.log("AppEventSource", event.type, event.data);
if (type === "open" || type === "error") {
this.dispatchEvent(new Event(event.type));
} else {
const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined;
console.log("AppEventSource", event.type, data);
} else if (type === "message") {
const data = event.data ? JSON.parse(event.data) as ApiEventStreamMessage : undefined;
if (data?.type === "connected") {
this.#sourceSessionId = data.session?.id;
}
@ -34,17 +33,27 @@ class AppEventSource extends EventTarget {
source: event.source,
ports: [...event.ports],
}));
} else {
const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined;
this.dispatchEvent(new MessageEvent(event.type, {
data,
origin: event.origin,
lastEventId: event.lastEventId,
source: event.source,
ports: [...event.ports],
}));
}
});
}
open(sessionId: number | undefined) {
open(sessionId: number | undefined, lastEventId: number) {
console.log("Opening event source sid:", sessionId);
this.#sourceSessionId = sessionId;
this.#source = new EventSource("/api/events");
const query = new URLSearchParams({ lastEventId: String(lastEventId) });
this.#source = new EventSource(`/api/events?${query}`);
this.#forwardEvent("open");
this.#forwardEvent("message");
this.#forwardEvent("update");
this.#forwardEvent("event");
this.#forwardEvent("error");
}
@ -58,20 +67,20 @@ class AppEventSource extends EventTarget {
}
#connectRefs = 0;
connect(sessionId: number | undefined) {
connect(sessionId: number | undefined, lastEventId: number) {
this.#connectRefs += 1;
if (this.#source && this.#sourceSessionId !== sessionId) {
this.close();
}
if (!this.#source) {
this.open(sessionId);
this.open(sessionId, lastEventId);
}
}
reconnect(sessionId: number | undefined) {
reconnect(sessionId: number | undefined, lastEventId: number) {
if (this.#source && this.#sourceSessionId !== sessionId) {
this.close();
this.open(sessionId);
this.open(sessionId, lastEventId);
}
}
@ -113,14 +122,15 @@ export const appEventSource = import.meta.client ? new AppEventSource() : null;
export function useEventSource() {
const sessionStore = useSessionStore();
const eventsStore = useEventsStore();
onMounted(() => {
console.log("useEventSource onMounted", sessionStore.id);
appEventSource!.connect(sessionStore.id);
appEventSource!.connect(sessionStore.id, eventsStore.lastEventId);
})
watch(() => sessionStore.id, () => {
console.log("useEventSource sessionStore.id change", sessionStore.id);
appEventSource!.reconnect(sessionStore.id);
appEventSource!.reconnect(sessionStore.id, eventsStore.lastEventId);
})
onUnmounted(() => {

View file

@ -11,3 +11,9 @@ To update in real time this application sends a `text/event-source` stream using
Upon connecting a `"connect"` event is emitted with the session the connection was made under. This is the primary mechanism a user agent discovers its own session having been rotated into a new one, which also happens when the access level of the account associated with the session changes.
After the `"connect"` event the user agent will start to receive updates to resources it has access to that has changed. There is no filtering for what resoucres the user agent receives updates for at the moment as there's not enough events to justify the complexity of server-side subscriptions and filtering.
## Id and order
Events are guaranteed to be delivered in order, and to maintain consistency the server provides the following guarantee: Any entities fetched after receiving a response from `/api/last-event-id` will include updates from all events up to and including the `id` received from the response.
This means that a client can fetch an up to date and live representation of any API entity by first fetching the last event from `/api/last-event-id`, and then in parallel fetch any entities as well as opening the `/api/events` stream with the `lastEventId` query param set to the value received from the `/api/last-event-id` endpoint.

View file

@ -13,6 +13,7 @@
"dependencies": {
"@pinia/nuxt": "^0.11.1",
"luxon": "^3.6.1",
"micromark": "^4.0.2",
"nuxt": "^3.17.6",
"pinia": "^3.0.3",
"vue": "^3.5.17",

View file

@ -109,7 +109,3 @@ const tabs = [
{ id: "database", title: "Database" },
];
</script>
<style>
</style>

View file

@ -84,7 +84,7 @@ const { pending, data, error } = await useFetch(() => `/api/users/${id.value}/de
const userDetails = data as Ref<ApiUserDetails | ApiTombstone>;
</script>
<style>
<style scoped>
dl {
display: grid;
grid-template-columns: auto 1fr;

View file

@ -68,13 +68,19 @@
<ul>
<li v-for="location in schedule.locations.values()" :key="location.id">
<h3>{{ location.name }}</h3>
{{ location.description ?? "No description provided" }}
<div
v-if="location.description"
class="flow"
v-html="micromark(location.description)"
/>
</li>
</ul>
</main>
</template>
<script setup lang="ts">
import { micromark } from 'micromark';
useHead({
title: "Schedule",
});

243
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
luxon:
specifier: ^3.6.1
version: 3.6.1
micromark:
specifier: ^4.0.2
version: 4.0.2
nuxt:
specifier: ^3.17.6
version: 3.17.6(@parcel/watcher@2.5.1)(@types/node@24.0.10)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.43.1)(typescript@5.8.3)(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))(vue-tsc@3.0.1(typescript@5.8.3))(yaml@2.8.0)
@ -35,7 +38,7 @@ importers:
devDependencies:
'@nuxt/test-utils':
specifier: ^3.19.2
version: 3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
version: 3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
'@types/luxon':
specifier: ^3.6.2
version: 3.6.2
@ -47,7 +50,7 @@ importers:
version: 17.6.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)
vue-tsc:
specifier: ^3.0.1
version: 3.0.1(typescript@5.8.3)
@ -1080,6 +1083,9 @@ packages:
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -1089,6 +1095,9 @@ packages:
'@types/luxon@3.6.2':
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.0.10':
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
@ -1483,6 +1492,9 @@ packages:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@ -1714,6 +1726,9 @@ packages:
decache@4.6.2:
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@ -1749,6 +1764,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@ -1807,6 +1826,9 @@ packages:
devalue@5.1.1:
resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff@8.0.2:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
@ -2484,6 +2506,66 @@ packages:
micro-api-client@3.3.0:
resolution: {integrity: sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
micromark-factory-label@2.0.1:
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
micromark-factory-space@2.0.1:
resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
micromark-factory-title@2.0.1:
resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
micromark-factory-whitespace@2.0.1:
resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
micromark-util-chunked@2.0.1:
resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
micromark-util-classify-character@2.0.1:
resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
micromark-util-combine-extensions@2.0.1:
resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
micromark-util-decode-numeric-character-reference@2.0.2:
resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
micromark-util-html-tag-name@2.0.1:
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
micromark-util-normalize-identifier@2.0.1:
resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
micromark-util-resolve-all@2.0.1:
resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
micromark-util-subtokenize@2.1.0:
resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
micromark@4.0.2:
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@ -4622,7 +4704,7 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/test-utils@3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))':
'@nuxt/test-utils@3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))':
dependencies:
'@nuxt/kit': 3.17.6(magicast@0.3.5)
c12: 3.0.4(magicast@0.3.5)
@ -4646,11 +4728,11 @@ snapshots:
tinyexec: 1.0.1
ufo: 1.6.1
unplugin: 2.3.5
vitest-environment-nuxt: 1.0.1(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
vitest-environment-nuxt: 1.0.1(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
vue: 3.5.17(typescript@5.8.3)
optionalDependencies:
happy-dom: 17.6.3
vitest: 3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)
transitivePeerDependencies:
- magicast
- typescript
@ -4995,12 +5077,18 @@ snapshots:
dependencies:
'@types/deep-eql': 4.0.2
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/luxon@3.6.2': {}
'@types/ms@2.1.0': {}
'@types/node@24.0.10':
dependencies:
undici-types: 7.8.0
@ -5515,6 +5603,8 @@ snapshots:
loupe: 3.1.4
pathval: 2.0.1
character-entities@2.0.2: {}
check-error@2.1.1: {}
chokidar@4.0.3:
@ -5730,6 +5820,10 @@ snapshots:
dependencies:
callsite: 1.0.0
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
deep-eql@5.0.2: {}
deepmerge@4.3.1: {}
@ -5751,6 +5845,8 @@ snapshots:
depd@2.0.0: {}
dequal@2.0.3: {}
destr@2.0.5: {}
detect-libc@1.0.3: {}
@ -5815,6 +5911,10 @@ snapshots:
devalue@5.1.1: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
diff@8.0.2: {}
dom-serializer@2.0.0:
@ -6501,6 +6601,132 @@ snapshots:
micro-api-client@3.3.0: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-factory-destination: 2.0.1
micromark-factory-label: 2.0.1
micromark-factory-space: 2.0.1
micromark-factory-title: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-html-tag-name: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-label@2.0.1:
dependencies:
devlop: 1.1.0
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-space@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-types: 2.0.2
micromark-factory-title@2.0.1:
dependencies:
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-whitespace@2.0.1:
dependencies:
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-character@2.1.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-chunked@2.0.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-classify-character@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-combine-extensions@2.0.1:
dependencies:
micromark-util-chunked: 2.0.1
micromark-util-types: 2.0.2
micromark-util-decode-numeric-character-reference@2.0.2:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-encode@2.0.1: {}
micromark-util-html-tag-name@2.0.1: {}
micromark-util-normalize-identifier@2.0.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-resolve-all@2.0.1:
dependencies:
micromark-util-types: 2.0.2
micromark-util-sanitize-uri@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-encode: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-subtokenize@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-symbol@2.0.1: {}
micromark-util-types@2.0.2: {}
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.1
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-chunked: 2.0.1
micromark-util-combine-extensions: 2.0.1
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-encode: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
transitivePeerDependencies:
- supports-color
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@ -7919,9 +8145,9 @@ snapshots:
terser: 5.43.1
yaml: 2.8.0
vitest-environment-nuxt@1.0.1(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)):
vitest-environment-nuxt@1.0.1(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)):
dependencies:
'@nuxt/test-utils': 3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
'@nuxt/test-utils': 3.19.2(happy-dom@17.6.3)(magicast@0.3.5)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0))
transitivePeerDependencies:
- '@cucumber/cucumber'
- '@jest/globals'
@ -7936,7 +8162,7 @@ snapshots:
- typescript
- vitest
vitest@3.2.4(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@17.6.3)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@ -7962,6 +8188,7 @@ snapshots:
vite-node: 3.2.4(@types/node@24.0.10)(jiti@2.4.2)(terser@5.43.1)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.0.10
happy-dom: 17.6.3
transitivePeerDependencies:

View file

@ -9,11 +9,11 @@ export default defineEventHandler(async (event) => {
setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"');
setHeader(event, "Content-Type", "application/json; charset=utf-8");
return {
nextUserId: await readNextUserId(),
users: await readUsers(),
nextSessionId: await readNextSessionId(),
sessions: await readSessions(),
subscriptions: await readSubscriptions(),
schedule: await readSchedule(),
nextUserId: readNextUserId(),
users: readUsers(),
nextSessionId: readNextSessionId(),
sessions: readSessions(),
subscriptions: readSubscriptions(),
schedule: readSchedule(),
};
})

View file

@ -7,14 +7,14 @@ import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-de
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const accounts = generateDemoAccounts();
await writeUsers(accounts);
await writeSchedule(generateDemoSchedule());
await writeAuthenticationMethods(accounts.map((user, index) => ({
writeUsers(accounts);
writeSchedule(generateDemoSchedule());
writeAuthenticationMethods(accounts.map((user, index) => ({
id: index,
userId: user.id,
provider: "demo",
slug: user.name!,
name: user.name!,
})));
await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length));
writeNextAuthenticationMethodId(Math.max(nextAuthenticationMethodId(), accounts.length));
})

View file

@ -29,20 +29,20 @@ export default defineEventHandler(async (event) => {
});
}
const currentNextUserId = await readNextUserId();
await writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
await writeUsers(snapshot.users);
const currentNextSessionId = await readNextSessionId();
await writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
const currentSessions = new Map((await readSessions()).map(session => [session.id, session]));
await writeSessions(snapshot.sessions.filter(session => {
const currentNextUserId = readNextUserId();
writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
writeUsers(snapshot.users);
const currentNextSessionId = readNextSessionId();
writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
const currentSessions = new Map((readSessions()).map(session => [session.id, session]));
writeSessions(snapshot.sessions.filter(session => {
const current = currentSessions.get(session.id);
// Only keep sessions that match the account id in both sets to avoid
// resurrecting deleted sessions. This will still cause session cross
// pollution if a snapshot from another instance is loaded here.
return current?.accountId !== undefined && current.accountId === session.accountId;
}));
await writeSubscriptions(snapshot.subscriptions);
await writeSchedule(snapshot.schedule);
writeSubscriptions(snapshot.subscriptions);
writeSchedule(snapshot.schedule);
await sendRedirect(event, "/");
})

View file

@ -6,5 +6,5 @@ import { deleteDatabase } from "~/server/database";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
await deleteDatabase();
deleteDatabase();
})

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { nextEventId, readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { apiUserPatchSchema } from "~/shared/types/api";
import { z } from "zod/v4-mini";
import { broadcastEvent } from "~/server/streams";
@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
});
}
const users = await readUsers();
const users = readUsers();
const user = users.find(user => user.id === patch.id);
if (!user || user.deleted) {
throw createError({
@ -52,26 +52,28 @@ export default defineEventHandler(async (event) => {
user.name = patch.name;
}
user.updatedAt = new Date().toISOString();
await writeUsers(users);
writeUsers(users);
broadcastEvent({
id: nextEventId(),
type: "user-update",
data: serverUserToApi(user),
});
// Rotate sessions with the user in it if the access changed
if (accessChanged) {
const sessions = await readSessions();
const sessions = readSessions();
const nowMs = Date.now();
for (const session of sessions) {
if (session.accountId === user.id) {
session.rotatesAtMs = nowMs;
broadcastEvent({
id: nextEventId(),
type: "session-expired",
sessionId: session.id,
});
}
}
await writeSessions(sessions);
writeSessions(sessions);
}
// Update Schedule counts.

View file

@ -5,16 +5,17 @@
import {
readUsers, readSessions, readSubscriptions,
writeUsers, writeSessions, writeSubscriptions,
nextEventId,
} from "~/server/database";
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
export default defineEventHandler(async (event) => {
const serverSession = await requireServerSessionWithUser(event);
let users = await readUsers();
let users = readUsers();
// Expire sessions for this user
const expiredSessionIds = new Set<number>();
let sessions = await readSessions();
let sessions = readSessions();
const nowMs = Date.now();
for (const session of sessions) {
if (
@ -24,6 +25,7 @@ export default defineEventHandler(async (event) => {
) {
session.expiresAtMs = nowMs;
broadcastEvent({
id: nextEventId(),
type: "session-expired",
sessionId: session.id,
});
@ -31,23 +33,24 @@ export default defineEventHandler(async (event) => {
}
}
cancelAccountStreams(serverSession.accountId);
await writeSessions(sessions);
writeSessions(sessions);
await deleteCookie(event, "session");
// Remove subscriptions for this user
let subscriptions = await readSubscriptions();
let subscriptions = readSubscriptions();
subscriptions = subscriptions.filter(
subscription => !expiredSessionIds.has(subscription.sessionId)
);
await writeSubscriptions(subscriptions);
writeSubscriptions(subscriptions);
// Remove the user
const account = users.find(user => user.id === serverSession.accountId)!;
const now = new Date(nowMs).toISOString();
account.deleted = true;
account.updatedAt = now;
await writeUsers(users);
writeUsers(users);
await broadcastEvent({
id: nextEventId(),
type: "user-update",
data: {
id: account.id,

View file

@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
}
}
const users = await readUsers();
const users = readUsers();
const account = users.find(user => user.id === session.accountId);
if (!account) {
throw Error("Account does not exist");
@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
else
delete account.locale;
}
await writeUsers(users);
writeUsers(users);
// Update Schedule counts.
await updateScheduleInterestedCounts(users);

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database";
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods, nextEventId } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import type { ApiSession } from "~/shared/types/api";
@ -18,7 +18,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
const body = await readBody(event);
const name = body?.name;
const users = await readUsers();
const users = readUsers();
let user: ServerUser;
if (typeof name === "string") {
if (name === "") {
@ -36,7 +36,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
const firstUser = users.every(user => user.type === "anonymous");
user = {
id: await nextUserId(),
id: nextUserId(),
updatedAt: new Date().toISOString(),
type: firstUser ? "admin" : "regular",
name,
@ -44,7 +44,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
} else if (name === undefined) {
user = {
id: await nextUserId(),
id: nextUserId(),
updatedAt: new Date().toISOString(),
type: "anonymous",
};
@ -76,18 +76,19 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
});
}
authMethods.push({
id: await nextAuthenticationMethodId(),
id: nextAuthenticationMethodId(),
userId: user.id,
provider: session.authenticationProvider,
slug: session.authenticationSlug!,
name: session.authenticationName!,
})
await writeAuthenticationMethods(authMethods);
writeAuthenticationMethods(authMethods);
}
users.push(user);
await writeUsers(users);
writeUsers(users);
await broadcastEvent({
id: nextEventId(),
type: "user-update",
data: user,
});

View file

@ -24,11 +24,11 @@ export default defineEventHandler(async (event) => {
});
}
const authMethods = await readAuthenticationMethods();
const authMethods = readAuthenticationMethods();
const method = authMethods.find(method => method.provider === "demo" && method.slug === slug);
let session;
if (method) {
const users = await readUsers();
const users = readUsers();
const account = users.find(user => !user.deleted && user.id === method.userId);
session = await setServerSession(event, account);
} else {

View file

@ -84,11 +84,11 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
}
const slug = String(data.authData.id);
const authMethods = await readAuthenticationMethods();
const authMethods = readAuthenticationMethods();
const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug);
let session;
if (method) {
const users = await readUsers();
const users = readUsers();
const account = users.find(user => !user.deleted && user.id === method.userId);
session = await setServerSession(event, account);
} else {

View file

@ -8,7 +8,7 @@ import { cancelSessionStreams } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event, true);
if (session) {
const users = await readUsers();
const users = readUsers();
const account = users.find(user => user.id === session.accountId);
if (account?.type === "anonymous") {
throw createError({

View file

@ -3,33 +3,47 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { pipeline } from "node:stream";
import { addStream, deleteStream } from "~/server/streams";
import { createEventStream } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event, false);
const encoder = new TextEncoder();
const source = event.headers.get("x-forwarded-for");
console.log(`starting event stream for ${source}`)
const stream = new TransformStream<string, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(encoder.encode(chunk));
},
flush(controller) {
console.log(`finished event stream for ${source}`);
deleteStream(stream.writable);
},
// @ts-expect-error experimental API
cancel(reason) {
console.log(`cancelled event stream for ${source}`);
deleteStream(stream.writable);
let lastEventId: number | undefined;
const lastEventIdHeader = event.headers.get("Last-Event-ID");
const lastEventIdQuery = getQuery(event)["lastEventId"];
if (lastEventIdHeader) {
if (!/^[0-9]{1,15}$/.test(lastEventIdHeader)) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Malformed Last-Event-ID header",
});
}
});
addStream(event, stream.writable, session);
lastEventId = Number.parseInt(lastEventIdHeader, 10);
} else if (lastEventIdQuery) {
if (typeof lastEventIdQuery !== "string" || !/^[0-9]{1,15}$/.test(lastEventIdQuery)) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Malformed lastEventId",
});
}
lastEventId = Number.parseInt(lastEventIdQuery, 10);
} else {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "lastEventId is required",
});
}
const source = event.headers.get("x-forwarded-for") ?? "";
const stream = await createEventStream(event, source, lastEventId, session);
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
setHeader(event, "Access-Control-Allow-Origin", "*");
setHeader(event, "Content-Type", "text/event-stream");
pipeline(stream.readable as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ });
setHeader(event, "Cache-Control", "no-store");
pipeline(stream as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ });
event._handled = true;
});

View file

@ -0,0 +1,11 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readEvents } from "../database";
export default defineEventHandler(async (event) => {
const events = readEvents();
return events[events.length - 1]?. id ?? 0;
});

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from "zod/v4-mini";
import { readSchedule, writeSchedule } from "~/server/database";
import { nextEventId, readSchedule, writeSchedule } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import { apiScheduleSchema } from "~/shared/types/api";
import { applyUpdatesToArray } from "~/shared/utils/update";
@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => {
});
}
const schedule = await readSchedule();
const schedule = readSchedule();
if (schedule.deleted) {
throw createError({
@ -85,8 +85,9 @@ export default defineEventHandler(async (event) => {
applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []);
}
await writeSchedule(schedule);
writeSchedule(schedule);
await broadcastEvent({
id: nextEventId(),
type: "schedule-update",
updatedFrom,
data: update,

View file

@ -6,6 +6,6 @@ import { readSchedule } from "~/server/database";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event, false);
const schedule = await readSchedule();
const schedule = readSchedule();
return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule);
});

View file

@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
message: z.prettifyError(error),
});
}
const subscriptions = await readSubscriptions();
const subscriptions = readSubscriptions();
const existingIndex = subscriptions.findIndex(
sub => sub.type === "push" && sub.sessionId === session.id
);
@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
} else {
subscriptions.push(subscription);
}
await writeSubscriptions(subscriptions);
writeSubscriptions(subscriptions);
if (existingIndex !== -1) {
return { message: "Existing subscription refreshed."};
}

View file

@ -6,7 +6,7 @@ import { readSubscriptions, writeSubscriptions } from "~/server/database";
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const subscriptions = await readSubscriptions();
const subscriptions = readSubscriptions();
const existingIndex = subscriptions.findIndex(
sub => sub.type === "push" && sub.sessionId === session.id
);
@ -15,6 +15,6 @@ export default defineEventHandler(async (event) => {
} else {
return { message: "No subscription registered."};
}
await writeSubscriptions(subscriptions);
writeSubscriptions(subscriptions);
return { message: "Existing subscription removed."};
});

View file

@ -13,7 +13,7 @@ const detailsSchema = z.object({
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const users = await readUsers();
const users = readUsers();
const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event));
if (!success) {
throw createError({

View file

@ -6,7 +6,7 @@ import { readUsers } from "~/server/database"
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const users = await readUsers();
const users = readUsers();
if (session.access === "admin") {
return users.map(serverUserToApi);

View file

@ -2,8 +2,8 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readFile, unlink, writeFile } from "node:fs/promises";
import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
import type { ApiAuthenticationProvider, ApiEvent, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
export interface ServerSession {
@ -50,10 +50,12 @@ const sessionsPath = "data/sessions.json";
const nextSessionIdPath = "data/next-session-id.json";
const authMethodPath = "data/auth-method.json";
const nextAuthenticationMethodIdPath = "data/auth-method-id.json"
const nextEventIdPath = "data/next-event-id.json";
const eventsPath = "data/events.json";
async function remove(path: string) {
function remove(path: string) {
try {
await unlink(path);
unlinkSync(path);
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
@ -61,17 +63,17 @@ async function remove(path: string) {
}
}
export async function deleteDatabase() {
await remove(schedulePath);
await remove(subscriptionsPath);
await remove(usersPath);
await remove(sessionsPath);
export function deleteDatabase() {
remove(schedulePath);
remove(subscriptionsPath);
remove(usersPath);
remove(sessionsPath);
}
async function readJson<T>(filePath: string, fallback: T) {
function readJson<T>(filePath: string, fallback: T) {
let data: T extends () => infer R ? R : T;
try {
data = JSON.parse(await readFile(filePath, "utf-8"));
data = JSON.parse(readFileSync(filePath, "utf-8"));
} catch (err: any) {
if (err.code !== "ENOENT")
throw err;
@ -80,19 +82,19 @@ async function readJson<T>(filePath: string, fallback: T) {
return data;
}
export async function readSchedule() {
export function readSchedule() {
return readJson(schedulePath, (): ApiSchedule => ({
id: 111,
updatedAt: new Date().toISOString(),
}));
}
export async function writeSchedule(schedule: ApiSchedule) {
await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
export function writeSchedule(schedule: ApiSchedule) {
writeFileSync(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
}
export async function readSubscriptions() {
let subscriptions = await readJson<ApiSubscription[]>(subscriptionsPath, []);
export function readSubscriptions() {
let subscriptions = readJson<ApiSubscription[]>(subscriptionsPath, []);
if (subscriptions.length && "keys" in subscriptions[0]) {
// Discard old format
subscriptions = [];
@ -100,71 +102,89 @@ export async function readSubscriptions() {
return subscriptions;
}
export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
export function writeSubscriptions(subscriptions: ApiSubscription[]) {
writeFileSync(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
}
export async function readNextUserId() {
return await readJson(nextUserIdPath, 0);
export function readNextUserId() {
return readJson(nextUserIdPath, 0);
}
export async function writeNextUserId(nextId: number) {
await writeFile(nextUserIdPath, String(nextId), "utf-8");
export function writeNextUserId(nextId: number) {
writeFileSync(nextUserIdPath, String(nextId), "utf-8");
}
export async function nextUserId() {
let nextId = await readJson(nextUserIdPath, 0);
export function nextUserId() {
let nextId = readJson(nextUserIdPath, 0);
if (nextId === 0) {
nextId = Math.max(...(await readUsers()).map(user => user.id), -1) + 1;
nextId = Math.max(...(readUsers()).map(user => user.id), -1) + 1;
}
await writeFile(nextUserIdPath, String(nextId + 1), "utf-8");
writeFileSync(nextUserIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export async function readUsers() {
return await readJson(usersPath, (): ServerUser[] => []);
export function readUsers() {
return readJson(usersPath, (): ServerUser[] => []);
}
export async function writeUsers(users: ServerUser[]) {
await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
export function writeUsers(users: ServerUser[]) {
writeFileSync(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
}
export async function readNextSessionId() {
return await readJson(nextSessionIdPath, 0);
export function readNextSessionId() {
return readJson(nextSessionIdPath, 0);
}
export async function writeNextSessionId(nextId: number) {
await writeFile(nextSessionIdPath, String(nextId), "utf-8");
export function writeNextSessionId(nextId: number) {
writeFileSync(nextSessionIdPath, String(nextId), "utf-8");
}
export async function nextSessionId() {
const nextId = await readJson(nextSessionIdPath, 0);
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");
export function nextSessionId() {
const nextId = readJson(nextSessionIdPath, 0);
writeFileSync(nextSessionIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export async function readSessions() {
export function readSessions() {
return readJson<ServerSession[]>(sessionsPath, [])
}
export async function writeSessions(sessions: ServerSession[]) {
await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
export function writeSessions(sessions: ServerSession[]) {
writeFileSync(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
}
export async function nextAuthenticationMethodId() {
const nextId = await readJson(nextAuthenticationMethodIdPath, 0);
await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
export function nextAuthenticationMethodId() {
const nextId = readJson(nextAuthenticationMethodIdPath, 0);
writeFileSync(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export async function writeNextAuthenticationMethodId(nextId: number) {
await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
export function writeNextAuthenticationMethodId(nextId: number) {
writeFileSync(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
}
export async function readAuthenticationMethods() {
export function readAuthenticationMethods() {
return readJson<ServerAuthenticationMethod[]>(authMethodPath, [])
}
export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8");
export function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
writeFileSync(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8");
}
export function nextEventId() {
const nextId = readJson(nextEventIdPath, 0);
writeFileSync(nextEventIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export function writeNextEventId(nextId: number) {
writeFileSync(nextEventIdPath, String(nextId), "utf-8");
}
export function readEvents() {
return readJson<ApiEvent[]>(eventsPath, [])
}
export function writeEvents(events: ApiEvent[]) {
writeFileSync(eventsPath, JSON.stringify(events, undefined, "\t") + "\n", "utf-8");
}

View file

@ -2,82 +2,134 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, type ServerSession } from "~/server/database";
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
import { readEvents, writeEvents, readUsers, type ServerSession } from "~/server/database";
import type { ApiAccount, ApiDisconnected, ApiEvent, ApiEventStreamMessage, ApiUserType } from "~/shared/types/api";
import { serverSessionToApi } from "./utils/session";
import { H3Event } from "h3";
function sendMessage(
stream: WritableStream<string>,
message: string,
) {
const writer = stream.getWriter();
writer.ready
.then(() => writer.write(message))
.catch(console.error)
.finally(() => writer.releaseLock())
;
const keepaliveTimeoutMs = 45e3;
const eventUpdateTimeMs = 1e3;
class EventStream {
write!: (data: string) => void;
close!: (reason?: string) => void;
constructor(
public sessionId: number | undefined,
public accountId: number | undefined,
public userType: ApiUserType | undefined,
public rotatesAtMs: number ,
public lastKeepAliveMs: number,
public lastEventId: number,
) {
}
}
function sendMessageAndClose(
stream: WritableStream<string>,
message: string,
) {
const writer = stream.getWriter();
writer.ready
.then(() => {
writer.write(message);
writer.close();
}).catch(console.error)
.finally(() => writer.releaseLock())
;
}
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
let keepaliveInterval: ReturnType<typeof setInterval> | null = null
export async function addStream(
export async function createEventStream(
event: H3Event,
stream: WritableStream<string>,
source: string,
lastEventId: number,
session?: ServerSession,
) {
if (streams.size === 0) {
console.log("Starting keepalive")
keepaliveInterval = setInterval(sendKeepalive, 4000)
}
const runtimeConfig = useRuntimeConfig(event);
streams.set(stream, {
sessionId: session?.id,
accountId: session?.accountId,
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
const now = Date.now();
const events = (readEvents()).filter(e => e.id > lastEventId);
const users = readUsers();
const apiSession = session ? await serverSessionToApi(event, session) : undefined;
let userType: ApiAccount["type"] | undefined;
if (session?.accountId !== undefined) {
userType = users.find(a => !a.deleted && a.id === session.accountId)?.type
}
const stream = new EventStream(
session?.id,
session?.accountId,
userType,
session?.rotatesAtMs ?? now + runtimeConfig.sessionRotatesTimeout * 1000,
now,
events[events.length - 1]?.id ?? lastEventId,
);
const readableStream = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
stream.write = (data: string) => {
controller.enqueue(encoder.encode(data));
}
stream.close = (reason?: string) => {
const data: ApiDisconnected = {
type: "disconnect",
reason,
};
stream.write(`data: ${JSON.stringify(data)}\n\n`);
controller.close();
deleteStream(stream);
},
console.log(`Starting event stream for ${source}`)
addStream(stream);
},
cancel(reason) {
console.log(`Cancelled event stream for ${source}:`, reason);
deleteStream(stream);
}
});
// Produce a response immediately to avoid the reply waiting for content.
const update: ApiEvent = {
const update: ApiEventStreamMessage = {
type: "connected",
session: session ? await serverSessionToApi(event, session) : undefined,
session: apiSession,
};
sendMessage(stream, `event: update\ndata: ${JSON.stringify(update)}\n\n`);
stream.write(`data: ${JSON.stringify(update)}\n\n`);
/*
Send events since the provided lastEventId
Warning: This have to happen either before addStream(stream) is
called, or as done here synchronously after it. Otherwise there's a
possibility of events being delivered out of order, which will break
the assumption made by the schedule updating logic.
*/
if (events.length)
console.log(`Sending ${events.length} event(s) to ${source}`);
for (const event of events) {
if (!sendEventToStream(stream, event)) {
break;
}
}
return readableStream;
}
export function deleteStream(stream: WritableStream<string>) {
let updateInterval: ReturnType<typeof setInterval> | null = null
const streams = new Set<EventStream>();
function addStream(
stream: EventStream,
) {
if (streams.size === 0) {
console.log("Starting event updates")
updateInterval = setInterval(sendEventUpdates, eventUpdateTimeMs)
}
streams.add(stream);
}
function deleteStream(stream: EventStream) {
streams.delete(stream);
if (streams.size === 0) {
console.log("Ending keepalive")
clearInterval(keepaliveInterval!);
console.log("Ending event updates")
clearInterval(updateInterval!);
updateInterval = null;
}
}
export function cancelAccountStreams(accountId: number) {
for (const [stream, data] of streams) {
if (data.accountId === accountId) {
sendMessageAndClose(stream, `data: cancelled\n\n`);
for (const stream of streams.values()) {
if (stream.accountId === accountId) {
stream.close("cancelled");
}
}
}
export function cancelSessionStreams(sessionId: number) {
for (const [stream, data] of streams) {
if (data.sessionId === sessionId) {
sendMessageAndClose(stream, `data: cancelled\n\n`);
for (const stream of streams.values()) {
if (stream.sessionId === sessionId) {
stream.close("cancelled");
}
}
}
@ -94,6 +146,7 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined)
if (event.type === "schedule-update") {
if (!canSeeCrew(userType)) {
event = {
id: event.id,
type: event.type,
updatedFrom: event.updatedFrom,
data: filterSchedule(event.data),
@ -106,6 +159,7 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined)
|| !event.data.deleted && event.data.type === "anonymous" && !canSeeAnonymous(userType)
) {
event = {
id: event.id,
type: event.type,
data: {
id: event.data.id,
@ -128,44 +182,68 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined)
}
export async function broadcastEvent(event: ApiEvent) {
const id = Date.now();
console.log(`broadcasting update to ${streams.size} clients`);
if (!streams.size) {
return;
}
const events = readEvents();
events.push(event);
writeEvents(events);
}
function sendEventToStream(stream: EventStream, event: ApiEvent) {
// Session expiry events cause the streams belonging to that session to be terminated
if (event.type === "session-expired") {
cancelSessionStreams(event.sessionId);
return;
}
const users = await readUsers();
for (const [stream, streamData] of streams) {
// Account events are specially handled and only sent to the user they belong to.
if (event.type === "account-update") {
if (streamData.accountId === event.data.id) {
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${JSON.stringify(event)}\n\n`);
}
} else {
let userType: ApiAccount["type"] | undefined;
if (streamData.accountId !== undefined) {
userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type
}
const data = encodeEvent(event, userType)
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
if (stream.sessionId === event.sessionId) {
stream.close("session expired");
return false;
}
return true;
}
// Account events are specially handled and only sent to the user they belong to.
if (event.type === "account-update") {
if (stream.accountId === event.data.id) {
stream.write(`id: ${event.id}\nevent: event\ndata: ${JSON.stringify(event)}\n\n`);
}
return true;
}
// All other events are encoded according to the user access level seeing it.
const data = encodeEvent(event, stream.userType)
stream.write(`id: ${event.id}\nevent: event\ndata: ${data}\n\n`);
return true;
}
function sendKeepalive() {
async function sendEventUpdates() {
// Cancel streams that need to be rotated.
const now = Date.now();
for (const [stream, streamData] of streams) {
if (streamData.rotatesAtMs > now) {
sendMessage(stream, ": keepalive\n");
} else {
sendMessageAndClose(stream, `data: cancelled\n\n`);
for (const stream of streams.values()) {
if (stream.rotatesAtMs < now) {
stream.close("session rotation");
continue;
}
}
// Send events.
const skipEventId = Math.min(...[...streams.values()].map(s => s.lastEventId));
const events = (readEvents()).filter(e => e.id > skipEventId);
if (events.length)
console.log(`broadcasting ${events.length} event(s) to ${streams.size} client(s)`);
for (const stream of streams.values()) {
for (const event of events) {
if (event.id > stream.lastEventId) {
stream.lastEventId = event.id;
stream.lastKeepAliveMs = now;
if (!sendEventToStream(stream, event)) {
break;
}
}
}
}
// Send Keepalives to streams with no activity.
for (const stream of streams.values()) {
if (stream.lastKeepAliveMs + keepaliveTimeoutMs < now) {
stream.write(": keepalive\n");
stream.lastKeepAliveMs = now;
}
}
}

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readSchedule, type ServerUser, writeSchedule } from '~/server/database';
import { nextEventId, readSchedule, type ServerUser, writeSchedule } from '~/server/database';
import { broadcastEvent } from '~/server/streams';
import type { ApiSchedule, ApiTombstone } from '~/shared/types/api';
@ -20,7 +20,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) {
eventSlotCounts.set(id, (eventSlotCounts.get(id) ?? 0) + 1);
}
const schedule = await readSchedule();
const schedule = readSchedule();
if (schedule.deleted) {
throw new Error("Deleted schedule not implemented");
}
@ -58,6 +58,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) {
schedule.updatedAt = updatedFrom;
await writeSchedule(schedule);
await broadcastEvent({
id: nextEventId(),
type: "schedule-update",
updatedFrom,
data: update,

View file

@ -4,6 +4,7 @@
*/
import type { H3Event } from "h3";
import {
nextEventId,
nextSessionId,
readSessions,
readSubscriptions,
@ -18,11 +19,11 @@ import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
import { serverUserToApiAccount } from "./user";
async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions();
const subscriptions = readSubscriptions();
const index = subscriptions.findIndex(subscription => subscription.sessionId === sessionId);
if (index !== -1) {
subscriptions.splice(index, 1);
await writeSubscriptions(subscriptions);
writeSubscriptions(subscriptions);
}
}
@ -34,6 +35,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
if (session) {
session.expiresAtMs = Date.now();
broadcastEvent({
id: nextEventId(),
type: "session-expired",
sessionId,
});
@ -45,9 +47,9 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
}
export async function clearServerSession(event: H3Event) {
const sessions = await readSessions();
const sessions = readSessions();
if (await clearServerSessionInternal(event, sessions)) {
await writeSessions(sessions);
writeSessions(sessions);
}
deleteCookie(event, "session");
}
@ -59,7 +61,7 @@ export async function setServerSession(
authenticationSlug?: string,
authenticationName?: string,
) {
const sessions = await readSessions();
const sessions = readSessions();
const runtimeConfig = useRuntimeConfig(event);
await clearServerSessionInternal(event, sessions);
@ -76,14 +78,14 @@ export async function setServerSession(
};
sessions.push(newSession);
await writeSessions(sessions);
writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
const runtimeConfig = useRuntimeConfig(event);
const users = await readUsers();
const users = readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const now = Date.now();
const newSession: ServerSession = {
@ -92,12 +94,12 @@ async function rotateSession(event: H3Event, sessions: ServerSession[], session:
// Authentication provider is removed to avoid possibility of an infinite delay before using it.
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
id: await nextSessionId(),
id: nextSessionId(),
};
session.successor = newSession.id;
session.expiresAtMs = Date.now() + 10 * 1000;
sessions.push(newSession);
await writeSessions(sessions);
writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
@ -106,7 +108,7 @@ export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
const sessions = readSessions();
const session = sessions.find(session => session.id === sessionId);
if (session) {
const nowMs = Date.now();
@ -146,7 +148,7 @@ export async function requireServerSession(event: H3Event, message: string) {
export async function requireServerSessionWithUser(event: H3Event) {
const message = "User session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const users = readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.accountId === undefined || !account || account.deleted)
throw createError({
@ -161,7 +163,7 @@ export async function requireServerSessionWithUser(event: H3Event) {
export async function requireServerSessionWithAdmin(event: H3Event) {
const message = "Admin session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const users = readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.access !== "admin" || account?.type !== "admin") {
throw createError({
@ -174,9 +176,9 @@ export async function requireServerSessionWithAdmin(event: H3Event) {
}
export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise<ApiSession> {
const users = await readUsers();
const users = readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const subscriptions = await readSubscriptions();
const subscriptions = readSubscriptions();
const push = Boolean(
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
);

View file

@ -33,7 +33,7 @@ async function useVapidDetails(event: H3Event) {
export async function sendPush(event: H3Event, title: string, body: string) {
const vapidDetails = await useVapidDetails(event);
const payload = JSON.stringify({ title, body });
const subscriptions = await readSubscriptions();
const subscriptions = readSubscriptions();
console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`);
const removeIndexes = [];
for (let index = 0; index < subscriptions.length; index += 1) {
@ -65,7 +65,7 @@ export async function sendPush(event: H3Event, title: string, body: string) {
for (const index of removeIndexes) {
subscriptions.splice(index, 1);
}
await writeSubscriptions(subscriptions);
writeSubscriptions(subscriptions);
}
console.log("Push notices sent");
}

View file

@ -155,27 +155,26 @@ export interface ApiUserDetails {
}
export interface ApiAccountUpdate {
id: Id,
type: "account-update",
data: ApiAccount,
}
export interface ApiConnected {
type: "connected",
session?: ApiSession,
}
export interface ApiScheduleUpdate {
id: Id,
type: "schedule-update",
updatedFrom?: string,
data: ApiSchedule | ApiTombstone,
}
export interface ApiSessionExpired {
id: Id,
type: "session-expired",
sessionId: Id,
}
export interface ApiUserUpdate {
id: Id,
type: "user-update",
updatedFrom?: string,
data: ApiUser | ApiTombstone,
@ -183,8 +182,22 @@ export interface ApiUserUpdate {
export type ApiEvent =
| ApiAccountUpdate
| ApiConnected
| ApiScheduleUpdate
| ApiSessionExpired
| ApiUserUpdate
;
export interface ApiConnected {
type: "connected",
session?: ApiSession,
}
export interface ApiDisconnected {
type: "disconnect",
reason?: string,
}
export type ApiEventStreamMessage =
| ApiConnected
| ApiDisconnected
;

31
stores/events.ts Normal file
View file

@ -0,0 +1,31 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const useEventsStore = defineStore("events", () => {
const state = {
lastEventId: ref(0),
};
const getters = {
}
const actions = {
async fetchLastEventId() {
const requestFetch = useRequestFetch();
state.lastEventId.value = await requestFetch("/api/last-event-id");
}
}
appEventSource?.addEventListener("event", (event) => {
if (event.data.id !== undefined) {
state.lastEventId.value = event.data.id
return;
}
});
return {
...state,
...getters,
...actions,
};
});

View file

@ -96,7 +96,7 @@ export const useSchedulesStore = defineStore("schedules", () => {
}
})
appEventSource?.addEventListener("update", (event) => {
appEventSource?.addEventListener("event", (event) => {
if (event.data.type !== "schedule-update") {
return;
}

View file

@ -57,7 +57,7 @@ export const useSessionStore = defineStore("session", () => {
},
};
appEventSource?.addEventListener("update", (event) => {
appEventSource?.addEventListener("message", (event) => {
if (event.data.type !== "connected") {
return;
}

View file

@ -75,7 +75,7 @@ export const useUsersStore = defineStore("users", () => {
},
}
appEventSource?.addEventListener("update", (event) => {
appEventSource?.addEventListener("event", (event) => {
if (event.data.type !== "user-update") {
return;
}