Re-add pi-web-access

This commit is contained in:
2026-02-19 22:23:48 +00:00
parent c242a0ca53
commit 774492f279
31 changed files with 8666 additions and 1 deletions

View File

@@ -0,0 +1,321 @@
import { execFile } from "node:child_process";
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir, homedir, platform } from "node:os";
import { join } from "node:path";
export type CookieMap = Record<string, string>;
const GOOGLE_ORIGINS = [
"https://gemini.google.com",
"https://accounts.google.com",
"https://www.google.com",
];
const ALL_COOKIE_NAMES = new Set([
"__Secure-1PSID",
"__Secure-1PSIDTS",
"__Secure-1PSIDCC",
"__Secure-1PAPISID",
"NID",
"AEC",
"SOCS",
"__Secure-BUCKET",
"__Secure-ENID",
"SID",
"HSID",
"SSID",
"APISID",
"SAPISID",
"__Secure-3PSID",
"__Secure-3PSIDTS",
"__Secure-3PAPISID",
"SIDCC",
]);
function getChromeCookiesPath(): string | null {
const plat = platform();
if (plat === "darwin") {
return join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies");
}
if (plat === "linux") {
const chromiumPath = join(homedir(), ".config/chromium/Default/Cookies");
if (existsSync(chromiumPath)) return chromiumPath;
const chromePath = join(homedir(), ".config/google-chrome/Default/Cookies");
if (existsSync(chromePath)) return chromePath;
return null;
}
return null;
}
export async function getGoogleCookies(): Promise<{ cookies: CookieMap; warnings: string[] } | null> {
const plat = platform();
if (plat !== "darwin" && plat !== "linux") return null;
const cookiesPath = getChromeCookiesPath();
if (!cookiesPath) return null;
const warnings: string[] = [];
const password = await readEncryptionPassword();
if (!password) {
warnings.push("Could not read Chrome encryption password");
return { cookies: {}, warnings };
}
const macKey = plat === "darwin" ? pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1") : Buffer.alloc(0);
const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-"));
try {
const tempDb = join(tempDir, "Cookies");
copyFileSync(cookiesPath, tempDb);
copySidecar(cookiesPath, tempDb, "-wal");
copySidecar(cookiesPath, tempDb, "-shm");
const metaVersion = await readMetaVersion(tempDb);
const stripHash = metaVersion >= 24;
const hosts = GOOGLE_ORIGINS.map((o) => new URL(o).hostname);
const rows = await queryCookieRows(tempDb, hosts);
if (!rows) {
warnings.push("Failed to query Chrome cookie database");
return { cookies: {}, warnings };
}
const cookies: CookieMap = {};
for (const row of rows) {
const name = row.name as string;
if (!ALL_COOKIE_NAMES.has(name)) continue;
if (cookies[name]) continue;
let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null;
if (!value) {
const encrypted = row.encrypted_value;
if (encrypted instanceof Uint8Array) {
if (plat === "linux") {
value = decryptLinuxCookieValue(encrypted, password, stripHash);
} else {
value = decryptCookieValue(encrypted, macKey, stripHash);
}
}
}
if (value) cookies[name] = value;
}
return { cookies, warnings };
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null {
const buf = Buffer.from(encrypted);
if (buf.length < 3) return null;
const prefix = buf.subarray(0, 3).toString("utf8");
if (!/^v\d\d$/.test(prefix)) return null;
const ciphertext = buf.subarray(3);
if (!ciphertext.length) return "";
try {
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(false);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const unpadded = removePkcs7Padding(plaintext);
const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
let i = 0;
while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
return decoded.slice(i);
} catch {
return null;
}
}
function decryptLinuxCookieValue(encrypted: Uint8Array, keyringSecret: string, stripHash: boolean): string | null {
const buf = Buffer.from(encrypted);
if (buf.length < 3) return null;
const prefix = buf.subarray(0, 3).toString("utf8");
if (prefix !== "v10" && prefix !== "v11") return null;
const ciphertext = buf.subarray(3);
if (!ciphertext.length) return "";
try {
const key = pbkdf2Sync(keyringSecret, "saltysalt", 1, 16, "sha1");
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(false);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const unpadded = removePkcs7Padding(plaintext);
const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
let i = 0;
while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
return decoded.slice(i);
} catch {
return null;
}
}
function removePkcs7Padding(buf: Buffer): Buffer {
if (!buf.length) return buf;
const padding = buf[buf.length - 1];
if (!padding || padding > 16) return buf;
return buf.subarray(0, buf.length - padding);
}
function readKeychainPassword(): Promise<string | null> {
return new Promise((resolve) => {
execFile(
"security",
["find-generic-password", "-w", "-a", "Chrome", "-s", "Chrome Safe Storage"],
{ timeout: 5000 },
(err, stdout) => {
if (err) { resolve(null); return; }
resolve(stdout.trim() || null);
},
);
});
}
function readLinuxKeyringPassword(): Promise<string | null> {
return new Promise((resolve) => {
execFile(
"secret-tool",
["lookup", "application", "chromium"],
{ timeout: 5000 },
(err, stdout) => {
if (!err && stdout.trim()) {
resolve(stdout.trim());
return;
}
execFile(
"secret-tool",
["lookup", "application", "chrome"],
{ timeout: 5000 },
(err2, stdout2) => {
if (!err2 && stdout2.trim()) {
resolve(stdout2.trim());
return;
}
resolve("peanuts");
},
);
},
);
});
}
async function readEncryptionPassword(): Promise<string | null> {
const plat = platform();
if (plat === "darwin") return readKeychainPassword();
if (plat === "linux") return readLinuxKeyringPassword();
return null;
}
let sqliteModule: typeof import("node:sqlite") | null = null;
async function importSqlite(): Promise<typeof import("node:sqlite") | null> {
if (sqliteModule) return sqliteModule;
const orig = process.emitWarning.bind(process);
process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
const msg = typeof warning === "string" ? warning : warning?.message ?? "";
if (msg.includes("SQLite is an experimental feature")) return;
return (orig as Function)(warning, ...args);
}) as typeof process.emitWarning;
try {
sqliteModule = await import("node:sqlite");
return sqliteModule;
} catch {
return null;
} finally {
process.emitWarning = orig;
}
}
function supportsReadBigInts(): boolean {
const [major, minor] = process.versions.node.split(".").map(Number);
if (major > 24) return true;
if (major < 24) return false;
return minor >= 4;
}
async function readMetaVersion(dbPath: string): Promise<number> {
const sqlite = await importSqlite();
if (!sqlite) return 0;
const opts: Record<string, unknown> = { readOnly: true };
if (supportsReadBigInts()) opts.readBigInts = true;
const db = new sqlite.DatabaseSync(dbPath, opts);
try {
const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array<Record<string, unknown>>;
const val = rows[0]?.value;
if (typeof val === "number") return Math.floor(val);
if (typeof val === "bigint") return Number(val);
if (typeof val === "string") return parseInt(val, 10) || 0;
return 0;
} catch {
return 0;
} finally {
db.close();
}
}
async function queryCookieRows(
dbPath: string,
hosts: string[],
): Promise<Array<Record<string, unknown>> | null> {
const sqlite = await importSqlite();
if (!sqlite) return null;
const clauses: string[] = [];
for (const host of hosts) {
for (const candidate of expandHosts(host)) {
const esc = candidate.replaceAll("'", "''");
clauses.push(`host_key = '${esc}'`);
clauses.push(`host_key = '.${esc}'`);
clauses.push(`host_key LIKE '%.${esc}'`);
}
}
const where = clauses.join(" OR ");
const opts: Record<string, unknown> = { readOnly: true };
if (supportsReadBigInts()) opts.readBigInts = true;
const db = new sqlite.DatabaseSync(dbPath, opts);
try {
return db
.prepare(
`SELECT name, value, host_key, encrypted_value FROM cookies WHERE (${where}) ORDER BY expires_utc DESC`,
)
.all() as Array<Record<string, unknown>>;
} catch {
return null;
} finally {
db.close();
}
}
function expandHosts(host: string): string[] {
const parts = host.split(".").filter(Boolean);
if (parts.length <= 1) return [host];
const candidates = new Set<string>();
candidates.add(host);
for (let i = 1; i <= parts.length - 2; i++) {
const c = parts.slice(i).join(".");
if (c) candidates.add(c);
}
return Array.from(candidates);
}
function copySidecar(srcDb: string, targetDb: string, suffix: string): void {
const sidecar = `${srcDb}${suffix}`;
if (!existsSync(sidecar)) return;
try {
copyFileSync(sidecar, `${targetDb}${suffix}`);
} catch {}
}