summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-12-17 01:44:48 +0100
committerRory& <root@rory.gay>2025-12-17 01:44:48 +0100
commite7a6e7c35f205612ad51032ab9f6954652f12594 (patch)
tree9519833a9478117cb336d8e30bc6e9b0050d8e69
parentUse query for sessions endpoint (diff)
downloadserver-ts-e7a6e7c35f205612ad51032ab9f6954652f12594.tar.xz
Update session info
-rw-r--r--assets/openapi.json127
-rw-r--r--assets/schemas.json129
-rw-r--r--src/gateway/events/Close.ts3
-rw-r--r--src/gateway/opcodes/Heartbeat.ts2
-rw-r--r--src/gateway/opcodes/Identify.ts56
-rw-r--r--src/schemas/api/users/SessionsSchemas.ts24
-rw-r--r--src/util/entities/Session.ts77
-rw-r--r--src/util/interfaces/Event.ts25
-rw-r--r--src/util/migration/postgres/1765932247127-UpdateSessionInfo.ts24
-rw-r--r--src/util/util/Token.ts9
10 files changed, 343 insertions, 133 deletions
diff --git a/assets/openapi.json b/assets/openapi.json

index 31f9559c..55e044ad 100644 --- a/assets/openapi.json +++ b/assets/openapi.json
@@ -7103,9 +7103,27 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "id_hash": { "type": "string" }, + "status": { + "type": "string" + }, + "activities": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Activity" + } + } + }, + "client_status": { + "$ref": "#/components/schemas/ClientStatus" + }, "approx_last_used_time": { "type": "string" }, @@ -7133,24 +7151,6 @@ "version" ] }, - "id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "activities": { - "type": "array", - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Activity" - } - } - }, - "client_status": { - "$ref": "#/components/schemas/ClientStatus" - }, "last_seen": { "type": "string", "format": "date-time" @@ -7164,9 +7164,13 @@ }, "additionalProperties": false, "required": [ + "activities", "approx_last_used_time", "client_info", - "id_hash" + "client_status", + "id", + "id_hash", + "status" ] } } @@ -8049,7 +8053,7 @@ "client_info": { "type": "object", "properties": { - "client": { + "platform": { "type": "string" }, "os": { @@ -8062,13 +8066,7 @@ "type": "string" } }, - "additionalProperties": false, - "required": [ - "client", - "location", - "os", - "version" - ] + "additionalProperties": false }, "client_status": { "$ref": "#/components/schemas/ClientStatus" @@ -8092,6 +8090,12 @@ }, "last_seen_location": { "type": "string" + }, + "last_seen_location_info": { + "$ref": "#/components/schemas/ExtendedLocationInfo" + }, + "session_nickname": { + "type": "string" } }, "required": [ @@ -8100,8 +8104,6 @@ "client_status", "created_at", "is_admin_session", - "last_seen", - "last_seen_ip", "session_id", "status", "user", @@ -8297,6 +8299,73 @@ ], "type": "string" }, + "ExtendedLocationInfo": { + "type": "object", + "properties": { + "is_eu": { + "type": "boolean" + }, + "city": { + "type": "string" + }, + "region": { + "type": "string" + }, + "region_code": { + "type": "string" + }, + "country_name": { + "type": "string" + }, + "country_code": { + "type": "string" + }, + "continent_name": { + "type": "string" + }, + "continent_code": { + "type": "string" + }, + "latitude": { + "type": "integer" + }, + "longitude": { + "type": "integer" + }, + "postal": { + "type": "string" + }, + "calling_code": { + "type": "string" + }, + "flag": { + "type": "string" + }, + "emoji_flag": { + "type": "string" + }, + "emoji_unicode": { + "type": "string" + } + }, + "required": [ + "calling_code", + "city", + "continent_code", + "continent_name", + "country_code", + "country_name", + "emoji_flag", + "emoji_unicode", + "flag", + "is_eu", + "latitude", + "longitude", + "postal", + "region", + "region_code" + ] + }, "Relationship": { "type": "object", "properties": { diff --git a/assets/schemas.json b/assets/schemas.json
index 643cd51e..64625399 100644 --- a/assets/schemas.json +++ b/assets/schemas.json
@@ -7559,9 +7559,27 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "id_hash": { "type": "string" }, + "status": { + "type": "string" + }, + "activities": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/Activity" + } + } + }, + "client_status": { + "$ref": "#/definitions/ClientStatus" + }, "approx_last_used_time": { "type": "string" }, @@ -7589,24 +7607,6 @@ "version" ] }, - "id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "activities": { - "type": "array", - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/Activity" - } - } - }, - "client_status": { - "$ref": "#/definitions/ClientStatus" - }, "last_seen": { "type": "string", "format": "date-time" @@ -7620,9 +7620,13 @@ }, "additionalProperties": false, "required": [ + "activities", "approx_last_used_time", "client_info", - "id_hash" + "client_status", + "id", + "id_hash", + "status" ] } } @@ -8537,7 +8541,7 @@ "client_info": { "type": "object", "properties": { - "client": { + "platform": { "type": "string" }, "os": { @@ -8550,13 +8554,7 @@ "type": "string" } }, - "additionalProperties": false, - "required": [ - "client", - "location", - "os", - "version" - ] + "additionalProperties": false }, "client_status": { "$ref": "#/definitions/ClientStatus" @@ -8580,6 +8578,12 @@ }, "last_seen_location": { "type": "string" + }, + "last_seen_location_info": { + "$ref": "#/definitions/ExtendedLocationInfo" + }, + "session_nickname": { + "type": "string" } }, "additionalProperties": false, @@ -8589,8 +8593,6 @@ "client_status", "created_at", "is_admin_session", - "last_seen", - "last_seen_ip", "session_id", "status", "user", @@ -8793,6 +8795,75 @@ "type": "string", "$schema": "http://json-schema.org/draft-07/schema#" }, + "ExtendedLocationInfo": { + "type": "object", + "properties": { + "is_eu": { + "type": "boolean" + }, + "city": { + "type": "string" + }, + "region": { + "type": "string" + }, + "region_code": { + "type": "string" + }, + "country_name": { + "type": "string" + }, + "country_code": { + "type": "string" + }, + "continent_name": { + "type": "string" + }, + "continent_code": { + "type": "string" + }, + "latitude": { + "type": "integer" + }, + "longitude": { + "type": "integer" + }, + "postal": { + "type": "string" + }, + "calling_code": { + "type": "string" + }, + "flag": { + "type": "string" + }, + "emoji_flag": { + "type": "string" + }, + "emoji_unicode": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "calling_code", + "city", + "continent_code", + "continent_name", + "country_code", + "country_name", + "emoji_flag", + "emoji_unicode", + "flag", + "is_eu", + "latitude", + "longitude", + "postal", + "region", + "region_code" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, "Relationship": { "type": "object", "properties": { diff --git a/src/gateway/events/Close.ts b/src/gateway/events/Close.ts
index eff1b8ff..9e51a026 100644 --- a/src/gateway/events/Close.ts +++ b/src/gateway/events/Close.ts
@@ -63,12 +63,11 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) { if (this.user_id) { const sessions = await Session.find({ where: { user_id: this.user_id }, - select: PrivateSessionProjection, }); await emitEvent({ event: "SESSIONS_REPLACE", user_id: this.user_id, - data: sessions, + data: sessions.map(x=>x.toPrivateGatewayDeviceInfo()), } as SessionsReplace); const session = sessions[0] || { activities: [], diff --git a/src/gateway/opcodes/Heartbeat.ts b/src/gateway/opcodes/Heartbeat.ts
index 396af397..3dc713f5 100644 --- a/src/gateway/opcodes/Heartbeat.ts +++ b/src/gateway/opcodes/Heartbeat.ts
@@ -51,7 +51,7 @@ export async function onHeartbeat(this: WebSocket, data: Payload) { Session.update( { session_id: this.session_id!, - user_id: this.user_id!, + user_id: this.user_id } as FindOptionsWhere<Session>, newSessionData, ), diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index 7288df17..923ff533 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts
@@ -140,20 +140,34 @@ export async function onIdentify(this: WebSocket, data: Payload) { }); this.session.status = identify.presence?.status || "online"; - this.session.client_info ??= { - os: "Unknown", - client: "Unknown", - version: 0, - location: "Unknown", - }; - this.session.client_info.client = identify.properties?.device ?? identify.properties?.$device ?? this.session.client_info.client; - this.session.client_info.os = (identify.properties?.os || identify.properties?.$os) ?? this.session.client_info.os; + this.session.client_info ??= {}; + this.session.client_info.platform = identify.properties?.$device ?? identify.properties?.$device; + this.session.client_info.os = identify.properties?.os || identify.properties?.$os; this.session.client_status = {}; this.session.activities = identify.presence?.activities ?? []; // TODO: validation if (this.ipAddress && this.ipAddress !== this.session.last_seen_ip) { this.session.last_seen_ip = this.ipAddress; const ipInfo = await IpDataClient.getIpInfo(this.ipAddress); - if (ipInfo?.ip) this.session.last_seen_location = `${ipInfo.emoji_flag} ${ipInfo.postal} ${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country_name}`; + if (ipInfo?.ip) { + this.session.last_seen_location = `${ipInfo.emoji_flag} ${ipInfo.postal} ${ipInfo.city}, ${ipInfo.region}, ${ipInfo.country_name}`; + this.session.last_seen_location_info = { + is_eu: ipInfo.is_eu, + city: ipInfo.city, + region: ipInfo.region, + region_code: ipInfo.region_code, + country_name: ipInfo.country_name, + country_code: ipInfo.country_code, + continent_name: ipInfo.continent_name, + continent_code: ipInfo.continent_code, + latitude: ipInfo.latitude, + longitude: ipInfo.longitude, + postal: ipInfo.postal, + calling_code: ipInfo.calling_code, + flag: ipInfo.flag, + emoji_flag: ipInfo.emoji_flag, + emoji_unicode: ipInfo.emoji_unicode, + }; + } } const createSessionTime = taskSw.getElapsedAndReset(); @@ -491,18 +505,18 @@ export async function onIdentify(this: WebSocket, data: Payload) { // Send SESSIONS_REPLACE and PRESENCE_UPDATE const allSessions = ( await Session.find({ - where: { user_id: this.user_id }, - select: PrivateSessionProjection, + where: { user_id: this.user_id } }) - ).map((x) => ({ - // TODO how is active determined? - // in our lazy request impl, we just pick the 'most relevant' session - active: x.session_id == this.session!.session_id, - activities: x.activities ?? [], - client_info: x.client_info, - session_id: x.session_id, // TODO: discord.com sends 'all', what is that??? - status: x.status, - })); + ).map(x=>x.toPrivateGatewayDeviceInfo()); + // ).map((x) => ({ + // // TODO how is active determined? + // // in our lazy request impl, we just pick the 'most relevant' session + // active: x.session_id == this.session!.session_id, + // activities: x.activities ?? [], + // client_info: x.client_info, + // session_id: x.session_id, // TODO: discord.com sends 'all', what is that??? + // status: x.status, + // })); const findAndGenerateSessionReplaceTime = taskSw.getElapsedAndReset(); const [{ elapsed: emitSessionsReplaceTime }, { elapsed: emitPresenceUpdateTime }] = await Promise.all([ @@ -589,7 +603,7 @@ export async function onIdentify(this: WebSocket, data: Payload) { game_relationships: [], }; - if(this.capabilities.has(Capabilities.FLAGS.AUTH_TOKEN_REFRESH) && tokenData.tokenVersion != CurrentTokenFormatVersion) { + if (this.capabilities.has(Capabilities.FLAGS.AUTH_TOKEN_REFRESH) && tokenData.tokenVersion != CurrentTokenFormatVersion) { d.auth_token = await generateToken(this.user_id); } const buildReadyEventDataTime = taskSw.getElapsedAndReset(); diff --git a/src/schemas/api/users/SessionsSchemas.ts b/src/schemas/api/users/SessionsSchemas.ts
index 35dee7c6..583b3490 100644 --- a/src/schemas/api/users/SessionsSchemas.ts +++ b/src/schemas/api/users/SessionsSchemas.ts
@@ -21,23 +21,13 @@ 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: string; id_hash: string; + status: string; + activities: ActivitySchema["activities"][]; + client_status: ClientStatus; approx_last_used_time: string; client_info: { client: string; @@ -45,10 +35,6 @@ export type DeviceInfo = { 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; diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts
index d1b31b1d..3f5543fd 100644 --- a/src/util/entities/Session.ts +++ b/src/util/entities/Session.ts
@@ -1,6 +1,6 @@ /* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors + 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 @@ -16,13 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ +import crypto from "crypto"; import { User } from "./User"; import { BaseClassWithoutId } from "./BaseClass"; import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm"; -import { ClientStatus, Status } from "../interfaces/Status"; -import { Activity } from "../interfaces/Activity"; -import crypto from "crypto"; +import { Activity, ClientStatus, GatewaySession, GatewaySessionClientInfo, Status } from "../interfaces"; import { randomUpperString } from "@spacebar/api"; +import { IpDataIpLookupResponse } from "../util/networking/ipdata/IpDataSampleResponses"; +import { DateBuilder, TimeSpan } from "../util"; //TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them @@ -47,12 +48,12 @@ export class Session extends BaseClassWithoutId { @Column({ type: "simple-json", default: "[]" }) activities: Activity[]; - @Column({ type: "simple-json", select: false }) + @Column({ type: "simple-json"}) client_info: { - client: string; - os: string; - version: number; - location: string; + platform?: string; + os?: string; + version?: number; + location?: string; }; @Column({ type: "simple-json" }) @@ -67,15 +68,21 @@ export class Session extends BaseClassWithoutId { @CreateDateColumn({ type: Date }) created_at: Date; - @Column({ default: 0, type: Date }) - last_seen: Date; + @Column({ nullable: true, type: Date }) + last_seen?: Date; - @Column({ default: "127.0.0.1", type: String }) - last_seen_ip: string; + @Column({ nullable: true, type: String }) + last_seen_ip?: string; @Column({ nullable: true, type: String }) last_seen_location?: string; + @Column({ nullable: true, type: "simple-json" }) + last_seen_location_info?: ExtendedLocationInfo; + + @Column({ nullable: true, type: String }) + session_nickname?: string; + getPublicStatus() { return this.status === "invisible" ? "offline" : this.status; } @@ -83,10 +90,10 @@ export class Session extends BaseClassWithoutId { getDiscordDeviceInfo() { return { id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"), - approx_last_used_time: this.last_seen.toISOString(), + approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(), client_info: { os: this.client_info?.os, - client: this.client_info?.client, + platform: this.client_info?.platform + (this.client_info?.version ? ` ${this.client_info?.version}` : "") + (this.session_nickname ? ` (${this.session_nickname})` : ""), location: this.last_seen_location, }, }; @@ -99,7 +106,7 @@ export class Session extends BaseClassWithoutId { status: this.status, activities: this.activities, client_status: this.client_status, - approx_last_used_time: this.last_seen.toISOString(), + approx_last_used_time: (this.last_seen ?? new Date(0)).toISOString(), client_info: { ...this.client_info ?? {}, location: this.last_seen_location, @@ -107,8 +114,46 @@ export class Session extends BaseClassWithoutId { last_seen: this.last_seen, last_seen_ip: this.last_seen_ip, last_seen_location: this.last_seen_location, + last_seen_location_info: this.last_seen_location_info, }; } + + toPrivateGatewayDeviceInfo(): GatewaySession { + // TODO: ... or has `show_current_game` privacy setting enabled - except spotify (always visible) + const hasPrivateActivities = this.status == "offline" || this.status == "invisible"; + const inactiveTreshold = new DateBuilder(new Date(0)).addMinutes(5).buildTimestamp(); + + return { + session_id: this.session_id, + client_info: { + client: this.client_info?.platform ?? "", + os: this.client_info?.os ?? "", + version: this.client_info?.version ?? 0, + } as GatewaySessionClientInfo, + status: this.status, + activities: hasPrivateActivities ? [] : this.activities, + hidden_activities: hasPrivateActivities ? this.activities : [], + active: TimeSpan.fromDates(this.last_seen?.getTime() ?? 0, new Date().getTime()).totalMillis < inactiveTreshold, + } + } +} + +export interface ExtendedLocationInfo { + is_eu: boolean, + city: string, + region: string, + region_code: string, + country_name: string, + country_code: string, + continent_name: string, + continent_code: string, + latitude: number, + longitude: number, + postal: string, + calling_code: string, + flag: string, + emoji_flag: string, + emoji_unicode: string, } export const PrivateSessionProjection: (keyof Session)[] = ["user_id", "session_id", "activities", "client_info", "status"]; diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index e390605b..8fb9b904 100644 --- a/src/util/interfaces/Event.ts +++ b/src/util/interfaces/Event.ts
@@ -562,17 +562,24 @@ export interface RelationshipRemoveEvent extends Event { data: Omit<PublicRelationship, "nickname">; } +export interface GatewaySessionClientInfo { + version: number; + os: string; + client: string; +}; + +export interface GatewaySession { + session_id: string; + activities: Activity[]; + hidden_activities: Activity[]; + client_info: GatewaySessionClientInfo; + status: Status; + active?: boolean; // How is this even defined? +} + export interface SessionsReplace extends Event { event: "SESSIONS_REPLACE"; - data: { - activities: Activity[]; - client_info: { - version: number; - os: string; - client: string; - }; - status: Status; - }[]; + data: GatewaySession[]; } export interface GuildMemberListUpdate extends Event { diff --git a/src/util/migration/postgres/1765932247127-UpdateSessionInfo.ts b/src/util/migration/postgres/1765932247127-UpdateSessionInfo.ts new file mode 100644
index 00000000..d18dfbdc --- /dev/null +++ b/src/util/migration/postgres/1765932247127-UpdateSessionInfo.ts
@@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateSessionInfo1765932247127 implements MigrationInterface { + name = 'UpdateSessionInfo1765932247127' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "sessions" ADD "last_seen_location_info" text`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "session_nickname" character varying`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen_ip" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen_ip" DROP DEFAULT`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen_ip" SET DEFAULT '127.0.0.1'`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen_ip" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen" SET DEFAULT '1970-01-01 00:00:00'`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "last_seen" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "session_nickname"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "last_seen_location_info"`); + } + +} diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index 887c58cc..a1d0ca20 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts
@@ -120,7 +120,7 @@ export const checkToken = ( return rejectAndLog(reject, "Invalid Token"); } - if (session && TimeSpan.fromDates(session.last_seen.getTime(), new Date().getTime()).totalSeconds >= 15) { + if (session && TimeSpan.fromDates(session.last_seen?.getTime() ?? 0, new Date().getTime()).totalSeconds >= 15) { session.last_seen = new Date(); if (opts?.ipAddress && opts?.ipAddress !== session.last_seen_ip) { session.last_seen_ip = opts.ipAddress; @@ -171,12 +171,7 @@ export async function generateToken(id: string, isAdminSession: boolean = false) is_admin_session: isAdminSession, client_status: {}, status: "online", - client_info: { - os: "Unknown", - client: "Unknown", - version: 0, - location: "Unknown", - }, + client_info: { }, }); } while (await Session.findOne({ where: { session_id: newSession.session_id } }));