Skip to content

Commit bdb169d

Browse files
Merge pull request #352 from appdevforall/feat/ADFA-5044-server-verify-calibre
ADFA-5044: dash-node 1.1.5 — verify Calibre-Web on save + expose default password for prefill
2 parents bc73419 + 043a7b9 commit bdb169d

6 files changed

Lines changed: 76 additions & 23 deletions

File tree

‎static/dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
44
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
55
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
66

7+
- **1.1.5** - Credentials: `POST /credentials/calibre` now validates against live Calibre-Web (401 on reject, save-unverified when the service is down); `GET /credentials/:service` returns the default password only while still at the factory default, for full form prefill. (ADFA-5044)
78
- **1.1.4** - Calibre-Web auto-login: send Flask-Login `remember_me` so the session includes a persistent `remember_token`, which sticks in the WebView despite anonymous/guest browsing. (ADFA-5043)
89
- **1.1.3** - `/auth/:service/session`: server-side login returning the session cookie, for WebView auto-login as box admin (Calibre-Web / Kolibri). (ADFA-5043)
910
- **1.1.2** - ZIM download URL built from the project subdir (`/zim/<project>/<file>`), so non-Wikipedia ZIMs download instead of 404ing. Pairs with the app sending `<project>/<file>`. (ADFA-5042)

‎static/dashboard/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎static/dashboard/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dashboard-console",
3-
"version": "1.1.4",
3+
"version": "1.1.5",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

‎static/dashboard/routes.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { spawn } from 'child_process';
99
import fs from 'fs';
1010
import path from 'path';
1111
import { jobs, Job, JobType } from './sockets/jobs';
12-
import { searchCatalog, listLibrary, removeBook, listLanguages, getCalibreSession } from './sockets/books.query';
12+
import { searchCatalog, listLibrary, removeBook, listLanguages, getCalibreSession, verifyCalibreCredentials } from './sockets/books.query';
1313
import { parseBox, parseEstimate } from './sockets/maps.socket';
1414
import {
1515
preflight, listInstalledChannels, browseRemoteChannels, resolveIdentifier,
@@ -521,15 +521,45 @@ apiRouter.post('/credentials/:service', async (req: Request, res: Response): Pro
521521
return;
522522
}
523523

524-
// Por ahora solo Kolibri se valida en vivo. Para otros servicios se guarda tal
525-
// cual (calibre sigue con sus constantes hasta que se migre en su propio PR).
526-
if (service !== 'kolibri') {
524+
// Persist + respond in one place so the verified/unverified branches can't drift apart.
525+
const saveAndRespond = (verified: boolean): void => {
527526
try {
528527
setCredential(service, { username, password });
529-
res.json({ ok: true, verified: false, service });
528+
res.json({ ok: true, verified, service, username });
530529
} catch (e: any) {
531530
res.status(500).json({ error: e?.message || 'save failed' });
532531
}
532+
};
533+
534+
// ADFA-5044: Calibre-Web is now validated live too. If it authenticates we save verified; if it
535+
// rejects the credentials we return 401 (nothing saved); if it's unreachable (not installed yet)
536+
// we save unverified so the sign-in can be pre-set and applies once the service is up.
537+
// Note: unlike Kolibri (which also checks canManageContent -> 403), this only checks that the
538+
// credentials authenticate, not that the account can manage content — Calibre-Web role-checking
539+
// is a separate, heavier step and login success is a reasonable bar for the admin sign-in.
540+
if (service === 'calibre') {
541+
try {
542+
await verifyCalibreCredentials(username, password);
543+
} catch (e: any) {
544+
if (/invalid.*cred/i.test(e?.message || '')) {
545+
res.status(401).json({ error: 'Calibre-Web rejected these credentials', saved: false });
546+
return;
547+
}
548+
// Service unreachable (not installed/running) or its login form couldn't be parsed. We
549+
// still save so the sign-in can be pre-set, but log it: a parse failure while the service
550+
// is up would otherwise be an invisible "saved but never verified".
551+
console.warn('[credentials] calibre verify skipped: '
552+
+ (e?.message || e) + ' — saving unverified');
553+
saveAndRespond(false);
554+
return;
555+
}
556+
saveAndRespond(true);
557+
return;
558+
}
559+
560+
// Any other (future) service that has no live check yet is stored as-is.
561+
if (service !== 'kolibri') {
562+
saveAndRespond(false);
533563
return;
534564
}
535565

‎static/dashboard/sockets/books.query.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ export function listLibrary(): any[] {
107107
}
108108
}
109109

110-
export async function getCalibreSession(): Promise<{ cookie: string; csrfToken: string }> {
110+
/** Log into Calibre-Web with the given credentials and return the authenticated session.
111+
* A successful login answers with a 302/303 redirect; anything else means the credentials were
112+
* rejected (thrown as 'Invalid Calibre-Web credentials'). A connection error (service down) throws
113+
* the underlying fetch error, so callers can tell "wrong password" from "not running". */
114+
async function loginCalibre(username: string, password: string): Promise<{ cookie: string; csrfToken: string }> {
111115
const loginPageRes = await fetch(`${CALIBRE_WEB_LOCAL_URL}/login`);
112116
const initialCookies = loginPageRes.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
113117
const loginHtml = await loginPageRes.text();
@@ -117,9 +121,8 @@ export async function getCalibreSession(): Promise<{ cookie: string; csrfToken:
117121

118122
const loginData = new URLSearchParams();
119123
loginData.append('csrf_token', csrfToken);
120-
const cred = getCredential('calibre');
121-
loginData.append('username', cred.username);
122-
loginData.append('password', cred.password);
124+
loginData.append('username', username);
125+
loginData.append('password', password);
123126
// ADFA-5043: request Flask-Login's persistent "remember me" so the response also sets a
124127
// `remember_token` cookie. Calibre-Web allows anonymous (guest) browsing, so the session cookie
125128
// alone doesn't stick in the WebView; the remember_token re-authenticates as admin reliably.
@@ -145,6 +148,18 @@ export async function getCalibreSession(): Promise<{ cookie: string; csrfToken:
145148
return { cookie: authCookieString, csrfToken: finalCsrfMatch ? finalCsrfMatch[1] : csrfToken };
146149
}
147150

151+
export async function getCalibreSession(): Promise<{ cookie: string; csrfToken: string }> {
152+
const cred = getCredential('calibre');
153+
return loginCalibre(cred.username, cred.password);
154+
}
155+
156+
/** ADFA-5044: check credentials against the live Calibre-Web before persisting them. Resolves on a
157+
* successful login; throws 'Invalid Calibre-Web credentials' when rejected, or the fetch error when
158+
* the service is unreachable (so the route can save-unverified instead of reporting a bad password). */
159+
export async function verifyCalibreCredentials(username: string, password: string): Promise<void> {
160+
await loginCalibre(username, password);
161+
}
162+
148163
/** Remove a book from Calibre-Web by its library id. */
149164
export async function removeBook(id: number): Promise<void> {
150165
const s = await getCalibreSession();

‎static/dashboard/sockets/credentials.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,24 +137,31 @@ export function clearCredential(service: ServiceName): void {
137137
writeStore(store);
138138
}
139139

140-
/** Vista segura para la UI: nunca devuelve la contraseña, solo si hay una y de
141-
* dónde viene. Es lo que el webview necesita para pintar el formulario. */
140+
/** Vista para la UI. La contraseña personalizada NUNCA se devuelve. Excepción (ADFA-5044): cuando el
141+
* servicio sigue con el default de fábrica —que es público y documentado (Admin/changeme en IIAB)— se
142+
* incluye para que el formulario pueda prellenar el sign-in completo. En cuanto hay un override, se
143+
* omite de nuevo. Es seguro porque no expone ningún secreto real y el API es localhost-only. */
142144
export function describeCredential(service: ServiceName): {
143145
service: ServiceName;
144146
username: string;
145147
origin: CredentialOrigin;
146148
isDefault: boolean;
149+
password?: string;
147150
} {
148151
const c = getCredential(service);
149-
return {
150-
service,
151-
username: c.username,
152-
origin: c.origin,
153-
// Señal para que la UI pueda avisar "sigues con la contraseña de fábrica".
154-
isDefault: c.origin === 'default'
155-
|| (c.username === DEFAULTS[service].username
156-
&& c.password === DEFAULTS[service].password),
157-
};
152+
// Señal para que la UI pueda avisar "sigues con la contraseña de fábrica".
153+
const isDefault = c.origin === 'default'
154+
|| (c.username === DEFAULTS[service].username
155+
&& c.password === DEFAULTS[service].password);
156+
const out: {
157+
service: ServiceName;
158+
username: string;
159+
origin: CredentialOrigin;
160+
isDefault: boolean;
161+
password?: string;
162+
} = { service, username: c.username, origin: c.origin, isDefault };
163+
if (isDefault) out.password = DEFAULTS[service].password;
164+
return out;
158165
}
159166

160167
export const CREDENTIALS_STORE_PATH = STORE_PATH;

0 commit comments

Comments
 (0)