Skip to content

Commit 3c5c885

Browse files
authored
Develop (#67)
2 parents 488997e + d7393f2 commit 3c5c885

10 files changed

Lines changed: 135 additions & 11 deletions

File tree

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Runtime
22
NODE_ENV=production
3+
PORT=3001
4+
5+
# CORS / frontend access
6+
CORS_ALLOWED_ORIGINS=https://painel-vagas-lake.vercel.app,http://localhost:5173,http://localhost:5174
37

48
# LinkedIn search filters
59
SEARCH_LOCATION=Brasil

backend/src/db/environment.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"KEYWORDS": [
3-
"Java"
3+
"Java",
4+
"Spring",
5+
"RabbitMQ",
6+
"Docker"
47
]
58
}

backend/src/jobsApiApp.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,59 @@ import { getConfig } from "./config.js";
88
import { searchJobsWithCache } from "./pipeline/searchJobsWithCache.js";
99
import { sources } from "./sources/index.js";
1010

11+
const DEFAULT_ALLOWED_ORIGINS = [
12+
"https://painel-vagas-lake.vercel.app",
13+
"http://localhost:5173",
14+
"http://localhost:5174",
15+
];
16+
17+
function parseAllowedOrigins(value) {
18+
const configuredOrigins = String(value ?? "")
19+
.split(",")
20+
.map((origin) => origin.trim())
21+
.filter(Boolean);
22+
23+
return new Set(configuredOrigins.length > 0 ? configuredOrigins : DEFAULT_ALLOWED_ORIGINS);
24+
}
25+
1126
/**
1227
* @param {{ outputDir?: string }} [options]
1328
*/
1429
export function createJobsApiApp(options = {}) {
1530
const outputDir = options.outputDir ?? path.resolve(process.cwd(), "output");
31+
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS);
32+
const corsOptions = {
33+
origin(origin, callback) {
34+
if (!origin || allowedOrigins.has(origin)) {
35+
callback(null, true);
36+
return;
37+
}
38+
39+
callback(new Error("Origin not allowed by CORS"));
40+
},
41+
methods: ["GET", "POST", "OPTIONS"],
42+
allowedHeaders: ["Content-Type"],
43+
credentials: false,
44+
maxAge: 86400,
45+
};
1646
const app = express();
1747
let activeScraperRun = null;
1848

19-
app.use(cors());
49+
app.disable("x-powered-by");
50+
app.use(express.json({ limit: "16kb" }));
51+
app.use((req, res, next) => {
52+
res.setHeader("X-Content-Type-Options", "nosniff");
53+
res.setHeader("X-Frame-Options", "DENY");
54+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
55+
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
56+
57+
if (req.secure || req.get("x-forwarded-proto") === "https") {
58+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
59+
}
60+
61+
next();
62+
});
63+
app.use(cors(corsOptions));
2064
app.use(express.json());
2165

2266
function listXlsxFiles() {
@@ -322,5 +366,15 @@ export function createJobsApiApp(options = {}) {
322366
}
323367
});
324368

369+
app.use((error, _req, res, next) => {
370+
if (error?.message === "Origin not allowed by CORS") {
371+
return res.status(403).json({
372+
message: "Origem nao permitida.",
373+
});
374+
}
375+
376+
return next(error);
377+
});
378+
325379
return app;
326380
}

backend/src/server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const outputDir = process.env.ELECTRON_OUTPUT_DIR
1313
: path.resolve(process.cwd(), "output");
1414

1515
const app = createJobsApiApp({ outputDir });
16+
app.set("trust proxy", 1);
1617

1718
async function registerSwaggerDocs() {
1819
try {

backend/tests/integration/jobsApi.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@ describe("jobs API", () => {
4040
const res = await request(app).get("/api/jobs/files").expect(200);
4141
expect(res.body.files).toEqual([]);
4242
});
43+
44+
it("permite CORS para o frontend oficial", async () => {
45+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
46+
const app = createJobsApiApp({ outputDir: tmpDir });
47+
const res = await request(app)
48+
.get("/api/health")
49+
.set("Origin", "https://painel-vagas-lake.vercel.app")
50+
.expect(200);
51+
52+
expect(res.headers["access-control-allow-origin"]).toBe("https://painel-vagas-lake.vercel.app");
53+
});
54+
55+
it("bloqueia origens nao autorizadas", async () => {
56+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
57+
const app = createJobsApiApp({ outputDir: tmpDir });
58+
const res = await request(app)
59+
.get("/api/health")
60+
.set("Origin", "https://malicioso.example")
61+
.expect(403);
62+
63+
expect(res.body.message).toBe("Origem nao permitida.");
64+
});
4365

4466
it("GET /api/jobs retorna 404 quando nao ha planilhas", async () => {
4567
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));

backend/tests/unit/services/server.test.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44
const mocks = vi.hoisted(() => ({
55
listen: vi.fn(),
66
use: vi.fn(),
7+
set: vi.fn(),
78
createJobsApiApp: vi.fn(),
89
consoleLog: vi.fn(),
910
existsSync: vi.fn(),
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
1617
mocks.createJobsApiApp.mockReturnValue({
1718
listen: mocks.listen,
1819
use: mocks.use,
20+
set: mocks.set,
1921
});
2022

2123
mocks.expressStatic.mockReturnValue(mocks.staticMiddleware);
@@ -68,8 +70,8 @@ describe("server entry", () => {
6870
await importServerEntry();
6971

7072
expect(mocks.createJobsApiApp).toHaveBeenCalledTimes(1);
71-
expect(mocks.listen).toHaveBeenCalled();
72-
expect(mocks.listen.mock.calls[0][0]).toBe(3100);
73+
expect(mocks.set).toHaveBeenCalledWith("trust proxy", 1);
74+
expect(mocks.listen).toHaveBeenCalled(); expect(mocks.listen.mock.calls[0][0]).toBe(3100);
7375
expect(typeof mocks.listen.mock.calls[0][1]).toBe("function");
7476

7577
const onListen = mocks.listen.mock.calls[0][1];

frontend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Frontend -> backend API endpoint for production deployments
2+
VITE_API_BASE_URL=https://jobsglobalscraper.ddns.net
3+
4+
# Optional override for local dev when using the Vite proxy
5+
# VITE_API_PROXY_TARGET=http://localhost:3001

frontend/src/services/jobsService.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import type { JobFile, JobsResponse } from "@/types/jobs";
22

3+
function normalizeBaseUrl(value: string | undefined): string {
4+
return typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
5+
}
6+
7+
function getApiBaseUrl(): string {
8+
const configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL);
9+
if (configuredBaseUrl) {
10+
return configuredBaseUrl;
11+
}
12+
13+
if (typeof window !== "undefined" && window.location.hostname === "painel-vagas-lake.vercel.app") {
14+
return "https://jobsglobalscraper.ddns.net";
15+
}
16+
17+
return "";
18+
}
19+
20+
function buildApiUrl(path: string): string {
21+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
22+
const baseUrl = getApiBaseUrl();
23+
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
24+
}
25+
326
function buildError(message: unknown, fallback: string): Error {
427
return new Error(typeof message === "string" && message ? message : fallback);
528
}
@@ -15,7 +38,7 @@ function readMessage(payload: unknown): string | undefined {
1538
}
1639

1740
export async function fetchJobFiles(): Promise<JobFile[]> {
18-
const response = await fetch("/api/jobs/files");
41+
const response = await fetch(buildApiUrl("/api/jobs/files"));
1942
const payload = (await response.json()) as { files?: unknown } & Record<string, unknown>;
2043

2144
if (!response.ok) {
@@ -34,7 +57,7 @@ export async function fetchJobFiles(): Promise<JobFile[]> {
3457

3558
export async function fetchJobsByFile(fileName: string): Promise<JobsResponse> {
3659
const suffix = fileName ? `?file=${encodeURIComponent(fileName)}` : "";
37-
const response = await fetch(`/api/jobs${suffix}`);
60+
const response = await fetch(buildApiUrl(`/api/jobs${suffix}`));
3861
const payload = (await response.json()) as Record<string, unknown>;
3962

4063
if (!response.ok) {
@@ -50,7 +73,7 @@ export async function fetchJobsByFile(fileName: string): Promise<JobsResponse> {
5073
}
5174

5275
export async function fetchKeywords(): Promise<string[]> {
53-
const response = await fetch("/api/keywords");
76+
const response = await fetch(buildApiUrl("/api/keywords"));
5477
const payload = (await response.json()) as { keywords?: unknown } & Record<string, unknown>;
5578

5679
if (!response.ok) {
@@ -61,7 +84,7 @@ export async function fetchKeywords(): Promise<string[]> {
6184
}
6285

6386
export async function saveKeywords(keywords: string[]): Promise<void> {
64-
const response = await fetch("/api/keywords", {
87+
const response = await fetch(buildApiUrl("/api/keywords"), {
6588
method: "POST",
6689
headers: {
6790
"Content-Type": "application/json",
@@ -76,7 +99,7 @@ export async function saveKeywords(keywords: string[]): Promise<void> {
7699
}
77100

78101
export async function runScraperRequest(): Promise<void> {
79-
const response = await fetch("/api/scraper/run", {
102+
const response = await fetch(buildApiUrl("/api/scraper/run"), {
80103
method: "POST",
81104
});
82105
const payload = (await response.json()) as Record<string, unknown>;

frontend/tests/unit/utils/jobsService.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
describe("jobsService", () => {
55
afterEach(() => {
66
vi.restoreAllMocks();
7+
vi.unstubAllEnvs();
78
});
89

910
it("retorna lista de arquivos", async () => {
@@ -16,6 +17,15 @@ describe("jobsService", () => {
1617
expect(files).toEqual([{ file: "vagas.xlsx" }]);
1718
});
1819

20+
it("usa a URL da VPS quando VITE_API_BASE_URL estiver configurada", async () => {
21+
vi.stubEnv("VITE_API_BASE_URL", "https://jobsglobalscraper.ddns.net/");
22+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ files: [] }) }));
23+
vi.stubGlobal("fetch", fetchMock);
24+
25+
await fetchJobFiles();
26+
expect(fetchMock).toHaveBeenCalledWith("https://jobsglobalscraper.ddns.net/api/jobs/files");
27+
});
28+
1929
it("filtra entradas invalidas ao listar arquivos", async () => {
2030
vi.stubGlobal(
2131
"fetch",

frontend/vite.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { defineConfig } from 'vite'
21
import react from '@vitejs/plugin-react'
32
import path from 'path'
43
import { fileURLToPath } from 'url'
4+
import { defineConfig } from 'vite'
55

66
const __dirname = path.dirname(fileURLToPath(import.meta.url))
7-
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://localhost:3001'
7+
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || process.env.VITE_API_BASE_URL || 'http://localhost:3001'
88

99
// https://vite.dev/config/
1010
export default defineConfig({

0 commit comments

Comments
 (0)