21b48d3d53
- iStore service (port 3021): fetches app manifests from git.falahos.my/falahos org via Gitea API, 5-min cache, query/search support - UmmahID login: replaced stub form with Casdoor OAuth2 PKCE flow (RFC 7636), no client secret required; /api/auth/config and /api/auth/token endpoints added to server.cjs - App Manager service (port 3022): installs/uninstalls Docker apps via /var/run/docker.sock, pulls images, starts containers on falah-net, persists state to /data/installed.json - docker-compose.yml: orchestrates falah-app + istore + app-manager with health checks, named volumes, and shared falah-net network - install.sh: one-line curl installer — checks prereqs, clones repo, prompts for credentials, builds and starts all containers - .env.example: all env vars documented (CASDOOR_CLIENT_ID, GITEA_TOKEN, APP_MANAGER_PORT, etc.) - Frontend: IStore overlay now fetches live apps from Gitea, real install/uninstall via App Manager with 2s polling, indeterminate progress bar, Uninstall button for installed apps - app.yml.example: developer manifest spec for publishing to iStore
154 lines
4.4 KiB
JavaScript
154 lines
4.4 KiB
JavaScript
'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}`)
|
|
)
|