Compare commits
11 commits
Author | SHA1 | Date | |
---|---|---|---|
1826529bc8 | |||
a32a49b281 | |||
7314b26c77 | |||
5d4cdb7b83 | |||
80cec71308 | |||
f9d188b2ba | |||
0083696343 | |||
753da6d3d4 | |||
0a0eb43d78 | |||
6d93e99858 | |||
400bb7bfe9 |
50 changed files with 804 additions and 284 deletions
5
app.vue
5
app.vue
|
@ -11,7 +11,10 @@
|
||||||
import "~/assets/global.css";
|
import "~/assets/global.css";
|
||||||
const event = useRequestEvent();
|
const event = useRequestEvent();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
await callOnce("fetch-session", async () => {
|
const eventsStore = useEventsStore();
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
await callOnce("fetch-globals", async () => {
|
||||||
await sessionStore.fetch(event);
|
await sessionStore.fetch(event);
|
||||||
|
await nuxtApp.runWithContext(eventsStore.fetchLastEventId);
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -76,7 +76,7 @@ label>* {
|
||||||
margin-inline-start: 0.5rem;
|
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;
|
margin-block-start: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<section class="event">
|
<section
|
||||||
|
class="event"
|
||||||
|
:class="{ cancelled: event.cancelled }"
|
||||||
|
>
|
||||||
<h3>{{ event.name }}</h3>
|
<h3>{{ event.name }}</h3>
|
||||||
<p v-if=event.host>
|
<p v-if=event.host>
|
||||||
Host: {{ event.host }}
|
Host: {{ event.host }}
|
||||||
|
@ -16,7 +19,11 @@
|
||||||
{{ event.notice }}
|
{{ event.notice }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="preWrap">{{ event.description ?? "No description provided" }}</p>
|
<div
|
||||||
|
v-if="descriptionHtml"
|
||||||
|
class="flow"
|
||||||
|
v-html="descriptionHtml"
|
||||||
|
/>
|
||||||
<p v-if="event.interested">
|
<p v-if="event.interested">
|
||||||
{{ event.interested }} interested
|
{{ event.interested }} interested
|
||||||
</p>
|
</p>
|
||||||
|
@ -32,7 +39,12 @@
|
||||||
|
|
||||||
<h4>Timeslots</h4>
|
<h4>Timeslots</h4>
|
||||||
<ul>
|
<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) }}
|
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
|
||||||
<button
|
<button
|
||||||
v-if="accountStore.valid && event.slots.size > 1"
|
v-if="accountStore.valid && event.slots.size > 1"
|
||||||
|
@ -56,12 +68,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { micromark } from 'micromark';
|
||||||
import { DateTime } from '~/shared/utils/luxon';
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
event: ClientScheduleEvent
|
event: ClientScheduleEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const descriptionHtml = computed(() => {
|
||||||
|
if (props.event.description) {
|
||||||
|
return micromark(props.event.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
@ -80,6 +99,10 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.event.cancelled>*, .slot.cancelled {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: grey;
|
||||||
|
}
|
||||||
.event h3 {
|
.event h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
@ -87,7 +110,9 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
|
||||||
margin-block-start: 0.5rem;
|
margin-block-start: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice {
|
.event .notice {
|
||||||
|
text-decoration: none;
|
||||||
|
color: CanvasText;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<section class="eventSlot">
|
<section
|
||||||
|
class="eventSlot"
|
||||||
|
:class="{ cancelled: slot.cancelled || event?.cancelled }"
|
||||||
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h3>{{ event?.name }}</h3>
|
<h3>{{ event?.name }}</h3>
|
||||||
<p>
|
<p>
|
||||||
|
@ -21,7 +24,11 @@
|
||||||
{{ event.notice }}
|
{{ event.notice }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<p v-if="locations.length">
|
||||||
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
|
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
|
||||||
</p>
|
</p>
|
||||||
|
@ -40,12 +47,20 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { micromark } from 'micromark';
|
||||||
import { DateTime } from '~/shared/utils/luxon';
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event?: ClientScheduleEvent,
|
event?: ClientScheduleEvent,
|
||||||
slot: ClientScheduleEventSlot,
|
slot: ClientScheduleEventSlot,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const descriptionHtml = computed(() => {
|
||||||
|
if (props.event?.description)
|
||||||
|
return micromark(props.event.description);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
const scheduleStore = useSchedulesStore();
|
const scheduleStore = useSchedulesStore();
|
||||||
const schedule = scheduleStore.activeSchedule;
|
const schedule = scheduleStore.activeSchedule;
|
||||||
|
|
||||||
|
@ -63,6 +78,10 @@ function formatTime(time: DateTime) {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.eventSlot.cancelled>* {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: grey;
|
||||||
|
}
|
||||||
.eventSlot h3 {
|
.eventSlot h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
@ -70,7 +89,9 @@ function formatTime(time: DateTime) {
|
||||||
margin-block-start: 0.5rem;
|
margin-block-start: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice {
|
.eventSlot .notice {
|
||||||
|
text-decoration: none;
|
||||||
|
color: CanvasText;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
|
@ -5,7 +5,11 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="shift">
|
<section class="shift">
|
||||||
<h3>{{ shift.name }}</h3>
|
<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>
|
<h4>Timeslots</h4>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -21,12 +25,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { micromark } from 'micromark';
|
||||||
import { DateTime } from '~/shared/utils/luxon';
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
shift: ClientScheduleShift
|
shift: ClientScheduleShift
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const descriptionHtml = computed(() => {
|
||||||
|
if (props.shift.description) {
|
||||||
|
return micromark(props.shift.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
function formatTime(time: DateTime) {
|
function formatTime(time: DateTime) {
|
||||||
|
|
|
@ -61,7 +61,7 @@ const shifts = computed(() => {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
h2 {
|
h2 {
|
||||||
margin-block-start: 0.2rem;
|
margin-block-start: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,18 @@
|
||||||
:after='event.crew ? "No" : "Yes"'
|
:after='event.crew ? "No" : "Yes"'
|
||||||
:state
|
: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
|
<DiffFieldString
|
||||||
title="Description"
|
title="Description"
|
||||||
:before="event.serverDescription"
|
:before="event.serverDescription"
|
||||||
|
|
|
@ -24,6 +24,12 @@
|
||||||
:entities="schedule.locations"
|
:entities="schedule.locations"
|
||||||
:state
|
:state
|
||||||
/>
|
/>
|
||||||
|
<DiffFieldString
|
||||||
|
title="Cancelled"
|
||||||
|
:before='slot.serverCancelled ? "Yes" : "No"'
|
||||||
|
:after='slot.cancelled ? "Yes" : "No"'
|
||||||
|
:state
|
||||||
|
/>
|
||||||
<DiffFieldSetEntityId
|
<DiffFieldSetEntityId
|
||||||
title="Assigned"
|
title="Assigned"
|
||||||
:before="slot.serverAssigned"
|
:before="slot.serverAssigned"
|
||||||
|
|
|
@ -35,7 +35,3 @@ async function logIn() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
<th>duration</th>
|
<th>duration</th>
|
||||||
<th>event</th>
|
<th>event</th>
|
||||||
<th>location</th>
|
<th>location</th>
|
||||||
|
<th title="cancelled">c</th>
|
||||||
<th>assigned</th>
|
<th>assigned</th>
|
||||||
<th v-if="edit"></th>
|
<th v-if="edit"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -50,6 +51,7 @@
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td></td>
|
||||||
<td>
|
<td>
|
||||||
Add at
|
Add at
|
||||||
<button
|
<button
|
||||||
|
@ -97,6 +99,11 @@
|
||||||
v-model="es.slot.locationIds"
|
v-model="es.slot.locationIds"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="es.slot.cancelled">
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<SelectMultiEntity
|
<SelectMultiEntity
|
||||||
:entities="usersStore.users"
|
:entities="usersStore.users"
|
||||||
|
@ -149,6 +156,7 @@
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td></td>
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -177,6 +185,7 @@
|
||||||
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
||||||
<td>{{ es.event?.name }}</td>
|
<td>{{ es.event?.name }}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td>{{ es.slot.cancelled ? "x" : undefined }}</td>
|
||||||
<td><AssignedCrew :modelValue="es.slot.assigned" :edit="false" /></td>
|
<td><AssignedCrew :modelValue="es.slot.assigned" :edit="false" /></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
<th>notice</th>
|
<th>notice</th>
|
||||||
<th>description</th>
|
<th>description</th>
|
||||||
<th>p</th>
|
<th>p</th>
|
||||||
|
<th title="cancelled">c</th>
|
||||||
<th>s</th>
|
<th>s</th>
|
||||||
<th v-if="edit"></th>
|
<th v-if="edit"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -69,6 +70,13 @@
|
||||||
@change="event.crew = !event.crew"
|
@change="event.crew = !event.crew"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="!canEdit(event)"
|
||||||
|
v-model="event.cancelled"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
|
@ -123,6 +131,7 @@
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td></td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
v-if="eventExists(newEventName)"
|
v-if="eventExists(newEventName)"
|
||||||
|
|
|
@ -61,7 +61,3 @@
|
||||||
useEventSource();
|
useEventSource();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -55,7 +55,6 @@ nav {
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
padding-block-start: 1px;
|
padding-block-start: 1px;
|
||||||
|
@ -65,6 +64,7 @@ nav {
|
||||||
}
|
}
|
||||||
.tab .spacer {
|
.tab .spacer {
|
||||||
flex: 1 0 0.75rem;
|
flex: 1 0 0.75rem;
|
||||||
|
width: 0.75rem;
|
||||||
}
|
}
|
||||||
.tab .flap,
|
.tab .flap,
|
||||||
.tab .spacer {
|
.tab .spacer {
|
||||||
|
|
|
@ -102,7 +102,11 @@
|
||||||
v-for="cell, index in row"
|
v-for="cell, index in row"
|
||||||
:key="index"
|
:key="index"
|
||||||
:colSpan="cell.span"
|
: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"
|
:title="cell.event?.name"
|
||||||
>
|
>
|
||||||
{{ cell.event?.notice ? "⚠️" : undefined }}
|
{{ cell.event?.notice ? "⚠️" : undefined }}
|
||||||
|
@ -791,4 +795,9 @@ tr.hours>th + th.dayShift div {
|
||||||
.event.crew {
|
.event.crew {
|
||||||
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
|
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>
|
</style>
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
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 {
|
interface AppEventMap {
|
||||||
"open": Event,
|
"open": Event,
|
||||||
"message": MessageEvent<string>,
|
"message": MessageEvent<ApiEventStreamMessage>,
|
||||||
"update": MessageEvent<ApiEvent>,
|
"event": MessageEvent<ApiEvent>,
|
||||||
"error": Event,
|
"error": Event,
|
||||||
"close": Event,
|
"close": Event,
|
||||||
}
|
}
|
||||||
|
@ -18,12 +18,11 @@ class AppEventSource extends EventTarget {
|
||||||
|
|
||||||
#forwardEvent(type: string) {
|
#forwardEvent(type: string) {
|
||||||
this.#source!.addEventListener(type, event => {
|
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));
|
this.dispatchEvent(new Event(event.type));
|
||||||
} else {
|
} else if (type === "message") {
|
||||||
const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined;
|
const data = event.data ? JSON.parse(event.data) as ApiEventStreamMessage : undefined;
|
||||||
console.log("AppEventSource", event.type, data);
|
|
||||||
if (data?.type === "connected") {
|
if (data?.type === "connected") {
|
||||||
this.#sourceSessionId = data.session?.id;
|
this.#sourceSessionId = data.session?.id;
|
||||||
}
|
}
|
||||||
|
@ -34,17 +33,27 @@ class AppEventSource extends EventTarget {
|
||||||
source: event.source,
|
source: event.source,
|
||||||
ports: [...event.ports],
|
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);
|
console.log("Opening event source sid:", sessionId);
|
||||||
this.#sourceSessionId = 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("open");
|
||||||
this.#forwardEvent("message");
|
this.#forwardEvent("message");
|
||||||
this.#forwardEvent("update");
|
this.#forwardEvent("event");
|
||||||
this.#forwardEvent("error");
|
this.#forwardEvent("error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,20 +67,20 @@ class AppEventSource extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
#connectRefs = 0;
|
#connectRefs = 0;
|
||||||
connect(sessionId: number | undefined) {
|
connect(sessionId: number | undefined, lastEventId: number) {
|
||||||
this.#connectRefs += 1;
|
this.#connectRefs += 1;
|
||||||
if (this.#source && this.#sourceSessionId !== sessionId) {
|
if (this.#source && this.#sourceSessionId !== sessionId) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
if (!this.#source) {
|
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) {
|
if (this.#source && this.#sourceSessionId !== sessionId) {
|
||||||
this.close();
|
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() {
|
export function useEventSource() {
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
const eventsStore = useEventsStore();
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log("useEventSource onMounted", sessionStore.id);
|
console.log("useEventSource onMounted", sessionStore.id);
|
||||||
appEventSource!.connect(sessionStore.id);
|
appEventSource!.connect(sessionStore.id, eventsStore.lastEventId);
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => sessionStore.id, () => {
|
watch(() => sessionStore.id, () => {
|
||||||
console.log("useEventSource sessionStore.id change", sessionStore.id);
|
console.log("useEventSource sessionStore.id change", sessionStore.id);
|
||||||
appEventSource!.reconnect(sessionStore.id);
|
appEventSource!.reconnect(sessionStore.id, eventsStore.lastEventId);
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
@ -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.
|
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.
|
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.
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.1",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
|
"micromark": "^4.0.2",
|
||||||
"nuxt": "^3.17.6",
|
"nuxt": "^3.17.6",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
|
|
|
@ -109,7 +109,3 @@ const tabs = [
|
||||||
{ id: "database", title: "Database" },
|
{ id: "database", title: "Database" },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -84,7 +84,7 @@ const { pending, data, error } = await useFetch(() => `/api/users/${id.value}/de
|
||||||
const userDetails = data as Ref<ApiUserDetails | ApiTombstone>;
|
const userDetails = data as Ref<ApiUserDetails | ApiTombstone>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
dl {
|
dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|
|
@ -68,13 +68,19 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="location in schedule.locations.values()" :key="location.id">
|
<li v-for="location in schedule.locations.values()" :key="location.id">
|
||||||
<h3>{{ location.name }}</h3>
|
<h3>{{ location.name }}</h3>
|
||||||
{{ location.description ?? "No description provided" }}
|
<div
|
||||||
|
v-if="location.description"
|
||||||
|
class="flow"
|
||||||
|
v-html="micromark(location.description)"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { micromark } from 'micromark';
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Schedule",
|
title: "Schedule",
|
||||||
});
|
});
|
||||||
|
|
243
pnpm-lock.yaml
generated
243
pnpm-lock.yaml
generated
|
@ -14,6 +14,9 @@ importers:
|
||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.6.1
|
specifier: ^3.6.1
|
||||||
version: 3.6.1
|
version: 3.6.1
|
||||||
|
micromark:
|
||||||
|
specifier: ^4.0.2
|
||||||
|
version: 4.0.2
|
||||||
nuxt:
|
nuxt:
|
||||||
specifier: ^3.17.6
|
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)
|
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:
|
devDependencies:
|
||||||
'@nuxt/test-utils':
|
'@nuxt/test-utils':
|
||||||
specifier: ^3.19.2
|
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':
|
'@types/luxon':
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
|
@ -47,7 +50,7 @@ importers:
|
||||||
version: 17.6.3
|
version: 17.6.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
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:
|
vue-tsc:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1(typescript@5.8.3)
|
version: 3.0.1(typescript@5.8.3)
|
||||||
|
@ -1080,6 +1083,9 @@ packages:
|
||||||
'@types/chai@5.2.2':
|
'@types/chai@5.2.2':
|
||||||
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
|
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':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
@ -1089,6 +1095,9 @@ packages:
|
||||||
'@types/luxon@3.6.2':
|
'@types/luxon@3.6.2':
|
||||||
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
|
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0':
|
||||||
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
'@types/node@24.0.10':
|
'@types/node@24.0.10':
|
||||||
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
|
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
|
||||||
|
|
||||||
|
@ -1483,6 +1492,9 @@ packages:
|
||||||
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
|
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
character-entities@2.0.2:
|
||||||
|
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||||
|
|
||||||
check-error@2.1.1:
|
check-error@2.1.1:
|
||||||
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
|
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
@ -1714,6 +1726,9 @@ packages:
|
||||||
decache@4.6.2:
|
decache@4.6.2:
|
||||||
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
|
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
|
||||||
|
|
||||||
|
decode-named-character-reference@1.2.0:
|
||||||
|
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||||
|
|
||||||
deep-eql@5.0.2:
|
deep-eql@5.0.2:
|
||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -1749,6 +1764,10 @@ packages:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
dequal@2.0.3:
|
||||||
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
@ -1807,6 +1826,9 @@ packages:
|
||||||
devalue@5.1.1:
|
devalue@5.1.1:
|
||||||
resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
|
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:
|
diff@8.0.2:
|
||||||
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
@ -2484,6 +2506,66 @@ packages:
|
||||||
micro-api-client@3.3.0:
|
micro-api-client@3.3.0:
|
||||||
resolution: {integrity: sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg==}
|
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:
|
micromatch@4.0.8:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
@ -4622,7 +4704,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- 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:
|
dependencies:
|
||||||
'@nuxt/kit': 3.17.6(magicast@0.3.5)
|
'@nuxt/kit': 3.17.6(magicast@0.3.5)
|
||||||
c12: 3.0.4(magicast@0.3.5)
|
c12: 3.0.4(magicast@0.3.5)
|
||||||
|
@ -4646,11 +4728,11 @@ snapshots:
|
||||||
tinyexec: 1.0.1
|
tinyexec: 1.0.1
|
||||||
ufo: 1.6.1
|
ufo: 1.6.1
|
||||||
unplugin: 2.3.5
|
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)
|
vue: 3.5.17(typescript@5.8.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
happy-dom: 17.6.3
|
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:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
- typescript
|
- typescript
|
||||||
|
@ -4995,12 +5077,18 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
|
|
||||||
|
'@types/debug@4.1.12':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/luxon@3.6.2': {}
|
'@types/luxon@3.6.2': {}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@24.0.10':
|
'@types/node@24.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.8.0
|
undici-types: 7.8.0
|
||||||
|
@ -5515,6 +5603,8 @@ snapshots:
|
||||||
loupe: 3.1.4
|
loupe: 3.1.4
|
||||||
pathval: 2.0.1
|
pathval: 2.0.1
|
||||||
|
|
||||||
|
character-entities@2.0.2: {}
|
||||||
|
|
||||||
check-error@2.1.1: {}
|
check-error@2.1.1: {}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
|
@ -5730,6 +5820,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsite: 1.0.0
|
callsite: 1.0.0
|
||||||
|
|
||||||
|
decode-named-character-reference@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
character-entities: 2.0.2
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
@ -5751,6 +5845,8 @@ snapshots:
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@1.0.3: {}
|
detect-libc@1.0.3: {}
|
||||||
|
@ -5815,6 +5911,10 @@ snapshots:
|
||||||
|
|
||||||
devalue@5.1.1: {}
|
devalue@5.1.1: {}
|
||||||
|
|
||||||
|
devlop@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
|
||||||
diff@8.0.2: {}
|
diff@8.0.2: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
|
@ -6501,6 +6601,132 @@ snapshots:
|
||||||
|
|
||||||
micro-api-client@3.3.0: {}
|
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:
|
micromatch@4.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
|
@ -7919,9 +8145,9 @@ snapshots:
|
||||||
terser: 5.43.1
|
terser: 5.43.1
|
||||||
yaml: 2.8.0
|
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:
|
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:
|
transitivePeerDependencies:
|
||||||
- '@cucumber/cucumber'
|
- '@cucumber/cucumber'
|
||||||
- '@jest/globals'
|
- '@jest/globals'
|
||||||
|
@ -7936,7 +8162,7 @@ snapshots:
|
||||||
- typescript
|
- typescript
|
||||||
- vitest
|
- 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:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
'@vitest/expect': 3.2.4
|
'@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)
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/debug': 4.1.12
|
||||||
'@types/node': 24.0.10
|
'@types/node': 24.0.10
|
||||||
happy-dom: 17.6.3
|
happy-dom: 17.6.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
|
@ -9,11 +9,11 @@ export default defineEventHandler(async (event) => {
|
||||||
setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"');
|
setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"');
|
||||||
setHeader(event, "Content-Type", "application/json; charset=utf-8");
|
setHeader(event, "Content-Type", "application/json; charset=utf-8");
|
||||||
return {
|
return {
|
||||||
nextUserId: await readNextUserId(),
|
nextUserId: readNextUserId(),
|
||||||
users: await readUsers(),
|
users: readUsers(),
|
||||||
nextSessionId: await readNextSessionId(),
|
nextSessionId: readNextSessionId(),
|
||||||
sessions: await readSessions(),
|
sessions: readSessions(),
|
||||||
subscriptions: await readSubscriptions(),
|
subscriptions: readSubscriptions(),
|
||||||
schedule: await readSchedule(),
|
schedule: readSchedule(),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,14 +7,14 @@ import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-de
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireServerSessionWithAdmin(event);
|
await requireServerSessionWithAdmin(event);
|
||||||
const accounts = generateDemoAccounts();
|
const accounts = generateDemoAccounts();
|
||||||
await writeUsers(accounts);
|
writeUsers(accounts);
|
||||||
await writeSchedule(generateDemoSchedule());
|
writeSchedule(generateDemoSchedule());
|
||||||
await writeAuthenticationMethods(accounts.map((user, index) => ({
|
writeAuthenticationMethods(accounts.map((user, index) => ({
|
||||||
id: index,
|
id: index,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: "demo",
|
provider: "demo",
|
||||||
slug: user.name!,
|
slug: user.name!,
|
||||||
name: user.name!,
|
name: user.name!,
|
||||||
})));
|
})));
|
||||||
await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length));
|
writeNextAuthenticationMethodId(Math.max(nextAuthenticationMethodId(), accounts.length));
|
||||||
})
|
})
|
||||||
|
|
|
@ -29,20 +29,20 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentNextUserId = await readNextUserId();
|
const currentNextUserId = readNextUserId();
|
||||||
await writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
|
writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
|
||||||
await writeUsers(snapshot.users);
|
writeUsers(snapshot.users);
|
||||||
const currentNextSessionId = await readNextSessionId();
|
const currentNextSessionId = readNextSessionId();
|
||||||
await writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
|
writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
|
||||||
const currentSessions = new Map((await readSessions()).map(session => [session.id, session]));
|
const currentSessions = new Map((readSessions()).map(session => [session.id, session]));
|
||||||
await writeSessions(snapshot.sessions.filter(session => {
|
writeSessions(snapshot.sessions.filter(session => {
|
||||||
const current = currentSessions.get(session.id);
|
const current = currentSessions.get(session.id);
|
||||||
// Only keep sessions that match the account id in both sets to avoid
|
// Only keep sessions that match the account id in both sets to avoid
|
||||||
// resurrecting deleted sessions. This will still cause session cross
|
// resurrecting deleted sessions. This will still cause session cross
|
||||||
// pollution if a snapshot from another instance is loaded here.
|
// pollution if a snapshot from another instance is loaded here.
|
||||||
return current?.accountId !== undefined && current.accountId === session.accountId;
|
return current?.accountId !== undefined && current.accountId === session.accountId;
|
||||||
}));
|
}));
|
||||||
await writeSubscriptions(snapshot.subscriptions);
|
writeSubscriptions(snapshot.subscriptions);
|
||||||
await writeSchedule(snapshot.schedule);
|
writeSchedule(snapshot.schedule);
|
||||||
await sendRedirect(event, "/");
|
await sendRedirect(event, "/");
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,5 +6,5 @@ import { deleteDatabase } from "~/server/database";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireServerSessionWithAdmin(event);
|
await requireServerSessionWithAdmin(event);
|
||||||
await deleteDatabase();
|
deleteDatabase();
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
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 { apiUserPatchSchema } from "~/shared/types/api";
|
||||||
import { z } from "zod/v4-mini";
|
import { z } from "zod/v4-mini";
|
||||||
import { broadcastEvent } from "~/server/streams";
|
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);
|
const user = users.find(user => user.id === patch.id);
|
||||||
if (!user || user.deleted) {
|
if (!user || user.deleted) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
@ -52,26 +52,28 @@ export default defineEventHandler(async (event) => {
|
||||||
user.name = patch.name;
|
user.name = patch.name;
|
||||||
}
|
}
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await writeUsers(users);
|
writeUsers(users);
|
||||||
broadcastEvent({
|
broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "user-update",
|
type: "user-update",
|
||||||
data: serverUserToApi(user),
|
data: serverUserToApi(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rotate sessions with the user in it if the access changed
|
// Rotate sessions with the user in it if the access changed
|
||||||
if (accessChanged) {
|
if (accessChanged) {
|
||||||
const sessions = await readSessions();
|
const sessions = readSessions();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (session.accountId === user.id) {
|
if (session.accountId === user.id) {
|
||||||
session.rotatesAtMs = nowMs;
|
session.rotatesAtMs = nowMs;
|
||||||
broadcastEvent({
|
broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "session-expired",
|
type: "session-expired",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await writeSessions(sessions);
|
writeSessions(sessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Schedule counts.
|
// Update Schedule counts.
|
||||||
|
|
|
@ -5,16 +5,17 @@
|
||||||
import {
|
import {
|
||||||
readUsers, readSessions, readSubscriptions,
|
readUsers, readSessions, readSubscriptions,
|
||||||
writeUsers, writeSessions, writeSubscriptions,
|
writeUsers, writeSessions, writeSubscriptions,
|
||||||
|
nextEventId,
|
||||||
} from "~/server/database";
|
} from "~/server/database";
|
||||||
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
|
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const serverSession = await requireServerSessionWithUser(event);
|
const serverSession = await requireServerSessionWithUser(event);
|
||||||
let users = await readUsers();
|
let users = readUsers();
|
||||||
|
|
||||||
// Expire sessions for this user
|
// Expire sessions for this user
|
||||||
const expiredSessionIds = new Set<number>();
|
const expiredSessionIds = new Set<number>();
|
||||||
let sessions = await readSessions();
|
let sessions = readSessions();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (
|
if (
|
||||||
|
@ -24,6 +25,7 @@ export default defineEventHandler(async (event) => {
|
||||||
) {
|
) {
|
||||||
session.expiresAtMs = nowMs;
|
session.expiresAtMs = nowMs;
|
||||||
broadcastEvent({
|
broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "session-expired",
|
type: "session-expired",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
});
|
});
|
||||||
|
@ -31,23 +33,24 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancelAccountStreams(serverSession.accountId);
|
cancelAccountStreams(serverSession.accountId);
|
||||||
await writeSessions(sessions);
|
writeSessions(sessions);
|
||||||
await deleteCookie(event, "session");
|
await deleteCookie(event, "session");
|
||||||
|
|
||||||
// Remove subscriptions for this user
|
// Remove subscriptions for this user
|
||||||
let subscriptions = await readSubscriptions();
|
let subscriptions = readSubscriptions();
|
||||||
subscriptions = subscriptions.filter(
|
subscriptions = subscriptions.filter(
|
||||||
subscription => !expiredSessionIds.has(subscription.sessionId)
|
subscription => !expiredSessionIds.has(subscription.sessionId)
|
||||||
);
|
);
|
||||||
await writeSubscriptions(subscriptions);
|
writeSubscriptions(subscriptions);
|
||||||
|
|
||||||
// Remove the user
|
// Remove the user
|
||||||
const account = users.find(user => user.id === serverSession.accountId)!;
|
const account = users.find(user => user.id === serverSession.accountId)!;
|
||||||
const now = new Date(nowMs).toISOString();
|
const now = new Date(nowMs).toISOString();
|
||||||
account.deleted = true;
|
account.deleted = true;
|
||||||
account.updatedAt = now;
|
account.updatedAt = now;
|
||||||
await writeUsers(users);
|
writeUsers(users);
|
||||||
await broadcastEvent({
|
await broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "user-update",
|
type: "user-update",
|
||||||
data: {
|
data: {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
|
|
|
@ -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);
|
const account = users.find(user => user.id === session.accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw Error("Account does not exist");
|
throw Error("Account does not exist");
|
||||||
|
@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
||||||
else
|
else
|
||||||
delete account.locale;
|
delete account.locale;
|
||||||
}
|
}
|
||||||
await writeUsers(users);
|
writeUsers(users);
|
||||||
|
|
||||||
// Update Schedule counts.
|
// Update Schedule counts.
|
||||||
await updateScheduleInterestedCounts(users);
|
await updateScheduleInterestedCounts(users);
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
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 { broadcastEvent } from "~/server/streams";
|
||||||
import type { ApiSession } from "~/shared/types/api";
|
import type { ApiSession } from "~/shared/types/api";
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const name = body?.name;
|
const name = body?.name;
|
||||||
|
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
let user: ServerUser;
|
let user: ServerUser;
|
||||||
if (typeof name === "string") {
|
if (typeof name === "string") {
|
||||||
if (name === "") {
|
if (name === "") {
|
||||||
|
@ -36,7 +36,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||||
|
|
||||||
const firstUser = users.every(user => user.type === "anonymous");
|
const firstUser = users.every(user => user.type === "anonymous");
|
||||||
user = {
|
user = {
|
||||||
id: await nextUserId(),
|
id: nextUserId(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
type: firstUser ? "admin" : "regular",
|
type: firstUser ? "admin" : "regular",
|
||||||
name,
|
name,
|
||||||
|
@ -44,7 +44,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||||
|
|
||||||
} else if (name === undefined) {
|
} else if (name === undefined) {
|
||||||
user = {
|
user = {
|
||||||
id: await nextUserId(),
|
id: nextUserId(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
type: "anonymous",
|
type: "anonymous",
|
||||||
};
|
};
|
||||||
|
@ -76,18 +76,19 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
authMethods.push({
|
authMethods.push({
|
||||||
id: await nextAuthenticationMethodId(),
|
id: nextAuthenticationMethodId(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: session.authenticationProvider,
|
provider: session.authenticationProvider,
|
||||||
slug: session.authenticationSlug!,
|
slug: session.authenticationSlug!,
|
||||||
name: session.authenticationName!,
|
name: session.authenticationName!,
|
||||||
})
|
})
|
||||||
await writeAuthenticationMethods(authMethods);
|
writeAuthenticationMethods(authMethods);
|
||||||
}
|
}
|
||||||
|
|
||||||
users.push(user);
|
users.push(user);
|
||||||
await writeUsers(users);
|
writeUsers(users);
|
||||||
await broadcastEvent({
|
await broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "user-update",
|
type: "user-update",
|
||||||
data: user,
|
data: user,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
const method = authMethods.find(method => method.provider === "demo" && method.slug === slug);
|
||||||
let session;
|
let session;
|
||||||
if (method) {
|
if (method) {
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => !user.deleted && user.id === method.userId);
|
const account = users.find(user => !user.deleted && user.id === method.userId);
|
||||||
session = await setServerSession(event, account);
|
session = await setServerSession(event, account);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -84,11 +84,11 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = String(data.authData.id);
|
const slug = String(data.authData.id);
|
||||||
const authMethods = await readAuthenticationMethods();
|
const authMethods = readAuthenticationMethods();
|
||||||
const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug);
|
const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug);
|
||||||
let session;
|
let session;
|
||||||
if (method) {
|
if (method) {
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => !user.deleted && user.id === method.userId);
|
const account = users.find(user => !user.deleted && user.id === method.userId);
|
||||||
session = await setServerSession(event, account);
|
session = await setServerSession(event, account);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { cancelSessionStreams } from "~/server/streams";
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await getServerSession(event, true);
|
const session = await getServerSession(event, true);
|
||||||
if (session) {
|
if (session) {
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => user.id === session.accountId);
|
const account = users.find(user => user.id === session.accountId);
|
||||||
if (account?.type === "anonymous") {
|
if (account?.type === "anonymous") {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
@ -3,33 +3,47 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
import { pipeline } from "node:stream";
|
import { pipeline } from "node:stream";
|
||||||
import { addStream, deleteStream } from "~/server/streams";
|
import { createEventStream } from "~/server/streams";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await getServerSession(event, false);
|
const session = await getServerSession(event, false);
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
let lastEventId: number | undefined;
|
||||||
const source = event.headers.get("x-forwarded-for");
|
const lastEventIdHeader = event.headers.get("Last-Event-ID");
|
||||||
console.log(`starting event stream for ${source}`)
|
const lastEventIdQuery = getQuery(event)["lastEventId"];
|
||||||
const stream = new TransformStream<string, Uint8Array>({
|
if (lastEventIdHeader) {
|
||||||
transform(chunk, controller) {
|
if (!/^[0-9]{1,15}$/.test(lastEventIdHeader)) {
|
||||||
controller.enqueue(encoder.encode(chunk));
|
throw createError({
|
||||||
},
|
statusCode: 400,
|
||||||
flush(controller) {
|
statusMessage: "Bad Request",
|
||||||
console.log(`finished event stream for ${source}`);
|
message: "Malformed Last-Event-ID header",
|
||||||
deleteStream(stream.writable);
|
|
||||||
},
|
|
||||||
// @ts-expect-error experimental API
|
|
||||||
cancel(reason) {
|
|
||||||
console.log(`cancelled event stream for ${source}`);
|
|
||||||
deleteStream(stream.writable);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
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
|
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
|
||||||
setHeader(event, "Access-Control-Allow-Origin", "*");
|
setHeader(event, "Access-Control-Allow-Origin", "*");
|
||||||
setHeader(event, "Content-Type", "text/event-stream");
|
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;
|
event._handled = true;
|
||||||
});
|
});
|
||||||
|
|
11
server/api/last-event-id.ts
Normal file
11
server/api/last-event-id.ts
Normal 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;
|
||||||
|
});
|
|
@ -3,7 +3,7 @@
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
import { z } from "zod/v4-mini";
|
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 { broadcastEvent } from "~/server/streams";
|
||||||
import { apiScheduleSchema } from "~/shared/types/api";
|
import { apiScheduleSchema } from "~/shared/types/api";
|
||||||
import { applyUpdatesToArray } from "~/shared/utils/update";
|
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) {
|
if (schedule.deleted) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
@ -85,8 +85,9 @@ export default defineEventHandler(async (event) => {
|
||||||
applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []);
|
applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeSchedule(schedule);
|
writeSchedule(schedule);
|
||||||
await broadcastEvent({
|
await broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "schedule-update",
|
type: "schedule-update",
|
||||||
updatedFrom,
|
updatedFrom,
|
||||||
data: update,
|
data: update,
|
||||||
|
|
|
@ -6,6 +6,6 @@ import { readSchedule } from "~/server/database";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await getServerSession(event, false);
|
const session = await getServerSession(event, false);
|
||||||
const schedule = await readSchedule();
|
const schedule = readSchedule();
|
||||||
return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule);
|
return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule);
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||||
message: z.prettifyError(error),
|
message: z.prettifyError(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = readSubscriptions();
|
||||||
const existingIndex = subscriptions.findIndex(
|
const existingIndex = subscriptions.findIndex(
|
||||||
sub => sub.type === "push" && sub.sessionId === session.id
|
sub => sub.type === "push" && sub.sessionId === session.id
|
||||||
);
|
);
|
||||||
|
@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||||
} else {
|
} else {
|
||||||
subscriptions.push(subscription);
|
subscriptions.push(subscription);
|
||||||
}
|
}
|
||||||
await writeSubscriptions(subscriptions);
|
writeSubscriptions(subscriptions);
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
return { message: "Existing subscription refreshed."};
|
return { message: "Existing subscription refreshed."};
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { readSubscriptions, writeSubscriptions } from "~/server/database";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireServerSessionWithUser(event);
|
const session = await requireServerSessionWithUser(event);
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = readSubscriptions();
|
||||||
const existingIndex = subscriptions.findIndex(
|
const existingIndex = subscriptions.findIndex(
|
||||||
sub => sub.type === "push" && sub.sessionId === session.id
|
sub => sub.type === "push" && sub.sessionId === session.id
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,6 @@ export default defineEventHandler(async (event) => {
|
||||||
} else {
|
} else {
|
||||||
return { message: "No subscription registered."};
|
return { message: "No subscription registered."};
|
||||||
}
|
}
|
||||||
await writeSubscriptions(subscriptions);
|
writeSubscriptions(subscriptions);
|
||||||
return { message: "Existing subscription removed."};
|
return { message: "Existing subscription removed."};
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,7 @@ const detailsSchema = z.object({
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireServerSessionWithAdmin(event);
|
await requireServerSessionWithAdmin(event);
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event));
|
const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event));
|
||||||
if (!success) {
|
if (!success) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { readUsers } from "~/server/database"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireServerSessionWithUser(event);
|
const session = await requireServerSessionWithUser(event);
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
|
|
||||||
if (session.access === "admin") {
|
if (session.access === "admin") {
|
||||||
return users.map(serverUserToApi);
|
return users.map(serverUserToApi);
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||||
import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
import type { ApiAuthenticationProvider, ApiEvent, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
||||||
import type { Id } from "~/shared/types/common";
|
import type { Id } from "~/shared/types/common";
|
||||||
|
|
||||||
export interface ServerSession {
|
export interface ServerSession {
|
||||||
|
@ -50,10 +50,12 @@ const sessionsPath = "data/sessions.json";
|
||||||
const nextSessionIdPath = "data/next-session-id.json";
|
const nextSessionIdPath = "data/next-session-id.json";
|
||||||
const authMethodPath = "data/auth-method.json";
|
const authMethodPath = "data/auth-method.json";
|
||||||
const nextAuthenticationMethodIdPath = "data/auth-method-id.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 {
|
try {
|
||||||
await unlink(path);
|
unlinkSync(path);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code !== "ENOENT") {
|
if (err.code !== "ENOENT") {
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -61,17 +63,17 @@ async function remove(path: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDatabase() {
|
export function deleteDatabase() {
|
||||||
await remove(schedulePath);
|
remove(schedulePath);
|
||||||
await remove(subscriptionsPath);
|
remove(subscriptionsPath);
|
||||||
await remove(usersPath);
|
remove(usersPath);
|
||||||
await remove(sessionsPath);
|
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;
|
let data: T extends () => infer R ? R : T;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(await readFile(filePath, "utf-8"));
|
data = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code !== "ENOENT")
|
if (err.code !== "ENOENT")
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -80,19 +82,19 @@ async function readJson<T>(filePath: string, fallback: T) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSchedule() {
|
export function readSchedule() {
|
||||||
return readJson(schedulePath, (): ApiSchedule => ({
|
return readJson(schedulePath, (): ApiSchedule => ({
|
||||||
id: 111,
|
id: 111,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSchedule(schedule: ApiSchedule) {
|
export function writeSchedule(schedule: ApiSchedule) {
|
||||||
await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
|
writeFileSync(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSubscriptions() {
|
export function readSubscriptions() {
|
||||||
let subscriptions = await readJson<ApiSubscription[]>(subscriptionsPath, []);
|
let subscriptions = readJson<ApiSubscription[]>(subscriptionsPath, []);
|
||||||
if (subscriptions.length && "keys" in subscriptions[0]) {
|
if (subscriptions.length && "keys" in subscriptions[0]) {
|
||||||
// Discard old format
|
// Discard old format
|
||||||
subscriptions = [];
|
subscriptions = [];
|
||||||
|
@ -100,71 +102,89 @@ export async function readSubscriptions() {
|
||||||
return subscriptions;
|
return subscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
|
export function writeSubscriptions(subscriptions: ApiSubscription[]) {
|
||||||
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
writeFileSync(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readNextUserId() {
|
export function readNextUserId() {
|
||||||
return await readJson(nextUserIdPath, 0);
|
return readJson(nextUserIdPath, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeNextUserId(nextId: number) {
|
export function writeNextUserId(nextId: number) {
|
||||||
await writeFile(nextUserIdPath, String(nextId), "utf-8");
|
writeFileSync(nextUserIdPath, String(nextId), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function nextUserId() {
|
export function nextUserId() {
|
||||||
let nextId = await readJson(nextUserIdPath, 0);
|
let nextId = readJson(nextUserIdPath, 0);
|
||||||
if (nextId === 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;
|
return nextId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readUsers() {
|
export function readUsers() {
|
||||||
return await readJson(usersPath, (): ServerUser[] => []);
|
return readJson(usersPath, (): ServerUser[] => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeUsers(users: ServerUser[]) {
|
export function writeUsers(users: ServerUser[]) {
|
||||||
await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
|
writeFileSync(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readNextSessionId() {
|
export function readNextSessionId() {
|
||||||
return await readJson(nextSessionIdPath, 0);
|
return readJson(nextSessionIdPath, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeNextSessionId(nextId: number) {
|
export function writeNextSessionId(nextId: number) {
|
||||||
await writeFile(nextSessionIdPath, String(nextId), "utf-8");
|
writeFileSync(nextSessionIdPath, String(nextId), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function nextSessionId() {
|
export function nextSessionId() {
|
||||||
const nextId = await readJson(nextSessionIdPath, 0);
|
const nextId = readJson(nextSessionIdPath, 0);
|
||||||
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");
|
writeFileSync(nextSessionIdPath, String(nextId + 1), "utf-8");
|
||||||
return nextId;
|
return nextId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSessions() {
|
export function readSessions() {
|
||||||
return readJson<ServerSession[]>(sessionsPath, [])
|
return readJson<ServerSession[]>(sessionsPath, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSessions(sessions: ServerSession[]) {
|
export function writeSessions(sessions: ServerSession[]) {
|
||||||
await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
|
writeFileSync(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function nextAuthenticationMethodId() {
|
export function nextAuthenticationMethodId() {
|
||||||
const nextId = await readJson(nextAuthenticationMethodIdPath, 0);
|
const nextId = readJson(nextAuthenticationMethodIdPath, 0);
|
||||||
await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
|
writeFileSync(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
|
||||||
return nextId;
|
return nextId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeNextAuthenticationMethodId(nextId: number) {
|
export function writeNextAuthenticationMethodId(nextId: number) {
|
||||||
await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
|
writeFileSync(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readAuthenticationMethods() {
|
export function readAuthenticationMethods() {
|
||||||
return readJson<ServerAuthenticationMethod[]>(authMethodPath, [])
|
return readJson<ServerAuthenticationMethod[]>(authMethodPath, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
|
export function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
|
||||||
await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8");
|
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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,82 +2,134 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
import { readUsers, type ServerSession } from "~/server/database";
|
import { readEvents, writeEvents, readUsers, type ServerSession } from "~/server/database";
|
||||||
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
|
import type { ApiAccount, ApiDisconnected, ApiEvent, ApiEventStreamMessage, ApiUserType } from "~/shared/types/api";
|
||||||
import { serverSessionToApi } from "./utils/session";
|
import { serverSessionToApi } from "./utils/session";
|
||||||
import { H3Event } from "h3";
|
import { H3Event } from "h3";
|
||||||
|
|
||||||
function sendMessage(
|
const keepaliveTimeoutMs = 45e3;
|
||||||
stream: WritableStream<string>,
|
const eventUpdateTimeMs = 1e3;
|
||||||
message: string,
|
|
||||||
) {
|
class EventStream {
|
||||||
const writer = stream.getWriter();
|
write!: (data: string) => void;
|
||||||
writer.ready
|
close!: (reason?: string) => void;
|
||||||
.then(() => writer.write(message))
|
|
||||||
.catch(console.error)
|
constructor(
|
||||||
.finally(() => writer.releaseLock())
|
public sessionId: number | undefined,
|
||||||
;
|
public accountId: number | undefined,
|
||||||
|
public userType: ApiUserType | undefined,
|
||||||
|
public rotatesAtMs: number ,
|
||||||
|
public lastKeepAliveMs: number,
|
||||||
|
public lastEventId: number,
|
||||||
|
) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessageAndClose(
|
export async function createEventStream(
|
||||||
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(
|
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
stream: WritableStream<string>,
|
source: string,
|
||||||
|
lastEventId: number,
|
||||||
session?: ServerSession,
|
session?: ServerSession,
|
||||||
) {
|
) {
|
||||||
if (streams.size === 0) {
|
|
||||||
console.log("Starting keepalive")
|
|
||||||
keepaliveInterval = setInterval(sendKeepalive, 4000)
|
|
||||||
}
|
|
||||||
const runtimeConfig = useRuntimeConfig(event);
|
const runtimeConfig = useRuntimeConfig(event);
|
||||||
streams.set(stream, {
|
const now = Date.now();
|
||||||
sessionId: session?.id,
|
const events = (readEvents()).filter(e => e.id > lastEventId);
|
||||||
accountId: session?.accountId,
|
const users = readUsers();
|
||||||
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
|
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.
|
// Produce a response immediately to avoid the reply waiting for content.
|
||||||
const update: ApiEvent = {
|
const update: ApiEventStreamMessage = {
|
||||||
type: "connected",
|
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);
|
streams.delete(stream);
|
||||||
if (streams.size === 0) {
|
if (streams.size === 0) {
|
||||||
console.log("Ending keepalive")
|
console.log("Ending event updates")
|
||||||
clearInterval(keepaliveInterval!);
|
clearInterval(updateInterval!);
|
||||||
|
updateInterval = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelAccountStreams(accountId: number) {
|
export function cancelAccountStreams(accountId: number) {
|
||||||
for (const [stream, data] of streams) {
|
for (const stream of streams.values()) {
|
||||||
if (data.accountId === accountId) {
|
if (stream.accountId === accountId) {
|
||||||
sendMessageAndClose(stream, `data: cancelled\n\n`);
|
stream.close("cancelled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelSessionStreams(sessionId: number) {
|
export function cancelSessionStreams(sessionId: number) {
|
||||||
for (const [stream, data] of streams) {
|
for (const stream of streams.values()) {
|
||||||
if (data.sessionId === sessionId) {
|
if (stream.sessionId === sessionId) {
|
||||||
sendMessageAndClose(stream, `data: cancelled\n\n`);
|
stream.close("cancelled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -94,6 +146,7 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined)
|
||||||
if (event.type === "schedule-update") {
|
if (event.type === "schedule-update") {
|
||||||
if (!canSeeCrew(userType)) {
|
if (!canSeeCrew(userType)) {
|
||||||
event = {
|
event = {
|
||||||
|
id: event.id,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
updatedFrom: event.updatedFrom,
|
updatedFrom: event.updatedFrom,
|
||||||
data: filterSchedule(event.data),
|
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.data.deleted && event.data.type === "anonymous" && !canSeeAnonymous(userType)
|
||||||
) {
|
) {
|
||||||
event = {
|
event = {
|
||||||
|
id: event.id,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
data: {
|
data: {
|
||||||
id: event.data.id,
|
id: event.data.id,
|
||||||
|
@ -128,44 +182,68 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function broadcastEvent(event: ApiEvent) {
|
export async function broadcastEvent(event: ApiEvent) {
|
||||||
const id = Date.now();
|
const events = readEvents();
|
||||||
console.log(`broadcasting update to ${streams.size} clients`);
|
events.push(event);
|
||||||
if (!streams.size) {
|
writeEvents(events);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
function sendEventToStream(stream: EventStream, event: ApiEvent) {
|
||||||
// Session expiry events cause the streams belonging to that session to be terminated
|
// Session expiry events cause the streams belonging to that session to be terminated
|
||||||
if (event.type === "session-expired") {
|
if (event.type === "session-expired") {
|
||||||
cancelSessionStreams(event.sessionId);
|
if (stream.sessionId === event.sessionId) {
|
||||||
return;
|
stream.close("session expired");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await readUsers();
|
|
||||||
for (const [stream, streamData] of streams) {
|
|
||||||
// Account events are specially handled and only sent to the user they belong to.
|
// Account events are specially handled and only sent to the user they belong to.
|
||||||
if (event.type === "account-update") {
|
if (event.type === "account-update") {
|
||||||
if (streamData.accountId === event.data.id) {
|
if (stream.accountId === event.data.id) {
|
||||||
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${JSON.stringify(event)}\n\n`);
|
stream.write(`id: ${event.id}\nevent: event\ndata: ${JSON.stringify(event)}\n\n`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
// All other events are encoded according to the user access level seeing it.
|
||||||
let userType: ApiAccount["type"] | undefined;
|
const data = encodeEvent(event, stream.userType)
|
||||||
if (streamData.accountId !== undefined) {
|
stream.write(`id: ${event.id}\nevent: event\ndata: ${data}\n\n`);
|
||||||
userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type
|
return true;
|
||||||
}
|
|
||||||
const data = encodeEvent(event, userType)
|
|
||||||
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendKeepalive() {
|
async function sendEventUpdates() {
|
||||||
|
// Cancel streams that need to be rotated.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [stream, streamData] of streams) {
|
for (const stream of streams.values()) {
|
||||||
if (streamData.rotatesAtMs > now) {
|
if (stream.rotatesAtMs < now) {
|
||||||
sendMessage(stream, ": keepalive\n");
|
stream.close("session rotation");
|
||||||
} else {
|
continue;
|
||||||
sendMessageAndClose(stream, `data: cancelled\n\n`);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
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 { broadcastEvent } from '~/server/streams';
|
||||||
import type { ApiSchedule, ApiTombstone } from '~/shared/types/api';
|
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);
|
eventSlotCounts.set(id, (eventSlotCounts.get(id) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = await readSchedule();
|
const schedule = readSchedule();
|
||||||
if (schedule.deleted) {
|
if (schedule.deleted) {
|
||||||
throw new Error("Deleted schedule not implemented");
|
throw new Error("Deleted schedule not implemented");
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) {
|
||||||
schedule.updatedAt = updatedFrom;
|
schedule.updatedAt = updatedFrom;
|
||||||
await writeSchedule(schedule);
|
await writeSchedule(schedule);
|
||||||
await broadcastEvent({
|
await broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "schedule-update",
|
type: "schedule-update",
|
||||||
updatedFrom,
|
updatedFrom,
|
||||||
data: update,
|
data: update,
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
import {
|
import {
|
||||||
|
nextEventId,
|
||||||
nextSessionId,
|
nextSessionId,
|
||||||
readSessions,
|
readSessions,
|
||||||
readSubscriptions,
|
readSubscriptions,
|
||||||
|
@ -18,11 +19,11 @@ import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
|
||||||
import { serverUserToApiAccount } from "./user";
|
import { serverUserToApiAccount } from "./user";
|
||||||
|
|
||||||
async function removeSessionSubscription(sessionId: number) {
|
async function removeSessionSubscription(sessionId: number) {
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = readSubscriptions();
|
||||||
const index = subscriptions.findIndex(subscription => subscription.sessionId === sessionId);
|
const index = subscriptions.findIndex(subscription => subscription.sessionId === sessionId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
subscriptions.splice(index, 1);
|
subscriptions.splice(index, 1);
|
||||||
await writeSubscriptions(subscriptions);
|
writeSubscriptions(subscriptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,6 +35,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
|
||||||
if (session) {
|
if (session) {
|
||||||
session.expiresAtMs = Date.now();
|
session.expiresAtMs = Date.now();
|
||||||
broadcastEvent({
|
broadcastEvent({
|
||||||
|
id: nextEventId(),
|
||||||
type: "session-expired",
|
type: "session-expired",
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
@ -45,9 +47,9 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearServerSession(event: H3Event) {
|
export async function clearServerSession(event: H3Event) {
|
||||||
const sessions = await readSessions();
|
const sessions = readSessions();
|
||||||
if (await clearServerSessionInternal(event, sessions)) {
|
if (await clearServerSessionInternal(event, sessions)) {
|
||||||
await writeSessions(sessions);
|
writeSessions(sessions);
|
||||||
}
|
}
|
||||||
deleteCookie(event, "session");
|
deleteCookie(event, "session");
|
||||||
}
|
}
|
||||||
|
@ -59,7 +61,7 @@ export async function setServerSession(
|
||||||
authenticationSlug?: string,
|
authenticationSlug?: string,
|
||||||
authenticationName?: string,
|
authenticationName?: string,
|
||||||
) {
|
) {
|
||||||
const sessions = await readSessions();
|
const sessions = readSessions();
|
||||||
const runtimeConfig = useRuntimeConfig(event);
|
const runtimeConfig = useRuntimeConfig(event);
|
||||||
await clearServerSessionInternal(event, sessions);
|
await clearServerSessionInternal(event, sessions);
|
||||||
|
|
||||||
|
@ -76,14 +78,14 @@ export async function setServerSession(
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.push(newSession);
|
sessions.push(newSession);
|
||||||
await writeSessions(sessions);
|
writeSessions(sessions);
|
||||||
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
||||||
return newSession;
|
return newSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
|
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
|
||||||
const runtimeConfig = useRuntimeConfig(event);
|
const runtimeConfig = useRuntimeConfig(event);
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => !user.deleted && user.id === session.accountId);
|
const account = users.find(user => !user.deleted && user.id === session.accountId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const newSession: ServerSession = {
|
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.
|
// Authentication provider is removed to avoid possibility of an infinite delay before using it.
|
||||||
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
|
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
|
||||||
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
|
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
|
||||||
id: await nextSessionId(),
|
id: nextSessionId(),
|
||||||
};
|
};
|
||||||
session.successor = newSession.id;
|
session.successor = newSession.id;
|
||||||
session.expiresAtMs = Date.now() + 10 * 1000;
|
session.expiresAtMs = Date.now() + 10 * 1000;
|
||||||
sessions.push(newSession);
|
sessions.push(newSession);
|
||||||
await writeSessions(sessions);
|
writeSessions(sessions);
|
||||||
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
||||||
return newSession;
|
return newSession;
|
||||||
}
|
}
|
||||||
|
@ -106,7 +108,7 @@ export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
|
||||||
const sessionCookie = await getSignedCookie(event, "session");
|
const sessionCookie = await getSignedCookie(event, "session");
|
||||||
if (sessionCookie) {
|
if (sessionCookie) {
|
||||||
const sessionId = parseInt(sessionCookie, 10);
|
const sessionId = parseInt(sessionCookie, 10);
|
||||||
const sessions = await readSessions();
|
const sessions = readSessions();
|
||||||
const session = sessions.find(session => session.id === sessionId);
|
const session = sessions.find(session => session.id === sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
@ -146,7 +148,7 @@ export async function requireServerSession(event: H3Event, message: string) {
|
||||||
export async function requireServerSessionWithUser(event: H3Event) {
|
export async function requireServerSessionWithUser(event: H3Event) {
|
||||||
const message = "User session required";
|
const message = "User session required";
|
||||||
const session = await requireServerSession(event, message);
|
const session = await requireServerSession(event, message);
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => user.id === session.accountId);
|
const account = users.find(user => user.id === session.accountId);
|
||||||
if (session.accountId === undefined || !account || account.deleted)
|
if (session.accountId === undefined || !account || account.deleted)
|
||||||
throw createError({
|
throw createError({
|
||||||
|
@ -161,7 +163,7 @@ export async function requireServerSessionWithUser(event: H3Event) {
|
||||||
export async function requireServerSessionWithAdmin(event: H3Event) {
|
export async function requireServerSessionWithAdmin(event: H3Event) {
|
||||||
const message = "Admin session required";
|
const message = "Admin session required";
|
||||||
const session = await requireServerSession(event, message);
|
const session = await requireServerSession(event, message);
|
||||||
const users = await readUsers();
|
const users = readUsers();
|
||||||
const account = users.find(user => user.id === session.accountId);
|
const account = users.find(user => user.id === session.accountId);
|
||||||
if (session.access !== "admin" || account?.type !== "admin") {
|
if (session.access !== "admin" || account?.type !== "admin") {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
@ -174,9 +176,9 @@ export async function requireServerSessionWithAdmin(event: H3Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise<ApiSession> {
|
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 account = users.find(user => !user.deleted && user.id === session.accountId);
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = readSubscriptions();
|
||||||
const push = Boolean(
|
const push = Boolean(
|
||||||
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
|
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,7 +33,7 @@ async function useVapidDetails(event: H3Event) {
|
||||||
export async function sendPush(event: H3Event, title: string, body: string) {
|
export async function sendPush(event: H3Event, title: string, body: string) {
|
||||||
const vapidDetails = await useVapidDetails(event);
|
const vapidDetails = await useVapidDetails(event);
|
||||||
const payload = JSON.stringify({ title, body });
|
const payload = JSON.stringify({ title, body });
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = readSubscriptions();
|
||||||
console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`);
|
console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`);
|
||||||
const removeIndexes = [];
|
const removeIndexes = [];
|
||||||
for (let index = 0; index < subscriptions.length; index += 1) {
|
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) {
|
for (const index of removeIndexes) {
|
||||||
subscriptions.splice(index, 1);
|
subscriptions.splice(index, 1);
|
||||||
}
|
}
|
||||||
await writeSubscriptions(subscriptions);
|
writeSubscriptions(subscriptions);
|
||||||
}
|
}
|
||||||
console.log("Push notices sent");
|
console.log("Push notices sent");
|
||||||
}
|
}
|
||||||
|
|
|
@ -155,27 +155,26 @@ export interface ApiUserDetails {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiAccountUpdate {
|
export interface ApiAccountUpdate {
|
||||||
|
id: Id,
|
||||||
type: "account-update",
|
type: "account-update",
|
||||||
data: ApiAccount,
|
data: ApiAccount,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiConnected {
|
|
||||||
type: "connected",
|
|
||||||
session?: ApiSession,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiScheduleUpdate {
|
export interface ApiScheduleUpdate {
|
||||||
|
id: Id,
|
||||||
type: "schedule-update",
|
type: "schedule-update",
|
||||||
updatedFrom?: string,
|
updatedFrom?: string,
|
||||||
data: ApiSchedule | ApiTombstone,
|
data: ApiSchedule | ApiTombstone,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiSessionExpired {
|
export interface ApiSessionExpired {
|
||||||
|
id: Id,
|
||||||
type: "session-expired",
|
type: "session-expired",
|
||||||
sessionId: Id,
|
sessionId: Id,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiUserUpdate {
|
export interface ApiUserUpdate {
|
||||||
|
id: Id,
|
||||||
type: "user-update",
|
type: "user-update",
|
||||||
updatedFrom?: string,
|
updatedFrom?: string,
|
||||||
data: ApiUser | ApiTombstone,
|
data: ApiUser | ApiTombstone,
|
||||||
|
@ -183,8 +182,22 @@ export interface ApiUserUpdate {
|
||||||
|
|
||||||
export type ApiEvent =
|
export type ApiEvent =
|
||||||
| ApiAccountUpdate
|
| ApiAccountUpdate
|
||||||
| ApiConnected
|
|
||||||
| ApiScheduleUpdate
|
| ApiScheduleUpdate
|
||||||
| ApiSessionExpired
|
| ApiSessionExpired
|
||||||
| ApiUserUpdate
|
| 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
31
stores/events.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
|
@ -96,7 +96,7 @@ export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
appEventSource?.addEventListener("update", (event) => {
|
appEventSource?.addEventListener("event", (event) => {
|
||||||
if (event.data.type !== "schedule-update") {
|
if (event.data.type !== "schedule-update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
appEventSource?.addEventListener("update", (event) => {
|
appEventSource?.addEventListener("message", (event) => {
|
||||||
if (event.data.type !== "connected") {
|
if (event.data.type !== "connected") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,7 @@ export const useUsersStore = defineStore("users", () => {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
appEventSource?.addEventListener("update", (event) => {
|
appEventSource?.addEventListener("event", (event) => {
|
||||||
if (event.data.type !== "user-update") {
|
if (event.data.type !== "user-update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue