const debug = require("debug")("edupage:log");
const warn = require("debug")("edupage:warn");
const error = require("debug")("edupage:error");
const fs = require("fs");
const stream = require("stream");
const {default: fetch} = require("node-fetch");
const Student = require("./Student");
const Teacher = require("./Teacher");
const User = require("./User");
const {btoa, iterate} = require("../lib/utils");
const {ENDPOINT, API_STATUS, TIMELINE_ITEM_TYPE} = require("./enums");
const {SESSION_PING_INTERVAL_MS} = require("./constants");
const Class = require("./Class");
const Classroom = require("./Classroom");
const Parent = require("./Parent");
const RawData = require("../lib/RawData");
const Subject = require("./Subject");
const Period = require("./Period");
const ASC = require("./ASC");
const {LoginError, EdupageError, AttachmentError, APIError, FatalError, ParseError} = require("./exceptions");
const Timetable = require("./Timetable");
const Message = require("./Message");
const Plan = require("./Plan");
const Attachment = require("./Attachment");
const Grade = require("./Grade");
const Season = require("./Season");
const Homework = require("./Homework");
const Assignment = require("./Assignment");
const Test = require("./Test");
const Application = require("./Application");
debug.log = console.log.bind(console);
class Edupage extends RawData {
constructor() {
super();
this.user = null;
this.seasons = [];
this.students = [];
this.teachers = [];
this.classes = [];
this.classrooms = [];
this.parents = [];
this.subjects = [];
this.periods = [];
this.timetables = [];
this.timelineItems = [];
this.timeline = [];
this.plans = [];
this.assignments = [];
this.homeworks = [];
this.tests = [];
this.applications = [];
this.ASC = null;
this.year = null;
this.baseUrl = null;
Object.defineProperty(this, "_sessionPingTimeout", {
enumerable: false,
writable: true
});
}
async login(username = this.user.credentials.username, password = this.user.credentials.password, options) {
return new Promise((resolve, reject) => {
const temp = new User();
temp.login(username, password, options).then(async user => {
this.user = temp;
this.baseUrl = `https://${this.user.origin}.edupage.org`;
await this.refresh().catch(reject);
this.scheduleSessionPing();
resolve(this.user);
}).catch(reject);
});
}
async refresh() {
await this.refreshEdupage(false);
await this.refreshTimeline(false);
await this.refreshCreatedItems(false);
await this.refreshGrades(false);
this._updateInternalValues();
}
async refreshEdupage(_update = true) {
const _html = await this.api({
url: ENDPOINT.DASHBOARD_GET_USER,
method: "GET",
type: "text"
});
const _json = Edupage.parse(_html);
this._data = {...this._data, ..._json};
this.year = this._data._edubar.autoYear || this._data._edubar.selectedYear;
const _asc = ASC.parse(_html);
this._data = {...this._data, ASC: _asc};
this.ASC = new ASC(this._data.ASC, this);
if(_update) this._updateInternalValues();
}
async refreshTimeline(_update = true) {
const _timeline = await this.api({
url: ENDPOINT.TIMELINE_GET_DATA,
data: {
datefrom: this.getYearStart(false)
}
});
this._data = {...this._data, ..._timeline};
if(_update) this._updateInternalValues();
}
async refreshCreatedItems(_update = true) {
const _created = await this.api({
url: ENDPOINT.TIMELINE_GET_CREATED_ITEMS,
data: {
odkedy: this.getYearStart()
}
});
this._data = {...this._data, _created};
if(_update) this._updateInternalValues();
}
async refreshGrades(_update = true) {
const _grades_html = await this.api({
url: ENDPOINT.GRADES_DATA,
method: "GET",
type: "text"
});
const _grades = Grade.parse(_grades_html);
this._data = {...this._data, _grades};
if(_update) this._updateInternalValues();
}
_updateInternalValues() {
this._data._grades._events = {};
iterate(this._data._grades.data?.vsetkyUdalosti || {})
.forEach(([i, provider, object]) => this._data._grades._events[provider] = Object.values(object));
this._data.timelineItems = [...this._data.timelineItems, ...this._data._created.data.items].filter((e, i, arr) =>
i == arr.findIndex(t => (
t.timelineid == e.timelineid
))
&& !(e.typ == TIMELINE_ITEM_TYPE.MESSAGE && e.pomocny_zaznam && arr.some(t => t.timelineid == e.reakcia_na))
);
this.timelineItems = [];
this.assignments = [];
this.homeworks = [];
this.tests = [];
this.seasons = Object.values(this._data._grades?.settings?.obdobia || {}).map(data => new Season(data));
this.classes = Object.values(this._data.dbi?.classes || {}).map(data => new Class(data));
this.classrooms = Object.values(this._data.dbi?.classrooms || {}).map(data => new Classroom(data, this));
this.teachers = Object.values(this._data.dbi?.teachers || {}).map(data => new Teacher(data, this));
this.parents = Object.values(this._data.dbi?.parents || {}).map(data => new Parent(data, this));
this.students = Object.values(this._data.dbi?.students || {}).map(data => new Student(data, this));
this.subjects = Object.values(this._data.dbi?.subjects || {}).map(data => new Subject(data));
this.periods = Object.values(this._data.dbi?.periods || {}).map(data => new Period(data));
this.plans = Object.values(this._data.dbi?.plans || {}).map(data => new Plan(data, this));
this.timetables = iterate(this._data?.dp?.dates || {}).map(([i, date, data]) => new Timetable(data, date));
this.grades = Object.values(this._data._grades?.data?.vsetkyZnamky || {}).map(data => new Grade(data, this));
this.applications = Object.values(this._data.dbi?.process_types || {}).map(data => new Application(data, this));
this._data.homeworks.forEach(data => {
const assignment = Assignment.from(data, this);
if(assignment instanceof Homework) this.homeworks.push(assignment);
if(assignment instanceof Test) this.tests.push(assignment);
this.assignments.push(assignment);
});
this._data.timelineItems
.sort((a, b) => new Date(a.cas_pridania).getTime() - new Date(b.cas_pridania).getTime())
.forEach(data => this.timelineItems.unshift(new Message(data, this)));
this.timeline = this.timelineItems.filter(e => e.type != TIMELINE_ITEM_TYPE.CONFIRMATION);
this.seasons.forEach(e => e.init(this));
this.classes.forEach(e => e.init(this));
this.timetables.forEach(e => e.init(this));
const _temp = this.user;
const user = this.getUserByUserString(this._data.userid);
if(!user) throw new EdupageError(`Failed to load currently logged in user`);
this.user = User.from(this.ASC.loggedUser, user._data, this);
this.user.credentials = _temp.credentials;
this.user.cookies = _temp.cookies;
this.user.isLoggedIn = _temp.isLoggedIn;
this.user.email = this._data.userrow.p_mail;
}
getUserById(id) {
return [this.user, ...this.teachers, ...this.students, ...this.parents].find(e => e.id == id);
}
getUserIdByUserString(userString) {
return (userString.match(/-?\d+/) || "")[0];
}
getUserByUserString(userString) {
return this.getUserById(this.getUserIdByUserString(userString));
}
getYearStart(time = true) {
return (this._data._edubar.year_turnover || `${this.year}-${this.ASC.schoolyearTurnover}`) + (time ? " 00:00:00" : "");
}
async getTimetableForDate(date) {
const timetable = this.timetables.find(e => Edupage.compareDay(e.date, date));
if(timetable) return timetable;
return (await this.fetchTimetablesForDates(date, date))[0];
}
async fetchTimetablesForDates(fromDate, toDate) {
return new Promise((resolve, reject) => {
const tryFetch = async _count => {
if(!this.ASC.gpid) {
debug(`[Timetable] 'gpid' property does not exists, trying to fetch it...`);
try {
const _html = await this.api({url: ENDPOINT.DASHBOARD_GET_CLASSBOOK, method: "GET", type: "text"});
const ids = [..._html.matchAll(/gpid="?(\d+)"?/gi)].map(e => e[1]);
if(ids.length) {
this.ASC.gpids = ids;
this.ASC.gpid = ids[ids.length - 1];
}
else throw new Error("Cannot find gpid value");
} catch(err) {
debug(`[Timetable] Could not get 'gpid' property`, err);
return reject(new EdupageError("Could not get 'gpid' property: " + err.message));
}
debug(`[Timetable] 'gpid' property fetched!`);
}
this.api({
url: ENDPOINT.DASHBOARD_GCALL,
method: "POST",
type: "text",
data: new URLSearchParams({
gpid: this.ASC.gpid,
gsh: this.ASC.gsecHash,
action: "loadData",
datefrom: Edupage.dateToString(fromDate),
dateto: Edupage.dateToString(toDate),
}).toString(),
encodeBody: false
}, _count).then(_html => {
const _json = Timetable.parse(_html);
const timetables = iterate(_json.dates).map(([i, date, data]) => new Timetable(data, date, this));
timetables.forEach(e => {
const i = this.timetables.findIndex(t => e.date.getTime() == t.date.getTime());
if(i > -1) this.timetables[i] = e;
else this.timetables.push(e);
});
resolve(timetables);
}).catch(err => {
if(err.retry) {
debug(`[Timetable] Got retry signal, retrying...`);
tryFetch(err.count + 1);
} else {
error(`[Timetable] Could not fetch timetables`, err);
reject(new EdupageError("Failed to fetch timetables: " + err.message));
}
});
};
tryFetch(-1);
});
}
async uploadAttachment(filepath) {
const CRLF = "\r\n";
const filename = (filepath.match(/(?:.+[\\\/])*(.+\..+)$/m) || "")[1] || "untitled.txt";
const buffer = Buffer.concat([
Buffer.from("--" + Attachment.formBoundary + CRLF + `Content-Disposition: form-data; name="att"; filename="${filename}"` + CRLF + CRLF, "utf8"),
await fs.promises.readFile(filepath).catch(err => {
throw new AttachmentError(`Error while reading input file: ` + err.message, err);
}),
Buffer.from(CRLF + "--" + Attachment.formBoundary + "--" + CRLF, "utf8")
]);
const res = await this.api({
url: ENDPOINT.TIMELINE_UPLOAD_ATTACHMENT,
headers: {
"content-type": `multipart/form-data; boundary=` + Attachment.formBoundary
},
data: buffer,
encodeBody: false
});
if(res.status !== API_STATUS.OK) throw new APIError(`Failed to upload file: Invalid status received '${res.status}'`, res);
return new Attachment(res.data, this);
}
async api(options, _count = 0) {
const {
headers = {},
data = {},
method = "POST",
encodeBody = true,
type = "json",
autoLogin = true
} = options;
let url = options.url;
return new Promise((resolve, reject) => {
const tryFetch = (tryCount = _count || 0) => {
debug(`[API] Trying to send request...`);
const tryLogIn = async () => {
debug(`[API] Logging in...`);
await this.user.login(this.user.credentials.username, this.user.credentials.password)
.then(() => {
tryFetch(++tryCount - 1);
}).catch(err => {
error(`[API] Failed to log in user:`, err);
reject(err);
});
};
if(tryCount > 1) {
error(`[API] Request terminated due to multiple failures`);
return reject(new Error("Failed to send request multiple times"));
}
if(!this.user.origin && autoLogin) {
debug(`[API] User is not logged in yet`);
return tryLogIn();
}
if(typeof url === "number") {
url = this.buildRequestUrl(url);
}
debug(`[API] Sending request to '${url}'...`);
fetch(url, {
"headers": {
"accept": "application/json, text/javascript, */*; q=0.01",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"Cookie": this.user.cookies.toString(false),
"x-requested-with": "XMLHttpRequest",
"referrer": `https://${this.user.origin}.edupage.org/`,
...headers
},
"body": "POST" == method ? (
"string" == typeof data || data instanceof stream.Readable || data instanceof Buffer ? data : (
encodeBody ? this.encodeRequestBody(data) : JSON.stringify(data)
)
) : undefined,
"method": method,
}).then(res => res.text()).catch(err => {
error(`[API] Error while sending request:`, err);
tryFetch(++tryCount);
}).then(text => {
if(!text) {
error(`[API] Empty response body`);
return tryFetch(++tryCount);
}
if(text.includes("edubarLogin.php") && autoLogin) {
debug(`[API] Server responded with login page`);
tryLogIn();
} else if(text.includes("Error6511024354099")) {
error(`[API] Invalid gsecHash, refreshing edupage...`);
this.refreshEdupage().then(() => {
debug(`[API] Edupage refreshed, trying again (${tryCount + 1})...`);
reject({retry: true, count: ++tryCount});
}).catch(err => {
error(`[API] Failed to refresh edupage:`, err);
reject(new APIError("Failed to refresh edupage while resolving Invalid gsecHash error", err));
});
} else {
if(type == "json") {
try {
var json = JSON.parse(text);
debug(`[API] Request successful`);
resolve(json);
} catch(err) {
error(`[API] Failed to parse response as '${type}':`, err, text.slice(0, 200));
tryFetch(++tryCount);
}
} else if(type == "text") {
debug(`[API] Request successful`);
resolve(text);
} else {
error(`[API] Invalid response type provided ('${type}')`);
throw new TypeError(`Invalid response type '${type}'. (Available: 'json', 'text')`);
}
}
});
};
tryFetch();
});
}
async pingSession() {
debug(`[Login] Sening a session ping request...`);
const gpids = this.ASC.gpids;
const success = await this.api({
url: ENDPOINT.SESSION_PING,
method: "POST",
type: "text",
data: {
gpids: gpids.join(";")
}
}).then(data => {
if(data == "notlogged") return false;
else if(data == "OK") return true;
try {
const obj = JSON.parse(data);
if(obj.status == "notlogged") return false;
else return true;
} catch(err) {
FatalError.throw(new ParseError(`Failed to parse session ping response as JSON: ${err.message}`), {data, gpids});
}
}).catch(err => {
FatalError.warn(new APIError(`Failed to ping session: ${err.message}`), {err, gpids});
return null;
});
this.scheduleSessionPing();
if(success === null) {
error(`[Login] Failed to ping session`);
return false;
}
if(success) {
debug(`[Login] Successfully pinged session`);
return true;
}
if(!success) {
warn(`[Login] Session is not logged in, trying to log in...`);
const loggedIn = await this.user.login(this.user.credentials.username, this.user.credentials.password)
.then(() => {
debug(`[Login] Successfully logged in`);
return true;
})
.catch(err => {
error(`[Login] Failed to log in:`, err);
return false;
});
return loggedIn;
}
}
scheduleSessionPing() {
if(this._sessionPingTimeout) {
clearTimeout(this._sessionPingTimeout);
this._sessionPingTimeout = null;
}
this._sessionPingTimeout = setTimeout(() => this.pingSession(), SESSION_PING_INTERVAL_MS);
debug(`[Login] Scheduled session ping in ${SESSION_PING_INTERVAL_MS}ms`);
}
exit() {
if(this._sessionPingTimeout) {
clearTimeout(this._sessionPingTimeout);
this._sessionPingTimeout = null;
}
}
static compareDay(date1, date2) {
if(typeof date1 === "number" || typeof date1 == "string") date1 = new Date(date1);
if(typeof date2 === "number" || typeof date2 == "string") date2 = new Date(date2);
return date1.getDate() == date2.getDate() &&
date1.getMonth() == date2.getMonth() &&
date1.getFullYear() == date2.getFullYear();
}
static dateToString(date) {
return date.toISOString().slice(0, 10);
}
encodeRequestBody(data) {
const query = new URLSearchParams(data).toString();
return `eqap=${encodeURIComponent(btoa(query))}&eqaz=0`;
}
buildRequestUrl(endpoint) {
if(!this.user.origin) throw new LoginError(`Failed to build URL: User is not logged in yet`);
let url = null;
if(endpoint == ENDPOINT.DASHBOARD_GET_USER) url = `/user/?`;
if(endpoint == ENDPOINT.DASHBOARD_GET_CLASSBOOK) url = `/dashboard/eb.php?barNoSkin=1`;
if(endpoint == ENDPOINT.DASHBOARD_GCALL) url = `/gcall`;
if(endpoint == ENDPOINT.DASHBOARD_SIGN_ONLINE_LESSON) url = `/dashboard/server/onlinelesson.js?__func=getOnlineLessonOpenUrl`;
if(endpoint == ENDPOINT.TIMELINE_GET_DATA) url = `/timeline/?akcia=getData`;
if(endpoint == ENDPOINT.TIMELINE_GET_REPLIES) url = `/timeline/?akcia=getRepliesItem`;
if(endpoint == ENDPOINT.TIMELINE_GET_CREATED_ITEMS) url = `/timeline/?cmd=created&akcia=getData`;
if(endpoint == ENDPOINT.TIMELINE_CREATE_ITEM) url = `/timeline/?akcia=createItem`;
if(endpoint == ENDPOINT.TIMELINE_CREATE_CONFIRMATION) url = `/timeline/?akcia=createConfirmation`;
if(endpoint == ENDPOINT.TIMELINE_CREATE_REPLY) url = `/timeline/?akcia=createReply`;
if(endpoint == ENDPOINT.TIMELINE_FLAG_HOMEWORK) url = `/timeline/?akcia=homeworkFlag`;
if(endpoint == ENDPOINT.TIMELINE_UPLOAD_ATTACHMENT) url = `/timeline/?akcia=uploadAtt`;
if(endpoint == ENDPOINT.ELEARNING_TEST_DATA) url = `/elearning/?cmd=MaterialPlayer&akcia=getETestData&ts=${new Date().getTime()}`;
if(endpoint == ENDPOINT.ELEARNING_TEST_RESULTS) url = `/elearning/?cmd=EtestCreator&akcia=getResultsData`;
if(endpoint == ENDPOINT.ELEARNING_CARDS_DATA) url = `/elearning/?cmd=EtestCreator&akcia=getCardsData`;
if(endpoint == ENDPOINT.GRADES_DATA) url = `/znamky/?barNoSkin=1`;
if(endpoint == ENDPOINT.SESSION_PING) url = this._data?._edubar?.sessionPingUrl || `/login/eauth?portalping`;
if(!url) throw new TypeError(`Invalid API endpoint '${endpoint}'`);
else return this.baseUrl + url;
}
static parse(html) {
let data = {
_edubar: {}
};
const match = (html.match(/\.userhome\((.+?)\);$/m) || "")[1];
if(!match) return FatalError.throw(new ParseError("Failed to parse Edupage data from html"), {html});
try {
data = {...JSON.parse(match)};
} catch(e) {
return FatalError.throw(new ParseError("Failed to parse JSON from Edupage html"), {html, match, e});
}
const match2 = (html.match(/edubar\(([\s\S]*?)\);/) || "")[1];
if(!match2) return FatalError.throw(new ParseError("Failed to parse edubar data from html"), {html});
try {
data._edubar = JSON.parse(match2) || {};
} catch(e) {
return FatalError.throw(new ParseError("Failed to parse JSON from edubar html"), {html, match2, e});
}
return data;
}
}
module.exports = Edupage;