Skip to content

Commit ce15916

Browse files
authored
Merge pull request #1 from New-nota/news_api_key_in_db
self_api_key_upd
2 parents d5d05b8 + 17f1fd5 commit ce15916

11 files changed

Lines changed: 134 additions & 26 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ DB_PORT=5432
33
DB_USER=postgres
44
DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
55
DB_ADMIN_DB=postgres
6-
DB_NEWS=news_db
6+
NEWS_DB=news_db
77

88
NEWSAPI_KEY=YOUR_REAL_NEWSAPI_KEY
99
NEWSAPI_URL=https://newsapi.org/v2/everything
1010
NEWSAPI_DEFAULT_LANGUAGE=ru
1111
NEWSAPI_SORT_BY=publishedAt
12+
NEWS_API_KEY_ENCRYPTION_SECRET=replace_me_with_32+_random_chars
1213

1314
REQUEST_TIMEOUT_SECONDS=15
1415
REQUEST_MAX_RETRIES=3

config/config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,13 @@ class Settings:
9595
db_user: str
9696
db_password: str
9797
db_admin_db: str
98-
db_news: str
98+
news_db: str
9999

100100
newsapi_key: str
101101
news_url: str
102102
default_language: str
103103
sort_by: str
104+
news_api_key_encryption_secret: str
104105

105106
request_timeout_seconds: float
106107
request_max_retries: int
@@ -145,11 +146,12 @@ def build_settings() -> Settings:
145146
db_user=_get_env_str("DB_USER", "postgres"),
146147
db_password=_get_env_str("DB_PASSWORD", "postgres"),
147148
db_admin_db=_get_env_str("DB_ADMIN_DB", "postgres"),
148-
db_news=_get_env_str("DB_NEWS", "news_db"),
149+
news_db=_get_env_str("NEWS_DB", "news_db"),
149150
newsapi_key=_get_env_str("NEWSAPI_KEY", ""),
150151
news_url=_get_env_str("NEWSAPI_URL", "https://newsapi.org/v2/everything"),
151152
default_language=_get_env_str("NEWSAPI_DEFAULT_LANGUAGE", "ru"),
152153
sort_by=sort_by,
154+
news_api_key_encryption_secret = _get_env_str("NEWS_API_KEY_ENCRYPTION_SECRET", "NO"),
153155
request_timeout_seconds=_get_env_float("REQUEST_TIMEOUT_SECONDS", 15.0, min_value=1.0),
154156
request_max_retries=_get_env_int("REQUEST_MAX_RETRIES", 3, min_value=0),
155157
request_backoff_factor=_get_env_float("REQUEST_BACKOFF_FACTOR", 1.0, min_value=0.0),

main.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
create_request_stats_table,
1414
create_search_requests_table,
1515
create_user_news_table,
16+
create_users_keys_table,
1617
ensure_tables_exist,
1718
init_database,
1819
run_debug_pipeline,
@@ -99,6 +100,7 @@ def init_all_tables(debug: bool) -> None:
99100
create_articles_table()
100101
create_user_news_table()
101102
create_request_stats_table()
103+
create_users_keys_table()
102104
if debug:
103105
create_news_tables()
104106

@@ -123,9 +125,9 @@ def _validate_web_context(user_id: int, search_request_id: int) -> None:
123125

124126

125127
def _ensure_runtime_schema(debug_mode: bool) -> None:
126-
if not database_exists(settings.db_news):
128+
if not database_exists(settings.news_db):
127129
raise RuntimeError(
128-
f"Database '{settings.db_news}' does not exist. "
130+
f"Database '{settings.news_db}' does not exist. "
129131
"Run `python main.py --init-only` once or re-run with `--bootstrap`."
130132
)
131133

@@ -134,11 +136,12 @@ def _ensure_runtime_schema(debug_mode: bool) -> None:
134136
"search_requests",
135137
"articles",
136138
"user_news",
139+
"users_keys",
137140
"request_stats",
138141
]
139-
ensure_tables_exist(settings.db_news, required_tables)
142+
ensure_tables_exist(settings.news_db, required_tables)
140143

141-
if debug_mode and not table_exists(settings.db_news, "bad_news_bears"):
144+
if debug_mode and not table_exists(settings.news_db, "bad_news_bears"):
142145
create_news_tables()
143146

144147

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
python-dotenv==1.2.2
22
psycopg2-binary>=2.9,<3.0
33
requests==2.32.5
4+
cryptography>=42.0,<46.0

src/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
create_request_stats_table,
99
create_search_requests_table,
1010
create_user_news_table,
11+
create_users_keys_table,
1112
database_exists,
1213
ensure_databases_exists,
1314
ensure_tables_exist,
@@ -34,6 +35,7 @@
3435
"create_request_stats_table",
3536
"create_search_requests_table",
3637
"create_user_news_table",
38+
"create_users_keys_table",
3739
"database_exists",
3840
"ensure_databases_exists",
3941
"ensure_tables_exist",

src/db.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def create_database_if_not_exists(db_name: str) -> None:
161161

162162

163163
def init_database() -> None:
164-
create_database_if_not_exists(settings.db_news)
164+
create_database_if_not_exists(settings.news_db)
165165

166166

167167
def create_search_requests_table() -> None:
@@ -192,7 +192,7 @@ def create_search_requests_table() -> None:
192192
""",
193193
]
194194

195-
with get_cursor(settings.db_news) as (conn, cur):
195+
with get_cursor(settings.news_db) as (conn, cur):
196196
cur.execute(query)
197197
for index_query in index_list:
198198
cur.execute(index_query)
@@ -218,7 +218,7 @@ def create_articles_table() -> None:
218218
ON articles(published_at DESC)
219219
"""
220220

221-
with get_cursor(settings.db_news) as (conn, cur):
221+
with get_cursor(settings.news_db) as (conn, cur):
222222
cur.execute(query)
223223
cur.execute(index)
224224
conn.commit()
@@ -252,7 +252,7 @@ def create_user_news_table() -> None:
252252
""",
253253
]
254254

255-
with get_cursor(settings.db_news) as (conn, cur):
255+
with get_cursor(settings.news_db) as (conn, cur):
256256
cur.execute(query)
257257
for index_query in index_list:
258258
cur.execute(index_query)
@@ -298,7 +298,7 @@ def create_request_stats_table() -> None:
298298
END $$;
299299
"""
300300

301-
with get_cursor(settings.db_news) as (conn, cur):
301+
with get_cursor(settings.news_db) as (conn, cur):
302302
cur.execute(query)
303303
cur.execute(trigger_function)
304304
cur.execute(trigger)
@@ -318,7 +318,7 @@ def create_app_users_table() -> None:
318318
)
319319
"""
320320

321-
with get_cursor(settings.db_news) as (conn, cur):
321+
with get_cursor(settings.news_db) as (conn, cur):
322322
cur.execute(query)
323323
conn.commit()
324324

@@ -338,10 +338,53 @@ def create_news_tables() -> None:
338338
)
339339
"""
340340

341-
with get_cursor(settings.db_news) as (conn, cur):
341+
with get_cursor(settings.news_db) as (conn, cur):
342342
cur.execute(query)
343343
conn.commit()
344344

345+
def create_users_keys_table() -> None:
346+
query = """
347+
CREATE TABLE IF NOT EXISTS users_keys(
348+
id BIGSERIAL PRIMARY KEY,
349+
user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
350+
service VARCHAR(50) NOT NULL,
351+
encrypted_key TEXT NOT NULL,
352+
iv TEXT NOT NULL,
353+
auth_tag TEXT NOT NULL,
354+
key_last4 VARCHAR(4) NOT NULL,
355+
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
356+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
357+
UNIQUE (user_id, service)
358+
);
359+
"""
360+
trigger_function = """
361+
CREATE OR REPLACE FUNCTION set_users_keys_updated_at()
362+
RETURNS TRIGGER AS $$
363+
BEGIN
364+
NEW.updated_at = NOW();
365+
RETURN NEW;
366+
END;
367+
$$ LANGUAGE plpgsql;
368+
"""
369+
trigger = """
370+
DO $$
371+
BEGIN
372+
IF NOT EXISTS (
373+
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_keys_updated_at'
374+
) THEN
375+
CREATE TRIGGER trg_users_keys_updated_at
376+
BEFORE UPDATE ON users_keys
377+
FOR EACH ROW
378+
EXECUTE FUNCTION set_users_keys_updated_at();
379+
END IF;
380+
END $$;
381+
"""
382+
with get_cursor(settings.news_db) as (conn, cur):
383+
cur.execute(query)
384+
cur.execute(trigger_function)
385+
cur.execute(trigger)
386+
conn.commit()
387+
345388

346389
def claim_next_search_request() -> dict | None:
347390
query = """
@@ -363,7 +406,7 @@ def claim_next_search_request() -> dict | None:
363406
RETURNING sr.id, sr.user_id, sr.keyword, sr.language, sr.limit_count, sr.page_size
364407
"""
365408

366-
with get_cursor(settings.db_news) as (conn, cur):
409+
with get_cursor(settings.news_db) as (conn, cur):
367410
cur.execute(query)
368411
row = cur.fetchone()
369412
conn.commit()
@@ -372,20 +415,20 @@ def claim_next_search_request() -> dict | None:
372415

373416
def search_request_exists(search_request_id: int) -> bool:
374417
query = "SELECT 1 FROM search_requests WHERE id = %s"
375-
with get_cursor(settings.db_news, autocommit=True) as (_, cur):
418+
with get_cursor(settings.news_db, autocommit=True) as (_, cur):
376419
cur.execute(query, (search_request_id,))
377420
return cur.fetchone() is not None
378421

379422

380423
def app_user_exists(user_id: int) -> bool:
381424
query = "SELECT 1 FROM app_users WHERE id = %s"
382-
with get_cursor(settings.db_news, autocommit=True) as (_, cur):
425+
with get_cursor(settings.news_db, autocommit=True) as (_, cur):
383426
cur.execute(query, (user_id,))
384427
return cur.fetchone() is not None
385428

386429

387430
def search_request_belongs_to_user(search_request_id: int, user_id: int) -> bool:
388431
query = "SELECT 1 FROM search_requests WHERE id = %s AND user_id = %s"
389-
with get_cursor(settings.db_news, autocommit=True) as (_, cur):
432+
with get_cursor(settings.news_db, autocommit=True) as (_, cur):
390433
cur.execute(query, (search_request_id, user_id))
391434
return cur.fetchone() is not None

src/extract.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def _fetch_payload(
8484
page: int,
8585
page_size: int,
8686
language: str,
87+
news_api_key: str | None = None,
8788
) -> tuple[dict[str, Any], int]:
8889
if page <= 0:
8990
raise ValueError("page must be > 0")
@@ -93,7 +94,7 @@ def _fetch_payload(
9394
normalized_page_size = min(page_size, settings.request_page_size_max)
9495

9596
params = {
96-
"apiKey": _require_newsapi_key(),
97+
"apiKey": news_api_key or _require_newsapi_key(),
9798
"language": language,
9899
"q": key_word,
99100
"pageSize": normalized_page_size,
@@ -189,8 +190,9 @@ def make_extract_web(
189190
page: int = 1,
190191
page_size: int = 20,
191192
language: str = "ru",
193+
news_api_key: str | None = None,
192194
) -> tuple[dict[str, Any], int]:
193-
payload, articles_count = _fetch_payload(key_word, page, page_size, language)
195+
payload, articles_count = _fetch_payload(key_word, page, page_size, language, news_api_key)
194196
if articles_count == 0:
195197
logger.info("There are no more articles for keyword=%s", key_word)
196198
return payload, articles_count

src/load.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def load_news(clean_news: str, max_rows: int | None = None) -> int:
4242
rows = data if max_rows is None else data[:max_rows]
4343

4444
loaded_count = 0
45-
with get_cursor(settings.db_news) as (conn, cur):
45+
with get_cursor(settings.news_db) as (conn, cur):
4646
for news in rows:
4747
cur.execute(
4848
query,
@@ -150,7 +150,7 @@ def load_request_stats(search_request_id: int, stats: dict[str, Any]) -> None:
150150
prime_reasons = EXCLUDED.prime_reasons
151151
"""
152152

153-
with get_cursor(settings.db_news) as (conn, cur):
153+
with get_cursor(settings.news_db) as (conn, cur):
154154
cur.execute(
155155
query,
156156
(
@@ -182,7 +182,7 @@ def load_web_pipeline(
182182
rows = clean_data
183183

184184
loaded_count = 0
185-
with get_cursor(settings.db_news) as (conn, cur):
185+
with get_cursor(settings.news_db) as (conn, cur):
186186
for article in rows:
187187
keyword = article["key_word"]
188188
fetched_at = article["fetched_at"]

src/pipeline.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def run_pipeline_for_web_user(
4444
limit: int,
4545
page_size: int,
4646
language: str | None = None,
47+
news_api_key: str | None = None,
4748
) -> int:
4849
if limit <= 0:
4950
raise ValueError("limit must be > 0")
@@ -60,7 +61,7 @@ def run_pipeline_for_web_user(
6061

6162
try:
6263
while loaded_total < limit and page <= max_pages:
63-
payload, raw_articles_count = make_extract_web(key_word, page, effective_page_size, language_to_use)
64+
payload, raw_articles_count = make_extract_web(key_word, page, effective_page_size, language_to_use, news_api_key=news_api_key)
6465

6566
if page == 1:
6667
max_pages = _resolve_max_pages(payload, effective_page_size)

src/user_news_api_key.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
import base64
3+
import hashlib
4+
from dataclasses import dataclass
5+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
6+
from config.config import settings
7+
from .db import get_cursor
8+
9+
@dataclass(frozen=True, slots=True)
10+
class EncryptedNewsApiKey:
11+
encrypted_key: str
12+
iv: str
13+
auth_tag: str
14+
15+
def _get_encryption_key() -> bytes:
16+
secret = settings.news_api_key_encryption_secret.strip()
17+
if not secret:
18+
raise RuntimeError("NEWS_API_KEY_ENCRYPTION_SECRET is not in config")
19+
return hashlib.sha256(secret.encode("utf-8")).digest()
20+
21+
def decrypt_news_api_key(row: EncryptedNewsApiKey) -> str:
22+
aesgcm = AESGCM(_get_encryption_key())
23+
ciphertext = base64.b64decode(row.encrypted_key)
24+
iv = base64.b64decode(row.iv)
25+
auth_tag = base64.b64decode(row.auth_tag)
26+
plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, None)
27+
return plaintext.decode("utf-8")
28+
29+
def get_decrypted_news_api_key_for_user(user_id: int) -> str | None:
30+
query = """
31+
SELECT encrypted_key, iv, auth_tag
32+
FROM users_keys
33+
WHERE user_id = %s AND service = %s
34+
LIMIT 1
35+
"""
36+
with get_cursor(settings.news_db, autocommit=True) as (_, cur):
37+
cur.execute(query, (user_id, "news_api"))
38+
row = cur.fetchone()
39+
if not row:
40+
return None
41+
42+
return decrypt_news_api_key(
43+
EncryptedNewsApiKey(
44+
encrypted_key = row["encrypted_key"],
45+
iv = row["iv"],
46+
auth_tag = row["auth_tag"],)
47+
)

0 commit comments

Comments
 (0)