Compare commits
13 commits
d9b78bff69
...
f69381c44c
Author | SHA1 | Date | |
---|---|---|---|
f69381c44c | |||
0d0e38e4b6 | |||
a33c8e9dac | |||
aaa2faffb1 | |||
2d6bcebc5a | |||
3f492edea2 | |||
352362b9c3 | |||
f4e4dc9f11 | |||
ebeedff5d0 | |||
011687b391 | |||
2d5af78568 | |||
ce1eab7ede | |||
1775fac5fd |
41 changed files with 2604 additions and 1756 deletions
|
@ -10,6 +10,6 @@ indent_style = tab
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
*{.yaml,.yml}]
|
||||
[*{.yaml,.yml}]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink to="/login">Log In</NuxtLink>
|
||||
<NuxtLink to="/register">Register</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
|
29
components/LogIn.vue
Normal file
29
components/LogIn.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div>
|
||||
<LogInDemo v-if="authDemoEnabled" />
|
||||
<LogInTelegram v-if="authTelegramEnabled" />
|
||||
<template v-if="noAuth">No authentication method has been enabled in the configuration.</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const authDemoEnabled = runtimeConfig.public.authDemoEnabled;
|
||||
const authTelegramEnabled = runtimeConfig.public.authTelegramEnabled;
|
||||
const noAuth =
|
||||
!authDemoEnabled
|
||||
&& !authTelegramEnabled
|
||||
;
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
div>* + *::before {
|
||||
content: "\2013 or \2013 ";
|
||||
display: block;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
</style>
|
41
components/LogInDemo.vue
Normal file
41
components/LogInDemo.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<form @submit.prevent="logIn">
|
||||
<label>
|
||||
Demo:
|
||||
<input v-model="name" type="text" placeholder="Name" required>
|
||||
<button type="submit">Log In</button>
|
||||
</label>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const sessionStore = useSessionStore();
|
||||
const route = useRoute();
|
||||
|
||||
const name = ref("");
|
||||
async function logIn() {
|
||||
try {
|
||||
const session = await $fetch("/api/auth/ap/demo-login", {
|
||||
method: "POST",
|
||||
body: { name: name.value },
|
||||
});
|
||||
await sessionStore.update(session);
|
||||
if (!session.account) {
|
||||
await navigateTo("/register");
|
||||
} else if (route.path === "/register") {
|
||||
await navigateTo("/");
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
alert(err.data?.message ?? err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
61
components/LogInTelegram.vue
Normal file
61
components/LogInTelegram.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div ref="div">
|
||||
<button
|
||||
v-if="!loaded"
|
||||
@click="load"
|
||||
>
|
||||
Enable Log in with Telegram
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TelegramAuthData } from '~/shared/types/telegram';
|
||||
|
||||
const divElement = useTemplateRef("div");
|
||||
const loaded = ref(false);
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const sessionStore = useSessionStore();
|
||||
const route = useRoute();
|
||||
|
||||
function load() {
|
||||
const script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.dataset.telegramLogin = runtimeConfig.public.telegramBotUsername;
|
||||
script.dataset.size = "medium";
|
||||
script.dataset.onauth = "onTelegramAuth(user)";
|
||||
script.dataset.requestAccess = "write";
|
||||
divElement.value?.appendChild(script);
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
async function login(authData: TelegramAuthData) {
|
||||
const session = await $fetch("/api/auth/ap/telegram-login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
authData,
|
||||
},
|
||||
});
|
||||
sessionStore.update(session);
|
||||
if (!session.account) {
|
||||
await navigateTo("/register");
|
||||
} else if (route.path === "/register") {
|
||||
await navigateTo("/");
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export function onTelegramAuth(user: any): void;
|
||||
}
|
||||
onMounted(() => {
|
||||
// XXX this asumes there's only ever one LogInTelegram component mounted
|
||||
window.onTelegramAuth = function(authData: TelegramAuthData) {
|
||||
login(authData).catch(err => alert(err.data?.message ?? err.message));
|
||||
};
|
||||
});
|
||||
</script>
|
|
@ -22,8 +22,11 @@ class AppEventSource extends EventTarget {
|
|||
console.log("AppEventSource", event.type, event.data);
|
||||
this.dispatchEvent(new Event(event.type));
|
||||
} else {
|
||||
const data = event.data ? JSON.parse(event.data) : undefined;
|
||||
const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined;
|
||||
console.log("AppEventSource", event.type, data);
|
||||
if (data?.type === "connected") {
|
||||
this.#sourceSessionId = data.session?.id;
|
||||
}
|
||||
this.dispatchEvent(new MessageEvent(event.type, {
|
||||
data,
|
||||
origin: event.origin,
|
||||
|
|
15
docs/admin/authentication.md
Normal file
15
docs/admin/authentication.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
# Authentication
|
||||
|
||||
It's possible to configure authentication using a third party Authentication Provider (referred to as AP). Currently only Telegram is supported as an AP.
|
||||
|
||||
## Telegram
|
||||
|
||||
In order to use Telegram as an AP you need to be hosting Owltide under a domain name over https, using http will not work.
|
||||
|
||||
You will also need a bot which can be created by messaging [@BotFather](https://t.me/BotFather), with the domain of the bot set using the `/setdomain` command to the domain Owltide is hosted under.
|
||||
|
||||
Once you have the pre-requisites you need to configure `NUXT_TELEGRAM_BOT_TOKEN_FILE` to a path to a file containing the token of the bot with no spaces or new-lines. `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` to the username of the bot. And finally `NUXT_AUTH_TELEGRAM_ENABLED` to `true` to enable authentication via Telegram.
|
45
docs/admin/config.md
Normal file
45
docs/admin/config.md
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
# Configuration
|
||||
|
||||
## Quoting
|
||||
|
||||
Environment variables are parsed using [destr](https://github.com/unjs/destr) which contain arbitrary unspecified and undocumented rules for converting strings to data. If an environment input looks like JSON it'll most likely be parsed as JSON and my cause a type missmatch error to be reported. To avoid strings being converted into other unintended values put the value into `"` marks. Depending on your configuration environment you may have to double up the quotation marks and/or use escapes.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### NUXT_SESSION_ROTATES_TIMEOUT
|
||||
|
||||
Time in seconds before a session need to be rotated over into a new session. When an endpoint using a session is hit after the session rotates timeout but before the session is discarded a new session is created as the successor with a new rotates and discard timeout. The old session then marked to expire in 10 seconds any requests using the old session will result in a 403 Forbidden with the message the session has been taken after the expiry.
|
||||
|
||||
### NUXT_SESSION_DISCARD_TIMEOUT
|
||||
|
||||
Time in seconds before a session is deleted from the client and server, resulting in the user having to authenticate again if the session wasn't rotated over into a new session before this timeout.
|
||||
|
||||
This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`.
|
||||
|
||||
### NUXT_AUTH_DEMO_ENABLED
|
||||
|
||||
Boolean indicating if the demo authentication provider should be enabled. This allows logging in using only a name with no additional checks or security and should _never_ be enabled on a production system. The purpose of this is to make it easier to demo the system.
|
||||
|
||||
Defaults to `false`.
|
||||
|
||||
### NUXT_TELEGRAM_BOT_TOKEN_FILE
|
||||
|
||||
Path to a file containing the token for the Telegram bot used for authenticating users via Telegram.
|
||||
|
||||
Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled.
|
||||
|
||||
### NUXT_PUBLIC_TELEGRAM_BOT_USERNAME
|
||||
|
||||
Username of the Telegram bot used for authenticating users via Telegram.
|
||||
|
||||
Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled.
|
||||
|
||||
### NUXT_AUTH_TELEGRAM_ENABLED
|
||||
|
||||
Boolean indicating if authentication via Telegram is enabled or not. Requires `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` and `NUXT_TELEGRAM_BOT_TOKEN_FILE` to be set in order to work.
|
||||
|
||||
Defaults to `false`.
|
13
docs/dev/server-sent-events.md
Normal file
13
docs/dev/server-sent-events.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
# Server-sent events
|
||||
|
||||
To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session rotates timeout is hit, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session rotates timeout).
|
||||
|
||||
## Events
|
||||
|
||||
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.
|
13
docs/dev/sessions.md
Normal file
13
docs/dev/sessions.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
# Sessions
|
||||
|
||||
When a user creates a new account or logs in to an existing account a session is created on the server and linked to the user's browser via a session cookie. This cookie contains a unique identifier along with a HMAC signature created using the server's cookie secret key. Since this unique identifier stored on the user's device is a technical requirement to securely do what the user is requesting the user's consent to its storage can be assumed.
|
||||
|
||||
Sessions have three future times associated with them: The rotates time is the point in time after the session will be recreated and the cookie reassigned, the expiry time is the point in time after which use of the session will be rejected, and the discard time is when the session is deleted from both the client and the server. The rotation time is short, by default 1 hour, while the discard time is long, by default 2 weeks.
|
||||
|
||||
When a request is made to a session that's past the rotates time a new session is created to replace the existing one, the expiry time is set on the existing session to 10 seconds later, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has expired the server responds with a message saying the "session has been taken". The reason for having the session expire 10 seconds after the rotation is to prevent race conditions from triggering the session taken error.
|
||||
|
||||
Sessions are created for a limited timespan, purpose and access level, and expires after the timespan is over, the purpose is fulfilled or the access level changes. For example if the user's account is promoted from regular to crew the session will no longer be valid and will be recreated as a new session with the new access level on the next request.
|
57
error.vue
57
error.vue
|
@ -3,26 +3,65 @@
|
|||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<Header />
|
||||
<h1>{{ error.statusCode }} {{ error.statusMessage }}</h1>
|
||||
<p v-if="error.message !== error.statusMessage">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
<pre v-if="error.stack"><code>{{ error.stack }}</code></pre>
|
||||
<template v-if='data?.data?.code === "SESSION_TAKEN"'>
|
||||
<h1>Session taken</h1>
|
||||
<p>
|
||||
Your session with the server has been taken over by another browser, device, or HTTP agent. This could happen due to one of the following reasons:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
The session cookie on this device got restored to an earlier state for example as a result of a restore or a crash.
|
||||
</li>
|
||||
<li>
|
||||
The server issued a new session to the client but the client didn't get the repsonse.
|
||||
</li>
|
||||
<li>
|
||||
The session cookie was copied and used on another device, browser or HTTP agent.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
It's possible however unlikely that someone else have hijacked your session.
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
@click="abandonSession"
|
||||
>
|
||||
Abandon Session
|
||||
</button>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Header />
|
||||
<h1>{{ error.statusCode }} {{ error.statusMessage }}</h1>
|
||||
<p v-if="error.message !== error.statusMessage">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
<pre v-if="error.stack"><code>{{ error.stack }}</code></pre>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ error: {
|
||||
const props = defineProps<{ error: {
|
||||
statusCode: number,
|
||||
fatal: boolean,
|
||||
unhandled: boolean,
|
||||
statusMessage?: string,
|
||||
data?: unknown,
|
||||
data?: string,
|
||||
cause?: unknown,
|
||||
|
||||
// Undocumented fields
|
||||
url?: string,
|
||||
message?: string,
|
||||
stack?: string,
|
||||
} }>()
|
||||
}}>();
|
||||
const data = computed<{
|
||||
data?: {
|
||||
code?: string,
|
||||
},
|
||||
}>(() => props.error.data ? JSON.parse(props.error.data) : undefined);
|
||||
async function abandonSession() {
|
||||
await $fetch("/api/auth/session", { method: "DELETE", }).catch(err => alert(err.message));
|
||||
await navigateTo("/");
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const enableDevtools = !process.env.DISABLE_DEV_TOOLS
|
||||
const oneHourSeconds = 60 * 60;
|
||||
const oneDaySeconds = 24 * oneHourSeconds;
|
||||
export default defineNuxtConfig({
|
||||
experimental: { renderJsonPayloads: true },
|
||||
compatibilityDate: '2024-11-01',
|
||||
|
@ -21,11 +23,17 @@ export default defineNuxtConfig({
|
|||
},
|
||||
runtimeConfig: {
|
||||
cookieSecretKeyFile: "",
|
||||
sessionRotatesTimeout: 1 * oneHourSeconds,
|
||||
sessionDiscardTimeout: 14 * oneDaySeconds,
|
||||
vapidSubject: "",
|
||||
vapidPrivateKeyFile: "",
|
||||
telegramBotTokenFile: "",
|
||||
public: {
|
||||
defaultTimezone: "Europe/Oslo",
|
||||
defaultLocale: "en-GB",
|
||||
authDemoEnabled: false,
|
||||
authTelegramEnabled: false,
|
||||
telegramBotUsername: "",
|
||||
vapidPublicKey: "",
|
||||
}
|
||||
},
|
||||
|
|
22
package.json
22
package.json
|
@ -11,14 +11,14 @@
|
|||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "0.11.0",
|
||||
"luxon": "^3.5.0",
|
||||
"nuxt": "^3.17.4",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"luxon": "^3.6.1",
|
||||
"nuxt": "^3.17.6",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
|
||||
"pnpm": {
|
||||
|
@ -28,11 +28,11 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/test-utils": "^3.19.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"happy-dom": "^17.6.3",
|
||||
"vitest": "^3.2.3",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,31 +5,10 @@
|
|||
<template>
|
||||
<main>
|
||||
<h1>Log In</h1>
|
||||
<form @submit.prevent="logIn">
|
||||
<input v-model="name" type="text" placeholder="Name" required>
|
||||
<button type="submit">Log In</button>
|
||||
</form>
|
||||
<h2 id="create-account">Create Account</h2>
|
||||
<p>If you don't have an account you may create one</p>
|
||||
<form @submit.prevent="createAccount">
|
||||
<fieldset>
|
||||
<legend>Regular Account</legend>
|
||||
<label>
|
||||
Name:
|
||||
<input v-model="createName" type="text" placeholder="Name" required />
|
||||
</label>
|
||||
<button type="submit">Create account</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<LogIn />
|
||||
<p>
|
||||
If you do not wish to deal with logins you may create an anonymous account tied to this device.
|
||||
If you don't have an account you may <NuxtLink to="/register">register for one</NuxtLink>.
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Anonymous Account</legend>
|
||||
<button type="button" @click="createAnonymousAccount">Create anonymous account</button>
|
||||
</fieldset>
|
||||
<pre><code>{{ result }}</code></pre>
|
||||
<pre><code>Session: {{ ({ id: sessionStore.id, account: sessionStore.account, push: sessionStore.push }) }}</code></pre>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
@ -37,64 +16,4 @@
|
|||
useHead({
|
||||
title: "Login",
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const { getSubscription, subscribe } = usePushNotification();
|
||||
|
||||
const name = ref("");
|
||||
const result = ref("")
|
||||
async function logIn() {
|
||||
try {
|
||||
result.value = await sessionStore.logIn(name.value);
|
||||
// Resubscribe push notifications if the user was subscribed before.
|
||||
const subscription = await getSubscription();
|
||||
if (subscription) {
|
||||
await subscribe();
|
||||
}
|
||||
// XXX Remove the need for this.
|
||||
await sessionStore.fetch();
|
||||
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
console.log(err.data);
|
||||
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
const createName = ref("");
|
||||
async function createAccount() {
|
||||
try {
|
||||
const res = await $fetch.raw("/api/auth/account", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ name: createName.value })
|
||||
});
|
||||
result.value = `Server replied: ${res.status} ${res.statusText}`;
|
||||
await sessionStore.fetch();
|
||||
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
console.log(err.data);
|
||||
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
|
||||
}
|
||||
}
|
||||
async function createAnonymousAccount() {
|
||||
try {
|
||||
const res = await $fetch.raw("/api/auth/account", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
result.value = `Server replied: ${res.status} ${res.statusText}`;
|
||||
await sessionStore.fetch();
|
||||
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
console.log(err.data);
|
||||
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
102
pages/register.vue
Normal file
102
pages/register.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<main>
|
||||
<h1>Register</h1>
|
||||
<div class="card">
|
||||
<h2>Regular User</h2>
|
||||
<p>
|
||||
Set up an authentication method and choose a username to register a new user.
|
||||
</p>
|
||||
<h3>Authentication Method</h3>
|
||||
<p v-if="sessionStore.authenticationProvider">
|
||||
Provider: {{ sessionStore.authenticationProvider }}
|
||||
<br>Identifier: {{ sessionStore.authenticationName }}
|
||||
<br><button type="button" @click="clearProvider">Clear Method</button>
|
||||
</p>
|
||||
<LogIn v-else />
|
||||
<form @submit.prevent="register(false)">
|
||||
<h3>User Details</h3>
|
||||
<p>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" v-model="username" required>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!sessionStore.authenticationProvider"
|
||||
>
|
||||
Register new user
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="alternatively">
|
||||
– or –
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Anonymous User</h2>
|
||||
<p>
|
||||
If you do not wish to deal with logins you may create an
|
||||
anonymous user authenticated with a session cookie stored on
|
||||
this device. Since there's no persistent authentication for the
|
||||
user should you lose the session cookie you will also lose
|
||||
access to the user.
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" @click="register(true)">
|
||||
Create anonymous user
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "Register",
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const username = ref("");
|
||||
|
||||
async function clearProvider() {
|
||||
sessionStore.logOut();
|
||||
}
|
||||
async function register(anonymous: boolean) {
|
||||
let session;
|
||||
try {
|
||||
session = await $fetch("/api/auth/account", {
|
||||
method: "POST",
|
||||
body: anonymous ? undefined : {
|
||||
name: username.value,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.data?.message ?? err.message);
|
||||
return;
|
||||
}
|
||||
sessionStore.update(session);
|
||||
if (session.account) {
|
||||
await navigateTo("/account/settings");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alternatively {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: color-mix(in oklab, var(--background), grey 20%);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
3191
pnpm-lock.yaml
generated
3191
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -2,10 +2,19 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { writeSchedule, writeUsers } from "~/server/database";
|
||||
import { nextAuthenticationMethodId, writeAuthenticationMethods, writeNextAuthenticationMethodId, writeSchedule, writeUsers } from "~/server/database";
|
||||
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireServerSessionWithAdmin(event);
|
||||
await writeUsers(generateDemoAccounts());
|
||||
const accounts = generateDemoAccounts();
|
||||
await writeUsers(accounts);
|
||||
await writeSchedule(generateDemoSchedule());
|
||||
await writeAuthenticationMethods(accounts.map((user, index) => ({
|
||||
id: index,
|
||||
userId: user.id,
|
||||
provider: "demo",
|
||||
slug: user.name!,
|
||||
name: user.name!,
|
||||
})));
|
||||
await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length));
|
||||
})
|
||||
|
|
|
@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
|||
// Only keep sessions that match the account id in both sets to avoid
|
||||
// resurrecting deleted sessions. This will still cause session cross
|
||||
// pollution if a snapshot from another instance is loaded here.
|
||||
return current && current.account.id === session.account.id;
|
||||
return current?.accountId !== undefined && current.accountId === session.accountId;
|
||||
}));
|
||||
await writeSubscriptions(snapshot.subscriptions);
|
||||
await writeSchedule(snapshot.schedule);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readUsers, writeUsers } from "~/server/database";
|
||||
import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
|
||||
import { apiUserPatchSchema } from "~/shared/types/api";
|
||||
import { z } from "zod/v4-mini";
|
||||
import { broadcastEvent } from "~/server/streams";
|
||||
|
@ -29,7 +29,8 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
}
|
||||
|
||||
if (patch.type) {
|
||||
let accessChanged = false;
|
||||
if (patch.type && patch.type !== user.type) {
|
||||
if (patch.type === "anonymous" || user.type === "anonymous") {
|
||||
throw createError({
|
||||
status: 409,
|
||||
|
@ -38,6 +39,7 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
user.type = patch.type;
|
||||
accessChanged = true;
|
||||
}
|
||||
if (patch.name) {
|
||||
if (user.type === "anonymous") {
|
||||
|
@ -54,7 +56,23 @@ export default defineEventHandler(async (event) => {
|
|||
broadcastEvent({
|
||||
type: "user-update",
|
||||
data: serverUserToApi(user),
|
||||
})
|
||||
});
|
||||
|
||||
// Rotate sessions with the user in it if the access changed
|
||||
if (accessChanged) {
|
||||
const sessions = await readSessions();
|
||||
const nowMs = Date.now();
|
||||
for (const session of sessions) {
|
||||
if (session.accountId === user.id) {
|
||||
session.rotatesAtMs = nowMs;
|
||||
broadcastEvent({
|
||||
type: "session-expired",
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
await writeSessions(sessions);
|
||||
}
|
||||
|
||||
// Update Schedule counts.
|
||||
await updateScheduleInterestedCounts(users);
|
||||
|
|
|
@ -9,33 +9,41 @@ import {
|
|||
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const serverSession = await requireServerSession(event);
|
||||
const serverSession = await requireServerSessionWithUser(event);
|
||||
let users = await readUsers();
|
||||
|
||||
// Remove sessions for this user
|
||||
const removedSessionIds = new Set<number>();
|
||||
// Expire sessions for this user
|
||||
const expiredSessionIds = new Set<number>();
|
||||
let sessions = await readSessions();
|
||||
sessions = sessions.filter(session => {
|
||||
if (session.account.id === serverSession.account.id) {
|
||||
removedSessionIds.add(session.id);
|
||||
return false;
|
||||
const nowMs = Date.now();
|
||||
for (const session of sessions) {
|
||||
if (
|
||||
session.successor !== undefined
|
||||
&& (session.expiresAtMs === undefined || session.expiresAtMs < nowMs)
|
||||
&& session.accountId === serverSession.accountId
|
||||
) {
|
||||
session.expiresAtMs = nowMs;
|
||||
broadcastEvent({
|
||||
type: "session-expired",
|
||||
sessionId: session.id,
|
||||
});
|
||||
expiredSessionIds.add(session.id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
cancelAccountStreams(serverSession.account.id);
|
||||
}
|
||||
cancelAccountStreams(serverSession.accountId);
|
||||
await writeSessions(sessions);
|
||||
await deleteCookie(event, "session");
|
||||
|
||||
// Remove subscriptions for this user
|
||||
let subscriptions = await readSubscriptions();
|
||||
subscriptions = subscriptions.filter(
|
||||
subscription => !removedSessionIds.has(subscription.sessionId)
|
||||
subscription => !expiredSessionIds.has(subscription.sessionId)
|
||||
);
|
||||
await writeSubscriptions(subscriptions);
|
||||
|
||||
// Remove the user
|
||||
const account = users.find(user => user.id === serverSession.account.id)!;
|
||||
const now = new Date().toISOString();
|
||||
const account = users.find(user => user.id === serverSession.accountId)!;
|
||||
const now = new Date(nowMs).toISOString();
|
||||
account.deleted = true;
|
||||
account.updatedAt = now;
|
||||
await writeUsers(users);
|
||||
|
|
|
@ -9,7 +9,7 @@ import { z } from "zod/v4-mini";
|
|||
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const session = await requireServerSessionWithUser(event);
|
||||
const { success, error, data: patch } = apiAccountPatchSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
|
@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => user.id === session.account.id);
|
||||
const account = users.find(user => user.id === session.accountId);
|
||||
if (!account) {
|
||||
throw Error("Account does not exist");
|
||||
}
|
||||
|
|
|
@ -2,20 +2,21 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readUsers, writeUsers, nextUserId, type ServerUser } from "~/server/database";
|
||||
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database";
|
||||
import { broadcastEvent } from "~/server/streams";
|
||||
import type { ApiSession } from "~/shared/types/api";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
let session = await getServerSession(event);
|
||||
if (session) {
|
||||
export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||
let session = await getServerSession(event, false);
|
||||
if (session?.accountId !== undefined) {
|
||||
throw createError({
|
||||
status: 409,
|
||||
message: "Cannot create account while having an active session."
|
||||
message: "Cannot create account while logged in to an account."
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await readFormData(event);
|
||||
const name = formData.get("name");
|
||||
const body = await readBody(event);
|
||||
const name = body?.name;
|
||||
|
||||
const users = await readUsers();
|
||||
let user: ServerUser;
|
||||
|
@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
|
|||
name,
|
||||
};
|
||||
|
||||
} else if (name === null) {
|
||||
} else if (name === undefined) {
|
||||
user = {
|
||||
id: await nextUserId(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
@ -54,11 +55,42 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (user.type !== "anonymous") {
|
||||
if (!session?.authenticationProvider) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Conflict",
|
||||
message: "User account need an authentication method associated with it.",
|
||||
});
|
||||
}
|
||||
const authMethods = await readAuthenticationMethods();
|
||||
const method = authMethods.find(method => (
|
||||
method.provider === session.authenticationProvider
|
||||
&& method.slug === session.authenticationSlug
|
||||
));
|
||||
if (method) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Conflict",
|
||||
message: "A user is already associated with the authentication method",
|
||||
});
|
||||
}
|
||||
authMethods.push({
|
||||
id: await nextAuthenticationMethodId(),
|
||||
userId: user.id,
|
||||
provider: session.authenticationProvider,
|
||||
slug: session.authenticationSlug!,
|
||||
name: session.authenticationName!,
|
||||
})
|
||||
await writeAuthenticationMethods(authMethods);
|
||||
}
|
||||
|
||||
users.push(user);
|
||||
await writeUsers(users);
|
||||
await broadcastEvent({
|
||||
type: "user-update",
|
||||
data: user,
|
||||
});
|
||||
await setServerSession(event, user);
|
||||
const newSession = await setServerSession(event, user);
|
||||
return await serverSessionToApi(event, newSession);
|
||||
})
|
||||
|
|
38
server/api/auth/ap/demo-login.post.ts
Normal file
38
server/api/auth/ap/demo-login.post.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readAuthenticationMethods, readUsers } from "~/server/database";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
if (!runtimeConfig.public.authDemoEnabled) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message: "Demo authentication is disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { name: slug } = await readBody(event);
|
||||
|
||||
if (typeof slug !== "string" || !slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Bad Request",
|
||||
message: "Missing name",
|
||||
});
|
||||
}
|
||||
|
||||
const authMethods = await readAuthenticationMethods();
|
||||
const method = authMethods.find(method => method.provider === "demo" && method.slug === slug);
|
||||
let session;
|
||||
if (method) {
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => !user.deleted && user.id === method.userId);
|
||||
session = await setServerSession(event, account);
|
||||
} else {
|
||||
session = await setServerSession(event, undefined, "demo", slug, slug);
|
||||
}
|
||||
return await serverSessionToApi(event, session);
|
||||
})
|
100
server/api/auth/ap/telegram-login.post.ts
Normal file
100
server/api/auth/ap/telegram-login.post.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import type { H3Event } from "h3";
|
||||
import { z } from "zod/v4-mini";
|
||||
import { readAuthenticationMethods, readUsers } from "~/server/database";
|
||||
import { type TelegramAuthData, telegramAuthDataSchema } from "~/shared/types/telegram";
|
||||
import type { ApiSession } from "~/shared/types/api";
|
||||
|
||||
const loginSchema = z.object({
|
||||
authData: telegramAuthDataSchema,
|
||||
});
|
||||
|
||||
let cachedTelegramConfig:
|
||||
| undefined
|
||||
| { enabled: false }
|
||||
| { enabled: true, botUsername: string, secretKey: CryptoKey }
|
||||
;
|
||||
async function useTelegramConfig(event: H3Event) {
|
||||
if (cachedTelegramConfig)
|
||||
return cachedTelegramConfig;
|
||||
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
if (!runtimeConfig.public.authTelegramEnabled) {
|
||||
return cachedTelegramConfig = {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
if (!runtimeConfig.telegramBotTokenFile) throw new Error("NUXT_TELEGRAM_BOT_TOKEN_FILE not configured");
|
||||
if (!runtimeConfig.public.telegramBotUsername) throw new Error("NUXT_PUBLIC_TELEGRAM_BOT_USERNAME not configured");
|
||||
|
||||
const botToken = await fs.readFile(runtimeConfig.telegramBotTokenFile);
|
||||
const secretKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
await crypto.subtle.digest("SHA-256", botToken),
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return cachedTelegramConfig = {
|
||||
enabled: true,
|
||||
botUsername: runtimeConfig.public.telegramBotUsername,
|
||||
secretKey,
|
||||
}
|
||||
}
|
||||
|
||||
async function validateTelegramAuthData(authData: TelegramAuthData, key: CryptoKey) {
|
||||
const { hash, ...checkData } = authData;
|
||||
const checkString = Object.entries(checkData).map(([key, value]) => `${key}=${value}`).sort().join("\n");
|
||||
const signature = Buffer.from(hash, "hex");
|
||||
return await crypto.subtle.verify("HMAC", key, signature, Buffer.from(checkString));
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<ApiSession> => {
|
||||
const { success, error, data } = loginSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Bad Request",
|
||||
message: z.prettifyError(error),
|
||||
});
|
||||
}
|
||||
|
||||
const config = await useTelegramConfig(event);
|
||||
if (!config.enabled) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message: "Telegram authentication is disabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!await validateTelegramAuthData(data.authData, config.secretKey)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message: "Validating authentication data failed",
|
||||
});
|
||||
}
|
||||
|
||||
const slug = String(data.authData.id);
|
||||
const authMethods = await readAuthenticationMethods();
|
||||
const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug);
|
||||
let session;
|
||||
if (method) {
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => !user.deleted && user.id === method.userId);
|
||||
session = await setServerSession(event, account);
|
||||
} else {
|
||||
const name = data.authData.username ? "@" + data.authData.username : slug;
|
||||
session = await setServerSession(event, undefined, "telegram", slug, name);
|
||||
}
|
||||
|
||||
return await serverSessionToApi(event, session);
|
||||
})
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readUsers } from "~/server/database";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { name } = await readBody(event);
|
||||
|
||||
if (!name) {
|
||||
return new Response(undefined, { status: 400 })
|
||||
}
|
||||
|
||||
const accounts = await readUsers();
|
||||
const account = accounts.find(a => a.name === name);
|
||||
|
||||
if (!account) {
|
||||
return new Response(undefined, { status: 403 })
|
||||
}
|
||||
|
||||
await setServerSession(event, account);
|
||||
})
|
|
@ -2,12 +2,15 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readUsers } from "~/server/database";
|
||||
import { cancelSessionStreams } from "~/server/streams";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await getServerSession(event);
|
||||
const session = await getServerSession(event, true);
|
||||
if (session) {
|
||||
if (session.account.type === "anonymous") {
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => user.id === session.accountId);
|
||||
if (account?.type === "anonymous") {
|
||||
throw createError({
|
||||
status: 409,
|
||||
message: "Cannot log out of an anonymous account",
|
||||
|
@ -15,8 +18,5 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
cancelSessionStreams(session.id);
|
||||
}
|
||||
await clearServerSession(event);
|
||||
})
|
||||
|
|
|
@ -2,23 +2,10 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readSubscriptions } from "~/server/database";
|
||||
import type { ApiSession } from "~/shared/types/api";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<ApiSession | undefined> => {
|
||||
const session = await getServerSession(event);
|
||||
export default defineEventHandler(async event => {
|
||||
const session = await getServerSession(event, false);
|
||||
if (!session)
|
||||
return;
|
||||
const subscriptions = await readSubscriptions();
|
||||
const push = Boolean(
|
||||
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
|
||||
);
|
||||
|
||||
await refreshServerSession(event, session);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
account: session.account,
|
||||
push,
|
||||
};
|
||||
return await serverSessionToApi(event, session);
|
||||
})
|
||||
|
|
|
@ -6,8 +6,7 @@ import { pipeline } from "node:stream";
|
|||
import { addStream, deleteStream } from "~/server/streams";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await getServerSession(event);
|
||||
const accountId = session?.account.id;
|
||||
const session = await getServerSession(event, false);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const source = event.headers.get("x-forwarded-for");
|
||||
|
@ -25,8 +24,8 @@ export default defineEventHandler(async (event) => {
|
|||
console.log(`cancelled event stream for ${source}`);
|
||||
deleteStream(stream.writable);
|
||||
}
|
||||
})
|
||||
addStream(stream.writable, session?.id, accountId);
|
||||
});
|
||||
addStream(event, stream.writable, session);
|
||||
|
||||
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
|
||||
setHeader(event, "Access-Control-Allow-Origin", "*");
|
||||
|
|
|
@ -9,9 +9,9 @@ import { apiScheduleSchema } from "~/shared/types/api";
|
|||
import { applyUpdatesToArray } from "~/shared/utils/update";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const session = await requireServerSessionWithUser(event);
|
||||
|
||||
if (session.account.type !== "admin" && session.account.type !== "crew") {
|
||||
if (session.access !== "admin" && session.access !== "crew") {
|
||||
throw createError({
|
||||
status: 403,
|
||||
statusMessage: "Forbidden",
|
||||
|
@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
// Validate edit restrictions for crew
|
||||
if (session.account.type === "crew") {
|
||||
if (session.access === "crew") {
|
||||
if (update.locations?.length) {
|
||||
throw createError({
|
||||
status: 403,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { readSchedule } from "~/server/database";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await getServerSession(event);
|
||||
const session = await getServerSession(event, false);
|
||||
const schedule = await readSchedule();
|
||||
return canSeeCrew(session?.account.type) ? schedule : filterSchedule(schedule);
|
||||
return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule);
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ const subscriptionSchema = z.strictObject({
|
|||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const session = await requireServerSessionWithUser(event);
|
||||
const { success, error, data: body } = subscriptionSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { readSubscriptions, writeSubscriptions } from "~/server/database";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const session = await requireServerSessionWithUser(event);
|
||||
const subscriptions = await readSubscriptions();
|
||||
const existingIndex = subscriptions.findIndex(
|
||||
sub => sub.type === "push" && sub.sessionId === session.id
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
import { readUsers } from "~/server/database"
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const session = await requireServerSessionWithUser(event);
|
||||
const users = await readUsers();
|
||||
|
||||
if (session.account.type === "admin") {
|
||||
if (session.access === "admin") {
|
||||
return users.map(serverUserToApi);
|
||||
}
|
||||
if (session.account.type === "crew") {
|
||||
if (session.access === "crew") {
|
||||
return users.filter(u => u.type === "crew" || u.type === "admin").map(serverUserToApi);
|
||||
}
|
||||
throw createError({
|
||||
|
|
|
@ -3,19 +3,22 @@
|
|||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
||||
import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
||||
import type { Id } from "~/shared/types/common";
|
||||
|
||||
export interface ServerSession {
|
||||
id: number,
|
||||
account: ServerUser,
|
||||
id: Id,
|
||||
access: ApiUserType,
|
||||
accountId?: number,
|
||||
authenticationProvider?: ApiAuthenticationProvider,
|
||||
authenticationSlug?: string,
|
||||
authenticationName?: string,
|
||||
rotatesAtMs: number,
|
||||
expiresAtMs?: number,
|
||||
discardAtMs: number,
|
||||
successor?: Id,
|
||||
};
|
||||
|
||||
interface StoredSession {
|
||||
id: number,
|
||||
accountId: number,
|
||||
}
|
||||
|
||||
export interface ServerUser {
|
||||
id: Id,
|
||||
updatedAt: string,
|
||||
|
@ -28,6 +31,14 @@ export interface ServerUser {
|
|||
locale?: string,
|
||||
}
|
||||
|
||||
export interface ServerAuthenticationMethod {
|
||||
id: Id,
|
||||
provider: ApiAuthenticationProvider,
|
||||
slug: string,
|
||||
name: string,
|
||||
userId: Id,
|
||||
}
|
||||
|
||||
// For this demo I'm just storing the runtime data in JSON files. When making
|
||||
// this into proper application this should be replaced with an actual database.
|
||||
|
||||
|
@ -37,6 +48,8 @@ const usersPath = "data/users.json";
|
|||
const nextUserIdPath = "data/next-user-id.json";
|
||||
const sessionsPath = "data/sessions.json";
|
||||
const nextSessionIdPath = "data/next-session-id.json";
|
||||
const authMethodPath = "data/auth-method.json";
|
||||
const nextAuthenticationMethodIdPath = "data/auth-method-id.json"
|
||||
|
||||
async function remove(path: string) {
|
||||
try {
|
||||
|
@ -131,26 +144,27 @@ export async function nextSessionId() {
|
|||
}
|
||||
|
||||
export async function readSessions() {
|
||||
const users = await readUsers();
|
||||
const sessions: ServerSession[] = [];
|
||||
for (const stored of await readJson<StoredSession[]>(sessionsPath, [])) {
|
||||
const user = users.find(user => user.id === stored.accountId);
|
||||
if (user) {
|
||||
sessions.push({
|
||||
id: stored.id,
|
||||
account: user,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
return readJson<ServerSession[]>(sessionsPath, [])
|
||||
}
|
||||
|
||||
export async function writeSessions(sessions: ServerSession[]) {
|
||||
const stored: StoredSession[] = sessions.map(
|
||||
session => ({
|
||||
id: session.id,
|
||||
accountId: session.account.id,
|
||||
}),
|
||||
);
|
||||
await writeFile(sessionsPath, JSON.stringify(stored, undefined, "\t") + "\n", "utf-8");
|
||||
await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
|
||||
}
|
||||
|
||||
export async function nextAuthenticationMethodId() {
|
||||
const nextId = await readJson(nextAuthenticationMethodIdPath, 0);
|
||||
await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
|
||||
return nextId;
|
||||
}
|
||||
|
||||
export async function writeNextAuthenticationMethodId(nextId: number) {
|
||||
await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
|
||||
}
|
||||
|
||||
export async function readAuthenticationMethods() {
|
||||
return readJson<ServerAuthenticationMethod[]>(authMethodPath, [])
|
||||
}
|
||||
|
||||
export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
|
||||
await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8");
|
||||
}
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { readUsers } from "~/server/database";
|
||||
import { readUsers, type ServerSession } from "~/server/database";
|
||||
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
|
||||
import { serverSessionToApi } from "./utils/session";
|
||||
import { H3Event } from "h3";
|
||||
|
||||
function sendMessage(
|
||||
stream: WritableStream<string>,
|
||||
|
@ -31,18 +33,30 @@ function sendMessageAndClose(
|
|||
;
|
||||
}
|
||||
|
||||
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number }>();
|
||||
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
|
||||
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null
|
||||
export function addStream(stream: WritableStream<string>, sessionId?: number, accountId?: number) {
|
||||
export async function addStream(
|
||||
event: H3Event,
|
||||
stream: WritableStream<string>,
|
||||
session?: ServerSession,
|
||||
) {
|
||||
if (streams.size === 0) {
|
||||
console.log("Starting keepalive")
|
||||
keepaliveInterval = setInterval(sendKeepalive, 4000)
|
||||
}
|
||||
streams.set(stream, { sessionId, accountId });
|
||||
// Produce a response immediately to avoid the reply waiting for content.
|
||||
const message = `data: connected sid:${sessionId ?? '-'} aid:${accountId ?? '-'}\n\n`;
|
||||
sendMessage(stream, message);
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
streams.set(stream, {
|
||||
sessionId: session?.id,
|
||||
accountId: session?.accountId,
|
||||
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
|
||||
});
|
||||
// Produce a response immediately to avoid the reply waiting for content.
|
||||
const update: ApiEvent = {
|
||||
type: "connected",
|
||||
session: session ? await serverSessionToApi(event, session) : undefined,
|
||||
};
|
||||
sendMessage(stream, `event: update\ndata: ${JSON.stringify(update)}\n\n`);
|
||||
}
|
||||
export function deleteStream(stream: WritableStream<string>) {
|
||||
streams.delete(stream);
|
||||
|
@ -119,6 +133,13 @@ export async function broadcastEvent(event: ApiEvent) {
|
|||
if (!streams.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Session expiry events cause the streams belonging to that session to be terminated
|
||||
if (event.type === "session-expired") {
|
||||
cancelSessionStreams(event.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await readUsers();
|
||||
for (const [stream, streamData] of streams) {
|
||||
// Account events are specially handled and only sent to the user they belong to.
|
||||
|
@ -130,7 +151,7 @@ export async function broadcastEvent(event: ApiEvent) {
|
|||
} else {
|
||||
let userType: ApiAccount["type"] | undefined;
|
||||
if (streamData.accountId !== undefined) {
|
||||
userType = users.find(a => a.id === streamData.accountId)?.type
|
||||
userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type
|
||||
}
|
||||
const data = encodeEvent(event, userType)
|
||||
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
|
||||
|
@ -139,7 +160,12 @@ export async function broadcastEvent(event: ApiEvent) {
|
|||
}
|
||||
|
||||
function sendKeepalive() {
|
||||
for (const stream of streams.keys()) {
|
||||
sendMessage(stream, ": keepalive\n");
|
||||
const now = Date.now();
|
||||
for (const [stream, streamData] of streams) {
|
||||
if (streamData.rotatesAtMs > now) {
|
||||
sendMessage(stream, ": keepalive\n");
|
||||
} else {
|
||||
sendMessageAndClose(stream, `data: cancelled\n\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,5 +3,8 @@
|
|||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
"extends": "../.nuxt/tsconfig.server.json",
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,9 +3,18 @@
|
|||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { H3Event } from "h3";
|
||||
import { nextSessionId, readSessions, readSubscriptions, type ServerSession, type ServerUser, writeSessions, writeSubscriptions } from "~/server/database";
|
||||
|
||||
const oneYearSeconds = 365 * 24 * 60 * 60;
|
||||
import {
|
||||
nextSessionId,
|
||||
readSessions,
|
||||
readSubscriptions,
|
||||
readUsers,
|
||||
type ServerSession,
|
||||
type ServerUser,
|
||||
writeSessions,
|
||||
writeSubscriptions
|
||||
} from "~/server/database";
|
||||
import { broadcastEvent } from "../streams";
|
||||
import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
|
||||
|
||||
async function removeSessionSubscription(sessionId: number) {
|
||||
const subscriptions = await readSubscriptions();
|
||||
|
@ -20,9 +29,13 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
|
|||
const existingSessionCookie = await getSignedCookie(event, "session");
|
||||
if (existingSessionCookie) {
|
||||
const sessionId = parseInt(existingSessionCookie, 10);
|
||||
const sessionIndex = sessions.findIndex(session => session.id === sessionId);
|
||||
if (sessionIndex !== -1) {
|
||||
sessions.splice(sessionIndex, 1);
|
||||
const session = sessions.find(session => session.id === sessionId);
|
||||
if (session) {
|
||||
session.expiresAtMs = Date.now();
|
||||
broadcastEvent({
|
||||
type: "session-expired",
|
||||
sessionId,
|
||||
});
|
||||
await removeSessionSubscription(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
@ -38,50 +51,140 @@ export async function clearServerSession(event: H3Event) {
|
|||
deleteCookie(event, "session");
|
||||
}
|
||||
|
||||
export async function setServerSession(event: H3Event, account: ServerUser) {
|
||||
export async function setServerSession(
|
||||
event: H3Event,
|
||||
account: ServerUser | undefined,
|
||||
authenticationProvider?: ApiAuthenticationProvider,
|
||||
authenticationSlug?: string,
|
||||
authenticationName?: string,
|
||||
) {
|
||||
const sessions = await readSessions();
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
await clearServerSessionInternal(event, sessions);
|
||||
|
||||
const now = Date.now();
|
||||
const newSession: ServerSession = {
|
||||
account,
|
||||
access: account?.type ?? "anonymous",
|
||||
accountId: account?.id,
|
||||
authenticationProvider,
|
||||
authenticationSlug,
|
||||
authenticationName,
|
||||
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
|
||||
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
|
||||
id: await nextSessionId(),
|
||||
};
|
||||
|
||||
sessions.push(newSession);
|
||||
await writeSessions(sessions);
|
||||
await setSignedCookie(event, "session", String(newSession.id), oneYearSeconds)
|
||||
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
||||
return newSession;
|
||||
}
|
||||
|
||||
export async function refreshServerSession(event: H3Event, session: ServerSession) {
|
||||
await setSignedCookie(event, "session", String(session.id), oneYearSeconds)
|
||||
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => !user.deleted && user.id === session.accountId);
|
||||
const now = Date.now();
|
||||
const newSession: ServerSession = {
|
||||
accountId: account?.id,
|
||||
access: account?.type ?? "anonymous",
|
||||
// Authentication provider is removed to avoid possibility of an infinite delay before using it.
|
||||
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
|
||||
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
|
||||
id: await nextSessionId(),
|
||||
};
|
||||
session.successor = newSession.id;
|
||||
session.expiresAtMs = Date.now() + 10 * 1000;
|
||||
sessions.push(newSession);
|
||||
await writeSessions(sessions);
|
||||
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
|
||||
return newSession;
|
||||
}
|
||||
|
||||
export async function getServerSession(event: H3Event) {
|
||||
export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
|
||||
const sessionCookie = await getSignedCookie(event, "session");
|
||||
if (sessionCookie) {
|
||||
const sessionId = parseInt(sessionCookie, 10);
|
||||
const sessions = await readSessions();
|
||||
return sessions.find(session => session.id === sessionId);
|
||||
const session = sessions.find(session => session.id === sessionId);
|
||||
if (session) {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs >= session.discardAtMs) {
|
||||
return undefined;
|
||||
}
|
||||
if (session.expiresAtMs !== undefined && nowMs >= session.expiresAtMs) {
|
||||
if (!ignoreTaken && session.successor !== undefined) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message: "Session has been taken by another agent.",
|
||||
data: { code: "SESSION_TAKEN" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (nowMs >= session.rotatesAtMs && session.successor === undefined) {
|
||||
return await rotateSession(event, sessions, session);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireServerSession(event: H3Event) {
|
||||
const session = await getServerSession(event);
|
||||
export async function requireServerSession(event: H3Event, message: string) {
|
||||
const session = await getServerSession(event, false);
|
||||
if (!session)
|
||||
throw createError({
|
||||
status: 401,
|
||||
message: "Account session required",
|
||||
statusCode: 401,
|
||||
statusMessage: "Unauthorized",
|
||||
message,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireServerSessionWithUser(event: H3Event) {
|
||||
const message = "User session required";
|
||||
const session = await requireServerSession(event, message);
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => user.id === session.accountId);
|
||||
if (session.accountId === undefined || !account || account.deleted)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Uauthorized",
|
||||
message,
|
||||
});
|
||||
return { ...session, accountId: session.accountId };
|
||||
}
|
||||
|
||||
|
||||
export async function requireServerSessionWithAdmin(event: H3Event) {
|
||||
const session = await requireServerSession(event);
|
||||
if (session.account.type !== "admin") {
|
||||
const message = "Admin session required";
|
||||
const session = await requireServerSession(event, message);
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => user.id === session.accountId);
|
||||
if (session.access !== "admin" || account?.type !== "admin") {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message,
|
||||
});
|
||||
}
|
||||
return session;
|
||||
return { ...session, accountId: session.accountId };
|
||||
}
|
||||
|
||||
export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise<ApiSession> {
|
||||
const users = await readUsers();
|
||||
const account = users.find(user => !user.deleted && user.id === session.accountId);
|
||||
const subscriptions = await readSubscriptions();
|
||||
const push = Boolean(
|
||||
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
|
||||
);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
account,
|
||||
authenticationProvider: session.authenticationProvider,
|
||||
authenticationName: session.authenticationName,
|
||||
push,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export async function setSignedCookie(event: H3Event, name: string, value: strin
|
|||
const secret = await useCookieSecret(event);
|
||||
const signature = await crypto.subtle.sign("HMAC", secret, Buffer.from(`${name}=${value}`));
|
||||
const cookie = `${value}.${Buffer.from(signature).toString("base64url")}`
|
||||
setCookie(event, name, cookie, { httpOnly: true, secure: true, sameSite: true, maxAge });
|
||||
setCookie(event, name, cookie, { httpOnly: true, secure: true, sameSite: "lax", maxAge });
|
||||
}
|
||||
|
||||
export async function getSignedCookie(event: H3Event, name: string) {
|
||||
|
|
|
@ -67,9 +67,16 @@ export const apiSubscriptionSchema = z.object({
|
|||
});
|
||||
export type ApiSubscription = z.infer<typeof apiSubscriptionSchema>;
|
||||
|
||||
export type ApiAuthenticationProvider =
|
||||
| "demo"
|
||||
| "telegram"
|
||||
;
|
||||
|
||||
export interface ApiSession {
|
||||
id: Id,
|
||||
account?: ApiAccount,
|
||||
authenticationProvider?: ApiAuthenticationProvider,
|
||||
authenticationName?: string,
|
||||
push: boolean,
|
||||
}
|
||||
|
||||
|
@ -149,12 +156,22 @@ export interface ApiAccountUpdate {
|
|||
data: ApiAccount,
|
||||
}
|
||||
|
||||
export interface ApiConnected {
|
||||
type: "connected",
|
||||
session?: ApiSession,
|
||||
}
|
||||
|
||||
export interface ApiScheduleUpdate {
|
||||
type: "schedule-update",
|
||||
updatedFrom?: string,
|
||||
data: ApiSchedule | ApiTombstone,
|
||||
}
|
||||
|
||||
export interface ApiSessionExpired {
|
||||
type: "session-expired",
|
||||
sessionId: Id,
|
||||
}
|
||||
|
||||
export interface ApiUserUpdate {
|
||||
type: "user-update",
|
||||
updatedFrom?: string,
|
||||
|
@ -163,6 +180,8 @@ export interface ApiUserUpdate {
|
|||
|
||||
export type ApiEvent =
|
||||
| ApiAccountUpdate
|
||||
| ApiConnected
|
||||
| ApiScheduleUpdate
|
||||
| ApiSessionExpired
|
||||
| ApiUserUpdate
|
||||
;
|
||||
|
|
20
shared/types/telegram.ts
Normal file
20
shared/types/telegram.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from "zod/v4-mini";
|
||||
|
||||
export const telegramAuthDataSchema = z.catchall(
|
||||
z.object({
|
||||
// These fields are pure speculation as the actual API is undocumented.
|
||||
auth_date: z.number(),
|
||||
first_name: z.optional(z.string()),
|
||||
hash: z.string(),
|
||||
id: z.number(),
|
||||
last_name: z.optional(z.string()),
|
||||
photo_url: z.optional(z.string()),
|
||||
username: z.optional(z.string()),
|
||||
}),
|
||||
z.union([z.string(), z.number()]),
|
||||
);
|
||||
export type TelegramAuthData = z.infer<typeof telegramAuthDataSchema>;
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
import { appendResponseHeader } from "h3";
|
||||
import type { H3Event } from "h3";
|
||||
import type { ApiAccount } from "~/shared/types/api";
|
||||
import type { ApiAccount, ApiSession } from "~/shared/types/api";
|
||||
|
||||
const fetchSessionWithCookie = async (event?: H3Event) => {
|
||||
// Client side
|
||||
|
@ -26,6 +26,8 @@ const fetchSessionWithCookie = async (event?: H3Event) => {
|
|||
export const useSessionStore = defineStore("session", () => {
|
||||
const state = {
|
||||
account: ref<ApiAccount>(),
|
||||
authenticationProvider: ref<string>(),
|
||||
authenticationName: ref<string>(),
|
||||
id: ref<number>(),
|
||||
push: ref<boolean>(false),
|
||||
};
|
||||
|
@ -33,18 +35,15 @@ export const useSessionStore = defineStore("session", () => {
|
|||
const actions = {
|
||||
async fetch(event?: H3Event) {
|
||||
const session = await fetchSessionWithCookie(event)
|
||||
actions.update(session);
|
||||
},
|
||||
update(session?: ApiSession) {
|
||||
state.account.value = session?.account;
|
||||
state.authenticationProvider.value = session?.authenticationProvider;
|
||||
state.authenticationName.value = session?.authenticationName;
|
||||
state.id.value = session?.id;
|
||||
state.push.value = session?.push ?? false;
|
||||
},
|
||||
async logIn(name: string) {
|
||||
const res = await $fetch.raw("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: { name },
|
||||
});
|
||||
await actions.fetch();
|
||||
return `/api/auth/login replied: ${res.status} ${res.statusText}`;
|
||||
},
|
||||
async logOut() {
|
||||
try {
|
||||
await $fetch.raw("/api/auth/session", {
|
||||
|
@ -58,6 +57,13 @@ export const useSessionStore = defineStore("session", () => {
|
|||
},
|
||||
};
|
||||
|
||||
appEventSource?.addEventListener("update", (event) => {
|
||||
if (event.data.type !== "connected") {
|
||||
return;
|
||||
}
|
||||
actions.update(event.data.session);
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
...actions,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue