kalvium-worklog 1.4.3 → 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,170 +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
- // Start local HTTP server
68
+ let capturedToken = null;
65
69
  const tokenPromise = new Promise((resolve) => {
66
70
  const server = createServer((req, res) => {
71
+ // Handle CORS
67
72
  res.setHeader("Access-Control-Allow-Origin", "*");
68
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
73
+ res.setHeader("Access-Control-Allow-Headers", "*");
69
74
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
70
75
  if (req.method === "OPTIONS") {
71
76
  res.writeHead(204);
72
77
  res.end();
73
78
  return;
74
79
  }
75
- let body = "";
76
- req.on("data", (chunk) => {
77
- body += chunk;
78
- });
79
- req.on("end", () => {
80
- // Token capture endpoint
81
- if (req.url?.startsWith("/capture")) {
82
- try {
83
- let data;
84
- if (body) {
85
- data = JSON.parse(body);
86
- }
87
- else {
88
- const url = new URL(req.url, `http://localhost:${PORT}`);
89
- const tokenParam = url.searchParams.get("token");
90
- if (!tokenParam) {
91
- res.writeHead(400);
92
- res.end("Missing token");
93
- 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
+ }
94
131
  }
95
- data = JSON.parse(tokenParam);
96
- }
97
- if (data.refresh_token) {
98
- res.writeHead(200, { "Content-Type": "text/html" });
99
- 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>");
100
- resolve(data);
101
- }
102
- else {
103
- res.writeHead(400, { "Content-Type": "text/html" });
104
- res.end("<html><body><h2>Invalid token — missing refresh_token</h2></body></html>");
105
- resolve(null);
106
- }
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;
107
181
  }
108
- catch {
109
- res.writeHead(400, { "Content-Type": "text/html" });
110
- res.end("<html><body><h2>Invalid JSON</h2></body></html>");
111
- 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;
112
195
  }
113
- return;
114
196
  }
115
- // Main page — serves instructions + manual paste form
116
- res.writeHead(200, { "Content-Type": "text/html" });
117
- res.end(`<!DOCTYPE html>
118
- <html>
119
- <head>
120
- <meta name="viewport" content="width=device-width, initial-scale=1">
121
- <title>Kalvium Worklog Login</title>
122
- <style>
123
- * { box-sizing: border-box; }
124
- body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 16px; line-height: 1.6; color: #333; }
125
- h2 { color: #1a73e8; }
126
- .step { background: #f0f4ff; border-radius: 12px; padding: 16px; margin: 12px 0; }
127
- .step h3 { margin-top: 0; color: #1a73e8; }
128
- .btn { display: block; text-align: center; background: #4285f4; color: white; padding: 14px; border-radius: 8px; text-decoration: none; font-size: 18px; font-weight: bold; margin: 12px 0; }
129
- .btn-green { background: #34a853; }
130
- .btn:active { opacity: 0.8; }
131
- .code-box { background: #1e1e1e; color: #0f0; padding: 12px; border-radius: 8px; font-family: monospace; font-size: 12px; word-break: break-all; white-space: pre-wrap; user-select: all; }
132
- .note { background: #fff8e1; border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 14px; }
133
- .warn { background: #fce4ec; border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 14px; }
134
- textarea { width: 100%; height: 120px; font-family: monospace; font-size: 12px; border: 2px solid #4285f4; border-radius: 8px; padding: 8px; }
135
- #result { font-weight: bold; padding: 12px; border-radius: 8px; margin: 8px 0; display: none; }
136
- .success { background: #e8f5e9; color: #2e7d32; }
137
- .error { background: #ffebee; color: #c62828; }
138
- </style>
139
- </head>
140
- <body>
141
- <h2>Kalvium Worklog — Login</h2>
142
-
143
- <div class="step">
144
- <h3>Step 1: Log in to Kalvium</h3>
145
- <p>Tap below, log in with Google (2FA if asked).</p>
146
- <a class="btn btn-green" href="https://kalvium.community/internships" target="_blank">Open Kalvium →</a>
147
- </div>
148
-
149
- <div class="step">
150
- <h3>Step 2: Extract token</h3>
151
- <p>After logging in, go to the Kalvium tab.<br>
152
- <b>Type</b> this in the address bar (don't paste — type <code>javascript:</code> yourself, then paste the rest):</p>
153
- <div class="code-box" id="jscode">javascript:void(function(){var t;for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i),v=localStorage.getItem(i);if(v&&v.includes('refresh_token')){t=v}}if(!t){for(var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i),v=sessionStorage.getItem(i);if(v&&v.includes('refresh_token')){t=v}}}if(t){fetch('http://localhost:${PORT}/capture',{method:'POST',body:t}).then(function(r){alert(r.ok?'Token sent! Go back to Termux':'Failed: '+r.status)}).catch(function(e){alert('Error: '+e)})}else{alert('No token found. Are you logged in?')}})()</div>
154
- <p><small>👆 Long-press the code above → "Copy", then go to Kalvium tab, type <code>javascript:</code> in address bar, paste the rest after it, press Enter.</small></p>
155
- </div>
156
-
157
- <div class="warn">
158
- <b>Brave users:</b> Brave strips <code>javascript:</code> when pasting. You must <b>type</b> <code>javascript:</code> manually, then paste only the code after <code>javascript:</code>.
159
- </div>
160
-
161
- <div class="step">
162
- <h3>Or: Paste token manually</h3>
163
- <p>If the above doesn't work, extract the token another way and paste it here:</p>
164
- <textarea id="manualToken" placeholder='Paste token JSON here (starts with {"access_token"...'></textarea>
165
- <button class="btn" onclick="sendManual()">Send Token</button>
166
- <div id="result"></div>
167
- </div>
168
-
169
- <script>
170
- function sendManual() {
171
- var val = document.getElementById('manualToken').value.trim();
172
- var result = document.getElementById('result');
173
- result.style.display = 'block';
174
- if (!val) { result.className = 'error'; result.textContent = 'Paste token JSON first'; return; }
175
- fetch('http://localhost:${PORT}/capture', { method: 'POST', body: val })
176
- .then(function(r) {
177
- if (r.ok) { result.className = 'success'; result.textContent = '✓ Token sent! Go back to Termux.'; }
178
- else { result.className = 'error'; result.textContent = 'Failed: ' + r.status; }
179
- })
180
- .catch(function(e) { result.className = 'error'; result.textContent = 'Error: ' + e; });
181
- }
182
- </script>
183
-
184
- </body>
185
- </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
+ }
186
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
+ }
187
233
  });
188
234
  server.listen(PORT, () => {
189
- console.log(` Local server running on http://localhost:${PORT}`);
235
+ console.log(` Proxy server running on http://localhost:${PORT}`);
190
236
  });
237
+ // Timeout after 5 minutes
191
238
  setTimeout(() => {
192
- server.close();
193
- resolve(null);
239
+ if (!capturedToken) {
240
+ server.close();
241
+ resolve(null);
242
+ }
194
243
  }, 300000);
195
244
  });
196
- // Open the localhost page in the browser
197
- const localUrl = `http://localhost:${PORT}`;
245
+ // Open the proxy URL in the phone's default browser
246
+ const proxyUrl = `http://localhost:${PORT}/internships`;
198
247
  try {
199
248
  if (process.platform === "android") {
200
- execSync(`termux-open-url ${localUrl}`, { stdio: "ignore" });
249
+ execSync(`termux-open-url ${proxyUrl}`, { stdio: "ignore" });
201
250
  }
202
251
  else {
203
- execSync(`xdg-open ${localUrl} || open ${localUrl}`, {
252
+ execSync(`xdg-open ${proxyUrl} || open ${proxyUrl}`, {
204
253
  stdio: "ignore",
205
254
  });
206
255
  }
207
- console.log(` Opened ${localUrl} in your browser.`);
256
+ console.log(` Opened ${proxyUrl} in your browser.`);
208
257
  }
209
258
  catch {
210
- console.log(` Could not auto-open browser. Please open: ${localUrl}`);
259
+ console.log(` Could not auto-open browser. Please open: ${proxyUrl}`);
211
260
  }
212
261
  console.log("\n ────────────────────────────────────────────");
213
- console.log(" A page opened in your browser with instructions:");
214
- console.log(" 1. Click 'Open Kalvium' and log in");
215
- console.log(" 2. Long-press 'Capture Token' Bookmark it");
216
- 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.");
217
266
  console.log(" ────────────────────────────────────────────");
218
- console.log("\n Waiting for token (up to 5 minutes)...\n");
267
+ console.log("\n Waiting for login (up to 5 minutes)...\n");
219
268
  const data = await tokenPromise;
220
269
  if (data) {
221
270
  saveToken(data);
@@ -225,12 +274,12 @@ function sendManual() {
225
274
  return true;
226
275
  }
227
276
  // Fallback: manual paste
228
- console.log("\n Automatic capture failed. Let's try manual paste.");
277
+ console.log("\n Proxy capture failed. Let's try manual paste.");
229
278
  return captureTokenManual();
230
279
  }
231
280
  /**
232
- * Manual token capture — fallback for platforms without Playwright.
233
- * 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.
234
283
  */
235
284
  async function captureTokenManual() {
236
285
  console.log("\n ========================================");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalvium-worklog",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Auto-submit your daily Kalvium worklog without opening the website",
5
5
  "keywords": [
6
6
  "kalvium",