feat: UmbrelOS-style CE with iStore, Casdoor auth, and App Manager

- 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
This commit is contained in:
Antigravity AI
2026-07-06 10:05:37 +08:00
parent 8f1295f59d
commit 21b48d3d53
19 changed files with 1206 additions and 252 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.cjs ./
EXPOSE 3021
ENV NODE_ENV=production
CMD ["node", "server.cjs"]
+26
View File
@@ -0,0 +1,26 @@
# Falah OS iStore — App Manifest
# Place this file as `app.yml` at the root of your Gitea repo under the `falahos` org.
# The iStore service reads this to list and describe your app.
# Required fields
id: my-app-id # Unique slug, kebab-case, no spaces (e.g. zakat-vault)
name: My App Name # Display name shown in iStore
version: 1.0.0 # Semver
tagline: One-line description of what this app does
category: Finance # Finance | Charity | Worship | Identity | Community | Contracts | Compliance | Dev Tools | Legal | Insurance | Payments | DeFi | Investment
# Shariah compliance
ramz_verified: true # true = RAMZ-screened, false = community-submitted (pending review)
# Docker
image: falahapps/my-app:1.0.0 # Docker Hub image (or registry URL)
port: 3030 # Container port this app listens on
# Optional display
icon: 📦 # Emoji icon shown in iStore and desktop
featured: false # true = shown in Featured section on iStore homepage
# Metadata
author: Falah Consultancy Limited
license: MIT
min_ce_version: 1.3.0 # Minimum Falah OS CE version required
+16
View File
@@ -0,0 +1,16 @@
{
"name": "falah-istore",
"version": "1.0.0",
"description": "Falah OS iStore — Gitea-backed app registry service",
"main": "server.cjs",
"scripts": {
"start": "node server.cjs"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2"
},
"engines": {
"node": ">=18"
}
}
+153
View File
@@ -0,0 +1,153 @@
'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}`)
)