diff --git a/assets/openapi.json b/assets/openapi.json index 6a5298b80..31f9559c5 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index eed813e05..643cd51ef 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/src/api/routes/auth/sessions.ts b/src/api/routes/auth/sessions.ts new file mode 100644 index 000000000..2c28353bb --- /dev/null +++ b/src/api/routes/auth/sessions.ts @@ -0,0 +1,75 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ +import { route } from "@spacebar/api"; +import { createHash } from "node:crypto"; +import { Session, Snowflake } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { SessionsLogoutSchema } from "../../../schemas/api/users/SessionsSchemas"; +import { In } from "typeorm"; +const router = Router({ mergeParams: true }); +router.get( + "/", + route({ + responses: { + 200: { + body: "GetSessionsResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { extended = false } = req.params; + const sessions = (await Session.find({ where: { user_id: req.user_id, is_admin_session: false } })) as Session[]; + + res.json({ + user_sessions: sessions.map((session) => (extended ? session.getExtendedDeviceInfo() : session.getDiscordDeviceInfo())), + }); + }, +); + +router.post( + "/logout", + route({ + requestBody: "SessionsLogoutSchema", + responses: { + 204: {}, + }, + }), + async (req: Request, res: Response) => { + const body = req.body as SessionsLogoutSchema; + + let sessions: Session[] = []; + if ("session_ids" in body) { + sessions = (await Session.find({ where: { user_id: req.user_id, session_id: In(body.session_ids!) } })) as Session[]; + } + + if ("session_id_hashes" in body) { + const allSessions = (await Session.find({ where: { user_id: req.user_id } })) as Session[]; + const hashSet = new Set(body.session_id_hashes); + const matchingSessions = allSessions.filter((session) => { + const hash = createHash("sha256").update(session.session_id).digest("hex"); + return hashSet.has(hash); + }); + sessions.push(...matchingSessions); + } + + for (const session of sessions) { + await session.remove(); + } + }, +); +export default router; diff --git a/src/schemas/api/users/SessionsSchemas.ts b/src/schemas/api/users/SessionsSchemas.ts new file mode 100644 index 000000000..35dee7c6c --- /dev/null +++ b/src/schemas/api/users/SessionsSchemas.ts @@ -0,0 +1,55 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { ActivitySchema, Snowflake } from "@spacebar/schemas"; +import { ClientStatus } from "@spacebar/util"; + +export type SessionsLogoutSchema = { session_ids?: Snowflake[]; session_id_hashes?: string[] }; +export type GetSessionsResponse = { user_sessions: DeviceInfo[]; }; +/*return { + id: this.session_id, + id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"), + status: this.status, + activities: this.activities, + client_status: this.client_status, + approx_last_used_time: this.last_seen.toISOString(), + client_info: { + ...this.client_info, + location: this.last_seen_location, + }, + last_seen: this.last_seen, + last_seen_ip: this.last_seen_ip, + last_seen_location: this.last_seen_location, + };*/ +export type DeviceInfo = { + id_hash: string; + approx_last_used_time: string; + client_info: { + client: string; + os: string; + version: number; + location: string; + }; + id?: string; + status?: string; + activities?: ActivitySchema["activities"][]; + client_status?: ClientStatus; + last_seen?: Date; + last_seen_ip?: string; + last_seen_location?: string; +}; \ No newline at end of file diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts index 1569543f3..4ff9e10c5 100644 --- a/src/util/entities/Session.ts +++ b/src/util/entities/Session.ts @@ -87,10 +87,28 @@ export class Session extends BaseClassWithoutId { client_info: { os: this.client_info.os, client: this.client_info.client, - location: this.last_seen_location + location: this.last_seen_location, }, }; } + + getExtendedDeviceInfo() { + return { + id: this.session_id, + id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"), + status: this.status, + activities: this.activities, + client_status: this.client_status, + approx_last_used_time: this.last_seen.toISOString(), + client_info: { + ...this.client_info, + location: this.last_seen_location, + }, + last_seen: this.last_seen, + last_seen_ip: this.last_seen_ip, + last_seen_location: this.last_seen_location, + }; + } } export const PrivateSessionProjection: (keyof Session)[] = ["user_id", "session_id", "activities", "client_info", "status"];