From aab958818255ec78433ef58f67e8946f0c48ea31 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Tue, 7 Apr 2026 11:50:15 -0300 Subject: [PATCH 1/2] feat: connect hosted frontend to secure VPS backend --- backend/.env.example | 4 ++ backend/src/jobsApiApp.js | 56 ++++++++++++++++++- backend/src/server.js | 1 + backend/tests/integration/jobsApi.test.js | 22 ++++++++ frontend/.env.example | 5 ++ frontend/src/services/jobsService.ts | 33 +++++++++-- frontend/tests/unit/utils/jobsService.test.ts | 10 ++++ frontend/vite.config.js | 4 +- 8 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 frontend/.env.example diff --git a/backend/.env.example b/backend/.env.example index 845a659..1ba237e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,9 @@ # Runtime NODE_ENV=production +PORT=3001 + +# CORS / frontend access +CORS_ALLOWED_ORIGINS=https://painel-vagas-lake.vercel.app,http://localhost:5173,http://localhost:5174 # LinkedIn search filters SEARCH_LOCATION=Brasil diff --git a/backend/src/jobsApiApp.js b/backend/src/jobsApiApp.js index eb33337..457e607 100644 --- a/backend/src/jobsApiApp.js +++ b/backend/src/jobsApiApp.js @@ -8,15 +8,59 @@ import { getConfig } from "./config.js"; import { searchJobsWithCache } from "./pipeline/searchJobsWithCache.js"; import { sources } from "./sources/index.js"; +const DEFAULT_ALLOWED_ORIGINS = [ + "https://painel-vagas-lake.vercel.app", + "http://localhost:5173", + "http://localhost:5174", +]; + +function parseAllowedOrigins(value) { + const configuredOrigins = String(value ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + + return new Set(configuredOrigins.length > 0 ? configuredOrigins : DEFAULT_ALLOWED_ORIGINS); +} + /** * @param {{ outputDir?: string }} [options] */ export function createJobsApiApp(options = {}) { const outputDir = options.outputDir ?? path.resolve(process.cwd(), "output"); + const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS); + const corsOptions = { + origin(origin, callback) { + if (!origin || allowedOrigins.has(origin)) { + callback(null, true); + return; + } + + callback(new Error("Origin not allowed by CORS")); + }, + methods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["Content-Type"], + credentials: false, + maxAge: 86400, + }; const app = express(); let activeScraperRun = null; - app.use(cors()); + app.disable("x-powered-by"); + app.use(express.json({ limit: "16kb" })); + app.use((req, res, next) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + + if (req.secure || req.get("x-forwarded-proto") === "https") { + res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + + next(); + }); + app.use(cors(corsOptions)); app.use(express.json()); function listXlsxFiles() { @@ -322,5 +366,15 @@ export function createJobsApiApp(options = {}) { } }); + app.use((error, _req, res, next) => { + if (error?.message === "Origin not allowed by CORS") { + return res.status(403).json({ + message: "Origem nao permitida.", + }); + } + + return next(error); + }); + return app; } diff --git a/backend/src/server.js b/backend/src/server.js index 9fb7c8d..fed890d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -13,6 +13,7 @@ const outputDir = process.env.ELECTRON_OUTPUT_DIR : path.resolve(process.cwd(), "output"); const app = createJobsApiApp({ outputDir }); +app.set("trust proxy", 1); async function registerSwaggerDocs() { try { diff --git a/backend/tests/integration/jobsApi.test.js b/backend/tests/integration/jobsApi.test.js index 456b268..394378c 100644 --- a/backend/tests/integration/jobsApi.test.js +++ b/backend/tests/integration/jobsApi.test.js @@ -40,6 +40,28 @@ describe("jobs API", () => { const res = await request(app).get("/api/jobs/files").expect(200); expect(res.body.files).toEqual([]); }); + + it("permite CORS para o frontend oficial", async () => { + tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); + const app = createJobsApiApp({ outputDir: tmpDir }); + const res = await request(app) + .get("/api/health") + .set("Origin", "https://painel-vagas-lake.vercel.app") + .expect(200); + + expect(res.headers["access-control-allow-origin"]).toBe("https://painel-vagas-lake.vercel.app"); + }); + + it("bloqueia origens nao autorizadas", async () => { + tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); + const app = createJobsApiApp({ outputDir: tmpDir }); + const res = await request(app) + .get("/api/health") + .set("Origin", "https://malicioso.example") + .expect(403); + + expect(res.body.message).toBe("Origem nao permitida."); + }); it("GET /api/jobs retorna 404 quando nao ha planilhas", async () => { tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6c9c9d7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Frontend -> backend API endpoint for production deployments +VITE_API_BASE_URL=https://jobsglobalscraper.ddns.net + +# Optional override for local dev when using the Vite proxy +# VITE_API_PROXY_TARGET=http://localhost:3001 diff --git a/frontend/src/services/jobsService.ts b/frontend/src/services/jobsService.ts index ab5b9d5..dc15085 100644 --- a/frontend/src/services/jobsService.ts +++ b/frontend/src/services/jobsService.ts @@ -1,5 +1,28 @@ import type { JobFile, JobsResponse } from "@/types/jobs"; +function normalizeBaseUrl(value: string | undefined): string { + return typeof value === "string" ? value.trim().replace(/\/+$/, "") : ""; +} + +function getApiBaseUrl(): string { + const configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL); + if (configuredBaseUrl) { + return configuredBaseUrl; + } + + if (typeof window !== "undefined" && window.location.hostname === "painel-vagas-lake.vercel.app") { + return "https://jobsglobalscraper.ddns.net"; + } + + return ""; +} + +function buildApiUrl(path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const baseUrl = getApiBaseUrl(); + return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; +} + function buildError(message: unknown, fallback: string): Error { return new Error(typeof message === "string" && message ? message : fallback); } @@ -15,7 +38,7 @@ function readMessage(payload: unknown): string | undefined { } export async function fetchJobFiles(): Promise { - const response = await fetch("/api/jobs/files"); + const response = await fetch(buildApiUrl("/api/jobs/files")); const payload = (await response.json()) as { files?: unknown } & Record; if (!response.ok) { @@ -34,7 +57,7 @@ export async function fetchJobFiles(): Promise { export async function fetchJobsByFile(fileName: string): Promise { const suffix = fileName ? `?file=${encodeURIComponent(fileName)}` : ""; - const response = await fetch(`/api/jobs${suffix}`); + const response = await fetch(buildApiUrl(`/api/jobs${suffix}`)); const payload = (await response.json()) as Record; if (!response.ok) { @@ -50,7 +73,7 @@ export async function fetchJobsByFile(fileName: string): Promise { } export async function fetchKeywords(): Promise { - const response = await fetch("/api/keywords"); + const response = await fetch(buildApiUrl("/api/keywords")); const payload = (await response.json()) as { keywords?: unknown } & Record; if (!response.ok) { @@ -61,7 +84,7 @@ export async function fetchKeywords(): Promise { } export async function saveKeywords(keywords: string[]): Promise { - const response = await fetch("/api/keywords", { + const response = await fetch(buildApiUrl("/api/keywords"), { method: "POST", headers: { "Content-Type": "application/json", @@ -76,7 +99,7 @@ export async function saveKeywords(keywords: string[]): Promise { } export async function runScraperRequest(): Promise { - const response = await fetch("/api/scraper/run", { + const response = await fetch(buildApiUrl("/api/scraper/run"), { method: "POST", }); const payload = (await response.json()) as Record; diff --git a/frontend/tests/unit/utils/jobsService.test.ts b/frontend/tests/unit/utils/jobsService.test.ts index 805c9c2..bc0fc32 100644 --- a/frontend/tests/unit/utils/jobsService.test.ts +++ b/frontend/tests/unit/utils/jobsService.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; describe("jobsService", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it("retorna lista de arquivos", async () => { @@ -16,6 +17,15 @@ describe("jobsService", () => { expect(files).toEqual([{ file: "vagas.xlsx" }]); }); + it("usa a URL da VPS quando VITE_API_BASE_URL estiver configurada", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://jobsglobalscraper.ddns.net/"); + const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ files: [] }) })); + vi.stubGlobal("fetch", fetchMock); + + await fetchJobFiles(); + expect(fetchMock).toHaveBeenCalledWith("https://jobsglobalscraper.ddns.net/api/jobs/files"); + }); + it("filtra entradas invalidas ao listar arquivos", async () => { vi.stubGlobal( "fetch", diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2422e86..c910189 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,10 +1,10 @@ -import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' import { fileURLToPath } from 'url' +import { defineConfig } from 'vite' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://localhost:3001' +const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || process.env.VITE_API_BASE_URL || 'http://localhost:3001' // https://vite.dev/config/ export default defineConfig({ From f6ca2a5eb4642cbba9cd184fb21394f652384957 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Tue, 7 Apr 2026 11:58:38 -0300 Subject: [PATCH 2/2] feat: update keywords in environment configuration and enhance server test mocks --- backend/src/db/environment.json | 5 ++++- backend/tests/unit/services/server.test.js | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/src/db/environment.json b/backend/src/db/environment.json index 1d2e8d3..7b4d508 100644 --- a/backend/src/db/environment.json +++ b/backend/src/db/environment.json @@ -1,5 +1,8 @@ { "KEYWORDS": [ - "Java" + "Java", + "Spring", + "RabbitMQ", + "Docker" ] } \ No newline at end of file diff --git a/backend/tests/unit/services/server.test.js b/backend/tests/unit/services/server.test.js index bdf2d5a..2e27016 100644 --- a/backend/tests/unit/services/server.test.js +++ b/backend/tests/unit/services/server.test.js @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listen: vi.fn(), use: vi.fn(), + set: vi.fn(), createJobsApiApp: vi.fn(), consoleLog: vi.fn(), existsSync: vi.fn(), @@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({ mocks.createJobsApiApp.mockReturnValue({ listen: mocks.listen, use: mocks.use, + set: mocks.set, }); mocks.expressStatic.mockReturnValue(mocks.staticMiddleware); @@ -68,8 +70,8 @@ describe("server entry", () => { await importServerEntry(); expect(mocks.createJobsApiApp).toHaveBeenCalledTimes(1); - expect(mocks.listen).toHaveBeenCalled(); - expect(mocks.listen.mock.calls[0][0]).toBe(3100); + expect(mocks.set).toHaveBeenCalledWith("trust proxy", 1); + expect(mocks.listen).toHaveBeenCalled(); expect(mocks.listen.mock.calls[0][0]).toBe(3100); expect(typeof mocks.listen.mock.calls[0][1]).toBe("function"); const onListen = mocks.listen.mock.calls[0][1];