Szymon Sosnowski commited on
Commit
fe2ccd0
·
1 Parent(s): 10de7a7

fix: add required Referer header - fix TLS handshake (librus-api PR#81)

Browse files
Files changed (1) hide show
  1. server.js +186 -154
server.js CHANGED
@@ -9,213 +9,234 @@ app.use(express.json());
9
 
10
  const PORT = process.env.PORT || 3001;
11
 
12
- // Helper: execute HTTPS request with cookies
13
- function httpsRequest(options, body, cookies) {
 
 
 
 
 
 
 
 
 
 
14
  return new Promise((resolve, reject) => {
15
  const cookieStr = Object.entries(cookies || {})
16
  .map(([k, v]) => `${k}=${v}`).join("; ");
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const reqOptions = {
19
  ...options,
20
- headers: {
21
- "User-Agent": "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0",
22
- "Content-Type": "application/x-www-form-urlencoded",
23
- "Cookie": cookieStr,
24
- ...(options.headers || {})
25
- }
26
  };
27
 
28
  const req = https.request(reqOptions, (res) => {
29
- let data = "";
30
  const setCookies = {};
31
  (res.headers["set-cookie"] || []).forEach(c => {
32
  const [kv] = c.split(";");
33
- const [k, v] = kv.split("=");
34
- if (k && v !== undefined) setCookies[k.trim()] = v.trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  });
36
- res.on("data", chunk => data += chunk);
37
- res.on("end", () => resolve({
38
- status: res.statusCode,
39
- headers: res.headers,
40
- cookies: setCookies,
41
- body: data,
42
- location: res.headers["location"] || ""
43
- }));
44
  });
 
45
  req.on("error", reject);
 
 
 
 
46
  if (body) req.write(body);
47
  req.end();
48
  });
49
  }
50
 
51
- // Logowanie OAuth: GET Authorization → POST Grant → ciasteczka sesji
52
  async function loginLibrus(login, pass) {
53
- let sessionCookies = {};
54
 
55
- // KROK 1: GET Authorization page - zdobądź ciasteczka i CSRF
56
  const step1 = await httpsRequest({
57
  hostname: "api.librus.pl",
58
  path: "/OAuth/Authorization?client_id=46&response_type=code&scope=mydata",
59
  method: "GET"
60
- }, null, {});
61
 
62
- Object.assign(sessionCookies, step1.cookies);
 
63
 
64
- // KROK 2: POST Grant - zaloguj się
65
  const formData = `action=login&login=${encodeURIComponent(login)}&pass=${encodeURIComponent(pass)}`;
66
 
67
  const step2 = await httpsRequest({
68
  hostname: "api.librus.pl",
69
  path: "/OAuth/Authorization/Grant?client_id=46",
70
- method: "POST",
71
- headers: {
72
- "Content-Length": Buffer.byteLength(formData),
73
- "Referer": "https://api.librus.pl/OAuth/Authorization?client_id=46"
74
- }
75
- }, formData, sessionCookies);
76
 
77
- Object.assign(sessionCookies, step2.cookies);
 
78
 
79
- // Sprawdź czy zalogowanie się powiodło (powinno zwrócić redirect z kodem)
80
- if (step2.status !== 302 && step2.status !== 200) {
81
- throw new Error(`auth_failed:${step2.status}`);
82
  }
83
 
84
- // Sprawdź czy body zawiera błąd
85
- if (step2.body && (
86
- step2.body.includes("Błędny login lub hasło") ||
87
- step2.body.includes("error") && step2.body.includes("invalid")
88
- )) {
89
- throw new Error("invalid_credentials");
90
  }
91
 
92
- // KROK 3: Pobierz redirect URL z kodu autoryzacji i zdobądź ciasteczka Synergii
93
- const redirectUrl = step2.location || step2.headers?.["location"] || "";
94
- if (redirectUrl && redirectUrl.includes("?code=")) {
95
- const url = new URL(redirectUrl.startsWith("http") ? redirectUrl : `https://synergia.librus.pl${redirectUrl}`);
 
96
  const step3 = await httpsRequest({
97
- hostname: "synergia.librus.pl",
98
- path: url.pathname + url.search,
99
  method: "GET"
100
- }, null, sessionCookies);
101
- Object.assign(sessionCookies, step3.cookies);
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
 
104
- // Sprawdź czy mamy ciasteczka sesji Synergii
105
- if (!sessionCookies["DZIENNIKSID"] && !sessionCookies["SDZIENNIKSID"]) {
106
- // Spróbuj bezpośrednio strony Synergii
107
- const directLogin = await httpsRequest({
108
- hostname: "synergia.librus.pl",
109
- path: "/loguj",
110
- method: "POST",
111
- headers: { "Content-Length": Buffer.byteLength(`login=${encodeURIComponent(login)}&pass=${encodeURIComponent(pass)}`) }
112
- }, `login=${encodeURIComponent(login)}&pass=${encodeURIComponent(pass)}`, sessionCookies);
113
- Object.assign(sessionCookies, directLogin.cookies);
114
  }
115
 
116
- return sessionCookies;
117
  }
118
 
119
- // Pobieranie strony z sesją Synergii
120
- async function fetchSynergiaPage(path, cookies) {
121
- const response = await httpsRequest({
122
  hostname: "synergia.librus.pl",
123
- path: path,
124
  method: "GET"
125
- }, null, cookies);
126
 
127
- if (response.status === 302) {
128
- // Nieautoryzowany - sesja wygasła
129
  throw new Error("session_expired");
130
  }
131
-
132
- return response.body;
133
  }
134
 
135
- // Parsowanie ocen z HTML
136
  function parseGrades(html) {
137
- const grades = [];
138
- if (!html) return grades;
139
-
140
- // Tabela z klasą "decorated" zawiera oceny
141
- const tableMatch = html.match(/<table[^>]*class="[^"]*decorated[^"]*"[^>]*>([\s\S]*?)<\/table>/i);
142
- if (!tableMatch) return grades;
143
 
144
- const tbody = tableMatch[1];
145
- const rows = tbody.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
 
 
146
 
 
147
  rows.forEach(row => {
148
- const cells = row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi) || [];
 
 
149
  if (cells.length < 2) return;
150
 
151
- const subject = cells[0].replace(/<[^>]+>/g, "").trim();
152
  if (!subject || subject.length < 2) return;
153
 
154
- // Poszukaj linków z ocenami (class="grade-box" lub tytuł w title=)
155
- const gradeLinks = row.match(/<a[^>]*title="([^"]*)"[^>]*>([^<]*)<\/a>/gi) || [];
156
- gradeLinks.forEach(link => {
157
- const titleMatch = link.match(/title="([^"]*)"/);
158
- const gradeMatch = link.match(/>([^<]+)<\/a>/);
159
- const grade = gradeMatch?.[1]?.trim();
160
- const desc = titleMatch?.[1]?.split(";")?.[0]?.trim() || "Ocena";
161
- if (grade && grade.length > 0 && grade !== " ") {
162
- grades.push({ subject, grade, desc });
 
 
 
163
  }
164
  });
165
  });
166
 
167
- return grades;
168
  }
169
 
170
- // Parsowanie frekwencji z HTML
171
  function parseAttendance(html) {
172
- if (!html) return { presence_percentage: 0, unexcused: 0, excused: 0, late: 0 };
 
173
 
174
- const percentMatch = html.match(/(\d+[,.]?\d*)\s*%/);
175
- const percent = percentMatch ? parseFloat(percentMatch[1].replace(",", ".")) : 0;
176
 
177
  let unexcused = 0, excused = 0, late = 0;
178
- const cells = html.match(/<td[^>]*>([^<]*)<\/td>/gi) || [];
179
- cells.forEach(cell => {
180
  const t = cell.replace(/<[^>]+>/g, "").trim().toLowerCase();
181
  if (t === "nb" || t === "u") unexcused++;
182
- else if (t === "nb_u" || t === "us" || t === "uw") excused++;
183
  else if (t === "sp") late++;
184
  });
185
 
186
  return { presence_percentage: percent, unexcused, excused, late };
187
  }
188
 
189
- // Parsowanie planu lekcji z HTML
190
  function parseTimetable(html) {
191
  if (!html) return [];
192
  const lessons = [];
193
  const days = ["Pn", "Wt", "Śr", "Czw", "Pt"];
194
 
195
- const rows = html.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
196
- rows.forEach(row => {
197
- const cells = row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi) || [];
198
  if (cells.length < 2) return;
199
-
200
- const timeCell = cells[0].replace(/<[^>]+>/g, "").trim();
201
 
202
  cells.slice(1).forEach((cell, idx) => {
203
- // Szukaj tekstu lekcji - zazwyczaj w divie
204
- const textMatch = cell.match(/class="text"[^>]*>([^<]+)/i) ||
205
- cell.match(/class="lesson"[^>]*>([^<]+)/i);
206
- const roomMatch = cell.match(/class="classroom"[^>]*>([^<]+)/i) ||
207
- cell.match(/class="room"[^>]*>([^<]+)/i);
208
-
209
- const subject = textMatch?.[1]?.trim();
210
- const room = roomMatch?.[1]?.trim() || "-";
211
-
212
- if (subject && idx < 5) {
213
- lessons.push({
214
- day: days[idx],
215
- subject,
216
- room,
217
- time: timeCell
218
- });
219
  }
220
  });
221
  });
@@ -223,58 +244,69 @@ function parseTimetable(html) {
223
  return lessons;
224
  }
225
 
226
- // Weryfikacja sesji po logowaniu
227
- async function verifySession(cookies) {
228
- try {
229
- const html = await fetchSynergiaPage("/uczen/index", cookies);
230
- // Sprawdź czy strona zawiera elementy logowanego użytkownika
231
- return !html.includes("loguj") && html.length > 1000;
232
- } catch {
233
- return false;
234
- }
235
- }
236
-
237
  app.post("/librus", async (req, res) => {
238
  const { login, pass } = req.body;
239
  if (!login || !pass) return res.status(400).json({ error: "Brak danych logowania." });
240
 
241
  try {
242
- // Zaloguj się przez OAuth API
243
- let sessionCookies;
244
  try {
245
- sessionCookies = await loginLibrus(login, pass);
246
  } catch (err) {
247
- if (err.message.includes("invalid_credentials") || err.message.includes("auth_failed")) {
248
- return res.status(401).json({ error: "Nieprawidłowy login lub hasło Synergia." });
 
 
 
249
  }
250
  throw err;
251
  }
252
 
253
- // Zweryfikuj sesję
254
- const isValid = await verifySession(sessionCookies);
255
- if (!isValid) {
256
- return res.status(401).json({ error: "Nie udało się zalogować do Synergii. Sprawdź dane." });
257
- }
258
-
259
  // Pobierz dane równolegle
260
- const [gradesHtml, attendanceHtml, timetableHtml] = await Promise.allSettled([
261
- fetchSynergiaPage("/przegladaj_oceny/uczen", sessionCookies),
262
- fetchSynergiaPage("/przegladaj_nb/uczen", sessionCookies),
263
- fetchSynergiaPage("/przegladaj_plan_lekcji", sessionCookies)
264
  ]);
265
 
266
- const grades = gradesHtml.status === "fulfilled" ? parseGrades(gradesHtml.value) : [];
267
- const attendance = attendanceHtml.status === "fulfilled" ? parseAttendance(attendanceHtml.value) : { presence_percentage: 0 };
268
- const timetable = timetableHtml.status === "fulfilled" ? parseTimetable(timetableHtml.value) : [];
269
 
270
- return res.json({
271
- status: "success",
272
- data: { grades, attendance, timetable }
273
- });
 
 
 
 
 
 
 
 
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  } catch (err) {
276
- console.error("Error:", err.message);
277
- return res.status(500).json({ error: "Błąd serwera: " + (err.message || "nieznany") });
278
  }
279
  });
280
 
 
9
 
10
  const PORT = process.env.PORT || 3001;
11
 
12
+ // Nagłówki wymagane przez api.librus.pl (PR #81 librus-api: Referer jest OBOWIĄZKOWY)
13
+ const BASE_HEADERS = {
14
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
15
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
16
+ "Accept-Language": "pl,en-US;q=0.7,en;q=0.3",
17
+ "Accept-Encoding": "gzip, deflate, br",
18
+ "Connection": "keep-alive",
19
+ "Upgrade-Insecure-Requests": "1"
20
+ };
21
+
22
+ // Helper: HTTPS request z obsługą ciasteczek i Referer
23
+ function httpsRequest(options, body, cookies, referer) {
24
  return new Promise((resolve, reject) => {
25
  const cookieStr = Object.entries(cookies || {})
26
  .map(([k, v]) => `${k}=${v}`).join("; ");
27
 
28
+ const reqHeaders = {
29
+ ...BASE_HEADERS,
30
+ "Cookie": cookieStr,
31
+ };
32
+
33
+ if (referer) {
34
+ reqHeaders["Referer"] = referer;
35
+ }
36
+
37
+ if (body) {
38
+ reqHeaders["Content-Type"] = "application/x-www-form-urlencoded";
39
+ reqHeaders["Content-Length"] = Buffer.byteLength(body);
40
+ }
41
+
42
  const reqOptions = {
43
  ...options,
44
+ headers: { ...reqHeaders, ...(options.headers || {}) }
 
 
 
 
 
45
  };
46
 
47
  const req = https.request(reqOptions, (res) => {
 
48
  const setCookies = {};
49
  (res.headers["set-cookie"] || []).forEach(c => {
50
  const [kv] = c.split(";");
51
+ const eqIdx = kv.indexOf("=");
52
+ if (eqIdx > 0) {
53
+ setCookies[kv.substring(0, eqIdx).trim()] = kv.substring(eqIdx + 1).trim();
54
+ }
55
+ });
56
+
57
+ // Zbierz chunki (może być gzip ale poczekamy na surowe dane)
58
+ const chunks = [];
59
+ res.on("data", chunk => chunks.push(chunk));
60
+ res.on("end", () => {
61
+ const data = Buffer.concat(chunks).toString("utf8");
62
+ resolve({
63
+ status: res.statusCode,
64
+ headers: res.headers,
65
+ cookies: setCookies,
66
+ body: data,
67
+ location: res.headers["location"] || ""
68
+ });
69
  });
 
 
 
 
 
 
 
 
70
  });
71
+
72
  req.on("error", reject);
73
+ req.setTimeout(30000, () => {
74
+ req.destroy(new Error("Request timeout"));
75
+ });
76
+
77
  if (body) req.write(body);
78
  req.end();
79
  });
80
  }
81
 
 
82
  async function loginLibrus(login, pass) {
83
+ let cookies = {};
84
 
85
+ // KROK 1: GET strony logowania - pobierz ciasteczka sesji
86
  const step1 = await httpsRequest({
87
  hostname: "api.librus.pl",
88
  path: "/OAuth/Authorization?client_id=46&response_type=code&scope=mydata",
89
  method: "GET"
90
+ }, null, {}, "https://portal.librus.pl/rodzina/synergia/loguj");
91
 
92
+ Object.assign(cookies, step1.cookies);
93
+ console.log("Step1 status:", step1.status, "cookies:", Object.keys(cookies));
94
 
95
+ // KROK 2: POST logowania z Referer (WYMAGANY od Nov 2025)
96
  const formData = `action=login&login=${encodeURIComponent(login)}&pass=${encodeURIComponent(pass)}`;
97
 
98
  const step2 = await httpsRequest({
99
  hostname: "api.librus.pl",
100
  path: "/OAuth/Authorization/Grant?client_id=46",
101
+ method: "POST"
102
+ }, formData, cookies, "https://api.librus.pl/OAuth/Authorization?client_id=46&response_type=code&scope=mydata");
 
 
 
 
103
 
104
+ Object.assign(cookies, step2.cookies);
105
+ console.log("Step2 status:", step2.status, "location:", step2.location, "body:", step2.body.substring(0, 200));
106
 
107
+ // Sprawdź czy OAuth zwrócił błąd logowania
108
+ if (step2.status === 200 && step2.body.includes("error")) {
109
+ throw new Error("invalid_credentials");
110
  }
111
 
112
+ // KROK 3: Podążaj za redirectem do Synergii
113
+ let redirectPath = step2.location;
114
+ if (!redirectPath && step2.body.includes("code=")) {
115
+ const codeMatch = step2.body.match(/code=([^&"]+)/);
116
+ if (codeMatch) redirectPath = `/OAuth/AuthorizationCode?code=${codeMatch[1]}`;
 
117
  }
118
 
119
+ if (redirectPath) {
120
+ const isAbsolute = redirectPath.startsWith("http");
121
+ const hostname = isAbsolute ? new URL(redirectPath).hostname : "synergia.librus.pl";
122
+ const path = isAbsolute ? new URL(redirectPath).pathname + new URL(redirectPath).search : redirectPath;
123
+
124
  const step3 = await httpsRequest({
125
+ hostname,
126
+ path,
127
  method: "GET"
128
+ }, null, cookies, `https://api.librus.pl${step2.location || ""}`);
129
+
130
+ Object.assign(cookies, step3.cookies);
131
+ console.log("Step3 status:", step3.status, "cookies now:", Object.keys(cookies));
132
+
133
+ // Jeśli kolejny redirect - idź dalej
134
+ if (step3.location) {
135
+ const step4 = await httpsRequest({
136
+ hostname: "synergia.librus.pl",
137
+ path: step3.location.startsWith("/") ? step3.location : new URL(step3.location).pathname + new URL(step3.location).search,
138
+ method: "GET"
139
+ }, null, cookies, `https://synergia.librus.pl${step3.location}`);
140
+ Object.assign(cookies, step4.cookies);
141
+ console.log("Step4 status:", step4.status, "cookies now:", Object.keys(cookies));
142
+ }
143
  }
144
 
145
+ // Weryfikujemy czy mamy ciasteczka sesji Synergii
146
+ if (!cookies["DZIENNIKSID"] && !cookies["SDZIENNIKSID"]) {
147
+ console.log("Brak ciasteczek Synergii. Dostępne:", Object.keys(cookies));
148
+ throw new Error("no_session_cookies");
 
 
 
 
 
 
149
  }
150
 
151
+ return cookies;
152
  }
153
 
154
+ async function fetchPage(path, cookies, referer) {
155
+ const resp = await httpsRequest({
 
156
  hostname: "synergia.librus.pl",
157
+ path,
158
  method: "GET"
159
+ }, null, cookies, referer || "https://synergia.librus.pl/uczen/index");
160
 
161
+ if (resp.status === 302 || resp.status === 301) {
 
162
  throw new Error("session_expired");
163
  }
164
+ return resp.body;
 
165
  }
166
 
 
167
  function parseGrades(html) {
168
+ if (!html || html.length < 100) return [];
169
+ const results = [];
 
 
 
 
170
 
171
+ // Szukamy tabeli z ocenami (index=1 zgodnie z fix PR #82)
172
+ const tables = html.match(/<table[^>]*class="[^"]*decorated[^"]*"[^>]*>[\s\S]*?<\/table>/gi) || [];
173
+ const gradesTable = tables[1] || tables[0]; // index 1 = właściwa tabela ocen
174
+ if (!gradesTable) return [];
175
 
176
+ const rows = gradesTable.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
177
  rows.forEach(row => {
178
+ if (row.includes("<th")) return; // pomiń nagłówki
179
+
180
+ const cells = row.match(/<td[^>]*>[\s\S]*?<\/td>/gi) || [];
181
  if (cells.length < 2) return;
182
 
183
+ const subject = cells[0].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
184
  if (!subject || subject.length < 2) return;
185
 
186
+ // Szukaj linków z ocenami (posiadają tytuł z opisem)
187
+ const links = row.match(/<a[^>]*title="([^"]+)"[^>]*>([\d.,+\-\/\\]+)<\/a>/gi) || [];
188
+ links.forEach(link => {
189
+ const titleM = link.match(/title="([^"]*)"/);
190
+ const gradeM = link.match(/>([^<]+)<\/a>/);
191
+ const grade = gradeM?.[1]?.trim();
192
+ if (grade) {
193
+ results.push({
194
+ subject,
195
+ grade,
196
+ desc: titleM?.[1]?.split(";")?.[0]?.trim() || "Ocena"
197
+ });
198
  }
199
  });
200
  });
201
 
202
+ return results;
203
  }
204
 
 
205
  function parseAttendance(html) {
206
+ const def = { presence_percentage: 0, unexcused: 0, excused: 0, late: 0 };
207
+ if (!html) return def;
208
 
209
+ const m = html.match(/(\d+[,.]?\d*)\s*%/);
210
+ const percent = m ? parseFloat(m[1].replace(",", ".")) : 0;
211
 
212
  let unexcused = 0, excused = 0, late = 0;
213
+ (html.match(/<td[^>]*>([^<]*)<\/td>/gi) || []).forEach(cell => {
 
214
  const t = cell.replace(/<[^>]+>/g, "").trim().toLowerCase();
215
  if (t === "nb" || t === "u") unexcused++;
216
+ else if (t === "nb_u" || t === "uw" || t === "us") excused++;
217
  else if (t === "sp") late++;
218
  });
219
 
220
  return { presence_percentage: percent, unexcused, excused, late };
221
  }
222
 
 
223
  function parseTimetable(html) {
224
  if (!html) return [];
225
  const lessons = [];
226
  const days = ["Pn", "Wt", "Śr", "Czw", "Pt"];
227
 
228
+ (html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || []).forEach(row => {
229
+ const cells = row.match(/<td[^>]*>[\s\S]*?<\/td>/gi) || [];
 
230
  if (cells.length < 2) return;
231
+ const time = cells[0].replace(/<[^>]+>/g, "").trim();
 
232
 
233
  cells.slice(1).forEach((cell, idx) => {
234
+ if (idx >= 5) return;
235
+ const textM = cell.match(/class="text"[^>]*>([\s\S]*?)<\/\w+>/i);
236
+ const roomM = cell.match(/class="classroom"[^>]*>([^<]+)/i);
237
+ const subject = textM?.[1]?.replace(/<[^>]+>/g, "")?.trim();
238
+ if (subject) {
239
+ lessons.push({ day: days[idx], subject, room: roomM?.[1]?.trim() || "-", time });
 
 
 
 
 
 
 
 
 
 
240
  }
241
  });
242
  });
 
244
  return lessons;
245
  }
246
 
247
+ // Główny endpoint
 
 
 
 
 
 
 
 
 
 
248
  app.post("/librus", async (req, res) => {
249
  const { login, pass } = req.body;
250
  if (!login || !pass) return res.status(400).json({ error: "Brak danych logowania." });
251
 
252
  try {
253
+ let cookies;
 
254
  try {
255
+ cookies = await loginLibrus(login, pass);
256
  } catch (err) {
257
+ if (err.message === "invalid_credentials") {
258
+ return res.status(401).json({ error: "Nieprawidłowy login lub hasło." });
259
+ }
260
+ if (err.message === "no_session_cookies") {
261
+ return res.status(401).json({ error: "Nie udało się uzyskać sesji Synergii." });
262
  }
263
  throw err;
264
  }
265
 
 
 
 
 
 
 
266
  // Pobierz dane równolegle
267
+ const [gradesRes, attendanceRes, timetableRes] = await Promise.allSettled([
268
+ fetchPage("/przegladaj_oceny/uczen", cookies),
269
+ fetchPage("/przegladaj_nb/uczen", cookies),
270
+ fetchPage("/przegladaj_plan_lekcji", cookies)
271
  ]);
272
 
273
+ const grades = gradesRes.status === "fulfilled" ? parseGrades(gradesRes.value) : [];
274
+ const attendance = attendanceRes.status === "fulfilled" ? parseAttendance(attendanceRes.value) : { presence_percentage: 0 };
275
+ const timetable = timetableRes.status === "fulfilled" ? parseTimetable(timetableRes.value) : [];
276
 
277
+ return res.json({ status: "success", data: { grades, attendance, timetable } });
278
+
279
+ } catch (err) {
280
+ console.error("Error:", err.message, err.stack?.split("\n")[1]);
281
+ return res.status(500).json({ error: "Błąd serwera: " + err.message });
282
+ }
283
+ });
284
+
285
+ // Endpoint diagnostyczny
286
+ app.post("/debug-login", async (req, res) => {
287
+ const { login, pass } = req.body;
288
+ if (!login || !pass) return res.status(400).json({ error: "Brak danych." });
289
 
290
+ const debug = {};
291
+ try {
292
+ const step1 = await httpsRequest({
293
+ hostname: "api.librus.pl",
294
+ path: "/OAuth/Authorization?client_id=46&response_type=code&scope=mydata",
295
+ method: "GET"
296
+ }, null, {}, "https://portal.librus.pl/rodzina/synergia/loguj");
297
+ debug.step1 = { status: step1.status, cookies: step1.cookies };
298
+
299
+ const formData = `action=login&login=${encodeURIComponent(login)}&pass=${encodeURIComponent(pass)}`;
300
+ const step2 = await httpsRequest({
301
+ hostname: "api.librus.pl",
302
+ path: "/OAuth/Authorization/Grant?client_id=46",
303
+ method: "POST"
304
+ }, formData, step1.cookies, "https://api.librus.pl/OAuth/Authorization?client_id=46&response_type=code&scope=mydata");
305
+ debug.step2 = { status: step2.status, cookies: step2.cookies, location: step2.location, body: step2.body.substring(0, 300) };
306
+
307
+ return res.json(debug);
308
  } catch (err) {
309
+ return res.status(500).json({ error: err.message, debug });
 
310
  }
311
  });
312