Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +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";
Expand Down
16 changes: 9 additions & 7 deletions src/modules/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
}
}

Expand Down Expand Up @@ -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}
}
}
}

Expand Down Expand Up @@ -129,4 +131,4 @@ export class AuthModules extends Modules {
this.checkSelectedAccount();
return (this.credentials.accounts[this.credentials.selectedAccounts]);
}
}
}
182 changes: 173 additions & 9 deletions src/modules/Downloader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,180 @@
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";
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 {
public async getStream(fileId: number, fileType: string): Promise<ReadableStream<Uint8Array<ArrayBuffer>> | null> {
this.checkToken();
private async executeBinaryRequest(request: BinaryRequest): Promise<BinaryResponse> {
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<string, string | number | boolean> {
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<string, string> {
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 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<BinaryResponse> {
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<BinaryResponse> {
return await this.executeBinaryRequest(this.getCloudFileRequest(file, bodyParams));
}

public async getStream(fileId: string | number, fileType: string, bodyParams?: DownloadBodyParams): Promise<ReadableStream<Uint8Array<ArrayBuffer>> | 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
);
}
}

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<string, string> {
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<BinaryResponse | null> {
const request = this.getProfilePhotoRequest();
if (!request) {
return null;
}

return await this.executeBinaryRequest(request);
}
}

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

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;
}
}
25 changes: 20 additions & 5 deletions src/modules/Modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export class Modules {
throw new InvalidCredentials("No token found in this client. Please login first.");
}

protected getAuthHeaders(extraHeaders: Record<string, string> = {}): Record<string, string> {
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
Expand All @@ -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");
}

Expand All @@ -61,13 +73,16 @@ 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);
}

protected getSelectedAccount(): Account
{
return this.getSelectedAccountWithModuleName(this.moduleName);
}
}
}
4 changes: 2 additions & 2 deletions src/rest/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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';
export const WALLETS_DETAILS = () => '/v3/comptes/detail.awp?verbe=get';
6 changes: 5 additions & 1 deletion src/struct/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

public getToken2FA(): string | undefined {
return this.credentials.token2fa;
}
}
3 changes: 2 additions & 1 deletion src/types/Credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Account} from "./Account";

export interface Credential {
token?: string;
token2fa?: string;
accounts: Account[];
selectedAccounts: number;
}
}
22 changes: 22 additions & 0 deletions src/types/DownloadRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type DownloadBodyParams = Record<string, string | number | boolean | undefined>;

export interface DownloadRequest {
url: string;
method: "POST";
headers: Record<string, string>;
body: string;
}

export interface BinaryRequest {
url: string;
method: "GET" | "POST";
headers: Record<string, string>;
body?: string;
}

export interface BinaryResponse {
data: ArrayBuffer;
contentType?: string;
contentDisposition?: string;
fileName?: string;
}
6 changes: 4 additions & 2 deletions src/types/HomeworkUpcoming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type Year = `${number}${number}${number}${number}`

export type ISODate = `${Year}-${Month}-${Day}`;

export type HomeworkUpcoming = Record<ISODate, {
export interface HomeworkUpcomingItem {
aFaire: boolean,
codeMatiere: string,
documentsAFaire: boolean,
Expand All @@ -19,4 +19,6 @@ export type HomeworkUpcoming = Record<ISODate, {
matiere: string,
rendreEnLigne: boolean,
tags: HomeworkTags[]
}>;
}

export type HomeworkUpcoming = Record<ISODate, HomeworkUpcomingItem[]>;