kafox-cli 0.10.0 → 0.10.2
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.
- package/package.json +1 -1
- package/src/auth.js +4 -2
- package/src/compact.js +40 -0
- package/src/gateway.js +178 -25
- package/src/mobile/auth.js +122 -0
- package/src/mobile/bridge.js +12 -1
- package/src/mobile/captcha.js +96 -0
- package/src/mobile/history.js +71 -0
- package/src/mobile/index.js +18 -0
- package/src/mobile/ratelimit.js +79 -0
- package/src/mobile/server.js +105 -3
- package/src/mobile/tui-bridge.js +1 -0
- package/src/mobile/view.js +223 -14
- package/src/tui.js +225 -45
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorte de historico para o celular.
|
|
3
|
+
*
|
|
4
|
+
* Regra do produto: o celular NUNCA sobe o chat inteiro. Sobe no maximo
|
|
5
|
+
* MAX_USER mensagens do usuario + MAX_ASSISTANT respostas da IA (as mais
|
|
6
|
+
* recentes), deixando a conversa leve no primeiro carregamento.
|
|
7
|
+
*
|
|
8
|
+
* O recorte preserva a ordem cronologica e, quando ha varias, prioriza as
|
|
9
|
+
* mensagens recentes de cada papel. Turnos auxiliares (system/erro/compaction)
|
|
10
|
+
* que caiam entre as selecionadas sao mantidos para o contexto fazer sentido.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_USER = 10;
|
|
14
|
+
const MAX_ASSISTANT = 10;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Array<{role:string}>} messages
|
|
18
|
+
* @param {{ maxUser?:number, maxAssistant?:number }} [opts]
|
|
19
|
+
* @returns {{ messages:Array, truncated:boolean, total:number, shown:number }}
|
|
20
|
+
*/
|
|
21
|
+
function trimHistory(messages, opts = {}) {
|
|
22
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
23
|
+
const maxUser = Number.isInteger(opts.maxUser) ? opts.maxUser : MAX_USER;
|
|
24
|
+
const maxAssistant = Number.isInteger(opts.maxAssistant) ? opts.maxAssistant : MAX_ASSISTANT;
|
|
25
|
+
|
|
26
|
+
// Conta quantas ainda devemos MANTER, varrendo do fim pro inicio.
|
|
27
|
+
let userLeft = maxUser;
|
|
28
|
+
let assistantLeft = maxAssistant;
|
|
29
|
+
const keep = new Array(list.length).fill(false);
|
|
30
|
+
|
|
31
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
32
|
+
const role = list[i] && list[i].role;
|
|
33
|
+
if (role === "user") {
|
|
34
|
+
if (userLeft > 0) {
|
|
35
|
+
keep[i] = true;
|
|
36
|
+
userLeft -= 1;
|
|
37
|
+
}
|
|
38
|
+
} else if (role === "assistant") {
|
|
39
|
+
if (assistantLeft > 0) {
|
|
40
|
+
keep[i] = true;
|
|
41
|
+
assistantLeft -= 1;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// system / error / compaction: acompanha o vizinho mais proximo que ficou.
|
|
45
|
+
// Mantem provisoriamente; a segunda passada remove orfaos das bordas.
|
|
46
|
+
keep[i] = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Remove auxiliares nas bordas que nao tem nenhum user/assistant mantido
|
|
51
|
+
// depois (ou antes) deles — evita abrir o chat com uma linha solta.
|
|
52
|
+
let first = keep.indexOf(true);
|
|
53
|
+
let last = keep.lastIndexOf(true);
|
|
54
|
+
if (first === -1) return { messages: [], truncated: list.length > 0, total: list.length, shown: 0 };
|
|
55
|
+
while (first <= last && !isKeptTurn(list[first])) first++;
|
|
56
|
+
while (last >= first && !isKeptTurn(list[last])) last--;
|
|
57
|
+
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let i = first; i <= last; i++) {
|
|
60
|
+
if (keep[i]) out.push(list[i]);
|
|
61
|
+
}
|
|
62
|
+
const truncated = out.length < list.length;
|
|
63
|
+
return { messages: out, truncated, total: list.length, shown: out.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isKeptTurn(m) {
|
|
67
|
+
const role = m && m.role;
|
|
68
|
+
return role === "user" || role === "assistant";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { trimHistory, MAX_USER, MAX_ASSISTANT };
|
package/src/mobile/index.js
CHANGED
|
@@ -309,6 +309,24 @@ async function runMobile({
|
|
|
309
309
|
console.log(chalk.dim(`\n${tail}\n`));
|
|
310
310
|
note(tail);
|
|
311
311
|
|
|
312
|
+
// O celular confirmou o modal e se pareou: some com o QR. Em terminal
|
|
313
|
+
// interativo limpamos a tela e redesenhamos sem o QR; no daemon (log em
|
|
314
|
+
// arquivo) so registramos a linha de sucesso. Usa onPaired (evento real),
|
|
315
|
+
// nao polling de activeTokens(), pra nao confundir token antigo do disco
|
|
316
|
+
// com um pareamento novo.
|
|
317
|
+
function onPaired(ev) {
|
|
318
|
+
const who = ev?.device?.name || "celular";
|
|
319
|
+
const ok = `✓ celular pareado (${who}) · abra ${base}`;
|
|
320
|
+
if (process.stdout.isTTY) {
|
|
321
|
+
process.stdout.write("\x1b[2J\x1b[H"); // limpa e volta ao topo
|
|
322
|
+
console.log(chalk.green(`\n${ok}\n`));
|
|
323
|
+
} else {
|
|
324
|
+
console.log(chalk.green(`\n${ok}\n`));
|
|
325
|
+
}
|
|
326
|
+
note(ok);
|
|
327
|
+
}
|
|
328
|
+
pairing.onPaired = onPaired;
|
|
329
|
+
|
|
312
330
|
await new Promise((resolve) => {
|
|
313
331
|
const shutdown = () => {
|
|
314
332
|
pairing.audit("shutdown", {});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limit do kafox mobile — janela deslizante por chave (IP + rota).
|
|
3
|
+
* Sem dependencia: mapa em memoria, poda periodica, sem timer pendurado.
|
|
4
|
+
*
|
|
5
|
+
* Objetivo: frear brute-force nas rotas publicas (adivinhar pair_code/sessao
|
|
6
|
+
* pela URL). Conta por IP com janela curta e teto generoso o bastante pra um
|
|
7
|
+
* usuario de verdade nem perceber, mas curto o bastante pra inviabilizar
|
|
8
|
+
* varredura de codigos.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
// POST /pair: tentativas de pareamento por IP.
|
|
13
|
+
pair: { max: 10, windowMs: 60_000 },
|
|
14
|
+
// GET /pair (poll): consulta de status do pedido. Por IP.
|
|
15
|
+
poll: { max: 120, windowMs: 60_000 },
|
|
16
|
+
// GET /captcha: emitir desafios (evita farmar captcha).
|
|
17
|
+
captcha: { max: 30, windowMs: 60_000 },
|
|
18
|
+
// Rotas autenticadas por token: teto por TOKEN (nao so por IP), pra frear
|
|
19
|
+
// quem varre a URL adivinhando sessao mesmo atras de um unico IP.
|
|
20
|
+
auth: { max: 150, windowMs: 60_000 },
|
|
21
|
+
// Tentativas de token INVALIDO por IP: piso curto. Bater aqui = varredura.
|
|
22
|
+
authFail: { max: 20, windowMs: 60_000 },
|
|
23
|
+
// POST /prompt e /approve: acoes que mexem no agente. Por token.
|
|
24
|
+
action: { max: 40, windowMs: 60_000 },
|
|
25
|
+
// GET / (a propria pagina) + qualquer rota nao classificada.
|
|
26
|
+
global: { max: 240, windowMs: 60_000 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
class SlideLimiter {
|
|
30
|
+
constructor(now = () => Date.now()) {
|
|
31
|
+
this.buckets = new Map();
|
|
32
|
+
this.now = now;
|
|
33
|
+
this.lastPrune = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Registra um hit. Devolve { ok, retryAfter } — ok=false quando estourou.
|
|
38
|
+
* @param {string} key ex: "pair:1.2.3.4"
|
|
39
|
+
* @param {{max:number, windowMs:number}} rule
|
|
40
|
+
*/
|
|
41
|
+
hit(key, rule) {
|
|
42
|
+
const now = this.now();
|
|
43
|
+
this.maybePrune(now);
|
|
44
|
+
const hits = this.buckets.get(key) || [];
|
|
45
|
+
// descarta timestamps fora da janela (sliding window)
|
|
46
|
+
const cutoff = now - rule.windowMs;
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < hits.length && hits[i] <= cutoff) i += 1;
|
|
49
|
+
if (i > 0) hits.splice(0, i);
|
|
50
|
+
|
|
51
|
+
if (hits.length >= rule.max) {
|
|
52
|
+
const retryAfter = Math.max(1, Math.ceil((hits[0] + rule.windowMs - now) / 1000));
|
|
53
|
+
this.buckets.set(key, hits);
|
|
54
|
+
return { ok: false, retryAfter };
|
|
55
|
+
}
|
|
56
|
+
hits.push(now);
|
|
57
|
+
this.buckets.set(key, hits);
|
|
58
|
+
return { ok: true, remaining: rule.max - hits.length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
maybePrune(now) {
|
|
62
|
+
if (now - this.lastPrune < 60_000) return;
|
|
63
|
+
this.lastPrune = now;
|
|
64
|
+
const longest = 5 * 60_000;
|
|
65
|
+
for (const [key, hits] of this.buckets) {
|
|
66
|
+
if (!hits.length || hits[hits.length - 1] < now - longest) this.buckets.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
size() {
|
|
71
|
+
return this.buckets.size;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
reset() {
|
|
75
|
+
this.buckets.clear();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { SlideLimiter, DEFAULTS };
|
package/src/mobile/server.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
const http = require("http");
|
|
18
18
|
const { PairingManager } = require("./auth");
|
|
19
19
|
const { renderPage } = require("./view");
|
|
20
|
+
const captcha = require("./captcha");
|
|
21
|
+
const { SlideLimiter, DEFAULTS } = require("./ratelimit");
|
|
20
22
|
|
|
21
23
|
const HEARTBEAT_MS = 5_000;
|
|
22
24
|
|
|
@@ -48,8 +50,38 @@ function json(res, status, body) {
|
|
|
48
50
|
function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
|
|
49
51
|
if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
|
|
50
52
|
|
|
53
|
+
const limiter = new SlideLimiter();
|
|
51
54
|
const sseClients = new Set();
|
|
52
55
|
|
|
56
|
+
// IP do cliente. Atras do Caddy (reverse_proxy) vem em x-forwarded-for; em
|
|
57
|
+
// acesso direto, o socket. Normaliza pro primeiro IP da cadeia.
|
|
58
|
+
function clientIp(req) {
|
|
59
|
+
const fwd = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
|
60
|
+
return fwd || req.socket.remoteAddress || "desconhecido";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Aplica o rate limit da rota. Devolve true se JA respondeu (bloqueado).
|
|
64
|
+
function blocked(req, res, ruleName, overrideKey) {
|
|
65
|
+
const rule = DEFAULTS[ruleName];
|
|
66
|
+
if (!rule) return false;
|
|
67
|
+
const key = `${ruleName}:${overrideKey || clientIp(req)}`;
|
|
68
|
+
const verdict = limiter.hit(key, rule);
|
|
69
|
+
if (verdict.ok) return false;
|
|
70
|
+
res.writeHead(429, {
|
|
71
|
+
"content-type": "application/json; charset=utf-8",
|
|
72
|
+
"retry-after": String(verdict.retryAfter),
|
|
73
|
+
"cache-control": "no-store",
|
|
74
|
+
});
|
|
75
|
+
res.end(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
error: "muitas requisições — aguarde alguns instantes",
|
|
78
|
+
code: "rate_limited",
|
|
79
|
+
retryAfter: verdict.retryAfter,
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
function broadcast(event) {
|
|
54
86
|
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
55
87
|
for (const client of sseClients) {
|
|
@@ -67,6 +99,10 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
67
99
|
const url = new URL(req.url, "http://localhost");
|
|
68
100
|
const pathname = url.pathname;
|
|
69
101
|
|
|
102
|
+
// Teto global por IP: nenhuma rota escapa de um piso minimo de protecao
|
|
103
|
+
// contra varredura de URLs.
|
|
104
|
+
if (blocked(req, res, "global")) return;
|
|
105
|
+
|
|
70
106
|
// A pagina e o proprio endpoint de pareamento nao exigem token.
|
|
71
107
|
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
72
108
|
res.writeHead(200, {
|
|
@@ -94,30 +130,94 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
94
130
|
return;
|
|
95
131
|
}
|
|
96
132
|
|
|
133
|
+
// CAPTCHA: emite um desafio novo (publico, de uso unico). O celular pede
|
|
134
|
+
// isso antes de tentar parear. Sem ele, o POST /pair e rejeitado.
|
|
135
|
+
if (req.method === "GET" && pathname === "/captcha") {
|
|
136
|
+
if (blocked(req, res, "captcha")) return;
|
|
137
|
+
return json(res, 200, captcha.issue());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PASSO 1: o celular pede entrada (com o codigo do QR). Nao recebe token
|
|
141
|
+
// ainda — cria um pedido pendente e o dono do terminal aprova/nega.
|
|
97
142
|
if (req.method === "POST" && pathname === "/pair") {
|
|
143
|
+
if (blocked(req, res, "pair")) return;
|
|
98
144
|
let body;
|
|
99
145
|
try {
|
|
100
146
|
body = JSON.parse((await readBody(req)) || "{}");
|
|
101
147
|
} catch {
|
|
102
148
|
return json(res, 400, { error: "json invalido" });
|
|
103
149
|
}
|
|
150
|
+
// Portao anti-bot: valida e consome o captcha ANTES de olhar o codigo.
|
|
151
|
+
const captchaCheck = captcha.verify(body.captchaId, body.captchaAnswer);
|
|
152
|
+
if (!captchaCheck.ok) {
|
|
153
|
+
return json(res, 400, { error: captchaCheck.reason || "captcha invalido", captcha: true });
|
|
154
|
+
}
|
|
104
155
|
const code = String(body.code || "").trim().toUpperCase();
|
|
105
156
|
const device = {
|
|
106
157
|
ua: String(req.headers["user-agent"] || "").slice(0, 160),
|
|
107
158
|
ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
|
|
108
159
|
name: String(body.device || "celular").slice(0, 60),
|
|
109
160
|
};
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
161
|
+
const snap = bridge.snapshot();
|
|
162
|
+
const request = pairing.requestPair(code, device, {
|
|
163
|
+
sessionTitle: snap.title || snap.name || "",
|
|
164
|
+
sessionId: snap.sessionId || "",
|
|
165
|
+
});
|
|
166
|
+
if (!request) return json(res, 401, { error: "codigo invalido ou expirado" });
|
|
167
|
+
return json(res, 200, {
|
|
168
|
+
requestId: request.requestId,
|
|
169
|
+
expiresAt: request.expiresAt,
|
|
170
|
+
status: "pending",
|
|
171
|
+
session: { sessionId: snap.sessionId, title: snap.title || snap.name || "" },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// PASSO 3: o celular consulta o status do pedido. Quando aprovado, recebe o
|
|
176
|
+
// token UMA vez. Rota publica (autenticada pelo requestId aleatorio).
|
|
177
|
+
if (req.method === "GET" && pathname === "/pair") {
|
|
178
|
+
if (blocked(req, res, "poll")) return;
|
|
179
|
+
const requestId = String(url.searchParams.get("id") || "");
|
|
180
|
+
if (!requestId) return json(res, 400, { error: "id obrigatorio" });
|
|
181
|
+
const status = pairing.pollRequest(requestId);
|
|
182
|
+
if (status.status === "unknown") return json(res, 404, { error: "pedido nao encontrado" });
|
|
183
|
+
if (status.status === "approved") {
|
|
184
|
+
return json(res, 200, { status: "approved", token: status.token, expiresAt: status.expiresAt, session: bridge.snapshot() });
|
|
185
|
+
}
|
|
186
|
+
return json(res, 200, { status: status.status });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// PASSO 2: o terminal aprova/nega um pedido pendente. Exige token de um
|
|
190
|
+
// pareamento JA existente OU vem do proprio dono (rota local). Aqui so
|
|
191
|
+
// aceitamos quando ha um pedido valido — a rota e usada pela TUI no PC.
|
|
192
|
+
if (req.method === "POST" && pathname === "/pair/decide") {
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
196
|
+
} catch {
|
|
197
|
+
return json(res, 400, { error: "json invalido" });
|
|
198
|
+
}
|
|
199
|
+
const requestId = String(body.id || "");
|
|
200
|
+
const approved = Boolean(body.approved);
|
|
201
|
+
if (!requestId) return json(res, 400, { error: "id obrigatorio" });
|
|
202
|
+
const ok = pairing.resolveRequest(requestId, approved);
|
|
203
|
+
if (!ok) return json(res, 404, { error: "pedido nao encontrado ou ja resolvido" });
|
|
204
|
+
return json(res, 200, { ok: true, status: approved ? "approved" : "denied" });
|
|
113
205
|
}
|
|
114
206
|
|
|
115
207
|
// A partir daqui, exige token de sessao.
|
|
116
208
|
const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
|
|
117
209
|
if (!pairing.validateToken(token)) {
|
|
210
|
+
// Teto curto por IP para TOKEN INVALIDO: bater aqui e sinal de varredura
|
|
211
|
+
// de sessao pela URL. Vale mesmo antes de responder o 401.
|
|
212
|
+
if (blocked(req, res, "authFail")) return;
|
|
118
213
|
return json(res, 401, { error: "sem token valido" });
|
|
119
214
|
}
|
|
120
215
|
|
|
216
|
+
// Token valido: conta por TOKEN (nao so por IP), pra um IP sozinho nao
|
|
217
|
+
// conseguir varrer sessoes sem estourar o bucket da propria sessao.
|
|
218
|
+
const tokenKey = String(token).slice(0, 16);
|
|
219
|
+
if (blocked(req, res, "auth", tokenKey)) return;
|
|
220
|
+
|
|
121
221
|
if (req.method === "GET" && pathname === "/state") {
|
|
122
222
|
return json(res, 200, bridge.snapshot());
|
|
123
223
|
}
|
|
@@ -195,6 +295,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
195
295
|
}
|
|
196
296
|
|
|
197
297
|
if (req.method === "POST" && pathname === "/prompt") {
|
|
298
|
+
if (blocked(req, res, "action", tokenKey)) return;
|
|
198
299
|
let body;
|
|
199
300
|
try {
|
|
200
301
|
body = JSON.parse((await readBody(req)) || "{}");
|
|
@@ -211,6 +312,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
211
312
|
}
|
|
212
313
|
|
|
213
314
|
if (req.method === "POST" && pathname === "/approve") {
|
|
315
|
+
if (blocked(req, res, "action", tokenKey)) return;
|
|
214
316
|
let body;
|
|
215
317
|
try {
|
|
216
318
|
body = JSON.parse((await readBody(req)) || "{}");
|
package/src/mobile/tui-bridge.js
CHANGED
|
@@ -71,6 +71,7 @@ class TuiBridge extends EventEmitter {
|
|
|
71
71
|
snapshot() {
|
|
72
72
|
return {
|
|
73
73
|
sessionId: this.sessionId,
|
|
74
|
+
title: this.state.title || (this.state.messages || []).find((m) => m.role === "user")?.text || null,
|
|
74
75
|
model: this.state.model || null,
|
|
75
76
|
cwd: this.cwd || process.cwd(),
|
|
76
77
|
allowAll: Boolean(this.state.autoApprove),
|
package/src/mobile/view.js
CHANGED
|
@@ -167,6 +167,36 @@ function renderPage() {
|
|
|
167
167
|
#pairing .code:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
|
|
168
168
|
#pairing .err { color:var(--err); min-height:1.2em; font-size:12px; }
|
|
169
169
|
#pairing button { width:min(92vw,420px); }
|
|
170
|
+
/* captcha: desafio em SVG + campo curto, lado a lado */
|
|
171
|
+
#pairing .captcha { display:flex; align-items:center; gap:10px; width:min(92vw,420px); }
|
|
172
|
+
#pairing .captcha .q { flex:1; height:56px; border:2px solid var(--neon); background:#000;
|
|
173
|
+
display:flex; align-items:center; justify-content:center; }
|
|
174
|
+
#pairing .captcha .q svg { display:block; }
|
|
175
|
+
#pairing .captcha .reload { width:44px; height:56px; flex:0 0 auto; border:2px solid var(--neon);
|
|
176
|
+
background:#000; color:var(--neon); font-size:18px; line-height:1; cursor:pointer; padding:0; }
|
|
177
|
+
#pairing .captcha .reload:active { background:var(--neon); color:#000; }
|
|
178
|
+
#pairing input.small { font-size:22px; font-weight:800; letter-spacing:0.2em; text-align:center;
|
|
179
|
+
border:2px solid var(--neon); background:#000; color:var(--neon); padding:12px;
|
|
180
|
+
width:min(92vw,420px); }
|
|
181
|
+
#pairing input.small:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
|
|
182
|
+
#pairing .hint { color:var(--dim); font-size:11px; letter-spacing:0.06em; max-width:34ch; }
|
|
183
|
+
|
|
184
|
+
/* modal de confirmacao de entrada (pedido de pareamento) */
|
|
185
|
+
#confirm { position:fixed; inset:0; background:rgba(0,0,0,0.92); z-index:21; display:none;
|
|
186
|
+
flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:24px;
|
|
187
|
+
text-align:center; }
|
|
188
|
+
#confirm.show { display:flex; }
|
|
189
|
+
#confirm h2 { font-size:16px; text-transform:uppercase; letter-spacing:0.14em; margin:0; color:var(--neon); }
|
|
190
|
+
#confirm p { color:var(--dim); font-size:12.5px; margin:0; max-width:34ch; line-height:1.6; }
|
|
191
|
+
#confirm .chat { color:#fff; font-size:15px; font-weight:700; border:2px solid #fff;
|
|
192
|
+
padding:10px 16px; max-width:min(92vw,420px); word-break:break-word; min-height:1.2em; }
|
|
193
|
+
#confirm .chat:empty { display:none; }
|
|
194
|
+
#confirm .err { color:var(--err); min-height:1.2em; font-size:12.5px; max-width:34ch; }
|
|
195
|
+
#confirm .wait { color:var(--neon); font-size:13px; letter-spacing:0.1em; }
|
|
196
|
+
#confirm button { width:min(92vw,420px); }
|
|
197
|
+
#confirm[data-state="waiting"] #confirmBtn,
|
|
198
|
+
#confirm[data-state="waiting"] #confirmCancel { display:none; }
|
|
199
|
+
#confirm:not([data-state="waiting"]) .wait { display:none; }
|
|
170
200
|
|
|
171
201
|
/* Pensamento recolhido: mostra so a primeira linha como "resumo" clicavel. */
|
|
172
202
|
.ln.think { cursor:pointer; }
|
|
@@ -196,9 +226,24 @@ function renderPage() {
|
|
|
196
226
|
<p>se a câmera não leu o QR, digite o código que aparece embaixo dele na tela do PC.</p>
|
|
197
227
|
<input class="code" id="paircode" inputmode="text" autocomplete="off" autocapitalize="characters"
|
|
198
228
|
spellcheck="false" placeholder="CÓDIGO" maxlength="12" />
|
|
229
|
+
<div class="hint" id="captchaHint">resolva a conta pra provar que você é humano:</div>
|
|
230
|
+
<div class="captcha">
|
|
231
|
+
<div class="q" id="captchaQ"></div>
|
|
232
|
+
<button class="reload" id="captchaReload" type="button" title="outro desafio" aria-label="outro desafio">↻</button>
|
|
233
|
+
</div>
|
|
234
|
+
<input class="small" id="captchaA" inputmode="numeric" autocomplete="off" placeholder="?" maxlength="3" />
|
|
199
235
|
<div class="err" id="pairerr"></div>
|
|
200
236
|
<button class="primary" id="pairbtn">parear</button>
|
|
201
237
|
</div>
|
|
238
|
+
<div id="confirm">
|
|
239
|
+
<h2>entrar no chat?</h2>
|
|
240
|
+
<p id="confirmText">você vai pedir acesso ao terminal deste PC.</p>
|
|
241
|
+
<div class="chat" id="confirmTitle"></div>
|
|
242
|
+
<div class="err" id="confirmErr"></div>
|
|
243
|
+
<div class="wait" id="confirmWait">aguardando aprovação no PC…</div>
|
|
244
|
+
<button class="primary" id="confirmBtn">entrar</button>
|
|
245
|
+
<button id="confirmCancel">cancelar</button>
|
|
246
|
+
</div>
|
|
202
247
|
</div>
|
|
203
248
|
<script>
|
|
204
249
|
(function () {
|
|
@@ -414,30 +459,190 @@ function renderPage() {
|
|
|
414
459
|
return BASE.replace(/\\/$/, "") + "/" + String(path).replace(/^\\//, "");
|
|
415
460
|
}
|
|
416
461
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
462
|
+
// PASSO 1: pede entrada. NAO recebe token ainda — o dono do terminal aprova.
|
|
463
|
+
// Devolve { requestId, session } ou null.
|
|
464
|
+
async function requestEntry(code) {
|
|
465
|
+
if (!code) return null;
|
|
466
|
+
var res, data;
|
|
420
467
|
try {
|
|
421
468
|
res = await fetch(url("pair"), {
|
|
422
469
|
method: "POST", headers: { "content-type": "application/json" },
|
|
423
|
-
body: JSON.stringify({
|
|
470
|
+
body: JSON.stringify({
|
|
471
|
+
code: code,
|
|
472
|
+
device: navigator.platform || "celular",
|
|
473
|
+
captchaId: captchaId,
|
|
474
|
+
captchaAnswer: ($("captchaA") && $("captchaA").value.trim()) || "",
|
|
475
|
+
}),
|
|
424
476
|
});
|
|
425
477
|
} catch (e) {
|
|
426
478
|
pairErr("sem ligação com o servidor — confira a URL");
|
|
427
|
-
return
|
|
479
|
+
return null;
|
|
428
480
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
481
|
+
try { data = await res.json(); } catch (e) { data = {}; }
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
if (data && data.captcha) {
|
|
484
|
+
pairErr("captcha incorreto — tente de novo");
|
|
485
|
+
loadCaptcha(); // desafio e de uso unico: pede outro
|
|
486
|
+
} else {
|
|
487
|
+
pairErr((data && data.error) || "código inválido ou expirado — gere outro no PC");
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return data;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- captcha anti-bot (desafio emitido pelo servidor) ---
|
|
495
|
+
var captchaId = "";
|
|
496
|
+
var captchaSvg = null;
|
|
497
|
+
|
|
498
|
+
// Desenha a pergunta como SVG com leve ruido/rotacao (dificulta OCR simples),
|
|
499
|
+
// tudo em texto vetorial — nada de canvas/imagem externa.
|
|
500
|
+
function renderCaptchaSvg(question, seed) {
|
|
501
|
+
var chars = String(question).split("");
|
|
502
|
+
var pad = 10, cw = 15, fs = 24;
|
|
503
|
+
var w = pad * 2 + chars.length * cw;
|
|
504
|
+
var h = 56;
|
|
505
|
+
var parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + " " + h + '">'];
|
|
506
|
+
parts.push('<rect width="' + w + '" height="' + h + '" fill="#000"/>');
|
|
507
|
+
// algumas linhas de ruido
|
|
508
|
+
var rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
|
|
509
|
+
for (var k = 0; k < 4; k++) {
|
|
510
|
+
var x1 = rnd(w), y1 = rnd(h), x2 = rnd(w), y2 = rnd(h);
|
|
511
|
+
parts.push('<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="#bfa682" stroke-width="1" opacity="0.35"/>');
|
|
512
|
+
}
|
|
513
|
+
for (var i = 0; i < chars.length; i++) {
|
|
514
|
+
var ch = chars[i];
|
|
515
|
+
if (ch === " ") { continue; }
|
|
516
|
+
var rot = (rnd(17) - 8);
|
|
517
|
+
var y = Math.round(h / 2 + 6 + (rnd(7) - 3));
|
|
518
|
+
var x = pad + i * cw;
|
|
519
|
+
parts.push(
|
|
520
|
+
'<text x="' + x + '" y="' + y + '" font-family="monospace" font-size="' + fs +
|
|
521
|
+
'" font-weight="700" fill="#bfa682" transform="rotate(' + rot + " " + x + " " + y + ')">' +
|
|
522
|
+
ch.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") +
|
|
523
|
+
"</text>"
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
parts.push("</svg>");
|
|
527
|
+
return parts.join("");
|
|
436
528
|
}
|
|
437
529
|
|
|
438
|
-
function
|
|
530
|
+
async function loadCaptcha() {
|
|
531
|
+
var box = $("captchaQ");
|
|
532
|
+
if (box) box.textContent = "…";
|
|
533
|
+
try {
|
|
534
|
+
var res = await fetch(url("captcha"), { cache: "no-store" });
|
|
535
|
+
var data = await res.json();
|
|
536
|
+
captchaId = data.id || "";
|
|
537
|
+
var seed = 0;
|
|
538
|
+
for (var i = 0; i < captchaId.length; i++) seed = (seed * 31 + captchaId.charCodeAt(i)) & 0x7fffffff;
|
|
539
|
+
if (box) box.innerHTML = renderCaptchaSvg(data.question || "", seed);
|
|
540
|
+
var a = $("captchaA");
|
|
541
|
+
if (a) { a.value = ""; }
|
|
542
|
+
} catch (e) {
|
|
543
|
+
captchaId = "";
|
|
544
|
+
if (box) box.textContent = "falha ao carregar";
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function showPairing(on) {
|
|
549
|
+
$("pairing").classList[on ? "add" : "remove"]("show");
|
|
550
|
+
if (on && !captchaId) loadCaptcha();
|
|
551
|
+
}
|
|
439
552
|
function pairErr(msg) { $("pairerr").textContent = msg || ""; }
|
|
440
553
|
|
|
554
|
+
// Modal de confirmacao: mostra o titulo do chat e pede "entrar".
|
|
555
|
+
function showConfirm(state, msg) {
|
|
556
|
+
var el = $("confirm");
|
|
557
|
+
if (!state) { el.classList.remove("show"); el.removeAttribute("data-state"); return; }
|
|
558
|
+
$("confirmErr").textContent = msg || "";
|
|
559
|
+
el.setAttribute("data-state", state);
|
|
560
|
+
el.classList.add("show");
|
|
561
|
+
}
|
|
562
|
+
function confirmErr(msg) { $("confirmErr").textContent = msg || ""; }
|
|
563
|
+
|
|
564
|
+
// Pareamento (Opção A): o celular se auto-aprova apos confirmar no modal.
|
|
565
|
+
// 1. pede entrada (sem token) -> cria um pedido PENDENTE no servidor;
|
|
566
|
+
// 2. mostra o modal "entrar no chat? <titulo>" com os botoes entrar/cancelar;
|
|
567
|
+
// ao clicar em "entrar", ESTE navegador chama /pair/decide {approved:true},
|
|
568
|
+
// o token de sessao e emitido para ele e o terminal detecta (some o QR).
|
|
569
|
+
// Cancelar nega o pedido (approved:false).
|
|
570
|
+
// Devolve true se pareou; false se negado/expirado/erro/cancelado.
|
|
571
|
+
function pair(code) {
|
|
572
|
+
if (!code) return false;
|
|
573
|
+
return new Promise(async function (resolve) {
|
|
574
|
+
// garante um captcha valido (fluxo QR nao passa por showPairing).
|
|
575
|
+
if (!captchaId) await loadCaptcha();
|
|
576
|
+
// valida o codigo primeiro (rapido). Se for valido, mostra direto o modal
|
|
577
|
+
// "entrar no chat?" — o proprio usuario confirma no celular.
|
|
578
|
+
var req = await requestEntry(code);
|
|
579
|
+
if (!req || !req.requestId) { showConfirm(null); resolve(false); return; }
|
|
580
|
+
$("confirmTitle").textContent = ((req.session && req.session.title) || req.title || "conversa sem título");
|
|
581
|
+
confirmErr("");
|
|
582
|
+
showConfirm("ask");
|
|
583
|
+
|
|
584
|
+
var settled = false;
|
|
585
|
+
function finish(ok, msg) {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
$("confirmBtn").onclick = null;
|
|
589
|
+
$("confirmCancel").onclick = null;
|
|
590
|
+
if (ok) { showConfirm(null); resolve(true); }
|
|
591
|
+
else { showConfirm("ask", msg); resolve(false); }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function decide(approved) {
|
|
595
|
+
return fetch(url("pair/decide"), {
|
|
596
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
597
|
+
body: JSON.stringify({ id: req.requestId, approved: approved }),
|
|
598
|
+
}).catch(function () { return null; });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
$("confirmBtn").onclick = function () {
|
|
602
|
+
$("confirmBtn").disabled = true;
|
|
603
|
+
// aprova explicitamente ("tem certeza?") e espera o token desta sessao.
|
|
604
|
+
(async function () {
|
|
605
|
+
await decide(true);
|
|
606
|
+
var decision;
|
|
607
|
+
while (!settled) {
|
|
608
|
+
decision = await awaitDecisionOnce(req.requestId);
|
|
609
|
+
if (decision.status !== "pending") break;
|
|
610
|
+
await new Promise(function (r) { setTimeout(r, 1000); });
|
|
611
|
+
}
|
|
612
|
+
$("confirmBtn").disabled = false;
|
|
613
|
+
if (decision && decision.status === "approved") finish(true);
|
|
614
|
+
else if (decision && decision.status === "denied") finish(false, "entrada negada — gere outro código");
|
|
615
|
+
else finish(false, "pedido expirou — gere outro código no PC");
|
|
616
|
+
})();
|
|
617
|
+
};
|
|
618
|
+
$("confirmCancel").onclick = function () {
|
|
619
|
+
decide(false);
|
|
620
|
+
finish(false, "cancelado — escaneie o QR ou digite o código de novo");
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Uma consulta de status (sem loop), para o modal controlar o polling.
|
|
626
|
+
async function awaitDecisionOnce(requestId) {
|
|
627
|
+
var res;
|
|
628
|
+
try {
|
|
629
|
+
res = await fetch(url("pair") + "?id=" + encodeURIComponent(requestId), { cache: "no-store" });
|
|
630
|
+
} catch (e) {
|
|
631
|
+
return { status: "pending" };
|
|
632
|
+
}
|
|
633
|
+
if (res.status === 404) return { status: "expired" };
|
|
634
|
+
var data = await res.json().catch(function () { return {}; });
|
|
635
|
+
if (data.status === "approved" && data.token) {
|
|
636
|
+
token = data.token;
|
|
637
|
+
sessionStorage.setItem("kafox_token", token);
|
|
638
|
+
sid.textContent = (data.session && data.session.sessionId) || "";
|
|
639
|
+
return { status: "approved" };
|
|
640
|
+
}
|
|
641
|
+
if (data.status === "denied") return { status: "denied" };
|
|
642
|
+
if (data.status === "expired" || data.status === "unknown") return { status: "expired" };
|
|
643
|
+
return { status: "pending" };
|
|
644
|
+
}
|
|
645
|
+
|
|
441
646
|
// Reconstroi o log a partir do historico (um turno por mensagem do usuario,
|
|
442
647
|
// com as respostas/tools/pensamentos abaixo).
|
|
443
648
|
function renderHistory(messages) {
|
|
@@ -663,14 +868,17 @@ function renderPage() {
|
|
|
663
868
|
|
|
664
869
|
async function boot() {
|
|
665
870
|
var m = location.hash.match(/p=([A-Za-z0-9]+)/);
|
|
871
|
+
var scanned = m ? m[1] : "";
|
|
666
872
|
if (m) {
|
|
667
873
|
await pair(m[1]);
|
|
668
874
|
history.replaceState(null, "", location.pathname);
|
|
669
875
|
}
|
|
670
876
|
if (!token) {
|
|
671
877
|
// Sem token (nem por QR, nem salvo): mostra a tela de pareamento manual.
|
|
878
|
+
// Se veio do QR, deixa o codigo preenchido pra so resolver o captcha.
|
|
879
|
+
if (scanned && !$("paircode").value) $("paircode").value = scanned.toUpperCase();
|
|
672
880
|
showPairing(true);
|
|
673
|
-
$("
|
|
881
|
+
$("captchaA").focus();
|
|
674
882
|
return;
|
|
675
883
|
}
|
|
676
884
|
var okState = await loadState();
|
|
@@ -692,6 +900,7 @@ function renderPage() {
|
|
|
692
900
|
}
|
|
693
901
|
}
|
|
694
902
|
$("pairbtn").onclick = pairManual;
|
|
903
|
+
$("captchaReload").onclick = function () { pairErr(""); loadCaptcha(); };
|
|
695
904
|
$("paircode").addEventListener("input", function () {
|
|
696
905
|
this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
697
906
|
});
|