Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/src/db/environment.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"KEYWORDS": [
"Java"
"Java",
"Spring",
"RabbitMQ",
"Docker"
]
}
56 changes: 55 additions & 1 deletion backend/src/jobsApiApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Comment on lines +49 to +51

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

express.json() is being registered twice: once with a 16kb limit and again later without options. The second call overrides the intended body-size limit and adds redundant parsing work; keep a single express.json() middleware (with the desired limit) and remove the duplicate registration.

Copilot uses AI. Check for mistakes.
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());

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second app.use(express.json()) is redundant (and currently defeats the earlier { limit: "16kb" } configuration). Remove it so requests are parsed once with the configured limits.

Suggested change
app.use(express.json());

Copilot uses AI. Check for mistakes.

function listXlsxFiles() {
Expand Down Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/integration/jobsApi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +44 to +50

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new CORS tests depend on whatever process.env.CORS_ALLOWED_ORIGINS is set to in the environment running the tests; if it is set without including the official origin, the test will fail. To make the suite deterministic, explicitly set (or delete) process.env.CORS_ALLOWED_ORIGINS in a beforeEach/afterEach for these cases.

Copilot uses AI. Check for mistakes.

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-"));
Expand Down
6 changes: 4 additions & 2 deletions backend/tests/unit/services/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two expect(...) statements on the same line here, which hurts readability and may violate the repo's formatting/lint rules. Split them into separate lines so failures are easier to pinpoint and the file remains consistently formatted.

Suggested change
expect(mocks.listen).toHaveBeenCalled(); expect(mocks.listen.mock.calls[0][0]).toBe(3100);
expect(mocks.listen).toHaveBeenCalled();
expect(mocks.listen.mock.calls[0][0]).toBe(3100);

Copilot uses AI. Check for mistakes.
expect(typeof mocks.listen.mock.calls[0][1]).toBe("function");

const onListen = mocks.listen.mock.calls[0][1];
Expand Down
5 changes: 5 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
33 changes: 28 additions & 5 deletions frontend/src/services/jobsService.ts
Original file line number Diff line number Diff line change
@@ -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 "";
}

Comment on lines +3 to +19

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getApiBaseUrl() adds a new production-only fallback branch based on window.location.hostname, but there is no unit test covering that behavior. Add a test that sets the hostname to painel-vagas-lake.vercel.app and asserts requests are built against the VPS base URL so this production routing can’t regress silently.

Suggested change
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 "";
}
const VERCEL_PRODUCTION_HOSTNAME = "painel-vagas-lake.vercel.app";
const VPS_API_BASE_URL = "https://jobsglobalscraper.ddns.net";
function normalizeBaseUrl(value: string | undefined): string {
return typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
}
export function resolveApiBaseUrl(configuredBaseUrl: string | undefined, hostname?: string): string {
const normalizedConfiguredBaseUrl = normalizeBaseUrl(configuredBaseUrl);
if (normalizedConfiguredBaseUrl) {
return normalizedConfiguredBaseUrl;
}
if (hostname === VERCEL_PRODUCTION_HOSTNAME) {
return VPS_API_BASE_URL;
}
return "";
}
function getApiBaseUrl(): string {
return resolveApiBaseUrl(
import.meta.env.VITE_API_BASE_URL,
typeof window !== "undefined" ? window.location.hostname : undefined,
);
}

Copilot uses AI. Check for mistakes.
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);
}
Expand All @@ -15,7 +38,7 @@ function readMessage(payload: unknown): string | undefined {
}

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

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

export async function fetchJobsByFile(fileName: string): Promise<JobsResponse> {
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<string, unknown>;

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

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

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

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

export async function runScraperRequest(): Promise<void> {
const response = await fetch("/api/scraper/run", {
const response = await fetch(buildApiUrl("/api/scraper/run"), {
method: "POST",
});
const payload = (await response.json()) as Record<string, unknown>;
Expand Down
10 changes: 10 additions & 0 deletions frontend/tests/unit/utils/jobsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Loading