From f17424e3b9d53c8f1dc320e95a6b324756737466 Mon Sep 17 00:00:00 2001 From: Fefedu973 <80718477+Fefedu973@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:39:53 +0200 Subject: [PATCH 1/2] Add download/profile photo and 2FA support Introduce download-related types and extend downloader functionality: add src/types/DownloadRequest.ts, export it from index, and implement helpers in Downloader (getDownloadURL/headers/request, getStream refactor, profile photo URL/headers/request and getProfilePhoto). Add clean JSON handling and use configured USER_AGENT/BASE_URL. Add centralized auth header helper in Modules (getAuthHeaders) and improve account/module checks. Update Auth to persist a 2FA token from response headers and removed stray console.log. Add Client.getToken2FA() getter and broaden DOWNLOADER_URL param types in endpoints to accept string|number. Small API surface and behavior changes to support 2FA flows and binary downloads. --- src/index.ts | 1 + src/modules/Auth.ts | 16 ++--- src/modules/Downloader.ts | 115 ++++++++++++++++++++++++++++++++--- src/modules/Modules.ts | 25 ++++++-- src/rest/endpoints.ts | 4 +- src/struct/Client.ts | 6 +- src/types/Credential.ts | 3 +- src/types/DownloadRequest.ts | 20 ++++++ 8 files changed, 165 insertions(+), 25 deletions(-) create mode 100644 src/types/DownloadRequest.ts diff --git a/src/index.ts b/src/index.ts index 3cf8dda..1183a79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from "./types/Account"; export * from "./types/AccountIndividualSettings"; export * from "./types/AccountKind"; export * from "./types/Credential"; +export * from "./types/DownloadRequest"; export * from "./types/DoubleAuthQuestions"; export * from "./types/DoubleAuthResult"; export * from "./types/Module"; diff --git a/src/modules/Auth.ts b/src/modules/Auth.ts index de8841a..0e3dc22 100644 --- a/src/modules/Auth.ts +++ b/src/modules/Auth.ts @@ -43,9 +43,11 @@ export class AuthModules extends Modules { throw new Require2FA("Your account require 2FA to login.", res.headers.get("x-token")!); case 505: throw new InvalidCredentials("Username or password is invalid."); - default: - Object.assign(this.credentials, {token: res.token, accounts: res.data.accounts}); + default: { + const token2fa = res.headers.get("2fa-token") ?? this.credentials.token2fa ?? undefined; + Object.assign(this.credentials, {token: res.token, token2fa, accounts: res.data.accounts}); return {...res.data, token: res.token} + } } } @@ -76,16 +78,16 @@ export class AuthModules extends Modules { } ); - console.log(res.message); - switch (res.code) { case 250: throw new Require2FA("Your account require 2FA to login.", res.headers.get("x-token")!); case 505: throw new InvalidCredentials("Username or token is invalid."); - default: - Object.assign(this.credentials, {token: res.token, accounts: res.data.accounts}); + default: { + const token2fa = res.headers.get("2fa-token") ?? this.credentials.token2fa ?? undefined; + Object.assign(this.credentials, {token: res.token, token2fa, accounts: res.data.accounts}); return {...res.data, token: res.token} + } } } @@ -129,4 +131,4 @@ export class AuthModules extends Modules { this.checkSelectedAccount(); return (this.credentials.accounts[this.credentials.selectedAccounts]); } -} \ No newline at end of file +} diff --git a/src/modules/Downloader.ts b/src/modules/Downloader.ts index 1c8ec85..99f13e3 100644 --- a/src/modules/Downloader.ts +++ b/src/modules/Downloader.ts @@ -1,16 +1,113 @@ import {Modules} from "./Modules"; -import {DOWNLOADER_URL} from "../rest/endpoints"; +import {BASE_URL, DOWNLOADER_URL, USER_AGENT} from "../rest/endpoints"; +import {cleanJSON} from "../utils/json"; +import {BinaryRequest, BinaryResponse, DownloadBodyParams, DownloadRequest} from "../types/DownloadRequest"; + +const ECOLEDIRECTE_ORIGIN = "https://www.ecoledirecte.com"; +const PROFILE_PHOTO_ACCEPT = "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"; export class DownloaderModules extends Modules { - public async getStream(fileId: number, fileType: string): Promise> | null> { - this.checkToken(); + private getDownloadData(bodyParams?: DownloadBodyParams): Record { + return cleanJSON({ forceDownload: 0, ...bodyParams }); + } + + private getDownloadBody(bodyParams?: DownloadBodyParams): string { + return new URLSearchParams({ + data: JSON.stringify(this.getDownloadData(bodyParams)), + }).toString(); + } + + public getDownloadURL(fileId: string | number, fileType: string): string { + return new URL(DOWNLOADER_URL(fileId, fileType), BASE_URL).toString(); + } + + public getDownloadHeaders(): Record { + return this.getAuthHeaders({ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://www.ecoledirecte.com", + "Referer": "https://www.ecoledirecte.com/", + "User-Agent": USER_AGENT, + }); + } + + public getDownloadRequest(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): DownloadRequest { + return { + url: this.getDownloadURL(fileId, fileType), + method: "POST", + headers: this.getDownloadHeaders(), + body: this.getDownloadBody(bodyParams), + }; + } + + public async getStream(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): Promise> | null> { + const request = this.getDownloadRequest(fileId, fileType, bodyParams); + const url = new URL(request.url); return await this.restManager.getStream( - DOWNLOADER_URL(fileId, fileType), - { forceDownload: 0 }, - { - "X-Token": this.credentials.token! - } + `${url.pathname}${url.search}`, + this.getDownloadData(bodyParams), + request.headers ); } -} \ No newline at end of file + + public getProfilePhotoURL(): string | undefined { + const profilePhotoURL = this.getSelectedAccount().profile?.photo; + if (typeof profilePhotoURL !== "string" || profilePhotoURL.trim().length === 0) { + return undefined; + } + + return new URL(profilePhotoURL, ECOLEDIRECTE_ORIGIN).toString(); + } + + public getProfilePhotoHeaders(): Record { + const account = this.getSelectedAccount(); + this.checkToken(); + + return { + "Accept": PROFILE_PHOTO_ACCEPT, + "Cache-Control": "no-cache", + "Origin": ECOLEDIRECTE_ORIGIN, + "Pragma": "no-cache", + "Referer": `${ECOLEDIRECTE_ORIGIN}/`, + "User-Agent": USER_AGENT, + "Cookie": `OGEC_ED_CAS=${account.codeOgec}; TOKEN_ED_CAS_0=${this.credentials.token!}`, + }; + } + + public getProfilePhotoRequest(): BinaryRequest | null { + const profilePhotoURL = this.getProfilePhotoURL(); + if (!profilePhotoURL) { + return null; + } + + const url = new URL(profilePhotoURL); + url.searchParams.set("_", Date.now().toString()); + + return { + url: url.toString(), + method: "GET", + headers: this.getProfilePhotoHeaders(), + }; + } + + public async getProfilePhoto(): Promise { + const request = this.getProfilePhotoRequest(); + if (!request) { + return null; + } + + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch profile photo (${response.status} ${response.statusText})`); + } + + return { + data: await response.arrayBuffer(), + contentType: response.headers.get("content-type") ?? undefined, + }; + } +} diff --git a/src/modules/Modules.ts b/src/modules/Modules.ts index b68bc63..a217b93 100644 --- a/src/modules/Modules.ts +++ b/src/modules/Modules.ts @@ -29,6 +29,16 @@ export class Modules { throw new InvalidCredentials("No token found in this client. Please login first."); } + protected getAuthHeaders(extraHeaders: Record = {}): Record { + this.checkToken(); + + return { + "X-Token": this.credentials.token!, + ...(this.credentials.token2fa ? { "2FA-Token": this.credentials.token2fa } : {}), + ...extraHeaders, + }; + } + /** * Check if the current selected account is valid. * @private @@ -50,8 +60,10 @@ export class Modules { protected isModuleAvailableForSelectedAccount(): boolean { if (!this.moduleName) throw new Error("[Developer note] 'moduleName' is unset. Make sure you're initialising the module with it before using this function."); - const accounts = this.getSelectedAccount(); - let module: Module | undefined = accounts.modules.find((module) => module.code === this.moduleName ); + this.checkSelectedAccount(); + + const account = this.credentials.accounts[this.credentials.selectedAccounts]; + let module: Module | undefined = account.modules.find((item) => item.code === this.moduleName ); return (typeof module !== "undefined"); } @@ -61,8 +73,11 @@ export class Modules { const account: Account = this.credentials.accounts[this.credentials.selectedAccounts]; - if (moduleName !== undefined && !this.isModuleAvailableForSelectedAccount()) - throw new UnsupportedModule(`The selected account does not support the module "${this.moduleName}"`); + if (moduleName !== undefined) { + const module: Module | undefined = account.modules.find((item) => item.code === moduleName); + if (typeof module === "undefined") + throw new UnsupportedModule(`The selected account does not support the module "${moduleName}"`); + } return (account); } @@ -70,4 +85,4 @@ export class Modules { { return this.getSelectedAccountWithModuleName(this.moduleName); } -} \ No newline at end of file +} diff --git a/src/rest/endpoints.ts b/src/rest/endpoints.ts index a8e0930..cfb188c 100644 --- a/src/rest/endpoints.ts +++ b/src/rest/endpoints.ts @@ -13,7 +13,7 @@ export const USER_AGENT: string = 'BlocksDirecte/1.0 (iPhone; CPU iPhone OS 18_7 /* Modules - Downloader */ /* *************************************************************** */ -export const DOWNLOADER_URL = (fileId: number, filetype: string) => `/v3/telechargement.awp?verbe=get&fichierId=${fileId}&leTypeDeFichier=${filetype}`; +export const DOWNLOADER_URL = (fileId: string | number, filetype: string) => `/v3/telechargement.awp?verbe=get&fichierId=${fileId}&leTypeDeFichier=${filetype}`; /* *************************************************************** */ /* Modules - Auth */ @@ -87,4 +87,4 @@ export const HOMEWORK_PUT = (account_kind: AccountKind, account_id: number) => ` /* Modules - WalletS */ /* *************************************************************** */ -export const WALLETS_DETAILS = () => '/v3/comptes/detail.awp?verbe=get'; \ No newline at end of file +export const WALLETS_DETAILS = () => '/v3/comptes/detail.awp?verbe=get'; diff --git a/src/struct/Client.ts b/src/struct/Client.ts index 12a94b6..5b1d6bf 100644 --- a/src/struct/Client.ts +++ b/src/struct/Client.ts @@ -39,4 +39,8 @@ export class Client { this.homework = new HomeworkModules(this.restManager, this.credentials, "CAHIER_DE_TEXTES"); this.wallets = new WalletsModule(this.restManager, this.credentials, "SITUATION_FINANCIERE"); } -} \ No newline at end of file + + public getToken2FA(): string | undefined { + return this.credentials.token2fa; + } +} diff --git a/src/types/Credential.ts b/src/types/Credential.ts index 62faff4..5ed5d2a 100644 --- a/src/types/Credential.ts +++ b/src/types/Credential.ts @@ -2,6 +2,7 @@ import {Account} from "./Account"; export interface Credential { token?: string; + token2fa?: string; accounts: Account[]; selectedAccounts: number; -} \ No newline at end of file +} diff --git a/src/types/DownloadRequest.ts b/src/types/DownloadRequest.ts new file mode 100644 index 0000000..f8988c0 --- /dev/null +++ b/src/types/DownloadRequest.ts @@ -0,0 +1,20 @@ +export type DownloadBodyParams = Record; + +export interface DownloadRequest { + url: string; + method: "POST"; + headers: Record; + body: string; +} + +export interface BinaryRequest { + url: string; + method: "GET" | "POST"; + headers: Record; + body?: string; +} + +export interface BinaryResponse { + data: ArrayBuffer; + contentType?: string; +} From 0c340c27a30ec2046bc3f948e226c7024661cd75 Mon Sep 17 00:00:00 2001 From: Fefedu973 <80718477+Fefedu973@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:38:56 +0200 Subject: [PATCH 2/2] Add binary download helpers and homework types Introduce centralized binary fetching in Downloader (executeBinaryRequest) and helper APIs (getBinaryDownloadRequest, getDownloadBinary, getCloudFileRequest, getCloudFile) plus content-disposition parsing (getResponseFileName, decodeContentDispositionValue). Extend BinaryResponse with contentDisposition and fileName. Export CloudFile and several homework-related types from index.ts. Refactor HomeworkUpcoming: add HomeworkUpcomingItem interface and change HomeworkUpcoming to map ISO dates to arrays of items. --- src/index.ts | 8 ++++ src/modules/Downloader.ts | 87 +++++++++++++++++++++++++++++++---- src/types/DownloadRequest.ts | 2 + src/types/HomeworkUpcoming.ts | 6 ++- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1183a79..c6dee71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,17 @@ export * from "./types/Account"; export * from "./types/AccountIndividualSettings"; export * from "./types/AccountKind"; export * from "./types/Credential"; +export * from "./types/CloudFile"; export * from "./types/DownloadRequest"; export * from "./types/DoubleAuthQuestions"; export * from "./types/DoubleAuthResult"; +export * from "./types/Homework"; +export * from "./types/HomeworkDate"; +export * from "./types/HomeworkEntityType"; +export * from "./types/HomeworkLessonContent"; +export * from "./types/HomeworkSubject"; +export * from "./types/HomeworkTags"; +export * from "./types/HomeworkUpcoming"; export * from "./types/Module"; export * from "./types/RequestHandler"; export * from "./types/ServerResponse"; diff --git a/src/modules/Downloader.ts b/src/modules/Downloader.ts index 99f13e3..a068044 100644 --- a/src/modules/Downloader.ts +++ b/src/modules/Downloader.ts @@ -2,11 +2,31 @@ import {Modules} from "./Modules"; import {BASE_URL, DOWNLOADER_URL, USER_AGENT} from "../rest/endpoints"; import {cleanJSON} from "../utils/json"; import {BinaryRequest, BinaryResponse, DownloadBodyParams, DownloadRequest} from "../types/DownloadRequest"; +import {CloudFile} from "../types/CloudFile"; const ECOLEDIRECTE_ORIGIN = "https://www.ecoledirecte.com"; const PROFILE_PHOTO_ACCEPT = "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"; export class DownloaderModules extends Modules { + private async executeBinaryRequest(request: BinaryRequest): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch binary resource (${response.status} ${response.statusText})`); + } + + return { + data: await response.arrayBuffer(), + contentType: response.headers.get("content-type") ?? undefined, + contentDisposition: response.headers.get("content-disposition") ?? undefined, + fileName: getResponseFileName(response.headers.get("content-disposition") ?? undefined), + }; + } + private getDownloadData(bodyParams?: DownloadBodyParams): Record { return cleanJSON({ forceDownload: 0, ...bodyParams }); } @@ -39,6 +59,30 @@ export class DownloaderModules extends Modules { }; } + public getBinaryDownloadRequest(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): BinaryRequest { + const request = this.getDownloadRequest(fileId, fileType, bodyParams); + return { + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, + }; + } + + public async getDownloadBinary(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): Promise { + return await this.executeBinaryRequest( + this.getBinaryDownloadRequest(fileId, fileType, bodyParams) + ); + } + + public getCloudFileRequest(file: CloudFile, bodyParams?: DownloadBodyParams): BinaryRequest { + return this.getBinaryDownloadRequest(file.id, file.type, bodyParams); + } + + public async getCloudFile(file: CloudFile, bodyParams?: DownloadBodyParams): Promise { + return await this.executeBinaryRequest(this.getCloudFileRequest(file, bodyParams)); + } + public async getStream(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): Promise> | null> { const request = this.getDownloadRequest(fileId, fileType, bodyParams); const url = new URL(request.url); @@ -96,18 +140,41 @@ export class DownloaderModules extends Modules { return null; } - const response = await fetch(request.url, { - method: request.method, - headers: request.headers, - }); + return await this.executeBinaryRequest(request); + } +} - if (!response.ok) { - throw new Error(`Failed to fetch profile photo (${response.status} ${response.statusText})`); +function getResponseFileName(contentDisposition?: string): string | undefined { + if (!contentDisposition) { + return undefined; + } + + const encodedMatch = contentDisposition.match(/filename\*\s*=\s*(?:UTF-8''|utf-8'')?([^;]+)/i); + if (encodedMatch?.[1]) { + const decoded = decodeContentDispositionValue(encodedMatch[1]); + if (decoded) { + return decoded; } + } - return { - data: await response.arrayBuffer(), - contentType: response.headers.get("content-type") ?? undefined, - }; + const quotedMatch = contentDisposition.match(/filename\s*=\s*"([^"]+)"/i); + if (quotedMatch?.[1]) { + return quotedMatch[1].trim(); + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i); + return plainMatch?.[1]?.trim(); +} + +function decodeContentDispositionValue(value: string): string | undefined { + const normalizedValue = value.trim().replace(/^"(.*)"$/, "$1"); + if (!normalizedValue) { + return undefined; + } + + try { + return decodeURIComponent(normalizedValue); + } catch { + return normalizedValue; } } diff --git a/src/types/DownloadRequest.ts b/src/types/DownloadRequest.ts index f8988c0..f9b1031 100644 --- a/src/types/DownloadRequest.ts +++ b/src/types/DownloadRequest.ts @@ -17,4 +17,6 @@ export interface BinaryRequest { export interface BinaryResponse { data: ArrayBuffer; contentType?: string; + contentDisposition?: string; + fileName?: string; } diff --git a/src/types/HomeworkUpcoming.ts b/src/types/HomeworkUpcoming.ts index 59eae6e..1d52b26 100644 --- a/src/types/HomeworkUpcoming.ts +++ b/src/types/HomeworkUpcoming.ts @@ -8,7 +8,7 @@ export type Year = `${number}${number}${number}${number}` export type ISODate = `${Year}-${Month}-${Day}`; -export type HomeworkUpcoming = Record; \ No newline at end of file +} + +export type HomeworkUpcoming = Record; \ No newline at end of file