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 } }));
|