// Copyright (c) 2022 MillenniumEarl // // This software is released under the MIT License. // https://opensource.org/licenses/MIT // Core modules import { promises as fs, existsSync } from "fs"; // Public modules from npm import { sha256 } from "js-sha256"; import tough, { CookieJar } from "tough-cookie"; import { ParameterError } from "./errors"; import { urls } from "../constants/url"; import { DEFAULT_DATE } from "../constants/generic"; export default class Session { //#region Fields /** * Max number of days the session is valid. */ private readonly SESSION_TIME: number = 30; private _path: string; private _isMapped: boolean; private _created: Date; private _hash: string; private _token: string; private _cookieJar: CookieJar; private _serializedCookieJar: CookieJar.Serialized; //#endregion Fields //#region Getters /** * Path of the session map file on disk. */ public get path(): string { return this._path; } /** * Indicates if the session is mapped on disk. */ public get isMapped(): boolean { return this._isMapped; } /** * Date of creation of the session. */ public get created(): Date { return this._created; } /** * MD5 hash of the username and the password. */ public get hash(): string { return this._hash; } /** * Token used for POST requests to the platform. */ public get token(): string { return this._token; } /** * Cookie holder. */ public get cookieJar(): tough.CookieJar { return this._cookieJar; } //#endregion Getters /** * Initializes the session by setting the path for saving information to disk. */ constructor(p: string) { if (!p || p === "") throw new ParameterError("Invalid path for the session file"); this._path = p; this._isMapped = existsSync(this.path); this._created = new Date(Date.now()); this._hash = null; this._token = null; this._cookieJar = new tough.CookieJar(); } //#region Private Methods /** * Get the difference in days between two dates. */ private dateDiffInDays(a: Date, b: Date) { const MS_PER_DAY = 1000 * 60 * 60 * 24; // Discard the time and time-zone information. const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); return Math.abs(Math.floor((utc2 - utc1) / MS_PER_DAY)); } /** * Convert the object to a dictionary serializable in JSON. */ private toJSON(): Record<string, unknown> { return { _created: this._created, _hash: this._hash, _token: this._token, _serializedCookieJar: this._serializedCookieJar }; } //#endregion Private Methods //#region Public Methods /** * Create a new session. */ create(username: string, password: string, token: string): void { // First, create the _hash of the credentials const value = `${username}%%%${password}`; this._hash = sha256(value); // Set the token this._token = token; // Update the creation date this._created = new Date(Date.now()); } /** * Save the session to disk. */ async save(): Promise<void> { // Update the creation date this._created = new Date(Date.now()); // Set the session as mapped on file this._isMapped = true; // Serialize the cookiejar this._serializedCookieJar = await this._cookieJar.serialize(); // Convert data const json = this.toJSON(); const data = JSON.stringify(json); // Write data await fs.writeFile(this.path, data); } /** * Load the session from disk. */ async load(): Promise<void> { if (this.isMapped) { // Read data const data = await fs.readFile(this.path, { encoding: "utf-8", flag: "r" }); const json = JSON.parse(data); // Assign values this._created = new Date(json._created); this._hash = json._hash; this._token = json._token; // Load cookiejar this._cookieJar = await CookieJar.deserialize(json._serializedCookieJar); // Remove session cookies await this.deleteSessionCookies(); } } /** * Delete the session from disk. */ async delete(): Promise<void> { // Delete the session data if (this.isMapped) await fs.unlink(this.path); } /** * Removes from memory the session cookies which * will have to be recreated to each session. */ async deleteSessionCookies(): Promise<void> { // Get all the stored cookies const cookies = await this._cookieJar.getCookies(urls.BASE); // Get the user cookie, the only not session-based const userCookie = cookies.find((cookie) => cookie.key === "xf_user"); // Remove all the cookies from the store and re-add the user cookie await this._cookieJar.removeAllCookies(); if (userCookie) await this._cookieJar.setCookie(userCookie, urls.BASE); } /** * Check if the session is valid. */ isValid(username: string, password: string): boolean { // Local variables const now = new Date(Date.now()); // Get the number of days from the file creation const sessionDateDiff = this.dateDiffInDays(now, this.created); // The session is valid if the number of days is minor than SESSION_TIME const sessionDateValid = sessionDateDiff < this.SESSION_TIME; // Check the hash const value = `${username}%%%${password}`; const hashValid = sha256(value) === this._hash; // Verify if the user cookie is valid const xfUser = this._cookieJar.getCookiesSync(urls.BASE).find((c) => c.key === "xf_user"); const cookieCreation = xfUser ? xfUser.creation : DEFAULT_DATE; const cookieDateDiff = this.dateDiffInDays(now, cookieCreation); // The cookie has a validity of one year, however it is limited to SESSION_TIME const cookieDateValid = cookieDateDiff < this.SESSION_TIME; return sessionDateValid && hashValid && cookieDateValid; } /** * Update the `_xfToken` token. */ updateToken(token: string): void { this._token = token; } //#endregion Public Methods }