From 3aca736a13f7c7472b626e58b43c9e3ccacd180f Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 20 Mar 2026 16:03:49 -0600 Subject: [PATCH 1/3] fix license mismatch, resolve all biome warnings, rewrite cache-purge script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json license field: MIT → AGPL-3.0-only to match LICENSE file - add aria-hidden to decorative SVGs (grid-layout-switcher, site-footer, apps filter) - add role="presentation" to click interceptor divs (alternative-card, apps filter backdrop) - add biome-ignore for intentional dangerouslySetInnerHTML (html-description) - remove stale biome-ignore comments (apps/index, alternatives/index) - remove unused loaderData param (search route head) - rewrite cache-purge to use wrangler CLI with targeted key deletion --- package.json | 2 +- scripts/cache-purge.ts | 133 +++++++++--------------- src/components/alternative-card.tsx | 2 + src/components/grid-layout-switcher.tsx | 28 ++++- src/components/html-description.tsx | 1 + src/components/layout/site-footer.tsx | 14 ++- src/routes/alternatives/index.tsx | 1 - src/routes/apps/index.tsx | 10 +- src/routes/search.tsx | 2 +- 9 files changed, 99 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index 4dd9408..0e68ac5 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "keywords": [], "author": "", - "license": "MIT", + "license": "AGPL-3.0-only", "packageManager": "pnpm@10.29.2", "dependencies": { "@base-ui/react": "^1.2.0", diff --git a/scripts/cache-purge.ts b/scripts/cache-purge.ts index cf7f672..d75a490 100644 --- a/scripts/cache-purge.ts +++ b/scripts/cache-purge.ts @@ -1,94 +1,63 @@ -import "dotenv/config"; - -const zoneId = process.env.CF_ZONE_ID; -const apiToken = process.env.CF_API_TOKEN; -const kvNamespaceId = process.env.CF_KV_NAMESPACE_ID; - -if (!zoneId || !apiToken) { - console.error("Missing CF_ZONE_ID or CF_API_TOKEN environment variables"); - process.exit(1); +import { execSync } from "node:child_process"; +import { writeFileSync, readFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const KV_NAMESPACE_ID = "47a12e13810f43f0b110ac082b60e133"; + +const args = process.argv.slice(2); +const purgeAll = args.includes("--all"); +const keyArgs = args.filter((a) => !a.startsWith("--")); + +if (!purgeAll && keyArgs.length === 0) { + console.log("Usage:"); + console.log(" pnpm cache:purge [key...] Purge specific KV keys"); + console.log(" pnpm cache:purge --all Purge all KV keys"); + console.log(""); + console.log("Examples:"); + console.log(" pnpm cache:purge getPopularApps getRecentApps"); + console.log(" pnpm cache:purge --all"); + process.exit(0); } -// Purge Cloudflare edge cache -const cacheResponse = await fetch( - `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, - { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ purge_everything: true }), - }, -); - -const cacheResult = await cacheResponse.json(); - -if (cacheResult.success) { - console.log("Edge cache purged"); -} else { - console.error("Edge cache purge failed:", cacheResult.errors); - process.exit(1); -} - -// Purge KV namespace -if (!kvNamespaceId) { - console.log("No CF_KV_NAMESPACE_ID set, skipping KV purge"); -} else { - const accountId = process.env.CF_ACCOUNT_ID; - if (!accountId) { - console.error("Missing CF_ACCOUNT_ID for KV purge"); - process.exit(1); - } - - // List all keys - let cursor: string | undefined; - const allKeys: string[] = []; - - do { - const params = new URLSearchParams(); - if (cursor) params.set("cursor", cursor); - - const listRes = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/keys?${params}`, - { headers: { Authorization: `Bearer ${apiToken}` } }, +if (keyArgs.length > 0) { + for (const key of keyArgs) { + console.log(`Deleting key: ${key}`); + execSync( + `npx wrangler kv key delete "${key}" --namespace-id=${KV_NAMESPACE_ID} --remote`, + { stdio: "inherit" }, ); + } + console.log(`Purged ${keyArgs.length} key(s)`); +} else { + const listFile = join(tmpdir(), "kv-keys-list.json"); + const deleteFile = join(tmpdir(), "kv-keys-to-delete.json"); - const listData: any = await listRes.json(); - if (!listData.success) { - console.error("KV list failed:", listData.errors); - process.exit(1); - } + console.log("Listing remote KV keys..."); + execSync( + `npx wrangler kv key list --namespace-id=${KV_NAMESPACE_ID} --remote > "${listFile}"`, + { stdio: ["pipe", "pipe", "pipe"] }, + ); - allKeys.push(...listData.result.map((k: { name: string }) => k.name)); - cursor = listData.result_info?.cursor; - } while (cursor); + const keys: { name: string }[] = JSON.parse( + readFileSync(listFile, "utf-8"), + ); + unlinkSync(listFile); - if (allKeys.length === 0) { + if (keys.length === 0) { console.log("KV namespace empty, nothing to purge"); } else { - // Bulk delete (max 10,000 per request) - for (let i = 0; i < allKeys.length; i += 10000) { - const batch = allKeys.slice(i, i + 10000); - const delRes = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/bulk`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(batch), - }, + writeFileSync(deleteFile, JSON.stringify(keys.map((k) => k.name))); + try { + execSync( + `npx wrangler kv bulk delete "${deleteFile}" --namespace-id=${KV_NAMESPACE_ID} --remote --force`, + { stdio: "inherit" }, ); - - const delData: any = await delRes.json(); - if (!delData.success) { - console.error("KV bulk delete failed:", delData.errors); - process.exit(1); - } + console.log(`KV purged: ${keys.length} keys deleted`); + } finally { + try { + unlinkSync(deleteFile); + } catch {} } - - console.log(`KV purged: ${allKeys.length} keys deleted`); } } diff --git a/src/components/alternative-card.tsx b/src/components/alternative-card.tsx index 5b9154e..37dd12e 100644 --- a/src/components/alternative-card.tsx +++ b/src/components/alternative-card.tsx @@ -103,8 +103,10 @@ export function AlternativeCard({ {app.sources && app.sources.length > 0 && ( + // biome-ignore lint/a11y/noStaticElementInteractions: prevents card link click-through
e.preventDefault()} onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); diff --git a/src/components/grid-layout-switcher.tsx b/src/components/grid-layout-switcher.tsx index 968d442..623e37f 100644 --- a/src/components/grid-layout-switcher.tsx +++ b/src/components/grid-layout-switcher.tsx @@ -7,7 +7,12 @@ const layouts: { value: GridLayout; label: string; icon: React.ReactNode }[] = [ value: "list", label: "List", icon: ( - +
setOpen(false)} /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss overlay */} +
setOpen(false)} + />
{tags.map((tag) => { const isActive = selectedSlugs.includes(tag.slug); diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 6d04ba4..3fb09dd 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -16,7 +16,7 @@ export const Route = createFileRoute("/search")({ if (!deps.q.trim()) return { apps: [], proprietaryApps: [] }; return fetchSearchResults({ data: { query: deps.q } }); }, - head: ({ loaderData }) => { + head: () => { const title = "Search — Unclouded"; const description = "Search for open source, privacy-respecting apps and alternatives."; From f3e8678ba14d90ae542e4182eb9801ce9f46ad58 Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 20 Mar 2026 16:14:54 -0600 Subject: [PATCH 2/3] fix broken Obtainium deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obtainium's App.fromJson hard-casts id, url, author, and name as non-nullable Strings. Our stored configs were missing id entirely, omitting author when empty, and including overrideSource: null — all of which crash the Dart type cast. Always include all required fields with defaults in both the seed parser and at link generation time (for existing DB data). --- db/seed/parsers/obtainium.ts | 15 ++++++++------- src/lib/obtainium.ts | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/db/seed/parsers/obtainium.ts b/db/seed/parsers/obtainium.ts index a060ef9..d7cbcd5 100644 --- a/db/seed/parsers/obtainium.ts +++ b/db/seed/parsers/obtainium.ts @@ -86,18 +86,19 @@ export function parseObtainiumConfigs(cacheDir: string): ParsedApp[] { const sourceType = resolveSourceType(primary.url); // Build the full Obtainium config object that can be used to generate - // a working obtainium://app/ deep link + // a working obtainium://app/ deep link. + // Obtainium's App.fromJson casts id, url, author, name as String + // (non-nullable), so all four must always be present. const obtainiumConfig: Record = { + id: primary.id, url: primary.url, + author: primary.author ?? "", name: primary.name, + preferredApkIndex: primary.preferredApkIndex ?? 0, + additionalSettings: primary.additionalSettings ?? "{}", }; - if (primary.author) obtainiumConfig.author = primary.author; - if (primary.additionalSettings) - obtainiumConfig.additionalSettings = primary.additionalSettings; - if (primary.overrideSource !== undefined) + if (primary.overrideSource != null) obtainiumConfig.overrideSource = primary.overrideSource; - if (primary.preferredApkIndex !== undefined) - obtainiumConfig.preferredApkIndex = primary.preferredApkIndex; const source: ParsedAppSource = { source: sourceType, diff --git a/src/lib/obtainium.ts b/src/lib/obtainium.ts index 0c1de83..c212671 100644 --- a/src/lib/obtainium.ts +++ b/src/lib/obtainium.ts @@ -45,7 +45,22 @@ export function generateObtainiumLink(source: SourceInfo): string | null { // If we have a full Obtainium config, use it for a proper deep link if (meta?.obtainiumConfig) { - return `obtainium://app/${encodeURIComponent(JSON.stringify(meta.obtainiumConfig))}`; + const raw = meta.obtainiumConfig; + // Obtainium's App.fromJson requires id, url, author, name as non-null + // Strings, and additionalSettings as a JSON string. Ensure defaults + // for any configs stored before these were included. + const config: Record = { + id: raw.id ?? source.packageName ?? "", + url: raw.url ?? source.url, + author: raw.author ?? "", + name: raw.name ?? "", + preferredApkIndex: raw.preferredApkIndex ?? 0, + additionalSettings: raw.additionalSettings ?? "{}", + }; + if (raw.overrideSource != null) { + config.overrideSource = raw.overrideSource; + } + return `obtainium://app/${encodeURIComponent(JSON.stringify(config))}`; } // Otherwise generate a simple add link based on source type From 8eaab572cec149a9b6310c2dea09a8fe363bd6fe Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Fri, 20 Mar 2026 16:17:10 -0600 Subject: [PATCH 3/3] fix lint errors in cache-purge script --- scripts/cache-purge.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/cache-purge.ts b/scripts/cache-purge.ts index d75a490..e86bec4 100644 --- a/scripts/cache-purge.ts +++ b/scripts/cache-purge.ts @@ -1,5 +1,5 @@ import { execSync } from "node:child_process"; -import { writeFileSync, readFileSync, unlinkSync } from "node:fs"; +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -39,9 +39,7 @@ if (keyArgs.length > 0) { { stdio: ["pipe", "pipe", "pipe"] }, ); - const keys: { name: string }[] = JSON.parse( - readFileSync(listFile, "utf-8"), - ); + const keys: { name: string }[] = JSON.parse(readFileSync(listFile, "utf-8")); unlinkSync(listFile); if (keys.length === 0) {