Files
falah-os-ce/server.cjs
T
Antigravity AI 21b48d3d53 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
2026-07-06 10:05:37 +08:00

141 lines
5.3 KiB
JavaScript

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());
// 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', timestamp: new Date().toISOString() });
});
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.');
}
});
// 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'));