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
141 lines
5.3 KiB
JavaScript
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'));
|