feat(settings): add Podcast Index fallback source with credential storage
Podcast Index (api.podcastindex.org) ships as a disabled, key-less source and is only consulted as a fallback when primary search results are fewer than 3 — never on the hot path, never when disabled or credential-less. A failed fallback leaves primary results intact. Credentials are user-supplied: enabling the source pops a dialog that asks for the free key+secret, prefilled masked (first 3 chars + "...") when already stored; toggling off never clears them. Secrets prefer the macOS keychain (security CLI, encrypted at rest) with a plaintext config.json fallback when the keychain is unavailable; sources carry only a hasCredentials/credentialStorage marker, and legacy plaintext keys in existing configs are migrated on load. Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and Authorization = sha1(key + secret + date). Dead feeds are filtered, feed URLs are used directly, and episode-scope search is a no-op (no endpoint).
This commit is contained in:
100
src/utils/source-credentials.ts
Normal file
100
src/utils/source-credentials.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Credential storage for keyed podcast sources.
|
||||
*
|
||||
* Preferred storage is the macOS keychain (encrypted at rest by the OS),
|
||||
* written through the `security` CLI — no native dependencies. When the
|
||||
* keychain is unavailable (non-macOS, locked, sandboxed) credentials fall
|
||||
* back to plaintext on the source itself (config.json) so the source still
|
||||
* works; `credentialStorage` on the source records which backend was used.
|
||||
*
|
||||
* Credentials are never presented in full — the UI always masks them (first
|
||||
* 3 chars + "..."). The keychain password is passed as an argv value to
|
||||
* `add-generic-password` (standard practice for CLI-driven keychain writes;
|
||||
* the item lands in the login keychain immediately).
|
||||
*/
|
||||
|
||||
import type { PodcastSource } from "../types/source"
|
||||
|
||||
const KEYCHAIN_SERVICE = "podtui"
|
||||
const KEYCHAIN_ACCOUNT = "podcastindex"
|
||||
|
||||
export type Credentials = {
|
||||
apiKey: string
|
||||
apiSecret: string
|
||||
}
|
||||
|
||||
/** Run a `security` subcommand; resolves with exit status + stdout. */
|
||||
async function runSecurity(
|
||||
args: string[],
|
||||
): Promise<{ ok: boolean; stdout: string }> {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["security", ...args],
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
return { ok: exitCode === 0, stdout }
|
||||
} catch {
|
||||
return { ok: false, stdout: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Store Podcast Index credentials in the macOS keychain. True on success. */
|
||||
export async function savePodcastIndexCredentials(
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
): Promise<boolean> {
|
||||
const payload = JSON.stringify({ apiKey, apiSecret })
|
||||
const { ok } = await runSecurity([
|
||||
"add-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
payload,
|
||||
"-U",
|
||||
])
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Read Podcast Index credentials from the macOS keychain. Null when absent
|
||||
* or unreadable (non-macOS, item deleted, keychain locked). */
|
||||
export async function loadPodcastIndexCredentials(): Promise<Credentials | null> {
|
||||
const { ok, stdout } = await runSecurity([
|
||||
"find-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
])
|
||||
if (!ok) return null
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim()) as Credentials
|
||||
if (!parsed.apiKey || !parsed.apiSecret) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a source's stored credentials: its plaintext fields when saved
|
||||
* with the plaintext fallback, else the macOS keychain. Null when the
|
||||
* source has no usable credentials. */
|
||||
export async function resolveSourceCredentials(
|
||||
source: PodcastSource,
|
||||
): Promise<Credentials | null> {
|
||||
if (source.credentialStorage === "plaintext") {
|
||||
return source.apiKey && source.apiSecret
|
||||
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
|
||||
: null
|
||||
}
|
||||
return loadPodcastIndexCredentials()
|
||||
}
|
||||
Reference in New Issue
Block a user