Add admin panel API with CRUD for apps

- Express.js backend with JWT authentication
- CRUD endpoints for apps management
- Health check endpoint
- Dockerfile per admin API (Node 18 Alpine)
- Kubernetes: admin-api deployment, service, ingress
- Admin panel at http://admin.apps.local
- Updated nginx.conf to route /api to admin API
- Fixed ingress rules for separate web and admin services
This commit is contained in:
capitano
2026-03-18 23:01:42 +01:00
parent 533cbed8d3
commit e89989e1d9
17 changed files with 857 additions and 7 deletions

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY server.js ./server.js
COPY data/apps.json ./data/apps.json
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

1
backend/data/apps.json Normal file
View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: admin-api
labels:
app: admin-api
spec:
replicas: 2
selector:
matchLabels:
app: admin-api
template:
metadata:
labels:
app: admin-api
spec:
containers:
- name: admin-api
image: admin-api:latest
imagePullPolicy: Never
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: admin-secrets
key: admin-token
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: admin-secrets
key: jwt-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /api/apps
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/apps
port: 3000
initialDelaySeconds: 5
periodSeconds: 5

View File

@@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: admin-api
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: admin.apps.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-api
port:
number: 80

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: admin-api
labels:
app: admin-api
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 3000
protocol: TCP
name: http
selector:
app: admin-api

16
backend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "linux-app-store-admin",
"version": "1.0.0",
"description": "Admin panel API for Linux App Store",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.18.2",
"express-jwt": "^8.4.1",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5"
}
}

173
backend/server.js Normal file
View File

@@ -0,0 +1,173 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-key';
const APPS_FILE = path.join(__dirname, 'data', 'apps.json');
app.use(express.json());
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Authorization header required' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
req.auth = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function readAppsFile() {
try {
if (fs.existsSync(APPS_FILE)) {
const data = fs.readFileSync(APPS_FILE, 'utf8');
return JSON.parse(data);
}
} catch (err) {
console.error('Error reading apps file:', err);
}
return [];
}
function writeAppsFile(apps) {
try {
const tempFile = `${APPS_FILE}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(apps, null, 2));
fs.renameSync(tempFile, APPS_FILE);
return true;
} catch (err) {
console.error('Error writing apps file:', err);
return false;
}
}
app.post('/api/admin/login', (req, res) => {
const { token } = req.body;
const adminToken = process.env.ADMIN_TOKEN;
if (!adminToken) {
return res.status(500).json({ error: 'ADMIN_TOKEN not configured' });
}
if (token === adminToken) {
const payload = {
admin: true,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24)
};
const secret = process.env.JWT_SECRET || 'fallback-secret-key';
const apiKey = jwt.sign(payload, secret);
return res.json({ apiKey });
}
return res.status(401).json({ error: 'Invalid token' });
});
app.get('/api/apps', (req, res) => {
const apps = readAppsFile();
return res.json(apps);
});
app.post('/api/admin/apps', verifyToken, (req, res) => {
if (!req.auth || !req.auth.admin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { name, description, icon, colors, packages } = req.body;
if (!name || !description) {
return res.status(400).json({ error: 'Name and description are required' });
}
let apps = readAppsFile();
const newId = apps.length > 0 ? Math.max(...apps.map(a => a.id)) + 1 : 1;
const newApp = {
id: newId,
name,
description,
icon: icon || '',
colors: colors || { background: '', text: '' },
packages: packages || { apt: '', appstream: '', flatpak: '' }
};
apps.push(newApp);
if (writeAppsFile(apps)) {
return res.status(201).json(newApp);
}
return res.status(500).json({ error: 'Failed to save app' });
});
app.put('/api/admin/apps/:id', verifyToken, (req, res) => {
if (!req.auth || !req.auth.admin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const updates = req.body;
let apps = readAppsFile();
const appIndex = apps.findIndex(a => a.id === parseInt(id));
if (appIndex === -1) {
return res.status(404).json({ error: 'App not found' });
}
const updatedApp = { ...apps[appIndex], ...updates, id: parseInt(id) };
apps[appIndex] = updatedApp;
if (writeAppsFile(apps)) {
return res.json(updatedApp);
}
return res.status(500).json({ error: 'Failed to update app' });
});
app.delete('/api/admin/apps/:id', verifyToken, (req, res) => {
if (!req.auth || !req.auth.admin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
let apps = readAppsFile();
const appIndex = apps.findIndex(a => a.id === parseInt(id));
if (appIndex === -1) {
return res.status(404).json({ error: 'App not found' });
}
apps.splice(appIndex, 1);
if (writeAppsFile(apps)) {
return res.json({ message: 'App deleted successfully' });
}
return res.status(500).json({ error: 'Failed to delete app' });
});
app.listen(PORT, () => {
console.log(`Admin API running on port ${PORT}`);
});