Skip to content

Commit 8826339

Browse files
authored
feat: secure redis env config for VPS (#75)
2 parents 63326a4 + 41cdc80 commit 8826339

18 files changed

Lines changed: 1029 additions & 127 deletions

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,29 @@ npm run dev:frontend
134134
npm run dev:backend
135135
```
136136

137+
## Configuracao segura na VPS (Redis / Postgres)
138+
139+
Para ambiente de VPS e projeto open source, **nao coloque `DATABASE_URL` nem `REDIS_URL` em `environment.json`**.
140+
Use somente `backend/.env` no servidor (arquivo ignorado pelo Git) e mantenha `KEYWORDS_STORAGE_MODE=env`.
141+
142+
Exemplo seguro:
143+
144+
```env
145+
KEYWORDS_STORAGE_MODE=env
146+
SEARCH_KEYWORDS=Java,Spring,RabbitMQ,Docker
147+
DATABASE_URL=postgresql://app_user:senha_forte@127.0.0.1:5432/jobsglobalscraper
148+
REDIS_URL=redis://:senha_forte@127.0.0.1:6379/0
149+
REDIS_KEY_PREFIX=vagas-full
150+
CACHE_TTL_MS=600000
151+
```
152+
153+
Recomendacoes:
154+
155+
- use `127.0.0.1` ou a rede interna do Docker na VPS, em vez de expor Redis publicamente
156+
- prefira `rediss://` se o Redis estiver fora da maquina local/VPS
157+
- mantenha a porta `6379` fechada para acesso externo no firewall
158+
- com `KEYWORDS_STORAGE_MODE=env`, o backend deixa de depender de `backend/src/db/environment.json` em producao
159+
137160
## Scripts principais
138161

139162
### Raiz

backend/.env.example

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ NODE_ENV=production
33
PORT=3001
44

55
# CORS / frontend access
6-
CORS_ALLOWED_ORIGINS=https://painel-vagas-lake.vercel.app,http://localhost:5173,http://localhost:5174
6+
CORS_ALLOWED_ORIGINS=https://painel-vagas-lake.vercel.app,https://painel-vagas-m6hbzlqeh-bene-teslas-projects.vercel.app,http://localhost:5173,http://localhost:5174,http://jobsglobalscraper.ddns.net,https://jobsglobalscraper.ddns.net
77

88
# LinkedIn search filters
99
SEARCH_LOCATION=Brasil
@@ -12,8 +12,20 @@ SEARCH_LANGUAGE=pt
1212
REMOTE_ONLY=true
1313
JOB_TYPES=C,F
1414
TIME_FILTER=r604800
15+
16+
# Keywords storage:
17+
# - redis => fonte de verdade para salvar e ler keywords
18+
# SEARCH_KEYWORDS fica apenas como fallback inicial se a chave ainda estiver vazia no Redis
19+
KEYWORDS_STORAGE_MODE=redis
1520
SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner
1621

22+
# External services (nunca commitar valores reais)
23+
DATABASE_URL=postgresql://app_user:change_me@127.0.0.1:5432/jobsglobalscraper
24+
REDIS_URL=redis://:change_me@127.0.0.1:6379/0
25+
REDIS_KEY_PREFIX=vagas-full
26+
KEYWORDS_REDIS_KEY=vagas-full:keywords
27+
CACHE_TTL_MS=600000
28+
1729
# Scraping behavior
1830
WAIT_BETWEEN_SEARCHES_MS=5000
1931
PAGE_TIMEOUT_MS=10000

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"dotenv": "^17.3.1",
3838
"express": "^5.2.1",
3939
"pdfkit": "^0.18.0",
40+
"redis": "^4.7.1",
4041
"swagger-jsdoc": "^6.2.8",
4142
"swagger-ui-express": "^5.0.1",
4243
"xlsx": "^0.18.5"

backend/src/app.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { getConfig } from "./config.js";
2+
import { loadKeywords } from "./db/keywordsStore.js";
23
import { exportToExcel, exportToPDF } from "./exporter.js";
34
import { logInfo } from "./logger.js";
45
import { searchJobsWithCache } from "./pipeline/searchJobsWithCache.js";
56
import { sources } from "./sources/index.js";
67

78
export async function run() {
8-
const config = getConfig();
9+
const baseConfig = getConfig();
10+
const config = {
11+
...baseConfig,
12+
keywords: await loadKeywords(baseConfig.keywords),
13+
};
914

1015
logInfo(`Localização da busca: ${config.searchLocation}`);
1116
logInfo(`Total de palavras-chave: ${config.keywords.length}`);

backend/src/cache/cache.js

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,131 @@
1+
let redisClientPromise = null;
2+
let redisClientUrl = "";
3+
let redisWarningShown = false;
4+
5+
const cacheStatus = {
6+
provider: "memory",
7+
configured: false,
8+
connected: false,
9+
lastError: null,
10+
};
11+
12+
function readRedisUrl() {
13+
return process.env.REDIS_URL?.trim() || "";
14+
}
15+
16+
function syncCacheStatus() {
17+
const redisUrl = readRedisUrl();
18+
cacheStatus.configured = Boolean(redisUrl);
19+
cacheStatus.provider = redisUrl ? "redis" : "memory";
20+
21+
if (!redisUrl) {
22+
cacheStatus.connected = false;
23+
cacheStatus.lastError = null;
24+
}
25+
}
26+
27+
function warnRedisFallback(message, error) {
28+
cacheStatus.connected = false;
29+
cacheStatus.lastError = error instanceof Error ? error.message : null;
30+
31+
if (redisWarningShown) {
32+
return;
33+
}
34+
35+
redisWarningShown = true;
36+
37+
const errorMessage = error instanceof Error ? error.message : "";
38+
// eslint-disable-next-line no-console
39+
console.warn(`${message}${errorMessage ? ` (${errorMessage})` : ""}`);
40+
}
41+
42+
export async function getRedisClient() {
43+
const redisUrl = readRedisUrl();
44+
syncCacheStatus();
45+
46+
if (!redisUrl) {
47+
return null;
48+
}
49+
50+
if (redisClientUrl !== redisUrl) {
51+
redisClientPromise = null;
52+
redisClientUrl = redisUrl;
53+
}
54+
55+
if (!redisClientPromise) {
56+
redisClientPromise = import("redis")
57+
.then(async ({ createClient }) => {
58+
const client = createClient({
59+
url: redisUrl,
60+
socket: {
61+
connectTimeout: 3_000,
62+
reconnectStrategy(retries) {
63+
if (retries >= 2) {
64+
return false;
65+
}
66+
67+
return Math.min((retries + 1) * 100, 500);
68+
},
69+
},
70+
});
71+
72+
client.on("ready", () => {
73+
cacheStatus.connected = true;
74+
cacheStatus.lastError = null;
75+
});
76+
77+
client.on("end", () => {
78+
cacheStatus.connected = false;
79+
});
80+
81+
client.on("error", (error) => {
82+
warnRedisFallback("Redis indisponivel, usando cache em memoria.", error);
83+
});
84+
85+
await client.connect();
86+
return client;
87+
})
88+
.catch((error) => {
89+
warnRedisFallback("Falha ao conectar no Redis, usando cache em memoria.", error);
90+
redisClientPromise = null;
91+
return null;
92+
});
93+
}
94+
95+
return redisClientPromise;
96+
}
97+
98+
export function getCacheStatus() {
99+
syncCacheStatus();
100+
return {
101+
...cacheStatus,
102+
type: cache.constructor.name,
103+
};
104+
}
105+
106+
export async function warmupCache() {
107+
syncCacheStatus();
108+
109+
if (!cacheStatus.configured) {
110+
return getCacheStatus();
111+
}
112+
113+
const client = await getRedisClient();
114+
if (!client) {
115+
return getCacheStatus();
116+
}
117+
118+
try {
119+
const pong = await client.ping();
120+
cacheStatus.connected = pong === "PONG";
121+
cacheStatus.lastError = cacheStatus.connected ? null : "PING sem resposta esperada";
122+
} catch (error) {
123+
warnRedisFallback("Falha ao validar conexao com Redis.", error);
124+
}
125+
126+
return getCacheStatus();
127+
}
128+
1129
export class MemoryCache {
2130
constructor() {
3131
this.store = new Map();
@@ -38,4 +166,89 @@ export class MemoryCache {
38166
}
39167
}
40168

41-
export const cache = new MemoryCache();
169+
export class RedisCache {
170+
constructor(options = {}) {
171+
this.prefix = options.prefix || process.env.REDIS_KEY_PREFIX?.trim() || "vagas-full";
172+
this.memoryFallback = new MemoryCache();
173+
}
174+
175+
buildKey(key) {
176+
return `${this.prefix}:${key}`;
177+
}
178+
179+
async get(key) {
180+
const client = await getRedisClient();
181+
182+
if (!client) {
183+
return this.memoryFallback.get(key);
184+
}
185+
186+
try {
187+
const raw = await client.get(this.buildKey(key));
188+
return raw ? JSON.parse(raw) : null;
189+
} catch (error) {
190+
warnRedisFallback("Erro ao ler do Redis, usando cache em memoria.", error);
191+
return this.memoryFallback.get(key);
192+
}
193+
}
194+
195+
async set(key, value, ttlMs) {
196+
this.memoryFallback.set(key, value, ttlMs);
197+
198+
const client = await getRedisClient();
199+
if (!client) {
200+
return;
201+
}
202+
203+
try {
204+
await client.set(this.buildKey(key), JSON.stringify(value), {
205+
PX: Math.max(1, Number(ttlMs) || 1),
206+
});
207+
} catch (error) {
208+
warnRedisFallback("Erro ao salvar no Redis, usando cache em memoria.", error);
209+
}
210+
}
211+
212+
async delete(key) {
213+
this.memoryFallback.delete(key);
214+
215+
const client = await getRedisClient();
216+
if (!client) {
217+
return;
218+
}
219+
220+
try {
221+
await client.del(this.buildKey(key));
222+
} catch (error) {
223+
warnRedisFallback("Erro ao remover chave do Redis.", error);
224+
}
225+
}
226+
227+
async clear() {
228+
this.memoryFallback.clear();
229+
230+
const client = await getRedisClient();
231+
if (!client) {
232+
return;
233+
}
234+
235+
try {
236+
const keys = [];
237+
for await (const key of client.scanIterator({ MATCH: `${this.prefix}:*` })) {
238+
keys.push(key);
239+
}
240+
241+
if (keys.length > 0) {
242+
await client.del(keys);
243+
}
244+
} catch (error) {
245+
warnRedisFallback("Erro ao limpar o Redis.", error);
246+
}
247+
}
248+
249+
async has(key) {
250+
return (await this.get(key)) !== null;
251+
}
252+
}
253+
254+
export const cache = process.env.REDIS_URL?.trim() ? new RedisCache() : new MemoryCache();

0 commit comments

Comments
 (0)