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:
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.cjs ./
|
||||
|
||||
RUN mkdir -p /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 3022
|
||||
ENV NODE_ENV=production
|
||||
ENV DATA_DIR=/data
|
||||
CMD ["node", "server.cjs"]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "falah-app-manager",
|
||||
"version": "1.0.0",
|
||||
"description": "Falah OS CE — App Manager (install/uninstall Docker apps)",
|
||||
"main": "server.cjs",
|
||||
"scripts": {
|
||||
"start": "node server.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^4.0.2",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict'
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const Docker = require('dockerode')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const app = express()
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
const PORT = process.env.PORT || 3022
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
|
||||
const ISTORE_URL = process.env.ISTORE_URL || 'http://istore:3021'
|
||||
const DOCKER_NETWORK = process.env.DOCKER_NETWORK || 'falah-net'
|
||||
const DB_FILE = path.join(DATA_DIR, 'installed.json')
|
||||
|
||||
// ── Persistence (JSON file) ───────────────────────────────────────────────────
|
||||
|
||||
function readDb() {
|
||||
try {
|
||||
if (!fs.existsSync(DB_FILE)) return {}
|
||||
return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeDb(data) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
function getApp(id) { return readDb()[id] || null }
|
||||
|
||||
function upsertApp(id, fields) {
|
||||
const db = readDb()
|
||||
db[id] = { ...(db[id] || {}), id, ...fields, updatedAt: new Date().toISOString() }
|
||||
writeDb(db)
|
||||
return db[id]
|
||||
}
|
||||
|
||||
function removeApp(id) {
|
||||
const db = readDb()
|
||||
delete db[id]
|
||||
writeDb(db)
|
||||
}
|
||||
|
||||
// ── Docker helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const CONTAINER_PREFIX = 'falah-app-'
|
||||
|
||||
function containerName(id) { return `${CONTAINER_PREFIX}${id}` }
|
||||
|
||||
async function pullImage(image) {
|
||||
return new Promise((resolve, reject) => {
|
||||
docker.pull(image, (err, stream) => {
|
||||
if (err) return reject(err)
|
||||
docker.modem.followProgress(stream, (err2) => {
|
||||
if (err2) reject(err2)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startContainer(id, image, port) {
|
||||
const name = containerName(id)
|
||||
|
||||
// Remove any existing stopped container with the same name
|
||||
try {
|
||||
const old = docker.getContainer(name)
|
||||
await old.remove({ force: true })
|
||||
} catch { /* not found — fine */ }
|
||||
|
||||
const portSpec = port ? { [`${port}/tcp`]: [{ HostPort: String(port) }] } : {}
|
||||
|
||||
const container = await docker.createContainer({
|
||||
name,
|
||||
Image: image,
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
HostConfig: {
|
||||
PortBindings: portSpec,
|
||||
NetworkMode: DOCKER_NETWORK,
|
||||
},
|
||||
ExposedPorts: port ? { [`${port}/tcp`]: {} } : {},
|
||||
Labels: { 'falah.managed': 'true', 'falah.app': id },
|
||||
})
|
||||
|
||||
await container.start()
|
||||
return container.id
|
||||
}
|
||||
|
||||
async function stopAndRemoveContainer(id) {
|
||||
const name = containerName(id)
|
||||
try {
|
||||
const container = docker.getContainer(name)
|
||||
const info = await container.inspect()
|
||||
if (info.State.Running) await container.stop()
|
||||
await container.remove()
|
||||
} catch (err) {
|
||||
if (!err.message?.includes('No such container')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function containerStatus(id) {
|
||||
try {
|
||||
const info = await docker.getContainer(containerName(id)).inspect()
|
||||
return info.State.Running ? 'running' : 'stopped'
|
||||
} catch {
|
||||
return 'not_found'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Install worker (async, non-blocking) ──────────────────────────────────────
|
||||
|
||||
async function installApp(id, manifest) {
|
||||
const { image, port, name, version } = manifest
|
||||
try {
|
||||
upsertApp(id, { name, version, image, port, status: 'pulling', error: null })
|
||||
|
||||
await pullImage(image)
|
||||
upsertApp(id, { status: 'starting' })
|
||||
|
||||
const containerId = await startContainer(id, image, port)
|
||||
upsertApp(id, { status: 'running', containerId, installedAt: new Date().toISOString() })
|
||||
} catch (err) {
|
||||
upsertApp(id, { status: 'error', error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }))
|
||||
|
||||
app.get('/api/installed', (_req, res) => {
|
||||
res.json({ apps: Object.values(readDb()) })
|
||||
})
|
||||
|
||||
app.get('/api/status/:id', (req, res) => {
|
||||
const record = getApp(req.params.id)
|
||||
if (!record) return res.json({ id: req.params.id, status: 'not_installed' })
|
||||
res.json(record)
|
||||
})
|
||||
|
||||
app.post('/api/install/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const existing = getApp(id)
|
||||
|
||||
if (existing && ['pulling', 'starting'].includes(existing.status)) {
|
||||
return res.status(409).json({ error: 'Install already in progress' })
|
||||
}
|
||||
if (existing?.status === 'running') {
|
||||
return res.status(409).json({ error: 'Already installed' })
|
||||
}
|
||||
|
||||
// Fetch manifest from iStore service
|
||||
let manifest
|
||||
try {
|
||||
const r = await fetch(`${ISTORE_URL}/api/apps/${id}`)
|
||||
if (!r.ok) return res.status(404).json({ error: 'App not found in iStore' })
|
||||
manifest = await r.json()
|
||||
} catch {
|
||||
return res.status(502).json({ error: 'Cannot reach iStore service' })
|
||||
}
|
||||
|
||||
if (!manifest.image) {
|
||||
return res.status(400).json({ error: 'App has no Docker image defined in manifest' })
|
||||
}
|
||||
|
||||
// Start install in background — respond immediately
|
||||
upsertApp(id, { status: 'pending', name: manifest.name, image: manifest.image })
|
||||
installApp(id, manifest).catch(console.error)
|
||||
|
||||
res.status(202).json({ id, status: 'pending', message: 'Installation started' })
|
||||
})
|
||||
|
||||
app.delete('/api/uninstall/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const record = getApp(id)
|
||||
if (!record) return res.status(404).json({ error: 'App not installed' })
|
||||
|
||||
try {
|
||||
await stopAndRemoveContainer(id)
|
||||
removeApp(id)
|
||||
res.json({ id, status: 'uninstalled' })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Sync container state with Docker reality (e.g. after CE restarts)
|
||||
app.post('/api/sync', async (_req, res) => {
|
||||
const db = readDb()
|
||||
for (const [id, record] of Object.entries(db)) {
|
||||
if (record.status === 'running') {
|
||||
const live = await containerStatus(id)
|
||||
if (live !== 'running') {
|
||||
upsertApp(id, { status: live === 'stopped' ? 'stopped' : 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({ synced: Object.keys(db).length })
|
||||
})
|
||||
|
||||
app.listen(PORT, () =>
|
||||
console.log(`[app-manager] running → http://localhost:${PORT} | Docker network: ${DOCKER_NETWORK}`)
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
)
|
||||
Reference in New Issue
Block a user