feat: persist branding, visual identity, and database integration changes

This commit is contained in:
VPS 4 Builder
2026-05-17 03:24:59 +02:00
parent 1bbb6cf138
commit ef77a36a95
13 changed files with 10787 additions and 250 deletions
+7032
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
const pool = new Pool({
host: process.env.PG_HOST || '10.99.0.3',
port: process.env.PG_PORT || 5432,
database: process.env.PG_DATABASE || 'subdominio',
user: process.env.PG_USER || 'mercado_admin',
password: process.env.PG_PASSWORD || 'mErC4d0_2026_Club3!#',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
export const query = (text, params) => pool.query(text, params);
export default pool;
+796
View File
@@ -0,0 +1,796 @@
{
"name": "subdominio-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "subdominio-backend",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
}
}
}
+2 -1
View File
@@ -9,6 +9,7 @@
"dependencies": { "dependencies": {
"express": "^4.19.2", "express": "^4.19.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5" "dotenv": "^16.4.5",
"pg": "^8.11.5"
} }
} }
+32
View File
@@ -0,0 +1,32 @@
-- Tabela de domínios principais
CREATE TABLE IF NOT EXISTS domains (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
hostinger_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabela de subdomínios (entradas DNS)
CREATE TABLE IF NOT EXISTS subdomains (
id SERIAL PRIMARY KEY,
domain_id INTEGER REFERENCES domains(id) ON DELETE CASCADE,
subdomain VARCHAR(255) NOT NULL,
full_domain VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(10) DEFAULT 'A',
value VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(domain_id, subdomain)
);
-- Tabela de branding (identidade visual)
CREATE TABLE IF NOT EXISTS branding (
id SERIAL PRIMARY KEY,
subdomain_id INTEGER UNIQUE REFERENCES subdomains(id) ON DELETE CASCADE,
logo_url TEXT,
primary_color VARCHAR(50) DEFAULT '#FF6B00',
secondary_color VARCHAR(50) DEFAULT '#1A1A1A',
title VARCHAR(255),
description TEXT,
favicon_url TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+120 -78
View File
@@ -1,120 +1,162 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import { readFileSync, writeFileSync, existsSync } from 'fs'; import dotenv from 'dotenv';
import { fileURLToPath } from 'url'; import { query } from './db.js';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url)); dotenv.config();
const CONFIG_FILE = join(__dirname, 'config.json');
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
function loadConfig() { // API Key da Hostinger (Fallback para env se não estiver no banco futuramente)
if (!existsSync(CONFIG_FILE)) return { hostinger_api_key: '' }; let HOSTINGER_API_KEY = process.env.HOSTINGER_API_KEY || '';
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return { hostinger_api_key: '' }; }
}
function saveConfig(data) { // --- Configuração ---
writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
}
// GET configuração (mascara a key)
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
const cfg = loadConfig(); res.json({ has_api_key: !!HOSTINGER_API_KEY });
res.json({ has_api_key: !!cfg.hostinger_api_key });
}); });
// POST salvar API key da Hostinger
app.post('/api/config/hostinger-key', (req, res) => { app.post('/api/config/hostinger-key', (req, res) => {
const { api_key } = req.body; const { api_key } = req.body;
if (!api_key?.trim()) return res.status(400).json({ error: 'api_key obrigatória' }); if (!api_key?.trim()) return res.status(400).json({ error: 'api_key obrigatória' });
const cfg = loadConfig(); HOSTINGER_API_KEY = api_key.trim();
cfg.hostinger_api_key = api_key.trim();
saveConfig(cfg);
res.json({ ok: true }); res.json({ ok: true });
}); });
// GET listar domínios via Hostinger API (Corrigido para v1/portfolio) // --- Domínios ---
app.get('/api/subdomains', async (req, res) => {
const cfg = loadConfig();
console.log('[BACKEND] Buscando domínios na Hostinger...');
if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' });
// Listar domínios salvos no banco
app.get('/api/domains', async (req, res) => {
try { try {
const r = await fetch('https://developers.hostinger.com/api/domains/v1/portfolio', { const result = await query('SELECT * FROM domains ORDER BY name ASC');
headers: { res.json(result.rows);
'Authorization': `Bearer ${cfg.hostinger_api_key}`, } catch (err) {
'Accept': 'application/json' res.status(500).json({ error: err.message });
}
});
console.log('[BACKEND] Status Hostinger:', r.status);
if (!r.ok) {
const errText = await r.text();
console.error('[BACKEND] Erro Hostinger:', errText);
return res.status(r.status).json({ error: `Hostinger Error: ${r.status}` });
}
const data = await r.json();
console.log('[BACKEND] Domínios encontrados:', Array.isArray(data) ? data.length : 'Não é array');
res.json(data);
} catch (e) {
console.error('[BACKEND] Crash fetch:', e.message);
res.status(500).json({ error: e.message });
} }
}); });
// POST criar subdomínio no DNS da Hostinger (Corrigido para v1/zones/{domain}) // Sincronizar domínios da Hostinger para o banco
app.post('/api/subdomains', async (req, res) => { app.post('/api/domains/sync', async (req, res) => {
const cfg = loadConfig(); if (!HOSTINGER_API_KEY) return res.status(400).json({ error: 'API key não configurada' });
if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' });
const { domain, subdomain, type = 'A', value } = req.body;
console.log(`[BACKEND] Criando subdomínio ${subdomain}.${domain} -> ${value}`);
if (!domain || !subdomain || !value) return res.status(400).json({ error: 'domain, subdomain e value obrigatórios' });
try { try {
const r = await fetch(`https://developers.hostinger.com/api/dns/v1/zones/${domain}`, { const r = await fetch('https://developers.hostinger.com/api/domains/v1/portfolio', {
headers: { 'Authorization': `Bearer ${HOSTINGER_API_KEY}`, 'Accept': 'application/json' }
});
if (!r.ok) {
const errText = await r.text();
return res.status(r.status).json({ error: `Hostinger Error: ${errText}` });
}
const domains = await r.json();
for (const dom of domains) {
await query(
'INSERT INTO domains (name, hostinger_id) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET hostinger_id = $2',
[dom.domain, dom.id || null]
);
}
const result = await query('SELECT * FROM domains ORDER BY name ASC');
res.json({ ok: true, domains: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Subdomínios ---
// Listar subdomínios de um domínio (do banco)
app.get('/api/domains/:domainId/subdomains', async (req, res) => {
try {
const result = await query(
'SELECT s.*, b.logo_url, b.primary_color, b.secondary_color, b.title, b.description FROM subdomains s LEFT JOIN branding b ON s.id = b.subdomain_id WHERE s.domain_id = $1 ORDER BY s.subdomain ASC',
[req.params.domainId]
);
res.json(result.rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Criar subdomínio (Hostinger + Banco)
app.post('/api/subdomains', async (req, res) => {
if (!HOSTINGER_API_KEY) return res.status(400).json({ error: 'API key não configurada' });
const { domainId, domainName, subdomain, type = 'A', value } = req.body;
if (!domainId || !domainName || !subdomain || !value) {
return res.status(400).json({ error: 'Campos obrigatórios ausentes' });
}
try {
// 1. Criar na Hostinger
const r = await fetch(`https://developers.hostinger.com/api/dns/v1/zones/${domainName}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': `Bearer ${cfg.hostinger_api_key}`, 'Authorization': `Bearer ${HOSTINGER_API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json' 'Accept': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
overwrite: false, overwrite: false,
zone: [ zone: [{ name: subdomain, type: type, records: [{ content: value, ttl: 300 }] }]
{
name: subdomain,
type: type,
records: [
{
content: value,
ttl: 300
}
]
}
]
}) })
}); });
console.log('[BACKEND] Status Criação DNS:', r.status);
if (!r.ok) { if (!r.ok) {
const errText = await r.text(); const errText = await r.text();
console.error('[BACKEND] Erro Criação DNS:', errText); return res.status(r.status).json({ error: `Erro Hostinger: ${errText}` });
return res.status(r.status).json({ error: `Erro DNS: ${r.status}` });
} }
const data = await r.json(); // 2. Salvar no Banco
res.json({ ok: true, data }); const fullDomain = `${subdomain}.${domainName}`;
} catch (e) { const dbResult = await query(
console.error('[BACKEND] Crash Criação DNS:', e.message); 'INSERT INTO subdomains (domain_id, subdomain, full_domain, type, value) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (full_domain) DO UPDATE SET value = $5 RETURNING *',
res.status(500).json({ error: e.message }); [domainId, subdomain, fullDomain, type, value]
);
res.json({ ok: true, subdomain: dbResult.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Branding / Identidade Visual ---
app.get('/api/branding/:subdomainId', async (req, res) => {
try {
const result = await query('SELECT * FROM branding WHERE subdomain_id = $1', [req.params.subdomainId]);
res.json(result.rows[0] || {});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/branding/:subdomainId', async (req, res) => {
const { logo_url, primary_color, secondary_color, title, description, favicon_url } = req.body;
const subdomainId = req.params.subdomainId;
try {
const result = await query(
`INSERT INTO branding (subdomain_id, logo_url, primary_color, secondary_color, title, description, favicon_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (subdomain_id)
DO UPDATE SET
logo_url = EXCLUDED.logo_url,
primary_color = EXCLUDED.primary_color,
secondary_color = EXCLUDED.secondary_color,
title = EXCLUDED.title,
description = EXCLUDED.description,
favicon_url = EXCLUDED.favicon_url,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[subdomainId, logo_url, primary_color, secondary_color, title, description, favicon_url]
);
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
} }
}); });
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# ─── deploy-prod-builder-subdominio.sh ───────────────────────────────────────
# Script sênior de automação de build e despacho (GitOps) de VPS 4 -> VPS 1.
# ─────────────────────────────────────────────────────────────────────────────
set -e
LOCK_FILE="/tmp/deploy-prod-subdominio.lock"
if [ -f "$LOCK_FILE" ]; then
echo "🚨 Deploy já está em andamento! Aguarde..."
exit 1
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
LOG_FILE="/home/deploy/stack/webhook-listener/deploy.log"
log() {
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] [SUBDOMINIO] $1" | tee -a "$LOG_FILE"
}
log "🚀 Iniciando ciclo de build e deploy automático para Subdominio..."
# 1. Atualizar repositório Git
log "📦 Sincronizando repositório Git local na VPS 4..."
cd /home/deploy/stack/subdominio.clube67.com
git pull origin main
# 1.1 Incrementar versão
log "🔢 Incrementando versão..."
node update-version.js
# 2. Compilar o Backend
log "⚙️ Instalando dependências do Backend..."
cd /home/deploy/stack/subdominio.clube67.com/backend
npm install
# 3. Compilar o Frontend
log "⚙️ Instalando dependências e compilando o Frontend (Vite)..."
cd /home/deploy/stack/subdominio.clube67.com/frontend
npm install
npm run build
# 4. Sincronizar arquivos com a VPS 1 (Produção) via Rsync VPN
log "🚚 Despachando Backend para a VPS 1 (Produção)..."
rsync -avz --delete \
--exclude 'node_modules/.cache' \
/home/deploy/stack/subdominio.clube67.com/backend/ \
deploy@10.99.0.1:/home/deploy/stack/subdominio.clube67.com/backend/
log "🚚 Despachando Frontend (build) para a VPS 1 (Produção)..."
rsync -avz --delete \
/home/deploy/stack/subdominio.clube67.com/frontend/dist/ \
deploy@10.99.0.1:/home/deploy/stack/subdominio.clube67.com/frontend/dist/
# 5. Reiniciar os serviços de produção na VPS 1
log "🔄 Reiniciando containers na VPS 1..."
ssh -o BatchMode=yes deploy@10.99.0.1 "cd /home/deploy/stack/subdominio.clube67.com && DOCKER_HOST=unix:///run/user/1000/docker.sock docker compose up -d"
# 6. Salvar incremento de versão no Git
log "💾 Comitando o incremento de versão de volta para o repositório central..."
cd /home/deploy/stack/subdominio.clube67.com
git add frontend/src/constants.ts
git commit -m "chore(deploy): bump version"
git push origin main
log "✅ Deploy de Subdominio concluído com absoluto sucesso!"
+14
View File
@@ -12,10 +12,18 @@ services:
context: ./backend context: ./backend
container_name: subdominio-backend container_name: subdominio-backend
restart: unless-stopped restart: unless-stopped
environment:
- PG_HOST=10.99.0.3
- PG_PORT=5432
- PG_DATABASE=subdominio
- PG_USER=mercado_admin
- PG_PASSWORD=mErC4d0_2026_Club3!#
- PORT=8019
volumes: volumes:
- ./backend/config.json:/app/config.json - ./backend/config.json:/app/config.json
networks: networks:
- subdominio-net - subdominio-net
- soc
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@@ -30,13 +38,19 @@ services:
- backend - backend
networks: networks:
- subdominio-net - subdominio-net
- web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.subdominio.rule=Host(`subdominio.clube67.com`)" - "traefik.http.routers.subdominio.rule=Host(`subdominio.clube67.com`)"
- "traefik.http.routers.subdominio.entrypoints=websecure" - "traefik.http.routers.subdominio.entrypoints=websecure"
- "traefik.http.routers.subdominio.tls.certresolver=le" - "traefik.http.routers.subdominio.tls.certresolver=le"
- "traefik.http.services.subdominio.loadbalancer.server.port=8021" - "traefik.http.services.subdominio.loadbalancer.server.port=8021"
- "traefik.docker.network=web"
networks: networks:
subdominio-net: subdominio-net:
driver: bridge driver: bridge
soc:
external: true
web:
external: true
+1
View File
@@ -7,4 +7,5 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/frontend/dist /usr/share/nginx/html COPY --from=builder /app/frontend/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3014 EXPOSE 3014
+1
View File
@@ -0,0 +1 @@
server { listen 3014; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } }
+2275
View File
File diff suppressed because it is too large Load Diff
+392 -157
View File
@@ -4,9 +4,24 @@ import { APP_VERSION } from './constants';
const API = '/api'; const API = '/api';
interface Domain { interface Domain {
domain: string; id: number;
id?: number; name: string;
status?: string; hostinger_id?: string;
}
interface Subdomain {
id: number;
domain_id: number;
subdomain: string;
full_domain: string;
type: string;
value: string;
logo_url?: string;
primary_color?: string;
secondary_color?: string;
title?: string;
description?: string;
favicon_url?: string;
} }
export default function App() { export default function App() {
@@ -18,19 +33,48 @@ export default function App() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
// Navigation states
const [selectedDomain, setSelectedDomain] = useState<Domain | null>(null);
const [subdomains, setSubdomains] = useState<Subdomain[]>([]);
const [loadingSubdomains, setLoadingSubdomains] = useState(false);
const [editingBranding, setEditingBranding] = useState<Subdomain | null>(null);
// Form states // Form states
const [selectedDomain, setSelectedDomain] = useState('');
const [newSubdomain, setNewSubdomain] = useState(''); const [newSubdomain, setNewSubdomain] = useState('');
const [newValue, setNewValue] = useState(''); const [newValue, setNewValue] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
// Branding states
const [brandingForm, setBrandingForm] = useState({
title: '',
description: '',
logo_url: '',
primary_color: '#FF6B00',
secondary_color: '#1A1A1A',
favicon_url: ''
});
useEffect(() => { useEffect(() => {
fetch(`${API}/config`) fetchConfig();
.then(r => r.json()) fetchDomains();
.then(d => setHasKey(d.has_api_key))
.catch(() => setHasKey(false));
}, []); }, []);
useEffect(() => {
if (selectedDomain) {
fetchSubdomains(selectedDomain.id);
}
}, [selectedDomain]);
async function fetchConfig() {
try {
const r = await fetch(`${API}/config`);
const d = await r.json();
setHasKey(d.has_api_key);
} catch (e) {
setHasKey(false);
}
}
async function saveKey() { async function saveKey() {
if (!apiKeyInput.trim()) return; if (!apiKeyInput.trim()) return;
setSaving(true); setSaving(true);
@@ -45,7 +89,8 @@ export default function App() {
if (r.ok) { if (r.ok) {
setHasKey(true); setHasKey(true);
setApiKeyInput(''); setApiKeyInput('');
setSuccess('Chave API salva e configurada com sucesso.'); setSuccess('Chave API salva e configurada.');
fetchDomains();
} else { } else {
const d = await r.json(); const d = await r.json();
setError(d.error || 'Erro ao salvar chave.'); setError(d.error || 'Erro ao salvar chave.');
@@ -55,25 +100,45 @@ export default function App() {
async function fetchDomains() { async function fetchDomains() {
setLoadingDomains(true); setLoadingDomains(true);
setError('');
setSuccess('');
try { try {
const r = await fetch(`${API}/subdomains`); const r = await fetch(`${API}/domains`);
const d = await r.json();
if (r.ok) { if (r.ok) {
setDomains(Array.isArray(d) ? d : d.data || []); const d = await r.json();
} else { setDomains(d);
setError(d.error || 'Erro ao buscar domínios na Hostinger.');
} }
} finally { setLoadingDomains(false); } } finally { setLoadingDomains(false); }
} }
async function syncDomains() {
setLoadingDomains(true);
setError('');
setSuccess('');
try {
const r = await fetch(`${API}/domains/sync`, { method: 'POST' });
const d = await r.json();
if (r.ok) {
setDomains(d.domains);
setSuccess('Sincronização concluída com sucesso!');
} else {
setError(d.error || 'Erro na sincronização.');
}
} finally { setLoadingDomains(false); }
}
async function fetchSubdomains(domainId: number) {
setLoadingSubdomains(true);
try {
const r = await fetch(`${API}/domains/${domainId}/subdomains`);
if (r.ok) {
const d = await r.json();
setSubdomains(d);
}
} finally { setLoadingSubdomains(false); }
}
async function createSubdomain(e: React.FormEvent) { async function createSubdomain(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!selectedDomain || !newSubdomain || !newValue) { if (!selectedDomain || !newSubdomain || !newValue) return;
setError('Preencha todos os campos do subdomínio.');
return;
}
setCreating(true); setCreating(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
@@ -82,201 +147,371 @@ export default function App() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
domain: selectedDomain, domainId: selectedDomain.id,
domainName: selectedDomain.name,
subdomain: newSubdomain, subdomain: newSubdomain,
value: newValue value: newValue
}) })
}); });
const d = await r.json();
if (r.ok) { if (r.ok) {
setSuccess(`Sucesso! ${newSubdomain}.${selectedDomain} foi criado/atualizado.`); setSuccess(`Subdomínio ${newSubdomain}.${selectedDomain.name} criado.`);
setNewSubdomain(''); setNewSubdomain('');
setNewValue(''); setNewValue('');
fetchSubdomains(selectedDomain.id);
} else { } else {
const d = await r.json();
setError(d.error || 'Erro ao criar subdomínio.'); setError(d.error || 'Erro ao criar subdomínio.');
} }
} catch (err: any) {
setError(err.message);
} finally { setCreating(false); } } finally { setCreating(false); }
} }
function openBranding(sub: Subdomain) {
setEditingBranding(sub);
setBrandingForm({
title: sub.title || '',
description: sub.description || '',
logo_url: sub.logo_url || '',
primary_color: sub.primary_color || '#FF6B00',
secondary_color: sub.secondary_color || '#1A1A1A',
favicon_url: sub.favicon_url || ''
});
}
async function saveBranding() {
if (!editingBranding) return;
try {
const r = await fetch(`${API}/branding/${editingBranding.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(brandingForm)
});
if (r.ok) {
setSuccess('Identidade visual atualizada!');
setEditingBranding(null);
if (selectedDomain) fetchSubdomains(selectedDomain.id);
}
} catch (e: any) {
setError(e.message);
}
}
return ( return (
<div className="min-h-screen bg-[#05080a] text-gray-100 p-4 md:p-10 font-sans selection:bg-orange-500/30"> <div className="min-h-screen bg-[#05080a] text-gray-100 font-sans selection:bg-orange-500/30">
<div className="max-w-4xl mx-auto space-y-8">
{/* BACKGROUND DECORATION */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-24 -left-24 w-96 h-96 bg-orange-600/10 rounded-full blur-3xl" />
<div className="absolute top-1/2 -right-24 w-64 h-64 bg-blue-600/5 rounded-full blur-3xl" />
</div>
<div className="max-w-7xl mx-auto px-6 py-10 relative">
{/* HEADER */} {/* HEADER */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 border-b border-gray-800/50 pb-8"> <header className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div className="flex items-center gap-4"> <div className="flex items-center gap-5">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-lg shadow-orange-500/20"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-2xl shadow-orange-500/20 transform hover:rotate-12 transition-transform cursor-pointer">
<span className="font-black text-2xl text-white">C</span> <span className="font-black text-3xl text-white">S</span>
</div> </div>
<div> <div>
<h1 className="font-black text-2xl uppercase tracking-tighter text-white">Gerenciador de DNS</h1> <h1 className="font-black text-3xl uppercase tracking-tighter text-white">Subdominio<span className="text-orange-500">.</span>Manager</h1>
<p className="text-gray-500 text-sm font-medium">Clube67 Automação Hostinger</p> <p className="text-gray-500 text-sm font-bold uppercase tracking-widest">Identidade Visual & DNS</p>
</div> </div>
</div> </div>
{hasKey && ( <div className="flex items-center gap-4">
<div className="flex items-center gap-2 bg-gray-900/50 border border-gray-800 px-4 py-2 rounded-2xl">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">API Hostinger Ativa</span>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* COLUNA ESQUERDA: CONFIG & LISTA */}
<div className="lg:col-span-5 space-y-8">
{/* CONFIG CARD */}
{!hasKey && (
<div className="bg-gray-900/40 border border-gray-800 rounded-3xl p-6 backdrop-blur-sm">
<h2 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500 mb-4">Configuração Necessária</h2>
<div className="space-y-4">
<input
type="password"
placeholder="Token de API Hostinger..."
value={apiKeyInput}
onChange={e => setApiKeyInput(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-orange-500/50 transition-all placeholder:text-gray-700"
/>
<button
onClick={saveKey}
disabled={saving || !apiKeyInput.trim()}
className="w-full bg-white text-black font-black py-4 rounded-2xl text-xs uppercase tracking-widest hover:bg-orange-500 hover:text-white transition-all disabled:opacity-20"
>
{saving ? 'Configurando...' : 'Ativar Sistema'}
</button>
</div>
</div>
)}
{/* LISTA DE DOMÍNIOS */}
{hasKey && ( {hasKey && (
<div className="bg-gray-900/40 border border-gray-800 rounded-3xl p-6 backdrop-blur-sm flex flex-col min-h-[400px]"> <button
<div className="flex items-center justify-between mb-6"> onClick={syncDomains}
<h2 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500">Meus Domínios</h2> disabled={loadingDomains}
className="group flex items-center gap-3 bg-gray-900/50 hover:bg-orange-500 border border-gray-800 hover:border-orange-400 px-6 py-3 rounded-2xl transition-all"
>
<div className={`w-2 h-2 rounded-full ${loadingDomains ? 'bg-orange-400 animate-spin' : 'bg-green-500 group-hover:bg-white'}`} />
<span className="text-xs font-black uppercase tracking-widest group-hover:text-white">Sincronizar Hostinger</span>
</button>
)}
<div className="bg-gray-900/50 border border-gray-800 px-4 py-3 rounded-2xl">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">v{APP_VERSION}</span>
</div>
</div>
</header>
{/* ALERTS */}
{(error || success) && (
<div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 p-5 rounded-3xl text-sm font-bold">🚨 {error}</div>}
{success && <div className="bg-green-500/10 border border-green-500/20 text-green-400 p-5 rounded-3xl text-sm font-bold"> {success}</div>}
</div>
)}
{!hasKey && hasKey !== null && (
<div className="max-w-xl mx-auto bg-gray-900/40 border border-gray-800 rounded-[2.5rem] p-10 backdrop-blur-xl text-center">
<h2 className="text-xl font-black mb-6">Configuração da API</h2>
<p className="text-gray-400 text-sm mb-8">Insira sua chave de API da Hostinger para começar a gerenciar seus domínios e identidades visuais.</p>
<div className="space-y-4">
<input
type="password"
placeholder="Token de API Hostinger..."
value={apiKeyInput}
onChange={e => setApiKeyInput(e.target.value)}
className="w-full bg-gray-950/50 border border-gray-800 rounded-2xl px-6 py-5 text-sm focus:outline-none focus:border-orange-500 transition-all text-center"
/>
<button
onClick={saveKey}
disabled={saving || !apiKeyInput.trim()}
className="w-full bg-white text-black font-black py-5 rounded-2xl text-xs uppercase tracking-[0.2em] hover:bg-orange-500 hover:text-white transition-all disabled:opacity-20"
>
{saving ? 'Validando...' : 'Ativar Sistema'}
</button>
</div>
</div>
)}
{hasKey && (
<div className="space-y-12">
{/* DOMAIN CARDS GRID */}
<section>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xs font-black uppercase tracking-[0.3em] text-gray-500">Selecione um Domínio</h2>
<div className="h-px bg-gray-800 flex-1 ml-6" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{domains.map((dom) => (
<button <button
onClick={fetchDomains} key={dom.id}
disabled={loadingDomains} onClick={() => setSelectedDomain(dom)}
className="text-[10px] font-bold text-orange-500 hover:text-orange-400 transition-colors uppercase tracking-widest" className={`group relative overflow-hidden p-8 rounded-[2rem] border transition-all text-left ${
selectedDomain?.id === dom.id
? 'bg-orange-500 border-orange-400 shadow-2xl shadow-orange-500/20'
: 'bg-gray-900/40 border-gray-800 hover:border-gray-600'
}`}
> >
{loadingDomains ? 'Atualizando...' : 'Sincronizar'} <div className={`absolute -right-4 -bottom-4 w-24 h-24 rounded-full blur-2xl transition-all ${
selectedDomain?.id === dom.id ? 'bg-white/20' : 'bg-gray-800/20 group-hover:bg-orange-500/10'
}`} />
<div className={`w-10 h-10 rounded-xl mb-6 flex items-center justify-center font-black text-lg ${
selectedDomain?.id === dom.id ? 'bg-white text-orange-500' : 'bg-gray-800 text-gray-400 group-hover:text-orange-500'
}`}>
{dom.name.charAt(0).toUpperCase()}
</div>
<h3 className={`font-black text-lg leading-tight mb-2 ${selectedDomain?.id === dom.id ? 'text-white' : 'text-gray-200'}`}>
{dom.name}
</h3>
<p className={`text-[10px] font-bold uppercase tracking-widest ${selectedDomain?.id === dom.id ? 'text-orange-100' : 'text-gray-500'}`}>
ID: {dom.hostinger_id || 'LOCAL'}
</p>
</button> </button>
))}
</div>
</section>
{/* SELECTED DOMAIN VIEW */}
{selectedDomain && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 animate-in fade-in slide-in-from-bottom-8 duration-500">
{/* SUBDOMAINS LIST */}
<div className="lg:col-span-8 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xs font-black uppercase tracking-[0.3em] text-gray-500">Subdomínios de {selectedDomain.name}</h2>
<span className="bg-gray-900 border border-gray-800 px-3 py-1 rounded-full text-[10px] font-bold text-gray-400">
{subdomains.length} registros
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{subdomains.map((sub) => (
<div key={sub.id} className="bg-gray-900/40 border border-gray-800 rounded-3xl p-6 hover:border-gray-700 transition-all group">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{sub.logo_url ? (
<img src={sub.logo_url} className="w-10 h-10 rounded-xl object-cover bg-white" alt="Logo" />
) : (
<div className="w-10 h-10 rounded-xl bg-gray-800 flex items-center justify-center text-xs font-black text-gray-500">
{sub.subdomain.charAt(0).toUpperCase()}
</div>
)}
<div>
<h4 className="font-black text-sm text-white">{sub.full_domain}</h4>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Aponta para: {sub.value}</p>
</div>
</div>
<button
onClick={() => openBranding(sub)}
className="w-8 h-8 rounded-lg bg-gray-800 flex items-center justify-center hover:bg-orange-500 transition-colors"
>
🎨
</button>
</div>
<div className="flex gap-2">
<div className="h-1.5 flex-1 rounded-full" style={{ backgroundColor: sub.primary_color || '#FF6B00' }} />
<div className="h-1.5 flex-1 rounded-full" style={{ backgroundColor: sub.secondary_color || '#1A1A1A' }} />
</div>
</div>
))}
{/* ADD NEW BOX */}
<div className="border-2 border-dashed border-gray-800 rounded-3xl p-6 flex flex-col items-center justify-center text-center group hover:border-orange-500/50 transition-all">
<div className="w-10 h-10 rounded-full bg-gray-900 flex items-center justify-center mb-3 group-hover:bg-orange-500 transition-colors">
<span className="font-black text-gray-500 group-hover:text-white">+</span>
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">Novo Subdomínio</p>
</div>
</div>
</div> </div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-[500px] pr-2 custom-scrollbar"> {/* CREATE FORM */}
{domains.length === 0 ? ( <div className="lg:col-span-4">
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-gray-800/50 rounded-3xl"> <div className="bg-gray-900/40 border border-gray-800 rounded-[2.5rem] p-8 sticky top-10 backdrop-blur-xl">
<p className="text-gray-600 text-xs text-center px-10">Clique em sincronizar para listar seus domínios da Hostinger.</p> <h2 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500 mb-8">Criar Subdomínio</h2>
</div> <form onSubmit={createSubdomain} className="space-y-6">
) : ( <div className="space-y-2">
domains.map((d, i) => ( <label className="text-[10px] font-black uppercase tracking-widest text-gray-600 ml-1">Subdomínio</label>
<div className="flex items-center bg-gray-950 border border-gray-800 rounded-2xl pr-5 overflow-hidden focus-within:border-orange-500 transition-all">
<input
placeholder="ex: app"
value={newSubdomain}
onChange={e => setNewSubdomain(e.target.value)}
className="flex-1 bg-transparent px-5 py-4 text-sm focus:outline-none"
/>
<span className="text-xs font-bold text-gray-600">.{selectedDomain.name}</span>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600 ml-1">Destino (IPv4)</label>
<input
placeholder="0.0.0.0"
value={newValue}
onChange={e => setNewValue(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-orange-500 transition-all"
/>
</div>
<button <button
key={i} type="submit"
onClick={() => setSelectedDomain(d.domain)} disabled={creating || !newSubdomain || !newValue}
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all border ${ className="w-full bg-orange-500 text-white font-black py-5 rounded-2xl text-xs uppercase tracking-widest hover:bg-orange-600 shadow-xl shadow-orange-500/20 transition-all disabled:opacity-20"
selectedDomain === d.domain
? 'bg-orange-500/10 border-orange-500/50 text-white'
: 'bg-gray-950/50 border-gray-800/50 text-gray-400 hover:border-gray-700'
}`}
> >
<span className="text-sm font-bold truncate">{d.domain}</span> {creating ? 'Criando...' : 'Confirmar Registro'}
<div className={`w-1.5 h-1.5 rounded-full ${selectedDomain === d.domain ? 'bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.6)]' : 'bg-gray-800'}`} />
</button> </button>
)) </form>
)} </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
)}
{/* COLUNA DIREITA: FORMULÁRIO DE CRIAÇÃO */} {/* BRANDING MODAL */}
<div className="lg:col-span-7"> {editingBranding && (
<div className="bg-gray-900/40 border border-gray-800 rounded-3xl p-8 backdrop-blur-sm h-full relative overflow-hidden"> <div className="fixed inset-0 z-50 flex items-center justify-center p-6 backdrop-blur-sm bg-black/60">
<div className="absolute top-0 right-0 p-8 opacity-5"> <div className="bg-gray-900 border border-gray-800 w-full max-w-2xl rounded-[2.5rem] overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<span className="text-8xl font-black">DNS</span> <div className="p-8 border-b border-gray-800 flex items-center justify-between bg-gray-900/50">
<div>
<h2 className="font-black text-xl text-white">Identidade Visual</h2>
<p className="text-gray-500 text-[10px] font-bold uppercase tracking-widest">{editingBranding.full_domain}</p>
</div>
<button onClick={() => setEditingBranding(null)} className="text-gray-500 hover:text-white transition-colors text-2xl font-black">×</button>
</div> </div>
<h2 className="text-xs font-black uppercase tracking-[0.2em] text-gray-500 mb-8">Novo Subdomínio</h2> <div className="p-8 grid grid-cols-1 md:grid-cols-2 gap-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div className="space-y-6">
<form onSubmit={createSubdomain} className="space-y-8">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600 ml-1">Domínio Selecionado</label>
<div className="bg-gray-950 border border-gray-800 rounded-2xl px-5 py-4 text-sm font-bold text-gray-300">
{selectedDomain || 'Selecione um domínio na lista ao lado'}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600 ml-1">Subdomínio (Nome)</label> <label className="text-[10px] font-black uppercase tracking-widest text-gray-600">Título da Página</label>
<input <input
placeholder="ex: app, bot, api" value={brandingForm.title}
value={newSubdomain} onChange={e => setBrandingForm({...brandingForm, title: e.target.value})}
onChange={e => setNewSubdomain(e.target.value)} className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-all"
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-orange-500/50 transition-all placeholder:text-gray-800"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600 ml-1">Destino (IPv4)</label> <label className="text-[10px] font-black uppercase tracking-widest text-gray-600">Descrição (Meta)</label>
<textarea
rows={3}
value={brandingForm.description}
onChange={e => setBrandingForm({...brandingForm, description: e.target.value})}
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600">URL do Logo</label>
<input <input
placeholder="0.0.0.0" value={brandingForm.logo_url}
value={newValue} onChange={e => setBrandingForm({...brandingForm, logo_url: e.target.value})}
onChange={e => setNewValue(e.target.value)} className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-all"
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-orange-500/50 transition-all placeholder:text-gray-800"
/> />
</div> </div>
</div> </div>
<div className="pt-4"> <div className="space-y-6">
<button <div className="grid grid-cols-2 gap-4">
type="submit" <div className="space-y-2">
disabled={creating || !selectedDomain || !newSubdomain || !newValue} <label className="text-[10px] font-black uppercase tracking-widest text-gray-600">Cor Primária</label>
className="w-full bg-orange-500 text-white font-black py-5 rounded-2xl text-sm uppercase tracking-widest hover:bg-orange-600 shadow-lg shadow-orange-500/20 transition-all disabled:opacity-20 disabled:grayscale" <input
> type="color"
{creating ? 'Processando na Hostinger...' : 'Criar Registro DNS'} value={brandingForm.primary_color}
</button> onChange={e => setBrandingForm({...brandingForm, primary_color: e.target.value})}
className="w-full h-12 bg-gray-950 border border-gray-800 rounded-2xl p-1 cursor-pointer"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600">Cor Secundária</label>
<input
type="color"
value={brandingForm.secondary_color}
onChange={e => setBrandingForm({...brandingForm, secondary_color: e.target.value})}
className="w-full h-12 bg-gray-950 border border-gray-800 rounded-2xl p-1 cursor-pointer"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-600">Favicon URL</label>
<input
value={brandingForm.favicon_url}
onChange={e => setBrandingForm({...brandingForm, favicon_url: e.target.value})}
className="w-full bg-gray-950 border border-gray-800 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-all"
/>
</div>
{/* PREVIEW */}
<div className="p-4 bg-gray-950 border border-gray-800 rounded-3xl mt-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-700 mb-3 text-center">Preview</p>
<div className="flex items-center gap-3 p-3 bg-white/5 rounded-2xl border border-white/5">
<div className="w-8 h-8 rounded-lg" style={{ backgroundColor: brandingForm.primary_color }} />
<div className="w-8 h-8 rounded-lg" style={{ backgroundColor: brandingForm.secondary_color }} />
<span className="text-[10px] font-bold text-gray-400">Paleta de Cores</span>
</div>
</div>
</div> </div>
</div>
{error && ( <div className="p-8 bg-gray-900/50 border-t border-gray-800 flex gap-4">
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-2xl text-xs font-bold animate-shake"> <button
🚨 {error} onClick={() => setEditingBranding(null)}
</div> className="flex-1 bg-gray-800 hover:bg-gray-700 text-white font-black py-4 rounded-2xl text-xs uppercase tracking-widest transition-all"
)} >
Cancelar
{success && ( </button>
<div className="bg-green-500/10 border border-green-500/20 text-green-400 p-4 rounded-2xl text-xs font-bold"> <button
{success} onClick={saveBranding}
</div> className="flex-1 bg-orange-500 hover:bg-orange-600 text-white font-black py-4 rounded-2xl text-xs uppercase tracking-widest transition-all shadow-lg shadow-orange-500/20"
)} >
</form> Salvar Identidade
</button>
</div>
</div> </div>
</div> </div>
</div> )}
{/* FOOTER */}
<div className="flex items-center justify-center gap-4 pt-4">
<div className="h-px bg-gray-800 flex-1" />
<p className="text-[10px] font-bold text-gray-600 uppercase tracking-[0.2em] whitespace-nowrap">
<span className="bg-gray-900 border border-gray-800 px-2 py-1 rounded-md text-gray-400 mr-2">{APP_VERSION}</span>
subdominio.clube67.com acesso restrito VPN
</p>
<div className="h-px bg-gray-800 flex-1" />
</div>
</div> </div>
<style>{` <style>{`
.custom-scrollbar::-webkit-scrollbar { width: 4px; } .custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; } .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 10px; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.animate-shake { animation: shake 0.2s ease-in-out 0s 2; }
`}</style> `}</style>
</div> </div>
); );
+22
View File
@@ -0,0 +1,22 @@
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const versionFile = join(__dirname, 'frontend/src/constants.ts');
try {
const content = readFileSync(versionFile, 'utf8');
const versionMatch = content.match(/V(\d+)\.(\d+)\.(\d+)/);
if (versionMatch) {
let [full, major, minor, patch] = versionMatch;
patch = parseInt(patch) + 1;
const newVersion = `V${major}.${minor}.${patch}`;
const newContent = content.replace(/V\d+\.\d+\.\d+/, newVersion);
writeFileSync(versionFile, newContent);
console.log(`Version bumped to ${newVersion}`);
}
} catch (e) {
console.error('Error updating version:', e.message);
}