kalvium-worklog 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Try headless mode first (fast — works if session is still valid).
3
3
  * If no session, opens a headed browser and waits for manual login.
4
- * On platforms without Playwright (e.g. Android/Termux), uses the
5
- * phone's built-in browser + a local HTTP server to capture the token.
4
+ * On platforms without Playwright (e.g. Android/Termux), uses a
5
+ * reverse proxy to capture the token automatically.
6
6
  */
7
7
  export declare function captureToken(): Promise<boolean>;
@@ -1,6 +1,7 @@
1
1
  import { PROFILE_DIR, TOKEN_FILE, ensureInstallDir, saveToken, } from "./config.js";
2
2
  import * as readline from "readline";
3
3
  import { createServer } from "http";
4
+ import { request as httpRequest } from "https";
4
5
  import { execSync } from "child_process";
5
6
  /**
6
7
  * Check if Playwright is available on this platform.
@@ -20,24 +21,21 @@ function isPlaywrightAvailable() {
20
21
  /**
21
22
  * Try headless mode first (fast — works if session is still valid).
22
23
  * If no session, opens a headed browser and waits for manual login.
23
- * On platforms without Playwright (e.g. Android/Termux), uses the
24
- * phone's built-in browser + a local HTTP server to capture the token.
24
+ * On platforms without Playwright (e.g. Android/Termux), uses a
25
+ * reverse proxy to capture the token automatically.
25
26
  */
26
27
  export async function captureToken() {
27
28
  ensureInstallDir();
28
29
  if (!isPlaywrightAvailable()) {
29
- return captureTokenViaBrowser();
30
+ return captureTokenViaProxy();
30
31
  }
31
- // Lazy import — only load Playwright when available
32
32
  const { chromium } = await import("playwright");
33
- // Step 1: Try headless (quick — works if session is still valid)
34
33
  console.log("Trying headless mode (existing session)...");
35
34
  let data = await tryCapture(chromium, true);
36
35
  if (data) {
37
36
  console.log(" Session still valid — token captured!");
38
37
  }
39
38
  else {
40
- // Step 2: Need manual login
41
39
  console.log(" No valid session. Opening browser for manual login...");
42
40
  data = await tryCapture(chromium, false);
43
41
  }
@@ -52,150 +50,221 @@ export async function captureToken() {
52
50
  return false;
53
51
  }
54
52
  /**
55
- * Capture token on platforms without Playwright (Android/Termux).
56
- * Starts a local HTTP server, opens the phone's browser to kalvium.community,
57
- * and after login the user pastes a JS snippet that sends the token back.
53
+ * Capture token via reverse proxy (for Android/Termux without Playwright).
54
+ *
55
+ * Starts a local server on port 4567 that proxies kalvium.community.
56
+ * The user navigates to http://localhost:4567, logs in normally,
57
+ * and the proxy intercepts the Keycloak token exchange automatically.
58
+ *
59
+ * No javascript: injection, no bookmarklets — just log in normally.
58
60
  */
59
- async function captureTokenViaBrowser() {
61
+ async function captureTokenViaProxy() {
60
62
  const PORT = 4567;
63
+ const TARGET = "kalvium.community";
64
+ const AUTH_TARGET = "auth.kalvium.community";
61
65
  console.log("\n ========================================");
62
- console.log(" Browser-based token capture (no Playwright)");
66
+ console.log(" Reverse proxy token capture (no Playwright)");
63
67
  console.log(" ========================================\n");
64
- // The JS that runs on kalvium.community to extract and send the token
65
- const bookmarkletJS = `javascript:void(function(){var t=localStorage.getItem('kc-token')||localStorage.getItem('keycloak-token');if(!t){for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i),v=localStorage.getItem(k);if(v&&v.includes('refresh_token')){t=v;break}}}if(!t){for(var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i),v=sessionStorage.getItem(k);if(v&&v.includes('refresh_token')){t=v;break}}}if(t){fetch('http://localhost:${PORT}/capture',{method:'POST',headers:{'Content-Type':'text/plain'},body:t}).then(function(r){if(r.ok)alert('Token sent! You can close this tab.');else alert('Failed: '+r.status)}).catch(function(e){alert('Error: '+e)})}else{alert('No token found in storage. Make sure you are logged in.')}})()`;
66
- // Start local HTTP server
68
+ let capturedToken = null;
67
69
  const tokenPromise = new Promise((resolve) => {
68
70
  const server = createServer((req, res) => {
71
+ // Handle CORS
69
72
  res.setHeader("Access-Control-Allow-Origin", "*");
70
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
73
+ res.setHeader("Access-Control-Allow-Headers", "*");
71
74
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
72
75
  if (req.method === "OPTIONS") {
73
76
  res.writeHead(204);
74
77
  res.end();
75
78
  return;
76
79
  }
77
- let body = "";
78
- req.on("data", (chunk) => {
79
- body += chunk;
80
- });
81
- req.on("end", () => {
82
- // Token capture endpoint
83
- if (req.url?.startsWith("/capture")) {
84
- try {
85
- let data;
86
- if (body) {
87
- data = JSON.parse(body);
88
- }
89
- else {
90
- const url = new URL(req.url, `http://localhost:${PORT}`);
91
- const tokenParam = url.searchParams.get("token");
92
- if (!tokenParam) {
93
- res.writeHead(400);
94
- res.end("Missing token");
95
- return;
80
+ const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
81
+ // Check if we already captured the token — serve success page
82
+ if (url.pathname === "/__done__") {
83
+ res.writeHead(200, { "Content-Type": "text/html" });
84
+ res.end(`<html><body style="font-family:sans-serif;text-align:center;padding:40px">
85
+ <h2>✓ Token captured!</h2>
86
+ <p>You can close this tab and go back to Termux.</p>
87
+ </body></html>`);
88
+ return;
89
+ }
90
+ // Determine which backend to proxy to
91
+ let targetHost = TARGET;
92
+ let targetPath = req.url ?? "/";
93
+ // Route auth requests to auth.kalvium.community
94
+ if (url.pathname.startsWith("/auth/") || url.pathname.startsWith("/realms/")) {
95
+ targetHost = AUTH_TARGET;
96
+ targetPath = req.url ?? "/";
97
+ }
98
+ // Intercept token exchange requests
99
+ if (url.pathname.includes("openid-connect/token") &&
100
+ req.method === "POST") {
101
+ // Collect body
102
+ let body = "";
103
+ req.on("data", (chunk) => {
104
+ body += chunk;
105
+ });
106
+ req.on("end", () => {
107
+ // Forward to actual Keycloak
108
+ const proxyReq = httpRequest({
109
+ hostname: AUTH_TARGET,
110
+ port: 443,
111
+ path: url.pathname,
112
+ method: "POST",
113
+ headers: {
114
+ "content-type": "application/x-www-form-urlencoded",
115
+ "content-length": Buffer.byteLength(body),
116
+ },
117
+ }, (proxyRes) => {
118
+ let responseBody = "";
119
+ proxyRes.on("data", (chunk) => {
120
+ responseBody += chunk;
121
+ });
122
+ proxyRes.on("end", () => {
123
+ // Try to capture token from response
124
+ try {
125
+ const data = JSON.parse(responseBody);
126
+ if (data.refresh_token) {
127
+ console.log(" Refresh token captured from proxy!");
128
+ capturedToken = data;
129
+ resolve(data);
130
+ }
96
131
  }
97
- data = JSON.parse(tokenParam);
98
- }
99
- if (data.refresh_token) {
100
- res.writeHead(200, { "Content-Type": "text/html" });
101
- res.end("<html><body style='font-family:sans-serif;text-align:center;padding:40px'><h2>✓ Token captured!</h2><p>You can close this tab and go back to Termux.</p></body></html>");
102
- resolve(data);
103
- }
104
- else {
105
- res.writeHead(400, { "Content-Type": "text/html" });
106
- res.end("<html><body><h2>Invalid token — missing refresh_token</h2></body></html>");
107
- resolve(null);
108
- }
132
+ catch {
133
+ // not JSON, ignore
134
+ }
135
+ // Send response back to browser
136
+ res.writeHead(proxyRes.statusCode ?? 200, {
137
+ "content-type": proxyRes.headers["content-type"] ?? "application/json",
138
+ "access-control-allow-origin": "*",
139
+ });
140
+ res.end(responseBody);
141
+ });
142
+ });
143
+ proxyReq.on("error", () => {
144
+ res.writeHead(502);
145
+ res.end("Proxy error");
146
+ });
147
+ proxyReq.write(body);
148
+ proxyReq.end();
149
+ });
150
+ return;
151
+ }
152
+ // Proxy all other requests to kalvium.community
153
+ const proxyHeaders = {};
154
+ for (const [key, value] of Object.entries(req.headers)) {
155
+ if (key === "host")
156
+ continue;
157
+ if (Array.isArray(value)) {
158
+ proxyHeaders[key] = value.join(", ");
159
+ }
160
+ else if (value) {
161
+ proxyHeaders[key] = value;
162
+ }
163
+ }
164
+ proxyHeaders["host"] = targetHost;
165
+ const proxyReq = httpRequest({
166
+ hostname: targetHost,
167
+ port: 443,
168
+ path: targetPath,
169
+ method: req.method ?? "GET",
170
+ headers: proxyHeaders,
171
+ }, (proxyRes) => {
172
+ // Rewrite location headers to point back to proxy
173
+ const headers = {};
174
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
175
+ if (key === "location" && typeof value === "string") {
176
+ // Rewrite kalvium.community URLs to localhost
177
+ let rewritten = value
178
+ .replace(/https:\/\/kalvium\.community/g, `http://localhost:${PORT}`)
179
+ .replace(/https:\/\/auth\.kalvium\.community/g, `http://localhost:${PORT}`);
180
+ headers[key] = rewritten;
109
181
  }
110
- catch {
111
- res.writeHead(400, { "Content-Type": "text/html" });
112
- res.end("<html><body><h2>Invalid JSON</h2></body></html>");
113
- resolve(null);
182
+ else if (key === "content-security-policy") {
183
+ // Relax CSP to allow the proxy to work
184
+ headers[key] = value;
185
+ }
186
+ else if (key === "strict-transport-security") {
187
+ // Skip HSTS
188
+ continue;
189
+ }
190
+ else if (Array.isArray(value)) {
191
+ headers[key] = value.join(", ");
192
+ }
193
+ else if (value) {
194
+ headers[key] = value;
114
195
  }
115
- return;
116
196
  }
117
- // Main page — serves the bookmarklet + instructions
118
- res.writeHead(200, { "Content-Type": "text/html" });
119
- res.end(`<!DOCTYPE html>
120
- <html>
121
- <head>
122
- <meta name="viewport" content="width=device-width, initial-scale=1">
123
- <title>Kalvium Worklog Token Capture</title>
124
- <style>
125
- body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; line-height: 1.6; }
126
- .step { background: #f0f4ff; border-radius: 12px; padding: 16px; margin: 12px 0; }
127
- .step h3 { margin-top: 0; }
128
- .bookmarklet { display: inline-block; background: #4285f4; color: white; padding: 14px 24px; border-radius: 8px; text-decoration: none; font-size: 18px; font-weight: bold; margin: 12px 0; }
129
- .bookmarklet:active { background: #3367d6; }
130
- .note { background: #fff8e1; border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 14px; }
131
- code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; word-break: break-all; }
132
- .kalvium-link { display: inline-block; background: #34a853; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-size: 16px; margin: 8px 0; }
133
- </style>
134
- </head>
135
- <body>
136
- <h2>Kalvium Worklog — Token Capture</h2>
137
-
138
- <div class="step">
139
- <h3>Step 1: Open Kalvium & log in</h3>
140
- <p>Tap the button below to open Kalvium. Log in with Google (complete 2FA if asked).</p>
141
- <a class="kalvium-link" href="https://kalvium.community/internships" target="_blank">Open Kalvium →</a>
142
- </div>
143
-
144
- <div class="step">
145
- <h3>Step 2: Save this bookmarklet</h3>
146
- <p><b>Long-press</b> the blue button below, then select <b>"Bookmark link"</b> or <b>"Add bookmark"</b>.</p>
147
- <a class="bookmarklet" href="${bookmarkletJS.replace(/"/g, "&quot;")}">📋 Capture Token</a>
148
- <p><small>If long-press doesn't work: bookmark this page, then edit the bookmark and change the URL to the code below.</small></p>
149
- </div>
150
-
151
- <div class="step">
152
- <h3>Step 3: Run the bookmarklet</h3>
153
- <p>Go back to the <b>Kalvium tab</b> (where you logged in).<br>
154
- Open your <b>bookmarks menu</b> and tap <b>"📋 Capture Token"</b>.</p>
155
- <p>You should see <b>"Token sent!"</b> — then come back to Termux.</p>
156
- </div>
157
-
158
- <div class="note">
159
- <b>Trouble with bookmarklet?</b> Copy this code, open Kalvium, then paste it in the address bar (type <code>javascript:</code> first, then paste the rest after the colon):
160
- <br><br>
161
- <code>${bookmarkletJS.replace(/^javascript:/, "")}</code>
162
- </div>
163
-
164
- </body>
165
- </html>`);
197
+ // Rewrite set-cookie domains
198
+ if (headers["set-cookie"]) {
199
+ headers["set-cookie"] = headers["set-cookie"].replace(/domain=\.kalvium\.community/gi, `domain=localhost`);
200
+ }
201
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
202
+ // For HTML responses, rewrite URLs
203
+ const contentType = proxyRes.headers["content-type"] ?? "";
204
+ if (contentType.includes("text/html") || contentType.includes("javascript") || contentType.includes("css")) {
205
+ let body = "";
206
+ proxyRes.on("data", (chunk) => {
207
+ body += chunk.toString();
208
+ });
209
+ proxyRes.on("end", () => {
210
+ // Rewrite absolute URLs to go through proxy
211
+ const rewritten = body
212
+ .replace(/https:\/\/kalvium\.community/g, `http://localhost:${PORT}`)
213
+ .replace(/https:\/\/auth\.kalvium\.community/g, `http://localhost:${PORT}`)
214
+ .replace(/https:\/\/student-api\.kalvium\.community/g, `http://localhost:${PORT}/api`);
215
+ res.end(rewritten);
216
+ });
217
+ }
218
+ else {
219
+ proxyRes.pipe(res);
220
+ }
166
221
  });
222
+ proxyReq.on("error", () => {
223
+ res.writeHead(502);
224
+ res.end("Proxy error — check your internet connection");
225
+ });
226
+ // Forward request body for POST/PUT
227
+ if (req.method === "POST" || req.method === "PUT") {
228
+ req.pipe(proxyReq);
229
+ }
230
+ else {
231
+ proxyReq.end();
232
+ }
167
233
  });
168
234
  server.listen(PORT, () => {
169
- console.log(` Local server running on http://localhost:${PORT}`);
235
+ console.log(` Proxy server running on http://localhost:${PORT}`);
170
236
  });
237
+ // Timeout after 5 minutes
171
238
  setTimeout(() => {
172
- server.close();
173
- resolve(null);
239
+ if (!capturedToken) {
240
+ server.close();
241
+ resolve(null);
242
+ }
174
243
  }, 300000);
175
244
  });
176
- // Open the localhost page in the browser
177
- const localUrl = `http://localhost:${PORT}`;
245
+ // Open the proxy URL in the phone's default browser
246
+ const proxyUrl = `http://localhost:${PORT}/internships`;
178
247
  try {
179
248
  if (process.platform === "android") {
180
- execSync(`termux-open-url ${localUrl}`, { stdio: "ignore" });
249
+ execSync(`termux-open-url ${proxyUrl}`, { stdio: "ignore" });
181
250
  }
182
251
  else {
183
- execSync(`xdg-open ${localUrl} || open ${localUrl}`, {
252
+ execSync(`xdg-open ${proxyUrl} || open ${proxyUrl}`, {
184
253
  stdio: "ignore",
185
254
  });
186
255
  }
187
- console.log(` Opened ${localUrl} in your browser.`);
256
+ console.log(` Opened ${proxyUrl} in your browser.`);
188
257
  }
189
258
  catch {
190
- console.log(` Could not auto-open browser. Please open: ${localUrl}`);
259
+ console.log(` Could not auto-open browser. Please open: ${proxyUrl}`);
191
260
  }
192
261
  console.log("\n ────────────────────────────────────────────");
193
- console.log(" A page opened in your browser with instructions:");
194
- console.log(" 1. Click 'Open Kalvium' and log in");
195
- console.log(" 2. Long-press 'Capture Token' Bookmark it");
196
- console.log(" 3. Go to Kalvium tab → open the bookmark");
262
+ console.log(" A page opened in your browser.");
263
+ console.log(" Just log in with Google normally.");
264
+ console.log(" The token is captured automatically no");
265
+ console.log(" javascript: or bookmarks needed.");
197
266
  console.log(" ────────────────────────────────────────────");
198
- console.log("\n Waiting for token (up to 5 minutes)...\n");
267
+ console.log("\n Waiting for login (up to 5 minutes)...\n");
199
268
  const data = await tokenPromise;
200
269
  if (data) {
201
270
  saveToken(data);
@@ -205,12 +274,12 @@ async function captureTokenViaBrowser() {
205
274
  return true;
206
275
  }
207
276
  // Fallback: manual paste
208
- console.log("\n Automatic capture failed. Let's try manual paste.");
277
+ console.log("\n Proxy capture failed. Let's try manual paste.");
209
278
  return captureTokenManual();
210
279
  }
211
280
  /**
212
- * Manual token capture — fallback for platforms without Playwright.
213
- * Prompts the user to paste a refresh token JSON obtained from a desktop.
281
+ * Manual token capture — fallback.
282
+ * Prompts the user to paste a refresh token JSON.
214
283
  */
215
284
  async function captureTokenManual() {
216
285
  console.log("\n ========================================");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalvium-worklog",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Auto-submit your daily Kalvium worklog without opening the website",
5
5
  "keywords": [
6
6
  "kalvium",