Compare commits

..

No commits in common. "f69381c44ca35d07f0fe7af0c821bffa73547763" and "d9b78bff69dff118cb4338c570d3cabffb6c50f7" have entirely different histories.

41 changed files with 1759 additions and 2607 deletions

View file

@ -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

View file

@ -33,7 +33,6 @@
</template>
<template v-else>
<NuxtLink to="/login">Log In</NuxtLink>
<NuxtLink to="/register">Register</NuxtLink>
</template>
</div>
</header>

View file

@ -1,29 +0,0 @@
<!--
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>

View file

@ -1,41 +0,0 @@
<!--
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>

View file

@ -1,61 +0,0 @@
<!--
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>

View file

@ -22,11 +22,8 @@ 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) as ApiEvent : undefined;
const data = event.data ? JSON.parse(event.data) : 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,

View file

@ -1,15 +0,0 @@
<!--
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.

View file

@ -1,45 +0,0 @@
<!--
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`.

View file

@ -1,13 +0,0 @@
<!--
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.

View file

@ -1,13 +0,0 @@
<!--
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.

View file

@ -3,65 +3,26 @@
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<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>
<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>
<script setup lang="ts">
const props = defineProps<{ error: {
defineProps<{ error: {
statusCode: number,
fatal: boolean,
unhandled: boolean,
statusMessage?: string,
data?: string,
data?: unknown,
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>

View file

@ -4,8 +4,6 @@
*/
// 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',
@ -23,17 +21,11 @@ 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: "",
}
},

View file

@ -11,14 +11,14 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@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",
"@pinia/nuxt": "0.11.0",
"luxon": "^3.5.0",
"nuxt": "^3.17.4",
"pinia": "^3.0.2",
"vue": "latest",
"vue-router": "latest",
"web-push": "^3.6.7",
"zod": "^3.25.75"
"zod": "^3.25.30"
},
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
"pnpm": {
@ -28,11 +28,11 @@
]
},
"devDependencies": {
"@nuxt/test-utils": "^3.19.2",
"@types/luxon": "^3.6.2",
"@nuxt/test-utils": "^3.19.1",
"@types/luxon": "^3.4.2",
"@types/web-push": "^3.6.4",
"happy-dom": "^17.6.3",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.1"
"vitest": "^3.2.3",
"vue-tsc": "^2.2.10"
}
}

View file

@ -5,10 +5,31 @@
<template>
<main>
<h1>Log In</h1>
<LogIn />
<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>
<p>
If you don't have an account you may <NuxtLink to="/register">register for one</NuxtLink>.
If you do not wish to deal with logins you may create an anonymous account tied to this device.
</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>
@ -16,4 +37,64 @@
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>

View file

@ -1,102 +0,0 @@
<!--
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">
&ndash; or &ndash;
</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>

3197
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -2,19 +2,10 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { nextAuthenticationMethodId, writeAuthenticationMethods, writeNextAuthenticationMethodId, writeSchedule, writeUsers } from "~/server/database";
import { writeSchedule, writeUsers } from "~/server/database";
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const accounts = generateDemoAccounts();
await writeUsers(accounts);
await writeUsers(generateDemoAccounts());
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));
})

View file

@ -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?.accountId !== undefined && current.accountId === session.accountId;
return current && current.account.id === session.account.id;
}));
await writeSubscriptions(snapshot.subscriptions);
await writeSchedule(snapshot.schedule);

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { readUsers, writeUsers } from "~/server/database";
import { apiUserPatchSchema } from "~/shared/types/api";
import { z } from "zod/v4-mini";
import { broadcastEvent } from "~/server/streams";
@ -29,8 +29,7 @@ export default defineEventHandler(async (event) => {
}
let accessChanged = false;
if (patch.type && patch.type !== user.type) {
if (patch.type) {
if (patch.type === "anonymous" || user.type === "anonymous") {
throw createError({
status: 409,
@ -39,7 +38,6 @@ export default defineEventHandler(async (event) => {
});
}
user.type = patch.type;
accessChanged = true;
}
if (patch.name) {
if (user.type === "anonymous") {
@ -56,23 +54,7 @@ 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);

View file

@ -9,41 +9,33 @@ import {
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
export default defineEventHandler(async (event) => {
const serverSession = await requireServerSessionWithUser(event);
const serverSession = await requireServerSession(event);
let users = await readUsers();
// Expire sessions for this user
const expiredSessionIds = new Set<number>();
// Remove sessions for this user
const removedSessionIds = new Set<number>();
let sessions = await readSessions();
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);
sessions = sessions.filter(session => {
if (session.account.id === serverSession.account.id) {
removedSessionIds.add(session.id);
return false;
}
}
cancelAccountStreams(serverSession.accountId);
return true;
});
cancelAccountStreams(serverSession.account.id);
await writeSessions(sessions);
await deleteCookie(event, "session");
// Remove subscriptions for this user
let subscriptions = await readSubscriptions();
subscriptions = subscriptions.filter(
subscription => !expiredSessionIds.has(subscription.sessionId)
subscription => !removedSessionIds.has(subscription.sessionId)
);
await writeSubscriptions(subscriptions);
// Remove the user
const account = users.find(user => user.id === serverSession.accountId)!;
const now = new Date(nowMs).toISOString();
const account = users.find(user => user.id === serverSession.account.id)!;
const now = new Date().toISOString();
account.deleted = true;
account.updatedAt = now;
await writeUsers(users);

View file

@ -9,7 +9,7 @@ import { z } from "zod/v4-mini";
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const session = await requireServerSession(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.accountId);
const account = users.find(user => user.id === session.account.id);
if (!account) {
throw Error("Account does not exist");
}

View file

@ -2,21 +2,20 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database";
import { readUsers, writeUsers, nextUserId, type ServerUser } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import type { ApiSession } from "~/shared/types/api";
export default defineEventHandler(async (event): Promise<ApiSession> => {
let session = await getServerSession(event, false);
if (session?.accountId !== undefined) {
export default defineEventHandler(async (event) => {
let session = await getServerSession(event);
if (session) {
throw createError({
status: 409,
message: "Cannot create account while logged in to an account."
message: "Cannot create account while having an active session."
});
}
const body = await readBody(event);
const name = body?.name;
const formData = await readFormData(event);
const name = formData.get("name");
const users = await readUsers();
let user: ServerUser;
@ -42,7 +41,7 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
name,
};
} else if (name === undefined) {
} else if (name === null) {
user = {
id: await nextUserId(),
updatedAt: new Date().toISOString(),
@ -55,42 +54,11 @@ export default defineEventHandler(async (event): Promise<ApiSession> => {
});
}
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,
});
const newSession = await setServerSession(event, user);
return await serverSessionToApi(event, newSession);
await setServerSession(event, user);
})

View file

@ -1,38 +0,0 @@
/*
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);
})

View file

@ -1,100 +0,0 @@
/*
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);
})

View file

@ -0,0 +1,22 @@
/*
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);
})

View file

@ -2,15 +2,12 @@
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, true);
const session = await getServerSession(event);
if (session) {
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (account?.type === "anonymous") {
if (session.account.type === "anonymous") {
throw createError({
status: 409,
message: "Cannot log out of an anonymous account",
@ -18,5 +15,8 @@ export default defineEventHandler(async (event) => {
}
}
if (session) {
cancelSessionStreams(session.id);
}
await clearServerSession(event);
})

View file

@ -2,10 +2,23 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default defineEventHandler(async event => {
const session = await getServerSession(event, false);
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);
if (!session)
return;
const subscriptions = await readSubscriptions();
const push = Boolean(
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
);
return await serverSessionToApi(event, session);
await refreshServerSession(event, session);
return {
id: session.id,
account: session.account,
push,
};
})

View file

@ -6,7 +6,8 @@ import { pipeline } from "node:stream";
import { addStream, deleteStream } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event, false);
const session = await getServerSession(event);
const accountId = session?.account.id;
const encoder = new TextEncoder();
const source = event.headers.get("x-forwarded-for");
@ -24,8 +25,8 @@ export default defineEventHandler(async (event) => {
console.log(`cancelled event stream for ${source}`);
deleteStream(stream.writable);
}
});
addStream(event, stream.writable, session);
})
addStream(stream.writable, session?.id, accountId);
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
setHeader(event, "Access-Control-Allow-Origin", "*");

View file

@ -9,9 +9,9 @@ import { apiScheduleSchema } from "~/shared/types/api";
import { applyUpdatesToArray } from "~/shared/utils/update";
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const session = await requireServerSession(event);
if (session.access !== "admin" && session.access !== "crew") {
if (session.account.type !== "admin" && session.account.type !== "crew") {
throw createError({
status: 403,
statusMessage: "Forbidden",
@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
}
// Validate edit restrictions for crew
if (session.access === "crew") {
if (session.account.type === "crew") {
if (update.locations?.length) {
throw createError({
status: 403,

View file

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

View file

@ -11,7 +11,7 @@ const subscriptionSchema = z.strictObject({
});
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const session = await requireServerSession(event);
const { success, error, data: body } = subscriptionSchema.safeParse(await readBody(event));
if (!success) {
throw createError({

View file

@ -5,7 +5,7 @@
import { readSubscriptions, writeSubscriptions } from "~/server/database";
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const session = await requireServerSession(event);
const subscriptions = await readSubscriptions();
const existingIndex = subscriptions.findIndex(
sub => sub.type === "push" && sub.sessionId === session.id

View file

@ -5,13 +5,13 @@
import { readUsers } from "~/server/database"
export default defineEventHandler(async (event) => {
const session = await requireServerSessionWithUser(event);
const session = await requireServerSession(event);
const users = await readUsers();
if (session.access === "admin") {
if (session.account.type === "admin") {
return users.map(serverUserToApi);
}
if (session.access === "crew") {
if (session.account.type === "crew") {
return users.filter(u => u.type === "crew" || u.type === "admin").map(serverUserToApi);
}
throw createError({

View file

@ -3,22 +3,19 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readFile, unlink, writeFile } from "node:fs/promises";
import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
export interface ServerSession {
id: Id,
access: ApiUserType,
accountId?: number,
authenticationProvider?: ApiAuthenticationProvider,
authenticationSlug?: string,
authenticationName?: string,
rotatesAtMs: number,
expiresAtMs?: number,
discardAtMs: number,
successor?: Id,
id: number,
account: ServerUser,
};
interface StoredSession {
id: number,
accountId: number,
}
export interface ServerUser {
id: Id,
updatedAt: string,
@ -31,14 +28,6 @@ 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.
@ -48,8 +37,6 @@ 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 {
@ -144,27 +131,26 @@ export async function nextSessionId() {
}
export async function readSessions() {
return readJson<ServerSession[]>(sessionsPath, [])
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;
}
export async function writeSessions(sessions: ServerSession[]) {
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");
const stored: StoredSession[] = sessions.map(
session => ({
id: session.id,
accountId: session.account.id,
}),
);
await writeFile(sessionsPath, JSON.stringify(stored, undefined, "\t") + "\n", "utf-8");
}

View file

@ -2,10 +2,8 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, type ServerSession } from "~/server/database";
import { readUsers } 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>,
@ -33,30 +31,18 @@ function sendMessageAndClose(
;
}
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number }>();
let keepaliveInterval: ReturnType<typeof setInterval> | null = null
export async function addStream(
event: H3Event,
stream: WritableStream<string>,
session?: ServerSession,
) {
export function addStream(stream: WritableStream<string>, sessionId?: number, accountId?: number) {
if (streams.size === 0) {
console.log("Starting keepalive")
keepaliveInterval = setInterval(sendKeepalive, 4000)
}
const runtimeConfig = useRuntimeConfig(event);
streams.set(stream, {
sessionId: session?.id,
accountId: session?.accountId,
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
});
// 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`);
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);
}
export function deleteStream(stream: WritableStream<string>) {
streams.delete(stream);
@ -133,13 +119,6 @@ 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.
@ -151,7 +130,7 @@ export async function broadcastEvent(event: ApiEvent) {
} else {
let userType: ApiAccount["type"] | undefined;
if (streamData.accountId !== undefined) {
userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type
userType = users.find(a => a.id === streamData.accountId)?.type
}
const data = encodeEvent(event, userType)
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
@ -160,12 +139,7 @@ export async function broadcastEvent(event: ApiEvent) {
}
function sendKeepalive() {
const now = Date.now();
for (const [stream, streamData] of streams) {
if (streamData.rotatesAtMs > now) {
sendMessage(stream, ": keepalive\n");
} else {
sendMessageAndClose(stream, `data: cancelled\n\n`);
}
for (const stream of streams.keys()) {
sendMessage(stream, ": keepalive\n");
}
}

View file

@ -3,8 +3,5 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
{
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"verbatimModuleSyntax": true,
},
"extends": "../.nuxt/tsconfig.server.json"
}

View file

@ -3,18 +3,9 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { H3Event } from "h3";
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";
import { nextSessionId, readSessions, readSubscriptions, type ServerSession, type ServerUser, writeSessions, writeSubscriptions } from "~/server/database";
const oneYearSeconds = 365 * 24 * 60 * 60;
async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions();
@ -29,13 +20,9 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
const existingSessionCookie = await getSignedCookie(event, "session");
if (existingSessionCookie) {
const sessionId = parseInt(existingSessionCookie, 10);
const session = sessions.find(session => session.id === sessionId);
if (session) {
session.expiresAtMs = Date.now();
broadcastEvent({
type: "session-expired",
sessionId,
});
const sessionIndex = sessions.findIndex(session => session.id === sessionId);
if (sessionIndex !== -1) {
sessions.splice(sessionIndex, 1);
await removeSessionSubscription(sessionId);
return true;
}
@ -51,140 +38,50 @@ export async function clearServerSession(event: H3Event) {
deleteCookie(event, "session");
}
export async function setServerSession(
event: H3Event,
account: ServerUser | undefined,
authenticationProvider?: ApiAuthenticationProvider,
authenticationSlug?: string,
authenticationName?: string,
) {
export async function setServerSession(event: H3Event, account: ServerUser) {
const sessions = await readSessions();
const runtimeConfig = useRuntimeConfig(event);
await clearServerSessionInternal(event, sessions);
const now = Date.now();
const newSession: ServerSession = {
access: account?.type ?? "anonymous",
accountId: account?.id,
authenticationProvider,
authenticationSlug,
authenticationName,
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
account,
id: await nextSessionId(),
};
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
await setSignedCookie(event, "session", String(newSession.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 refreshServerSession(event: H3Event, session: ServerSession) {
await setSignedCookie(event, "session", String(session.id), oneYearSeconds)
}
export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
export async function getServerSession(event: H3Event) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
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;
return sessions.find(session => session.id === sessionId);
}
}
export async function requireServerSession(event: H3Event, message: string) {
const session = await getServerSession(event, false);
export async function requireServerSession(event: H3Event) {
const session = await getServerSession(event);
if (!session)
throw createError({
statusCode: 401,
statusMessage: "Unauthorized",
message,
status: 401,
message: "Account session required",
});
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 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") {
const session = await requireServerSession(event);
if (session.account.type !== "admin") {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message,
});
}
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,
};
return session;
}

View file

@ -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: "lax", maxAge });
setCookie(event, name, cookie, { httpOnly: true, secure: true, sameSite: true, maxAge });
}
export async function getSignedCookie(event: H3Event, name: string) {

View file

@ -67,16 +67,9 @@ 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,
}
@ -156,22 +149,12 @@ 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,
@ -180,8 +163,6 @@ export interface ApiUserUpdate {
export type ApiEvent =
| ApiAccountUpdate
| ApiConnected
| ApiScheduleUpdate
| ApiSessionExpired
| ApiUserUpdate
;

View file

@ -1,20 +0,0 @@
/*
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>;

View file

@ -4,7 +4,7 @@
*/
import { appendResponseHeader } from "h3";
import type { H3Event } from "h3";
import type { ApiAccount, ApiSession } from "~/shared/types/api";
import type { ApiAccount } from "~/shared/types/api";
const fetchSessionWithCookie = async (event?: H3Event) => {
// Client side
@ -26,8 +26,6 @@ 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),
};
@ -35,15 +33,18 @@ 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", {
@ -57,13 +58,6 @@ export const useSessionStore = defineStore("session", () => {
},
};
appEventSource?.addEventListener("update", (event) => {
if (event.data.type !== "connected") {
return;
}
actions.update(event.data.session);
});
return {
...state,
...actions,