Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ tags:
- name: notifications
- name: pageConfig
- name: search
- name: sessions
- name: test
- name: wallpapers
- name: widgets
Expand Down Expand Up @@ -167,6 +168,29 @@ paths:
$ref: "#/components/responses/JsonOk"
"401":
$ref: "#/components/responses/JsonUnauthorized"
/sessions/current:
get:
tags:
- sessions
summary: Get the current client session
responses:
"200":
$ref: "#/components/responses/JsonOk"
"401":
$ref: "#/components/responses/JsonUnauthorized"
patch:
tags:
- sessions
summary: Rename the current client session
requestBody:
$ref: "#/components/requestBodies/JsonBody"
responses:
"200":
$ref: "#/components/responses/JsonOk"
"400":
$ref: "#/components/responses/JsonBadRequest"
"401":
$ref: "#/components/responses/JsonUnauthorized"
/integrations:
get:
tags:
Expand Down
33 changes: 29 additions & 4 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Client as SshClient } from "ssh2";

import { config } from "./lib/config";
import { subscribeActivity } from "./lib/activity";
import { ensureSession } from "./lib/data/sessions";
import { jobsApi, registerJobsCron } from "./jobs/index";
import { startPocketbase } from "./pocketbase";
import { createLogger } from "./lib/logger";
Expand All @@ -15,8 +16,9 @@ import { getNotifications } from "./lib/data/notifications/items";
import { listIntegrations } from "./lib/data/integrations";
import { getUpcomingEvents } from "./lib/calendar";
import { systemAgentClient } from "./lib/systemAgent";
import { requireAuth } from "./routes/shared";
import { readAuth, readSessionMetadata, requireAuth } from "./routes/shared";
import authRoute from "./routes/auth.route";
import sessionsRoute from "./routes/sessions.route";
import systemRoute from "./routes/system.route";
import dataRoute from "./routes/data.route";

Expand Down Expand Up @@ -64,7 +66,24 @@ app.use("*", async (c, next) => {

app.use("*", cors({ origin: "*" }));

// Session identity is deliberately independent from the auth token. Touch the
// current device on every authenticated API request that carries its stable id.
app.use("/api/v1/*", async (c, next) => {
const auth = readAuth(c);
if (auth.token && auth.sessionId) {
try {
const { pb, userId } = await requireAuth(auth);
await ensureSession(pb, userId, auth.sessionId, readSessionMetadata(c));
} catch {
// The route handler remains responsible for returning auth errors. This
// middleware should not turn a missing/expired session touch into one.
}
}
await next();
});

app.route("/", authRoute);
app.route("/", sessionsRoute);
app.route("/", systemRoute);
app.route("/", dataRoute);

Expand All @@ -77,8 +96,10 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => {
return {
async onOpen(_event, ws) {
const token = c.req.query("token") || "";
const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null;
try {
const { userId, pb } = await requireAuth({ token });
const { userId, pb } = await requireAuth({ token, sessionId });
await ensureSession(pb, userId, sessionId, readSessionMetadata(c));
const sendSnapshot = async () => {
const [notificationResult, integrationResult] = await Promise.all([
getNotifications(userId),
Expand Down Expand Up @@ -143,10 +164,12 @@ app.get("/api/v1/monitoring/ssh-hosts/:id/console", upgradeWebSocket((c) => {
return {
async onOpen(_event, ws) {
const token = c.req.query("token") || c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") || "";
const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null;
const hostId = c.req.param("id") || "";

try {
const { userId } = await requireAuth({ token });
const { userId, pb } = await requireAuth({ token, sessionId });
await ensureSession(pb, userId, sessionId, readSessionMetadata(c));
const host = await getMonitoringSshHostById(userId, hostId);
if (!host) {
ws.send(JSON.stringify({ type: "error", message: "SSH host not found" }));
Expand Down Expand Up @@ -232,8 +255,10 @@ app.get("/api/v1/monitoring/hosts/:id/stats/live", upgradeWebSocket((c) => {
return {
async onOpen(_event, ws) {
const token = c.req.query("token") || c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") || "";
const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null;
try {
const { userId } = await requireAuth({ token });
const { userId, pb } = await requireAuth({ token, sessionId });
await ensureSession(pb, userId, sessionId, readSessionMetadata(c));
const host = await getSystemAgentHostById(userId, c.req.param("id") || "");
if (!host) {
ws.close(1008, "Monitoring host not found");
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/lib/data/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class ApiActionError extends Error {

export type ActionAuth = {
token?: string | null;
sessionId?: string | null;
};

export type JsonPrimitive = string | number | boolean | null;
Expand Down
125 changes: 125 additions & 0 deletions apps/backend/src/lib/data/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { RecordModel } from "pocketbase";

import { ApiActionError } from "./auth";

export type SessionRecord = {
id: string;
user: string;
sessionId: string;
displayName: string;
clientType?: string;
platform?: string;
lastSeenAt: string;
created?: string;
updated?: string;
};

type SessionMetadata = {
clientType?: string;
platform?: string;
};

const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
const DEFAULT_DISPLAY_NAME = "Web browser";

export function normalizeSessionId(value: unknown) {
const sessionId = typeof value === "string" ? value.trim() : "";
return SESSION_ID_PATTERN.test(sessionId) ? sessionId : null;
}

function normalizeMetadata(metadata?: SessionMetadata) {
return {
...(metadata?.clientType?.trim() ? { clientType: metadata.clientType.trim().slice(0, 100) } : {}),
...(metadata?.platform?.trim() ? { platform: metadata.platform.trim().slice(0, 100) } : {}),
};
}

function toSessionRecord(record: RecordModel) {
return record as unknown as SessionRecord;
}

export async function ensureSession(
pb: { collection: (name: "sessions") => any },
userId: string,
rawSessionId: unknown,
metadata?: SessionMetadata,
) {
const sessionId = normalizeSessionId(rawSessionId);
if (!sessionId) return null;

const now = new Date().toISOString();
const collection = pb.collection("sessions");
const filter = `user = "${userId}" && sessionId = "${sessionId}"`;
const normalizedMetadata = normalizeMetadata(metadata);

let session: RecordModel | null = null;
try {
session = await collection.getFirstListItem(filter);
} catch {
// A missing record is created below. Other read errors are surfaced by create/update.
}

if (session) {
return toSessionRecord(await collection.update(session.id, {
lastSeenAt: now,
...normalizedMetadata,
}));
}

try {
return toSessionRecord(await collection.create({
user: userId,
sessionId,
displayName: DEFAULT_DISPLAY_NAME,
lastSeenAt: now,
...normalizedMetadata,
}));
} catch (error) {
// Another request from the same client may have won the race to create the unique pair.
try {
const existing = await collection.getFirstListItem(filter);
return toSessionRecord(await collection.update(existing.id, {
lastSeenAt: now,
...normalizedMetadata,
}));
} catch {
throw error;
}
}
}

export async function getCurrentSession(
pb: { collection: (name: "sessions") => any },
userId: string,
rawSessionId: unknown,
metadata?: SessionMetadata,
) {
const session = await ensureSession(pb, userId, rawSessionId, metadata);
if (!session) {
throw new ApiActionError("A valid session id is required", 400, {
error: "A valid session id is required",
});
}
return session;
}

export async function renameCurrentSession(
pb: { collection: (name: "sessions") => any },
userId: string,
rawSessionId: unknown,
displayName: unknown,
metadata?: SessionMetadata,
) {
const session = await getCurrentSession(pb, userId, rawSessionId, metadata);
const normalizedName = typeof displayName === "string" ? displayName.trim() : "";
if (!normalizedName || normalizedName.length > 100) {
throw new ApiActionError("Display name must be between 1 and 100 characters", 400, {
error: "Display name must be between 1 and 100 characters",
});
}

return toSessionRecord(await pb.collection("sessions").update(session.id, {
displayName: normalizedName,
lastSeenAt: new Date().toISOString(),
}));
}
2 changes: 1 addition & 1 deletion apps/backend/src/routes/auth.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ authRoute.get(
"/api/v1/auth/validate-auth",
withJson(async (c) => {
const body = await readJsonBody<
{ token?: string; auth?: { token?: string | null } }
{ token?: string; auth?: { token?: string | null; sessionId?: string | null } }
>(c);
return validateAuthToken(String(body?.token ?? body?.auth?.token ?? ""));
}),
Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/routes/sessions.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Hono } from "hono";

import { getCurrentSession, renameCurrentSession } from "../lib/data/sessions";
import { readAuth, readJsonBody, readSessionMetadata, requireAuth, withJson } from "./shared";

const sessionsRoute = new Hono();

sessionsRoute
.get("/api/v1/sessions/current", withJson(async (c) => {
const requestAuth = readAuth(c);
const auth = await requireAuth(requestAuth);
return getCurrentSession(auth.pb, auth.userId, requestAuth.sessionId, readSessionMetadata(c));
}))
.patch("/api/v1/sessions/current", withJson(async (c) => {
const body = await readJsonBody<{ displayName?: unknown }>(c);
const requestAuth = readAuth(c);
const auth = await requireAuth(requestAuth);
return renameCurrentSession(
auth.pb,
auth.userId,
requestAuth.sessionId,
body.displayName,
readSessionMetadata(c),
);
}));

export default sessionsRoute;
19 changes: 18 additions & 1 deletion apps/backend/src/routes/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { defaultHomeConfig } from "@dashwise/assets";

export type JsonHandler<C extends (import("hono").Context<any, any, any>) = import("hono").Context> = (c: C) => Promise<unknown> | unknown;

export const authInput = z.object({ token: z.string().nullable().optional() });
export const authInput = z.object({
token: z.string().nullable().optional(),
sessionId: z.string().nullable().optional(),
});

export function normalizePageName(pageName?: string | null) {
const cleaned = String(pageName ?? "home").trim().toLowerCase();
Expand Down Expand Up @@ -106,6 +109,20 @@ export function readAuthToken(c: Context) {
return c.req.query("token") ?? c.req.query("authToken") ?? null;
}

export function readAuth(c: Context) {
return {
token: readAuthToken(c),
sessionId: c.req.header("x-session-id") ?? null,
};
}

export function readSessionMetadata(c: Context) {
return {
clientType: c.req.header("x-client-type") ?? undefined,
platform: c.req.header("x-platform") ?? undefined,
};
}

export async function readJsonBody<T = Record<string, unknown>>(c: Context): Promise<T> {
try {
return await c.req.json();
Expand Down
39 changes: 39 additions & 0 deletions apps/web/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
{
"name": "search"
},
{
"name": "sessions"
},
{
"name": "test"
},
Expand Down Expand Up @@ -284,6 +287,42 @@
}
}
},
"/sessions/current": {
"get": {
"tags": [
"sessions"
],
"summary": "Get the current client session",
"responses": {
"200": {
"$ref": "#/components/responses/JsonOk"
},
"401": {
"$ref": "#/components/responses/JsonUnauthorized"
}
}
},
"patch": {
"tags": [
"sessions"
],
"summary": "Rename the current client session",
"requestBody": {
"$ref": "#/components/requestBodies/JsonBody"
},
"responses": {
"200": {
"$ref": "#/components/responses/JsonOk"
},
"400": {
"$ref": "#/components/responses/JsonBadRequest"
},
"401": {
"$ref": "#/components/responses/JsonUnauthorized"
}
}
}
},
"/integrations": {
"get": {
"tags": [
Expand Down
Loading
Loading