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:
+123
-5
@@ -1,22 +1,140 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const CASDOOR_ENDPOINT = process.env.CASDOOR_ENDPOINT || 'https://auth.falahos.my'
|
||||
const CASDOOR_CLIENT_ID = process.env.CASDOOR_CLIENT_ID || ''
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
// AWS-level standard: proper static file serving with cache control
|
||||
const distPath = path.join(__dirname, 'dist');
|
||||
if (!fs.existsSync(distPath)) {
|
||||
console.warn(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', service: 'app', msg: 'dist directory does not exist! The frontend will not be served.' }));
|
||||
}
|
||||
|
||||
app.use(express.static(distPath, {
|
||||
maxAge: '1d', // Cache static assets for performance
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
// Don't cache HTML to ensure users get the latest version on reload
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ── UmmahID / Casdoor auth ────────────────────────────────────────────────
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns safe public config — no secrets exposed
|
||||
app.get('/api/auth/config', (_req, res) => {
|
||||
if (!CASDOOR_CLIENT_ID) {
|
||||
return res.status(503).json({ error: 'CASDOOR_CLIENT_ID not configured on server' })
|
||||
}
|
||||
res.json({
|
||||
clientId: CASDOOR_CLIENT_ID,
|
||||
endpoint: CASDOOR_ENDPOINT,
|
||||
authorizePath: '/login/oauth/authorize',
|
||||
})
|
||||
})
|
||||
|
||||
// PKCE token exchange — code_verifier replaces client_secret for public clients
|
||||
app.post('/api/auth/token', async (req, res) => {
|
||||
const { code, codeVerifier, redirectUri } = req.body
|
||||
if (!code || !codeVerifier || !redirectUri) {
|
||||
return res.status(400).json({ error: 'Missing code, codeVerifier, or redirectUri' })
|
||||
}
|
||||
try {
|
||||
const tokenRes = await fetch(`${CASDOOR_ENDPOINT}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CASDOOR_CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
})
|
||||
const data = await tokenRes.json()
|
||||
if (!tokenRes.ok || data.error) {
|
||||
return res.status(400).json({ error: data.error_description || data.error || 'Token exchange failed' })
|
||||
}
|
||||
const claims = decodeJwtPayload(data.id_token || data.access_token)
|
||||
res.json({
|
||||
accessToken: data.access_token,
|
||||
user: {
|
||||
id: claims.sub || claims.id || '',
|
||||
email: claims.email || '',
|
||||
fullName: claims.name || claims.preferred_username || '',
|
||||
role: claims['falah/role'] || claims.role || 'user',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'auth', msg: err.message }))
|
||||
res.status(502).json({ error: 'Could not reach Casdoor' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'healthy', service: 'app', version: '1.3.0' });
|
||||
res.json({ status: 'healthy', service: 'app', version: '1.3.0', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
app.get('*', (req, res, next) => {
|
||||
const indexPath = path.join(distPath, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Error sending index.html', error: err.message }));
|
||||
next(err); // Pass to global error handler
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(404).send('Not Found: Application build missing.');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// Global Error Handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Unhandled error in Express', error: err.stack || err.message }));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `running on port ${PORT}` }));
|
||||
});
|
||||
|
||||
// Graceful Shutdown for DevOps / Orchestrators (ECS, K8s, Cloud Run)
|
||||
const shutdown = (signal) => {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `Received ${signal}. Shutting down gracefully...` }));
|
||||
server.close(() => {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: 'Closed remaining connections.' }));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force shutdown after 10s if connections linger
|
||||
setTimeout(() => {
|
||||
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'error', service: 'app', msg: 'Could not close connections in time, forcefully shutting down' }));
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
Reference in New Issue
Block a user