commit 7a3b421ab2b29410af8b3e137f007ffb2cd62bb9 Author: VPS 4 Builder Date: Fri May 15 07:20:19 2026 +0200 feat: estrutura inicial do gerenciador de subdomínios - backend Express com endpoints Hostinger DNS API - frontend React+Vite+Tailwind com card de API key - docker-compose (frontend:3014, backend:8019, nginx:8021) - Traefik label para subdominio.clube67.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb10a99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +backend/.env +backend/config.json +frontend/dist/ +.env diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6d3b4d8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1 @@ +PORT=8019 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9dc8971 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +EXPOSE 8019 +CMD ["node", "server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b78c998 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "subdominio-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.19.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..c410997 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,77 @@ +import express from 'express'; +import cors from 'cors'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_FILE = join(__dirname, 'config.json'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +function loadConfig() { + if (!existsSync(CONFIG_FILE)) return { hostinger_api_key: '' }; + try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return { hostinger_api_key: '' }; } +} + +function saveConfig(data) { + writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8'); +} + +// GET configuração (mascara a key) +app.get('/api/config', (req, res) => { + const cfg = loadConfig(); + res.json({ has_api_key: !!cfg.hostinger_api_key }); +}); + +// POST salvar API key da Hostinger +app.post('/api/config/hostinger-key', (req, res) => { + const { api_key } = req.body; + if (!api_key?.trim()) return res.status(400).json({ error: 'api_key obrigatória' }); + const cfg = loadConfig(); + cfg.hostinger_api_key = api_key.trim(); + saveConfig(cfg); + res.json({ ok: true }); +}); + +// GET listar subdomínios via Hostinger API +app.get('/api/subdomains', async (req, res) => { + const cfg = loadConfig(); + if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' }); + + try { + const r = await fetch('https://developers.hostinger.com/api/dns/v1/domains', { + headers: { Authorization: `Bearer ${cfg.hostinger_api_key}`, 'Content-Type': 'application/json' } + }); + const data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// POST criar subdomínio no DNS da Hostinger +app.post('/api/subdomains', async (req, res) => { + const cfg = loadConfig(); + if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' }); + + const { domain, subdomain, type = 'A', value } = req.body; + if (!domain || !subdomain || !value) return res.status(400).json({ error: 'domain, subdomain e value obrigatórios' }); + + try { + const r = await fetch(`https://developers.hostinger.com/api/dns/v1/domains/${domain}/records`, { + method: 'POST', + headers: { Authorization: `Bearer ${cfg.hostinger_api_key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, name: subdomain, content: value, ttl: 300 }) + }); + const data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +const PORT = process.env.PORT || 8019; +app.listen(PORT, () => console.log(`[subdominio-backend] porta ${PORT}`)); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..919b24d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + frontend: + build: + context: ./frontend + container_name: subdominio-frontend + restart: unless-stopped + networks: + - subdominio-net + + backend: + build: + context: ./backend + container_name: subdominio-backend + restart: unless-stopped + volumes: + - ./backend/config.json:/app/config.json + networks: + - subdominio-net + + nginx: + image: nginx:alpine + container_name: subdominio-nginx + restart: unless-stopped + ports: + - "8021:8021" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - frontend + - backend + networks: + - subdominio-net + labels: + - "traefik.enable=true" + - "traefik.http.routers.subdominio.rule=Host(`subdominio.clube67.com`)" + - "traefik.http.routers.subdominio.entrypoints=websecure" + - "traefik.http.routers.subdominio.tls.certresolver=le" + - "traefik.http.services.subdominio.loadbalancer.server.port=8021" + +networks: + subdominio-net: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0fc3369 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine AS builder +WORKDIR /app/frontend +COPY package*.json . +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/frontend/dist /usr/share/nginx/html +EXPOSE 3014 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..393070e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Gerenciador de Subdomínios — Clube67 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e848f14 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "subdominio-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.0", + "@vitejs/plugin-react": "^5.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.2.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d1e1400 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; + +const API = '/api'; + +export default function App() { + const [hasKey, setHasKey] = useState(null); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [saving, setSaving] = useState(false); + const [subdomains, setSubdomains] = useState([]); + const [loadingDomains, setLoadingDomains] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + useEffect(() => { + fetch(`${API}/config`) + .then(r => r.json()) + .then(d => setHasKey(d.has_api_key)) + .catch(() => setHasKey(false)); + }, []); + + async function saveKey() { + if (!apiKeyInput.trim()) return; + setSaving(true); + setError(''); + try { + const r = await fetch(`${API}/config/hostinger-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_key: apiKeyInput }) + }); + if (r.ok) { setHasKey(true); setApiKeyInput(''); setSuccess('Chave salva com sucesso.'); } + else { const d = await r.json(); setError(d.error || 'Erro ao salvar.'); } + } finally { setSaving(false); } + } + + async function fetchSubdomains() { + setLoadingDomains(true); + setError(''); + try { + const r = await fetch(`${API}/subdomains`); + const d = await r.json(); + if (r.ok) setSubdomains(Array.isArray(d) ? d : d.data || []); + else setError(d.error || 'Erro ao buscar domínios.'); + } finally { setLoadingDomains(false); } + } + + return ( +
+
+ + {/* Header */} +
+
C
+
+

Gerenciador de Subdomínios

+

clube67.com — Hostinger DNS

+
+
+ + {/* Card API Key */} +
+
+

Hostinger API Key

+ {hasKey === true && ( + + ● CONFIGURADA + + )} + {hasKey === false && ( + + ● NÃO CONFIGURADA + + )} +
+ +

+ Insira a API Key da Hostinger para gerenciar registros DNS e criar subdomínios automaticamente. +

+ +
+ setApiKeyInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && saveKey()} + className="flex-1 bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 placeholder-gray-600" + /> + +
+ + {success &&

{success}

} + {error &&

{error}

} +
+ + {/* Card Domínios */} + {hasKey && ( +
+
+

Domínios / DNS

+ +
+ + {subdomains.length === 0 && !loadingDomains && ( +

Clique em "Buscar da Hostinger" para listar os domínios.

+ )} + + {subdomains.length > 0 && ( +
+ {subdomains.map((d: any, i: number) => ( +
+ {d.domain || d.name || JSON.stringify(d)} + {d.type || ''} +
+ ))} +
+ )} +
+ )} + + {/* Rodapé */} +

+ subdominio.clube67.com — acesso restrito VPN +

+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..18619e4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f2ea913 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { '/api': 'http://localhost:8019' } + } +}); diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..8310294 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,14 @@ +server { + listen 8021; + + location /api/ { + proxy_pass http://subdominio-backend:8019; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + proxy_pass http://subdominio-frontend:3014; + proxy_set_header Host $host; + } +}