From e89989e1d9d1dd561557cc8dd72534657db33d90 Mon Sep 17 00:00:00 2001 From: capitano Date: Wed, 18 Mar 2026 23:01:42 +0100 Subject: [PATCH] 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 --- backend/Dockerfile | 14 ++ backend/data/apps.json | 1 + backend/k8s/admin-deployment.yaml | 54 +++++++ backend/k8s/admin-ingress.yaml | 18 +++ backend/k8s/admin-service.yaml | 15 ++ backend/package.json | 16 ++ backend/server.js | 173 +++++++++++++++++++++ deploy.sh | 24 ++- frontend/admin.html | 111 ++++++++++++++ frontend/admin.js | 244 ++++++++++++++++++++++++++++++ k8s/admin-deployment.yaml | 53 +++++++ k8s/admin-ingress.yaml | 22 +++ k8s/admin-service.yaml | 16 ++ k8s/deployment.yaml | 35 +++++ k8s/ingress.yaml | 24 +++ k8s/service.yaml | 21 +++ nginx.conf | 23 ++- 17 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/data/apps.json create mode 100644 backend/k8s/admin-deployment.yaml create mode 100644 backend/k8s/admin-ingress.yaml create mode 100644 backend/k8s/admin-service.yaml create mode 100644 backend/package.json create mode 100644 backend/server.js create mode 100644 frontend/admin.html create mode 100644 frontend/admin.js create mode 100644 k8s/admin-deployment.yaml create mode 100644 k8s/admin-ingress.yaml create mode 100644 k8s/admin-service.yaml diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a4d19e8 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/data/apps.json b/backend/data/apps.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/backend/data/apps.json @@ -0,0 +1 @@ +[] diff --git a/backend/k8s/admin-deployment.yaml b/backend/k8s/admin-deployment.yaml new file mode 100644 index 0000000..43e12e1 --- /dev/null +++ b/backend/k8s/admin-deployment.yaml @@ -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 diff --git a/backend/k8s/admin-ingress.yaml b/backend/k8s/admin-ingress.yaml new file mode 100644 index 0000000..1cf5026 --- /dev/null +++ b/backend/k8s/admin-ingress.yaml @@ -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 diff --git a/backend/k8s/admin-service.yaml b/backend/k8s/admin-service.yaml new file mode 100644 index 0000000..ff57274 --- /dev/null +++ b/backend/k8s/admin-service.yaml @@ -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 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5a55712 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..82a81f0 --- /dev/null +++ b/backend/server.js @@ -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}`); +}); diff --git a/deploy.sh b/deploy.sh index 2a21f12..2827e13 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,17 +2,31 @@ set -e REGISTRY="git.giaco.net" -IMAGE_NAME="capitano/webapplinux" +MAIN_IMAGE="capitano/webapplinux" +ADMIN_IMAGE="capitano/webapplinux-admin" TAG="latest" -echo "Building image..." -docker build -t $REGISTRY/$IMAGE_NAME:$TAG /home/capitano/kubernetes/infrastructure/webapplinux/ +echo "Building main image..." +docker build -t $REGISTRY/$MAIN_IMAGE:$TAG /home/capitano/kubernetes/infrastructure/webapplinux/ -echo "Pushing to registry..." -docker push $REGISTRY/$IMAGE_NAME:$TAG +echo "Building admin API image..." +docker build -t $REGISTRY/$ADMIN_IMAGE:$TAG /home/capitano/kubernetes/infrastructure/webapplinux/backend/ + +echo "Pushing main image..." +docker push $REGISTRY/$MAIN_IMAGE:$TAG + +echo "Pushing admin image..." +docker push $REGISTRY/$ADMIN_IMAGE:$TAG + +echo "Creating admin secret..." +kubectl create secret generic admin-secret -n linuxwebapp \ + --from-literal=admin-token="${ADMIN_TOKEN:-admin123}" \ + --from-literal=jwt-secret="${JWT_SECRET:-supersecretjwtkey}" \ + --dry-run=client -o yaml | kubectl apply -f - echo "Deploying to Kubernetes..." kubectl apply -f k8s/namespace.yaml 2>/dev/null || true kubectl apply -f k8s/ echo "Check status with: kubectl get pods -n linuxwebapp" +echo "Admin panel at: http://admin.apps.local" diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..8be801e --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,111 @@ + + + + + + Admin - Linux App Store + + + + + + +
+
+
+

Accesso Amministratore

+ +
+
+ + +
+ +
+
+
+ + +
+ + + + + + + diff --git a/frontend/admin.js b/frontend/admin.js new file mode 100644 index 0000000..af47bdb --- /dev/null +++ b/frontend/admin.js @@ -0,0 +1,244 @@ +const API_URL = 'http://localhost:3000'; +let authToken = localStorage.getItem('admin_token'); + +document.addEventListener('DOMContentLoaded', () => { + checkAuth(); + if (authToken) { + loadApps(); + } +}); + +function checkAuth() { + const loginSection = document.getElementById('login-section'); + const adminSection = document.getElementById('admin-section'); + const userStatus = document.getElementById('user-status'); + + if (authToken) { + loginSection.classList.add('hidden'); + adminSection.classList.remove('hidden'); + userStatus.classList.remove('hidden'); + userStatus.innerHTML = 'Admin'; + } +} + +async function login() { + const tokenInput = document.getElementById('admin-token'); + const token = tokenInput.value; + const errorDiv = document.getElementById('error-message'); + + if (!token) { + showError('Inserisci il token admin'); + return; + } + + try { + const response = await fetch(`${API_URL}/api/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + + const data = await response.json(); + + if (response.ok) { + authToken = data.apiKey; + localStorage.setItem('admin_token', authToken); + checkAuth(); + loadApps(); + tokenInput.value = ''; + } else { + showError(data.error || 'Login fallito'); + } + } catch (error) { + showError('Errore connessione API: ' + error.message); + } +} + +function logout() { + localStorage.removeItem('admin_token'); + authToken = null; + checkAuth(); +} + +async function loadApps() { + const container = document.getElementById('apps-list'); + container.innerHTML = '
'; + + try { + const response = await fetch(`${API_URL}/api/apps`); + const data = await response.json(); + renderApps(data.apps || data); + } catch (error) { + container.innerHTML = '
Errore caricamento app
'; + } +} + +function renderApps(apps) { + const container = document.getElementById('apps-list'); + + if (!apps || apps.length === 0) { + container.innerHTML = '
Nessuna applicazione. Aggiungine una!
'; + return; + } + + container.innerHTML = ''; + + apps.forEach(app => { + const appCard = document.createElement('div'); + appCard.className = 'bg-gray-850 rounded-xl overflow-hidden shadow-lg border border-gray-700 hover:shadow-xl transition-shadow duration-300'; + appCard.innerHTML = ` +
+
+
+
+ +
+
+

${app.name}

+ ID: ${app.id} +
+
+
+ + +
+
+

${app.description || ''}

+
+
+ APT: ${app.packages?.apt || 'N/A'} +
+
+ AppStream: ${app.packages?.appstream || 'N/A'} +
+
+ Flatpak: ${app.packages?.flatpak || 'N/A'} +
+
+
+ `; + container.appendChild(appCard); + }); +} + +function showError(message) { + const errorDiv = document.getElementById('error-message'); + errorDiv.textContent = message; + errorDiv.classList.remove('hidden'); + setTimeout(() => errorDiv.classList.add('hidden'), 5000); +} + +function showAddForm() { + document.getElementById('form-title').textContent = 'Aggiungi Applicazione'; + document.getElementById('app-form').reset(); + document.getElementById('app-id').value = ''; + document.getElementById('app-id-input').value = ''; + document.getElementById('app-name').value = ''; + document.getElementById('app-description').value = ''; + document.getElementById('app-icon').value = 'fa-package'; + document.getElementById('app-colors').value = 'from-purple-500 to-indigo-600'; + document.getElementById('app-apt').value = ''; + document.getElementById('app-appstream').value = ''; + document.getElementById('app-flatpak').value = ''; + document.getElementById('app-form-modal').classList.remove('hidden'); +} + +function closeForm() { + document.getElementById('app-form-modal').classList.add('hidden'); +} + +async function saveApp(event) { + event.preventDefault(); + + const idInput = document.getElementById('app-id-input').value; + const name = document.getElementById('app-name').value; + const description = document.getElementById('app-description').value; + const icon = document.getElementById('app-icon').value; + const colors = document.getElementById('app-colors').value; + const apt = document.getElementById('app-apt').value; + const appstream = document.getElementById('app-appstream').value; + const flatpak = document.getElementById('app-flatpak').value; + + const appData = { + id: idInput, + name, + description, + icon, + colors, + packages: { apt, appstream, flatpak } + }; + + const isEdit = document.getElementById('app-id').value; + const method = isEdit ? 'PUT' : 'POST'; + const url = isEdit ? `${API_URL}/api/admin/apps/${document.getElementById('app-id').value}` : `${API_URL}/api/admin/apps`; + + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify(appData) + }); + + if (response.ok) { + closeForm(); + loadApps(); + } else { + const error = await response.json(); + showError(error.error || 'Salvataggio fallito'); + } + } catch (error) { + showError('Errore: ' + error.message); + } +} + +async function editApp(id) { + const apps = await fetch(`${API_URL}/api/apps`).then(r => r.json()); + const app = (apps.apps || apps).find(a => a.id === id); + + if (!app) return; + + document.getElementById('form-title').textContent = 'Modifica Applicazione'; + document.getElementById('app-id').value = app.id; + document.getElementById('app-id-input').value = app.id; + document.getElementById('app-name').value = app.name; + document.getElementById('app-description').value = app.description; + document.getElementById('app-icon').value = app.icon || 'fa-package'; + document.getElementById('app-colors').value = app.colors || 'from-purple-500 to-indigo-600'; + document.getElementById('app-apt').value = app.packages?.apt || ''; + document.getElementById('app-appstream').value = app.packages?.appstream || ''; + document.getElementById('app-flatpak').value = app.packages?.flatpak || ''; + document.getElementById('app-form-modal').classList.remove('hidden'); +} + +async function deleteApp(id) { + if (!confirm('Vuoi eliminare questa applicazione?')) return; + + try { + const response = await fetch(`${API_URL}/api/admin/apps/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (response.ok) { + loadApps(); + } else { + showError('Eliminazione fallita'); + } + } catch (error) { + showError('Errore: ' + error.message); + } +} + +// Close modal on outside click +document.getElementById('app-form-modal').addEventListener('click', (e) => { + if (e.target.classList.contains('fixed')) { + closeForm(); + } +}); diff --git a/k8s/admin-deployment.yaml b/k8s/admin-deployment.yaml new file mode 100644 index 0000000..21829f5 --- /dev/null +++ b/k8s/admin-deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin-api + namespace: linuxwebapp + labels: + app: admin-api +spec: + replicas: 1 + selector: + matchLabels: + app: admin-api + template: + metadata: + labels: + app: admin-api + spec: + containers: + - name: admin-api + image: git.giaco.net/capitano/webapplinux-admin:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: admin-secret + key: admin-token + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: admin-secret + key: jwt-secret + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 diff --git a/k8s/admin-ingress.yaml b/k8s/admin-ingress.yaml new file mode 100644 index 0000000..5406d0a --- /dev/null +++ b/k8s/admin-ingress.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: admin-api-ingress + namespace: linuxwebapp + labels: + app: admin-api + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - host: admin.apps.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: admin-api-service + port: + number: 3000 diff --git a/k8s/admin-service.yaml b/k8s/admin-service.yaml new file mode 100644 index 0000000..b6815ca --- /dev/null +++ b/k8s/admin-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: admin-api-service + namespace: linuxwebapp + labels: + app: admin-api +spec: + type: ClusterIP + selector: + app: admin-api + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP + name: admin-api diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 97703a4..200b91a 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -45,3 +45,38 @@ spec: value: "apps.local" - name: NGINX_PORT value: "80" + - name: admin-api + image: git.giaco.net/capitano/webapplinux-admin:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: admin-secret + key: admin-token + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: admin-secret + key: jwt-secret + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index aa28344..16f2a5f 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -20,3 +20,27 @@ spec: name: linux-app-store-service port: number: 80 + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: admin-api-ingress + namespace: linuxwebapp + labels: + app: admin-api + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - host: admin.apps.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: admin-api-service + port: + number: 3000 diff --git a/k8s/service.yaml b/k8s/service.yaml index 272d5da..802bded 100644 --- a/k8s/service.yaml +++ b/k8s/service.yaml @@ -14,3 +14,24 @@ spec: targetPort: 80 protocol: TCP name: http + - port: 3000 + targetPort: 3000 + protocol: TCP + name: admin-api +--- +apiVersion: v1 +kind: Service +metadata: + name: admin-api-service + namespace: linuxwebapp + labels: + app: admin-api +spec: + type: ClusterIP + selector: + app: linux-app-store + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP + name: admin-api diff --git a/nginx.conf b/nginx.conf index ef16f18..541a5d8 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,19 +1,38 @@ server { listen 80; - server_name apps.local; + server_name apps.local admin.apps.local; root /usr/share/nginx/html; - index index.html; + index index.html admin.html; location / { try_files $uri $uri/ /index.html; } + location /admin { + alias /usr/share/nginx/html; + try_files $uri $uri/ /admin.html; + } + + location /admin/ { + alias /usr/share/nginx/html; + try_files $uri $uri/ /admin.html; + } + location /apps.json { add_header Content-Type application/json; add_header Access-Control-Allow-Origin *; } + location /api { + proxy_pass http://localhost:3000/api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + gzip on; gzip_vary on; gzip_min_length 1024;