fix: resolve CORS issues in 39dbc46 by moving verification to build-time

- Replace runtime Flathub API calls with static JSON generation
- Add prebuild and predev scripts to specificially fetch verification data
- Refactor useVerification hook to be synchronous
- Fixes Flathub API CORS errors in production
This commit is contained in:
N1C4T
2026-01-25 21:13:03 +04:00
parent 39dbc468cc
commit 87267b06ab
6 changed files with 470 additions and 215 deletions

View File

@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/fetch-verified-flatpaks.mjs",
"predev": "node scripts/fetch-verified-flatpaks.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",

View File

@@ -0,0 +1,102 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Build-time fetcher for Flathub verified apps
// Baking this into JSON avoids CORS headaches and runtime latency
const CONFIG = {
API_ENDPOINT: 'https://flathub.org/api/v2/search',
OUTPUT_FILE: '../src/lib/verified-flatpaks.json',
HITS_PER_PAGE: 250, // Flathub's max page size
MAX_RETRIES: 3,
TIMEOUT_MS: 15_000,
};
// resolvePath helper because ESM __dirname is awkward
const resolvePath = (rel) => path.join(path.dirname(fileURLToPath(import.meta.url)), rel);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Robust fetch wrapper - APIs are flaky, we are resilient
async function fetchWithRetry(url, options, retries = CONFIG.MAX_RETRIES) {
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), CONFIG.TIMEOUT_MS);
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(id);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (retries > 0) {
const delay = 1000 * (CONFIG.MAX_RETRIES - retries + 1);
console.warn(`Request failed (${error.message}). Retrying in ${delay}ms...`);
await sleep(delay);
return fetchWithRetry(url, options, retries - 1);
}
throw error;
}
}
async function fetchPage(page) {
const data = await fetchWithRetry(CONFIG.API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '',
filter: 'verification_verified=true',
page,
hitsPerPage: CONFIG.HITS_PER_PAGE,
}),
});
return {
ids: (data.hits || []).filter(h => h.verification_verified && h.app_id).map(h => h.app_id),
totalPages: data.totalPages || 0
};
}
async function main() {
console.log(`\n📦 Syncing Flathub verified apps...`);
const startTime = Date.now();
try {
// Grab page 1 to see what we're dealing with
const firstPage = await fetchPage(1);
const allApps = new Set(firstPage.ids);
// Cap at 50 pages because if there are >12,000 verified apps, we have bigger problems
const totalPages = Math.min(firstPage.totalPages, 50);
if (totalPages > 1) {
// Blast the remaining pages in parallel
// console.log(`Fetching ${totalPages - 1} more pages...`);
const pages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2);
const results = await Promise.all(
pages.map(p => fetchPage(p).then(r => r.ids).catch(() => []))
);
results.flat().forEach(id => allApps.add(id));
}
// Sort for deterministic output (git diffs will thank us)
const apps = Array.from(allApps).sort();
const outputPath = resolvePath(CONFIG.OUTPUT_FILE);
await fs.writeFile(outputPath, JSON.stringify({
meta: { fetchedAt: new Date().toISOString() },
count: apps.length,
apps
}, null, 2));
const time = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`✅ Cached ${apps.length} verified apps in ${time}s`);
} catch (e) {
console.error(`💥 Failed to sync verified apps:`, e);
process.exit(1);
}
}
main();

View File

@@ -1,78 +1,49 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useCallback } from 'react';
import type { DistroId } from '@/lib/data';
import {
fetchFlathubVerifiedApps,
isFlathubVerified,
isSnapVerified,
} from '@/lib/verification';
export interface UseVerificationResult {
// Kept for compatibility, always false now
isLoading: boolean;
hasError: boolean;
isVerified: (distro: DistroId, packageName: string) => boolean;
getVerificationSource: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null;
}
// Fetches Flathub data on mount, Snap uses static list (instant)
// Now purely synchronous using build-time generated data
export function useVerification(): UseVerificationResult {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [flathubReady, setFlathubReady] = useState(false);
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
let isMounted = true;
fetchFlathubVerifiedApps()
.then(() => {
if (isMounted) setFlathubReady(true);
})
.catch((error) => {
if (isMounted) {
console.error('Failed to fetch Flathub verification:', error);
setHasError(true);
}
})
.finally(() => {
if (isMounted) setIsLoading(false);
});
return () => {
isMounted = false;
};
}, []);
// Check if package is verified for the distro
const isVerified = useCallback((distro: DistroId, packageName: string): boolean => {
if (distro === 'flatpak' && flathubReady) {
if (distro === 'flatpak') {
return isFlathubVerified(packageName);
}
if (distro === 'snap') {
return isSnapVerified(packageName);
}
return false;
}, [flathubReady]);
}, []);
// Get verification source for badge styling
const getVerificationSource = useCallback((distro: DistroId, packageName: string): 'flathub' | 'snap' | null => {
if (distro === 'flatpak' && flathubReady && isFlathubVerified(packageName)) {
if (distro === 'flatpak' && isFlathubVerified(packageName)) {
return 'flathub';
}
if (distro === 'snap' && isSnapVerified(packageName)) {
return 'snap';
}
return null;
}, [flathubReady]);
}, []);
return {
isLoading,
hasError,
isLoading: false,
hasError: false,
isVerified,
getVerificationSource,
};
}

View File

@@ -1,188 +1,21 @@
// Flatpak/Snap verification status - shows badges for verified publishers
// Data is fetched at build time to avoid CORS issues with Flathub API
// Flathub API response shape
interface FlathubSearchResponse {
hits: Array<{
app_id: string;
verification_verified: boolean;
}>;
totalPages: number;
totalHits: number;
}
import verifiedFlatpaks from './verified-flatpaks.json';
import verifiedSnaps from './verified-snaps.json';
// Module-level cache
let flathubVerifiedCache: Set<string> | null = null;
// localStorage cache key and TTL (1 hour)
const CACHE_KEY = 'tuxmate_verified_flatpaks';
const CACHE_TTL_MS = 60 * 60 * 1000;
// Known verified Snap publishers (static list - Snapcraft API doesn't support CORS)
const KNOWN_VERIFIED_SNAP_PACKAGES = new Set([
// Mozilla
'firefox', 'thunderbird',
// Canonical/Ubuntu
'chromium',
// Brave
'brave',
// Spotify
'spotify',
// Microsoft
'code',
// JetBrains
'intellij-idea-community', 'intellij-idea-ultimate', 'pycharm-community', 'pycharm-professional',
// Slack
'slack',
// Discord
'discord',
// Signal
'signal-desktop',
// Telegram
'telegram-desktop',
// Zoom
'zoom-client',
// Obsidian
'obsidian',
// Bitwarden
'bitwarden',
// Creative
'blender', 'gimp', 'inkscape', 'krita',
// Media
'vlc', 'obs-studio',
// Office
'libreoffice',
// Dev
'node', 'go', 'rustup', 'ruby', 'cmake', 'docker', 'kubectl',
// Gaming
'steam', 'retroarch',
// Browser
'vivaldi',
]);
// Try to load from localStorage cache
function loadFromCache(): Set<string> | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_TTL_MS) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return new Set(data);
} catch {
return null;
}
}
// Save to localStorage cache
function saveToCache(apps: Set<string>): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
data: Array.from(apps),
timestamp: Date.now(),
}));
} catch {
// localStorage might be full or disabled
}
}
// Fetch a single page
async function fetchPage(page: number): Promise<string[]> {
const response = await fetch('https://flathub.org/api/v2/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '',
filter: 'verification_verified=true',
page,
hitsPerPage: 250,
}),
signal: AbortSignal.timeout(15000),
});
if (!response.ok) return [];
const data: FlathubSearchResponse = await response.json();
return data.hits
.filter(h => h.verification_verified && h.app_id)
.map(h => h.app_id);
}
// Fetch all verified Flatpak app IDs (parallel + cached)
export async function fetchFlathubVerifiedApps(): Promise<Set<string>> {
// Return memory cache if available
if (flathubVerifiedCache !== null) {
return flathubVerifiedCache;
}
// Try localStorage cache
const cached = loadFromCache();
if (cached) {
flathubVerifiedCache = cached;
return cached;
}
// Fetch page 1 to get totalPages
try {
const firstResponse = await fetch('https://flathub.org/api/v2/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '',
filter: 'verification_verified=true',
page: 1,
hitsPerPage: 250,
}),
signal: AbortSignal.timeout(15000),
});
if (!firstResponse.ok) {
console.warn('Flathub API returned', firstResponse.status);
flathubVerifiedCache = new Set();
return flathubVerifiedCache;
}
const firstData: FlathubSearchResponse = await firstResponse.json();
const verifiedApps = new Set<string>(
firstData.hits.filter(h => h.verification_verified && h.app_id).map(h => h.app_id)
);
// Fetch remaining pages in parallel (limit to 20 pages = 5,000 apps)
const totalPages = Math.min(firstData.totalPages, 20);
if (totalPages > 1) {
const pagePromises = [];
for (let p = 2; p <= totalPages; p++) {
pagePromises.push(fetchPage(p));
}
const results = await Promise.all(pagePromises);
for (const appIds of results) {
for (const id of appIds) {
verifiedApps.add(id);
}
}
}
flathubVerifiedCache = verifiedApps;
saveToCache(verifiedApps);
return verifiedApps;
} catch (error) {
console.warn('Failed to fetch Flathub verification data:', error);
flathubVerifiedCache = new Set();
return flathubVerifiedCache;
}
}
// Load static lists (fetched at build time)
const VERIFIED_FLATPAK_APPS = new Set(verifiedFlatpaks.apps);
const VERIFIED_SNAP_PACKAGES = new Set(verifiedSnaps.apps);
// Check if a Flatpak app ID is verified
export function isFlathubVerified(appId: string): boolean {
return flathubVerifiedCache?.has(appId) ?? false;
return VERIFIED_FLATPAK_APPS.has(appId);
}
// Check if a Snap package is from a verified publisher
export function isSnapVerified(snapName: string): boolean {
const cleanName = snapName.split(' ')[0];
return KNOWN_VERIFIED_SNAP_PACKAGES.has(cleanName);
return VERIFIED_SNAP_PACKAGES.has(cleanName);
}

View File

@@ -0,0 +1,309 @@
{
"fetchedAt": "2026-01-25T17:01:05.599Z",
"count": 303,
"apps": [
"app.drey.Warp",
"app.organicmaps.desktop",
"app.polychromatic.controller",
"app.twintaillauncher.ttl",
"app.xemu.xemu",
"app.zen_browser.zen",
"be.alexandervanhee.gradia",
"best.ellie.StartupConfiguration",
"ca.parallel_launcher.ParallelLauncher",
"ch.tlaun.TL",
"com.actualbudget.actual",
"com.adamcake.Bolt",
"com.atlauncher.ATLauncher",
"com.bambulab.BambuStudio",
"com.bitwarden.desktop",
"com.bitwig.BitwigStudio",
"com.brave.Browser",
"com.collaboraoffice.Office",
"com.dec05eba.gpu_screen_recorder",
"com.discordapp.Discord",
"com.freerdp.FreeRDP",
"com.geeks3d.furmark",
"com.github.IsmaelMartinez.teams_for_linux",
"com.github.KRTirtho.Spotube",
"com.github.Matoking.protontricks",
"com.github.PintaProject.Pinta",
"com.github.Rosalie241.RMG",
"com.github.d4nj1.tlpui",
"com.github.dail8859.NotepadNext",
"com.github.finefindus.eyedropper",
"com.github.flxzt.rnote",
"com.github.hluk.copyq",
"com.github.iwalton3.jellyfin-media-player",
"com.github.jeromerobert.pdfarranger",
"com.github.jkotra.eovpn",
"com.github.johnfactotum.Foliate",
"com.github.joseexposito.touche",
"com.github.louis77.tuner",
"com.github.maoschanz.drawing",
"com.github.mtkennerly.ludusavi",
"com.github.neithern.g4music",
"com.github.phase1geo.minder",
"com.github.qarmin.czkawka",
"com.github.rafostar.Clapper",
"com.github.sdv43.whaler",
"com.github.taiko2k.tauonmb",
"com.github.tchx84.Flatseal",
"com.github.tenderowl.frog",
"com.github.unrud.VideoDownloader",
"com.github.wwmm.easyeffects",
"com.github.wwmm.pulseeffects",
"com.github.xournalpp.xournalpp",
"com.github.zocker_160.SyncThingy",
"com.github.ztefn.haguichi",
"com.heroicgameslauncher.hgl",
"com.jeffser.Alpaca",
"com.ktechpit.orion",
"com.ktechpit.ultimate-media-downloader",
"com.ktechpit.whatsie",
"com.logseq.Logseq",
"com.lunarclient.LunarClient",
"com.markopejic.downloader",
"com.mastermindzh.tidal-hifi",
"com.mattjakeman.ExtensionManager",
"com.ml4w.dotfilesinstaller",
"com.modrinth.ModrinthApp",
"com.moonlight_stream.Moonlight",
"com.notesnook.Notesnook",
"com.obsproject.Studio",
"com.play0ad.zeroad",
"com.plexamp.Plexamp",
"com.pokemmo.PokeMMO",
"com.prusa3d.PrusaSlicer",
"com.rafaelmardojai.Blanket",
"com.ranfdev.DistroShelf",
"com.rcloneui.RcloneUI",
"com.rtosta.zapzap",
"com.rustdesk.RustDesk",
"com.stremio.Stremio",
"com.surfshark.Surfshark",
"com.thincast.client",
"com.tomjwatson.Emote",
"com.usebottles.bottles",
"com.usebruno.Bruno",
"com.valvesoftware.SteamLink",
"com.vixalien.sticky",
"com.vscodium.codium",
"com.vysp3r.ProtonPlus",
"com.warlordsoftwares.media-downloader",
"com.warlordsoftwares.tube2go",
"com.warlordsoftwares.youtube-downloader-4ktube",
"de.haeckerfelix.Fragments",
"de.haeckerfelix.Shortwave",
"dev.bragefuglseth.Keypunch",
"dev.deedles.Trayscale",
"dev.edfloreshz.CosmicTweaks",
"dev.fredol.open-tv",
"dev.goats.xivlauncher",
"dev.lizardbyte.app.Sunshine",
"dev.qwery.AddWater",
"dev.vencord.Vesktop",
"eu.betterbird.Betterbird",
"fr.handbrake.ghb",
"hu.irl.cameractrls",
"info.cemu.Cemu",
"info.febvre.Komikku",
"info.smplayer.SMPlayer",
"io.bassi.Amberol",
"io.freetubeapp.FreeTube",
"io.gdevelop.ide",
"io.github.Faugus.faugus-launcher",
"io.github.Foldex.AdwSteamGtk",
"io.github.IshuSinghSE.aurynk",
"io.github.JakubMelka.Pdf4qt",
"io.github.Soundux",
"io.github.aandrew_me.ytdn",
"io.github.alainm23.planify",
"io.github.amit9838.mousam",
"io.github.antimicrox.antimicrox",
"io.github.arunsivaramanneo.GPUViewer",
"io.github.astralvixen.geforce-infinity",
"io.github.benjamimgois.goverlay",
"io.github.celluloid_player.Celluloid",
"io.github.cosmic_utils.camera",
"io.github.debasish_patra_1987.linuxthemestore",
"io.github.dvlv.boxbuddyrs",
"io.github.fastrizwaan.WineCharm",
"io.github.fastrizwaan.WineZGUI",
"io.github.flattool.Ignition",
"io.github.flattool.Warehouse",
"io.github.getnf.embellish",
"io.github.giantpinkrobots.bootqt",
"io.github.giantpinkrobots.flatsweep",
"io.github.giantpinkrobots.varia",
"io.github.ilya_zlobintsev.LACT",
"io.github.jeffshee.Hidamari",
"io.github.jonmagon.kdiskmark",
"io.github.kolunmi.Bazaar",
"io.github.linx_systems.ClamUI",
"io.github.mhogomchungu.media-downloader",
"io.github.nozwock.Packet",
"io.github.peazip.PeaZip",
"io.github.prateekmedia.appimagepool",
"io.github.qwersyk.Newelle",
"io.github.radiolamp.mangojuice",
"io.github.realmazharhussain.GdmSettings",
"io.github.revisto.drum-machine",
"io.github.seadve.Kooha",
"io.github.shiiion.primehack",
"io.github.shonebinu.Brief",
"io.github.streetpea.Chiaki4deck",
"io.github.thetumultuousunicornofdarkness.cpu-x",
"io.github.tobagin.karere",
"io.github.ungoogled_software.ungoogled_chromium",
"io.github.unknownskl.greenlight",
"io.github.wiiznokes.fan-control",
"io.github.wivrn.wivrn",
"io.github.zarestia_dev.rclone-manager",
"io.gitlab.adhami3310.Converter",
"io.gitlab.adhami3310.Impression",
"io.gitlab.librewolf-community",
"io.gitlab.news_flash.NewsFlash",
"io.gitlab.theevilskeleton.Upscaler",
"io.missioncenter.MissionCenter",
"io.podman_desktop.PodmanDesktop",
"it.mijorus.gearlever",
"jp.nonbili.noutube",
"md.obsidian.Obsidian",
"me.iepure.devtoolbox",
"me.timschneeberger.jdsp4linux",
"moe.launcher.an-anime-game-launcher",
"net.codelogistics.webapps",
"net.davidotek.pupgui2",
"net.kuribo64.melonDS",
"net.lutris.Lutris",
"net.mkiol.SpeechNote",
"net.nokyan.Resources",
"net.nymtech.NymVPN",
"net.pcsx2.PCSX2",
"net.retrodeck.retrodeck",
"net.shadps4.shadPS4",
"net.supertuxkart.SuperTuxKart",
"net.waterfox.waterfox",
"network.loki.Session",
"one.ablaze.floorp",
"org.azahar_emu.Azahar",
"org.bunkus.mkvtoolnix-gui",
"org.cryptomator.Cryptomator",
"org.deskflow.deskflow",
"org.dupot.easyflatpak",
"org.equicord.equibop",
"org.fcitx.Fcitx5",
"org.fedoraproject.MediaWriter",
"org.fkoehler.KTailctl",
"org.flameshot.Flameshot",
"org.freac.freac",
"org.freecad.FreeCAD",
"org.freedownloadmanager.Manager",
"org.gimp.GIMP",
"org.gnome.Boxes",
"org.gnome.Calculator",
"org.gnome.Calendar",
"org.gnome.Characters",
"org.gnome.Connections",
"org.gnome.Contacts",
"org.gnome.Decibels",
"org.gnome.DejaDup",
"org.gnome.Epiphany",
"org.gnome.Evince",
"org.gnome.Evolution",
"org.gnome.Extensions",
"org.gnome.Firmware",
"org.gnome.Logs",
"org.gnome.Loupe",
"org.gnome.Mahjongg",
"org.gnome.Maps",
"org.gnome.Mines",
"org.gnome.Papers",
"org.gnome.Shotwell",
"org.gnome.Showtime",
"org.gnome.SimpleScan",
"org.gnome.Snapshot",
"org.gnome.TextEditor",
"org.gnome.Weather",
"org.gnome.World.PikaBackup",
"org.gnome.World.Secrets",
"org.gnome.baobab",
"org.gnome.clocks",
"org.gnome.font-viewer",
"org.gnome.gitlab.YaLTeR.VideoTrimmer",
"org.gnome.gitlab.somas.Apostrophe",
"org.inkscape.Inkscape",
"org.jellyfin.JellyfinDesktop",
"org.jellyfin.JellyfinServer",
"org.kde.ark",
"org.kde.audiotube",
"org.kde.digikam",
"org.kde.dolphin",
"org.kde.elisa",
"org.kde.falkon",
"org.kde.filelight",
"org.kde.gcompris",
"org.kde.gwenview",
"org.kde.haruna",
"org.kde.isoimagewriter",
"org.kde.kamoso",
"org.kde.kate",
"org.kde.kcalc",
"org.kde.kclock",
"org.kde.kdenlive",
"org.kde.kolourpaint",
"org.kde.konsole",
"org.kde.kpat",
"org.kde.krita",
"org.kde.ktorrent",
"org.kde.kweather",
"org.kde.minuet",
"org.kde.okular",
"org.keepassxc.KeePassXC",
"org.kicad.KiCad",
"org.learningequality.Kolibri",
"org.librecad.librecad",
"org.libreoffice.LibreOffice",
"org.libretro.RetroArch",
"org.linux_hardware.hw-probe",
"org.localsend.localsend_app",
"org.luanti.luanti",
"org.mixxx.Mixxx",
"org.mozilla.Thunderbird",
"org.mozilla.firefox",
"org.mozilla.vpn",
"org.nickvision.tubeconverter",
"org.nicotine_plus.Nicotine",
"org.onlyoffice.desktopeditors",
"org.openrgb.OpenRGB",
"org.ppsspp.PPSSPP",
"org.prismlauncher.PrismLauncher",
"org.pulseaudio.pavucontrol",
"org.qbittorrent.qBittorrent",
"org.remmina.Remmina",
"org.scummvm.ScummVM",
"org.shotcut.Shotcut",
"org.srb2.SRB2",
"org.stellarium.Stellarium",
"org.strawberrymusicplayer.strawberry",
"org.telegram.desktop",
"org.tenacityaudio.Tenacity",
"org.torproject.torbrowser-launcher",
"org.turbowarp.TurboWarp",
"org.upscayl.Upscayl",
"org.vinegarhq.Sober",
"org.vinegarhq.Vinegar",
"org.x.Warpinator",
"page.codeberg.libre_menu_editor.LibreMenuEditor",
"page.kramo.Cartridges",
"page.tesk.Refine",
"rocks.shy.VacuumTube",
"ru.linux_gaming.PortProton",
"ru.yandex.Browser",
"tv.kodi.Kodi",
"tv.plex.PlexDesktop",
"xyz.ketok.Speedtest",
"xyz.z3ntu.razergenie"
]
}

View File

@@ -0,0 +1,38 @@
{
"apps": [
"firefox",
"thunderbird",
"chromium",
"brave",
"spotify",
"code",
"intellij-idea-community",
"intellij-idea-ultimate",
"pycharm-community",
"pycharm-professional",
"slack",
"discord",
"signal-desktop",
"telegram-desktop",
"zoom-client",
"obsidian",
"bitwarden",
"blender",
"gimp",
"inkscape",
"krita",
"vlc",
"obs-studio",
"libreoffice",
"node",
"go",
"rustup",
"ruby",
"cmake",
"docker",
"kubectl",
"steam",
"retroarch",
"vivaldi"
]
}