'use strict' const express = require('express') const cors = require('cors') const app = express() app.use(cors()) app.use(express.json()) const GITEA_BASE = 'https://git.falahos.my/api/v1' const GITEA_ORG = 'falahos' const GITEA_TOKEN = process.env.GITEA_TOKEN || '' const PORT = process.env.PORT || 3021 const CACHE_TTL = 5 * 60 * 1000 let _cache = null let _cacheTs = 0 function giteaHeaders() { const h = { Accept: 'application/json' } if (GITEA_TOKEN) h['Authorization'] = `token ${GITEA_TOKEN}` return h } // Minimal flat YAML parser — covers the app.yml format only function parseAppYml(text) { const result = {} for (const line of text.split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const colonIdx = trimmed.indexOf(':') if (colonIdx === -1) continue const key = trimmed.slice(0, colonIdx).trim() const raw = trimmed.slice(colonIdx + 1).trim() if (raw === 'true') result[key] = true else if (raw === 'false') result[key] = false else if (raw !== '' && !isNaN(Number(raw))) result[key] = Number(raw) else result[key] = raw.replace(/^["']|["']$/g, '') } return result } async function fetchAllApps() { if (_cache && Date.now() - _cacheTs < CACHE_TTL) return _cache // Paginate org repos let page = 1 let repos = [] while (true) { const res = await fetch( `${GITEA_BASE}/orgs/${GITEA_ORG}/repos?limit=50&page=${page}`, { headers: giteaHeaders() } ) if (!res.ok) break const batch = await res.json() if (!Array.isArray(batch) || !batch.length) break repos = repos.concat(batch) if (batch.length < 50) break page++ } // Read app.yml from each repo concurrently const settled = await Promise.allSettled( repos.map(async (repo) => { const res = await fetch( `${GITEA_BASE}/repos/${GITEA_ORG}/${repo.name}/contents/app.yml`, { headers: giteaHeaders() } ) if (!res.ok) return null const data = await res.json() if (!data.content) return null const yaml = Buffer.from(data.content, 'base64').toString('utf-8') const m = parseAppYml(yaml) if (!m.id || !m.name) return null return { id: String(m.id), name: String(m.name), icon: String(m.icon || '📦'), iconClass: `icon-${m.id}`, tagline: String(m.tagline || ''), category: String(m.category || 'Other'), ramzVerified: m.ramz_verified === true, installed: false, featured: m.featured === true, version: String(m.version || '1.0.0'), port: m.port ? Number(m.port) : null, image: m.image ? String(m.image) : null, author: String(m.author || ''), license: String(m.license || ''), minCeVersion: String(m.min_ce_version || '1.0.0'), repoUrl: repo.html_url, updatedAt: repo.updated_at, } }) ) const apps = settled .filter(r => r.status === 'fulfilled' && r.value !== null) .map(r => r.value) _cache = apps _cacheTs = Date.now() return apps } app.get('/health', (_req, res) => res.json({ ok: true })) app.get('/api/apps', async (req, res) => { try { const apps = await fetchAllApps() const { category, search, featured } = req.query let filtered = apps if (category && category !== 'All') filtered = filtered.filter(a => a.category === category) if (search) { const q = String(search).toLowerCase() filtered = filtered.filter(a => a.name.toLowerCase().includes(q) || a.tagline.toLowerCase().includes(q) ) } if (featured === 'true') filtered = filtered.filter(a => a.featured) res.json({ apps: filtered, total: filtered.length, cachedAt: _cacheTs }) } catch (err) { console.error('[istore] fetchAllApps error:', err) res.status(502).json({ error: 'Failed to reach Gitea', detail: err.message }) } }) app.get('/api/apps/:id', async (req, res) => { try { const apps = await fetchAllApps() const found = apps.find(a => a.id === req.params.id) if (!found) return res.status(404).json({ error: 'App not found' }) res.json(found) } catch (err) { res.status(502).json({ error: err.message }) } }) // Force cache refresh app.post('/api/cache/clear', (_req, res) => { _cache = null _cacheTs = 0 res.json({ ok: true }) }) app.listen(PORT, () => console.log(`[istore] running → http://localhost:${PORT} | org: ${GITEA_ORG} @ ${GITEA_BASE}`) )