sushilideaclan01 commited on
Commit
c4a64b4
·
1 Parent(s): 8a5c9de

refactor: migrate user and gallery data storage from JSON files to MongoDB

Browse files

- Updated auth.py to load and save users from/to MongoDB instead of a JSON file.
- Refactored gallery.py to manage gallery entries in MongoDB, removing local file dependencies.
- Introduced mongo.py for MongoDB connection handling and index management.
- Removed obsolete JSON data files for users and gallery entries.
- Updated create_user.py to create users directly in MongoDB.

This change enhances data persistence and scalability by leveraging MongoDB.

backend/app/auth.py CHANGED
@@ -1,42 +1,40 @@
1
  """
2
- Simple auth: users in JSON file, JWT for sessions.
3
  Use scripts/create_user.py to add users.
4
  """
5
 
6
- import json
7
  import os
8
- from pathlib import Path
9
 
10
  import bcrypt
11
  import jwt
12
  from fastapi import Depends, HTTPException, status
13
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 
14
 
15
- # Users file next to backend app (backend/data/users.json). On Hugging Face use DATA_DIR=/tmp/amalfa-data
16
- _data_dir = os.environ.get("DATA_DIR")
17
- DATA_DIR = Path(_data_dir) if _data_dir else Path(__file__).resolve().parent.parent / "data"
18
- USERS_PATH = DATA_DIR / "users.json"
19
- DATA_DIR.mkdir(parents=True, exist_ok=True)
20
  JWT_ALGORITHM = "HS256"
21
  JWT_SECRET = os.environ.get("JWT_SECRET", "amalfa-dev-secret-change-in-production")
22
  TOKEN_EXPIRE_HOURS = 24 * 7 # 7 days
 
23
 
24
  security = HTTPBearer(auto_error=False)
25
 
26
 
27
  def _load_users() -> list[dict]:
28
- if not USERS_PATH.exists():
29
- return []
30
- try:
31
- with open(USERS_PATH, "r") as f:
32
- return json.load(f)
33
- except (json.JSONDecodeError, OSError):
34
  return []
 
 
35
 
36
 
37
  def _save_users(users: list[dict]) -> None:
38
- with open(USERS_PATH, "w") as f:
39
- json.dump(users, f, indent=2)
 
 
 
 
 
40
 
41
 
42
  def verify_password(plain: str, hashed: str) -> bool:
@@ -51,9 +49,16 @@ def hash_password(plain: str) -> str:
51
 
52
 
53
  def get_user_by_username(username: str) -> dict | None:
 
 
 
 
 
 
 
54
  users = _load_users()
55
  for u in users:
56
- if (u.get("username") or "").strip().lower() == username.strip().lower():
57
  return u
58
  return None
59
 
 
1
  """
2
+ Simple auth: users in MongoDB, JWT for sessions.
3
  Use scripts/create_user.py to add users.
4
  """
5
 
 
6
  import os
 
7
 
8
  import bcrypt
9
  import jwt
10
  from fastapi import Depends, HTTPException, status
11
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
12
+ from app.mongo import get_mongo_db
13
 
 
 
 
 
 
14
  JWT_ALGORITHM = "HS256"
15
  JWT_SECRET = os.environ.get("JWT_SECRET", "amalfa-dev-secret-change-in-production")
16
  TOKEN_EXPIRE_HOURS = 24 * 7 # 7 days
17
+ USERS_COLLECTION = "users"
18
 
19
  security = HTTPBearer(auto_error=False)
20
 
21
 
22
  def _load_users() -> list[dict]:
23
+ db = get_mongo_db()
24
+ if db is None:
 
 
 
 
25
  return []
26
+ users = list(db[USERS_COLLECTION].find({}, {"_id": 0}))
27
+ return users
28
 
29
 
30
  def _save_users(users: list[dict]) -> None:
31
+ db = get_mongo_db()
32
+ if db is None:
33
+ return
34
+ coll = db[USERS_COLLECTION]
35
+ coll.delete_many({})
36
+ if users:
37
+ coll.insert_many(users)
38
 
39
 
40
  def verify_password(plain: str, hashed: str) -> bool:
 
49
 
50
 
51
  def get_user_by_username(username: str) -> dict | None:
52
+ db = get_mongo_db()
53
+ normalized = (username or "").strip().lower()
54
+ if db is None:
55
+ return None
56
+ doc = db[USERS_COLLECTION].find_one({"username_lower": normalized}, {"_id": 0})
57
+ if doc:
58
+ return doc
59
  users = _load_users()
60
  for u in users:
61
+ if (u.get("username") or "").strip().lower() == normalized:
62
  return u
63
  return None
64
 
backend/app/gallery.py CHANGED
@@ -1,90 +1,44 @@
1
  """
2
- Gallery store: persisted list of generated creatives per user (JSON file per user).
3
- Uses DATA_DIR/gallery when DATA_DIR env is set (e.g. Hugging Face: /tmp/amalfa-data).
4
- When R2 is configured, gallery index is also stored in R2 (gallery/{username}.json) so it
5
- survives Hugging Face redeploys where /tmp is wiped.
6
  """
7
 
8
- import json
9
- import os
10
- import re
11
  import uuid
12
  from datetime import datetime, timezone, timedelta
13
- from pathlib import Path
14
  from typing import Any
15
 
16
- from app.r2 import delete_object as r2_delete_object, get_object as r2_get_object, put_object as r2_put_object
17
-
18
- _data_dir = os.environ.get("DATA_DIR")
19
- _base = Path(_data_dir) if _data_dir else Path(__file__).resolve().parent.parent / "data"
20
- GALLERY_DIR = _base / "gallery"
21
- GALLERY_DIR.mkdir(parents=True, exist_ok=True)
22
-
23
- # Safe filename: alphanumeric and underscore only
24
- USERNAME_SAFE = re.compile(r"[^a-zA-Z0-9_]")
25
  DEFAULT_LIMIT = 100
26
  MAX_LIMIT = 500
27
- R2_GALLERY_PREFIX = "gallery/"
28
-
29
-
30
- def _safe_username(username: str) -> str:
31
- return USERNAME_SAFE.sub("_", (username or "").strip()) or "default"
32
-
33
-
34
- def _user_path(username: str) -> Path:
35
- return GALLERY_DIR / f"{_safe_username(username)}.json"
36
-
37
 
38
- def _r2_gallery_key(username: str) -> str:
39
- return f"{R2_GALLERY_PREFIX}{_safe_username(username)}.json"
40
-
41
-
42
- def _load_from_r2(username: str) -> list[dict[str, Any]] | None:
43
- """Load gallery entries from R2. Returns None if R2 not configured or key missing."""
44
- raw = r2_get_object(_r2_gallery_key(username))
45
- if raw is None:
46
- return None
47
- try:
48
- data = json.loads(raw.decode("utf-8"))
49
- return data if isinstance(data, list) else None
50
- except (json.JSONDecodeError, UnicodeDecodeError):
51
  return None
 
 
 
 
 
 
52
 
53
 
54
- def _save_to_r2(username: str, entries: list[dict[str, Any]]) -> bool:
55
- """Persist gallery entries to R2. Returns True if written."""
56
- body = json.dumps(entries, indent=2).encode("utf-8")
57
- return r2_put_object(_r2_gallery_key(username), body)
 
 
 
 
 
58
 
59
 
60
  def load_entries(username: str, limit: int = DEFAULT_LIMIT, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
61
- """Load gallery entries for user, newest first. Returns (page_slice, total_count). Uses R2 and local; prefers whichever has more entries (never write on read)."""
62
- entries: list[dict[str, Any]] = []
63
- path = _user_path(username)
64
- from_r2 = _load_from_r2(username)
65
- local_list: list[dict[str, Any]] = []
66
- if path.exists():
67
- try:
68
- with open(path, "r") as f:
69
- raw = json.load(f)
70
- local_list = raw if isinstance(raw, list) else []
71
- except (json.JSONDecodeError, OSError):
72
- pass
73
- if from_r2 is not None:
74
- # Use the source with more entries so stale R2 (e.g. one item) doesn't hide a full local gallery.
75
- if len(local_list) > len(from_r2):
76
- entries = local_list
77
- _save_to_r2(username, entries) # repair R2
78
- else:
79
- entries = from_r2
80
- if len(entries) == 0 and len(local_list) > 0:
81
- entries = local_list
82
- _save_to_r2(username, entries)
83
- else:
84
- entries = local_list
85
- if local_list:
86
- _save_to_r2(username, entries) # initial sync: local has data but R2 key is absent
87
- entries = sorted(entries, key=lambda e: e.get("created_at", ""), reverse=True)
88
  total = len(entries)
89
  page = entries[offset : offset + min(limit, MAX_LIMIT)]
90
  return (page, total)
@@ -100,18 +54,8 @@ def append_entry(
100
  scene_prompt: str | None = None,
101
  image_model: str | None = None,
102
  ) -> dict[str, Any]:
103
- """Append a gallery entry. Writes to local file and R2 (when configured). Returns the new entry."""
104
  import datetime
105
- path = _user_path(username)
106
- entries = []
107
- if path.exists():
108
- try:
109
- with open(path, "r") as f:
110
- entries = json.load(f)
111
- if not isinstance(entries, list):
112
- entries = []
113
- except (json.JSONDecodeError, OSError):
114
- entries = []
115
  now = datetime.datetime.now(datetime.timezone.utc).isoformat()
116
  entry = {
117
  "id": str(uuid.uuid4()),
@@ -126,10 +70,10 @@ def append_entry(
126
  entry["scene_prompt"] = scene_prompt
127
  if image_model is not None:
128
  entry["image_model"] = image_model
129
- entries.append(entry)
130
- with open(path, "w") as f:
131
- json.dump(entries, f, indent=2)
132
- _save_to_r2(username, entries)
133
  return entry
134
 
135
 
@@ -139,104 +83,46 @@ def update_entry(
139
  **updates: Any,
140
  ) -> bool:
141
  """Update fields of an existing gallery entry. Returns True if updated."""
142
- path = _user_path(username)
143
- if not path.exists():
144
- from_r2 = _load_from_r2(username)
145
- if from_r2 is None:
146
- return False
147
- entries = from_r2
148
- else:
149
- try:
150
- with open(path, "r") as f:
151
- entries = json.load(f)
152
- except (json.JSONDecodeError, OSError):
153
- return False
154
- if not isinstance(entries, list):
155
- return False
156
  allowed = {"r2_key", "concept_name", "creative_id", "product_name", "scene_prompt", "image_model"}
157
  for key in list(updates.keys()):
158
  if key not in allowed:
159
  del updates[key]
160
  if not updates:
161
  return True
162
- found = False
163
- for e in entries:
164
- if e.get("id") == entry_id:
165
- e.update(updates)
166
- found = True
167
- break
168
- if not found:
169
- return False
170
- with open(path, "w") as f:
171
- json.dump(entries, f, indent=2)
172
- _save_to_r2(username, entries)
173
- return True
174
 
175
 
176
  def delete_entry(username: str, entry_id: str) -> bool:
177
- """Remove entry by id. Writes to local file and R2 (when configured). Returns True if removed."""
178
- path = _user_path(username)
179
- if not path.exists():
180
- from_r2 = _load_from_r2(username)
181
- if from_r2 is None:
182
- return False
183
- entries = from_r2
184
- else:
185
- try:
186
- with open(path, "r") as f:
187
- entries = json.load(f)
188
- except (json.JSONDecodeError, OSError):
189
- return False
190
- if not isinstance(entries, list):
191
- return False
192
- orig_len = len(entries)
193
- entries = [e for e in entries if e.get("id") != entry_id]
194
- if len(entries) == orig_len:
195
- return False
196
- with open(path, "w") as f:
197
- json.dump(entries, f, indent=2)
198
- _save_to_r2(username, entries)
199
- return True
200
 
201
 
202
  def get_entry(username: str, entry_id: str) -> dict[str, Any] | None:
203
- """Get a single entry by id (for presigned URL / delete check). Uses R2 when local file missing."""
204
- entries: list[dict[str, Any]] = []
205
- path = _user_path(username)
206
- if path.exists():
207
- try:
208
- with open(path, "r") as f:
209
- raw = json.load(f)
210
- entries = raw if isinstance(raw, list) else []
211
- except (json.JSONDecodeError, OSError):
212
- pass
213
- else:
214
- from_r2 = _load_from_r2(username)
215
- if from_r2 is not None:
216
- entries = from_r2
217
- for e in entries:
218
- if e.get("id") == entry_id:
219
- return e
220
  return None
221
 
222
 
223
  def _load_all_entries_for_user(username: str) -> list[dict[str, Any]]:
224
- """Load full list of gallery entries for a user (local + R2 merge, same logic as load_entries)."""
225
- path = _user_path(username)
226
- from_r2 = _load_from_r2(username)
227
- local_list: list[dict[str, Any]] = []
228
- if path.exists():
229
- try:
230
- with open(path, "r") as f:
231
- raw = json.load(f)
232
- local_list = raw if isinstance(raw, list) else []
233
- except (json.JSONDecodeError, OSError):
234
- pass
235
- if from_r2 is not None:
236
- if len(local_list) > len(from_r2):
237
- return local_list
238
- return from_r2
239
- return local_list
240
 
241
 
242
  def delete_entries_older_than_days(
@@ -263,9 +149,10 @@ def delete_entries_older_than_days(
263
  return None
264
 
265
  if username is not None:
266
- usernames = [_safe_username(username)]
267
  else:
268
- usernames = [p.stem for p in GALLERY_DIR.glob("*.json") if p.is_file()]
 
269
 
270
  for uname in usernames:
271
  entries = _load_all_entries_for_user(uname)
@@ -287,11 +174,8 @@ def delete_entries_older_than_days(
287
  errors.append(f"R2 delete failed: {r2_key}")
288
  deleted += 1
289
  if len(to_keep) < len(entries):
290
- try:
291
- with open(_user_path(uname), "w") as f:
292
- json.dump(to_keep, f, indent=2)
293
- _save_to_r2(uname, to_keep)
294
- except OSError as err:
295
- errors.append(f"Save failed for {uname}: {err}")
296
 
297
  return {"deleted": deleted, "users_processed": users_processed, "errors": errors}
 
1
  """
2
+ Gallery store: persisted list of generated creatives per user in MongoDB.
 
 
 
3
  """
4
 
 
 
 
5
  import uuid
6
  from datetime import datetime, timezone, timedelta
 
7
  from typing import Any
8
 
9
+ from app.mongo import get_mongo_db
10
+ from app.r2 import delete_object as r2_delete_object
 
 
 
 
 
 
 
11
  DEFAULT_LIMIT = 100
12
  MAX_LIMIT = 500
13
+ GALLERY_COLLECTION = "gallery_entries"
 
 
 
 
 
 
 
 
 
14
 
15
+ def _load_from_mongo(username: str) -> list[dict[str, Any]] | None:
16
+ db = get_mongo_db()
17
+ if db is None:
 
 
 
 
 
 
 
 
 
 
18
  return None
19
+ docs = list(
20
+ db[GALLERY_COLLECTION]
21
+ .find({"username": username}, {"_id": 0})
22
+ .sort("created_at", -1)
23
+ )
24
+ return docs
25
 
26
 
27
+ def _save_to_mongo(username: str, entries: list[dict[str, Any]]) -> bool:
28
+ db = get_mongo_db()
29
+ if db is None:
30
+ return False
31
+ coll = db[GALLERY_COLLECTION]
32
+ coll.delete_many({"username": username})
33
+ if entries:
34
+ coll.insert_many(entries)
35
+ return True
36
 
37
 
38
  def load_entries(username: str, limit: int = DEFAULT_LIMIT, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
39
+ """Load gallery entries for user, newest first. Returns (page_slice, total_count)."""
40
+ from_mongo = _load_from_mongo(username)
41
+ entries: list[dict[str, Any]] = from_mongo or []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  total = len(entries)
43
  page = entries[offset : offset + min(limit, MAX_LIMIT)]
44
  return (page, total)
 
54
  scene_prompt: str | None = None,
55
  image_model: str | None = None,
56
  ) -> dict[str, Any]:
57
+ """Append a gallery entry in MongoDB. Returns the new entry."""
58
  import datetime
 
 
 
 
 
 
 
 
 
 
59
  now = datetime.datetime.now(datetime.timezone.utc).isoformat()
60
  entry = {
61
  "id": str(uuid.uuid4()),
 
70
  entry["scene_prompt"] = scene_prompt
71
  if image_model is not None:
72
  entry["image_model"] = image_model
73
+ db = get_mongo_db()
74
+ if db is None:
75
+ raise RuntimeError("MongoDB is not configured")
76
+ db[GALLERY_COLLECTION].insert_one(entry)
77
  return entry
78
 
79
 
 
83
  **updates: Any,
84
  ) -> bool:
85
  """Update fields of an existing gallery entry. Returns True if updated."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  allowed = {"r2_key", "concept_name", "creative_id", "product_name", "scene_prompt", "image_model"}
87
  for key in list(updates.keys()):
88
  if key not in allowed:
89
  del updates[key]
90
  if not updates:
91
  return True
92
+ db = get_mongo_db()
93
+ if db is not None:
94
+ result = db[GALLERY_COLLECTION].update_one(
95
+ {"username": username, "id": entry_id},
96
+ {"$set": updates},
97
+ )
98
+ return result.matched_count > 0
99
+ return False
 
 
 
 
100
 
101
 
102
  def delete_entry(username: str, entry_id: str) -> bool:
103
+ """Remove entry by id from MongoDB."""
104
+ db = get_mongo_db()
105
+ if db is not None:
106
+ result = db[GALLERY_COLLECTION].delete_one({"username": username, "id": entry_id})
107
+ return result.deleted_count > 0
108
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def get_entry(username: str, entry_id: str) -> dict[str, Any] | None:
112
+ """Get a single entry by id."""
113
+ db = get_mongo_db()
114
+ if db is not None:
115
+ return db[GALLERY_COLLECTION].find_one(
116
+ {"username": username, "id": entry_id},
117
+ {"_id": 0},
118
+ )
 
 
 
 
 
 
 
 
 
 
119
  return None
120
 
121
 
122
  def _load_all_entries_for_user(username: str) -> list[dict[str, Any]]:
123
+ """Load full list of gallery entries for a user."""
124
+ from_mongo = _load_from_mongo(username)
125
+ return from_mongo or []
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
 
128
  def delete_entries_older_than_days(
 
149
  return None
150
 
151
  if username is not None:
152
+ usernames = [username]
153
  else:
154
+ db = get_mongo_db()
155
+ usernames = db[GALLERY_COLLECTION].distinct("username") if db is not None else []
156
 
157
  for uname in usernames:
158
  entries = _load_all_entries_for_user(uname)
 
174
  errors.append(f"R2 delete failed: {r2_key}")
175
  deleted += 1
176
  if len(to_keep) < len(entries):
177
+ saved_to_mongo = _save_to_mongo(uname, to_keep)
178
+ if not saved_to_mongo:
179
+ errors.append(f"Save failed for {uname}: MongoDB unavailable")
 
 
 
180
 
181
  return {"deleted": deleted, "users_processed": users_processed, "errors": errors}
backend/app/main.py CHANGED
@@ -22,6 +22,7 @@ from app.analysis import analyze_product
22
  from app.analysis_flows import analyze_product_archetype, analyze_product_cross_vertical
23
  from app.auth import authenticate_user, create_access_token, get_current_user
24
  from app.creatives import generate_ad_creatives
 
25
  from app.gallery import (
26
  append_entry as gallery_append_entry,
27
  delete_entries_older_than_days as gallery_delete_entries_older_than_days,
@@ -65,6 +66,8 @@ _ENV_SPEC = [
65
  ("CANVA_CLIENT_SECRET", False, True),
66
  ("CANVA_REDIRECT_URI", False, False),
67
  ("CANVA_SCOPES", False, False),
 
 
68
  ]
69
 
70
 
@@ -195,17 +198,22 @@ def _run_gallery_cleanup():
195
 
196
  @app.on_event("startup")
197
  def _startup():
198
- """Check env vars and ensure DATA_DIR/users on Hugging Face."""
199
  _check_env_on_startup()
200
- # On Hugging Face (DATA_DIR set), copy default users.json into DATA_DIR if missing.
201
- if os.environ.get("DATA_DIR"):
202
- from app.auth import USERS_PATH
203
- if not USERS_PATH.exists():
204
- default = Path(__file__).resolve().parent.parent / "data" / "users.json"
205
- if default.exists():
206
- USERS_PATH.parent.mkdir(parents=True, exist_ok=True)
207
- import shutil
208
- shutil.copy(default, USERS_PATH)
 
 
 
 
 
209
  # Delete gallery entries (and R2 images) older than 60 days, in background
210
  import threading
211
  threading.Thread(target=_run_gallery_cleanup, daemon=True).start()
 
22
  from app.analysis_flows import analyze_product_archetype, analyze_product_cross_vertical
23
  from app.auth import authenticate_user, create_access_token, get_current_user
24
  from app.creatives import generate_ad_creatives
25
+ from app.mongo import ensure_mongo_indexes, mongo_is_configured, ping_mongo
26
  from app.gallery import (
27
  append_entry as gallery_append_entry,
28
  delete_entries_older_than_days as gallery_delete_entries_older_than_days,
 
66
  ("CANVA_CLIENT_SECRET", False, True),
67
  ("CANVA_REDIRECT_URI", False, False),
68
  ("CANVA_SCOPES", False, False),
69
+ ("MONGODB_URL", False, True),
70
+ ("MONGODB_DB_NAME", False, False),
71
  ]
72
 
73
 
 
198
 
199
  @app.on_event("startup")
200
  def _startup():
201
+ """Check env vars and initialize MongoDB."""
202
  _check_env_on_startup()
203
+ log = logging.getLogger("uvicorn.error")
204
+ if mongo_is_configured():
205
+ ok, err = ping_mongo()
206
+ if ok:
207
+ log.info("MongoDB connection: ok")
208
+ idx_ok, idx_err = ensure_mongo_indexes()
209
+ if idx_ok:
210
+ log.info("MongoDB indexes: ensured")
211
+ else:
212
+ log.warning("MongoDB indexes ensure failed: %s", idx_err)
213
+ else:
214
+ log.warning("MongoDB configured but connection failed: %s", err)
215
+ else:
216
+ log.warning("MongoDB is not configured; auth and gallery will not function correctly")
217
  # Delete gallery entries (and R2 images) older than 60 days, in background
218
  import threading
219
  threading.Thread(target=_run_gallery_cleanup, daemon=True).start()
backend/app/mongo.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MongoDB connection helpers.
3
+
4
+ Uses:
5
+ - MONGODB_URL
6
+ - MONGODB_DB_NAME
7
+ """
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ from dotenv import load_dotenv
13
+ from pymongo import MongoClient
14
+ from pymongo.errors import PyMongoError
15
+ from pymongo.database import Database
16
+
17
+ load_dotenv()
18
+
19
+ _MONGO_CLIENT: MongoClient[Any] | None = None
20
+
21
+
22
+ def mongo_is_configured() -> bool:
23
+ url = (os.environ.get("MONGODB_URL") or "").strip()
24
+ db_name = (os.environ.get("MONGODB_DB_NAME") or "").strip()
25
+ return bool(url and db_name)
26
+
27
+
28
+ def get_mongo_db() -> Database[Any] | None:
29
+ """Return Mongo database when configured; otherwise None."""
30
+ global _MONGO_CLIENT
31
+ if not mongo_is_configured():
32
+ return None
33
+ if _MONGO_CLIENT is None:
34
+ _MONGO_CLIENT = MongoClient(
35
+ (os.environ.get("MONGODB_URL") or "").strip(),
36
+ serverSelectionTimeoutMS=3000,
37
+ )
38
+ db_name = (os.environ.get("MONGODB_DB_NAME") or "").strip()
39
+ return _MONGO_CLIENT[db_name]
40
+
41
+
42
+ def ping_mongo() -> tuple[bool, str | None]:
43
+ """Return (ok, error_message)."""
44
+ try:
45
+ db = get_mongo_db()
46
+ if db is None:
47
+ return (False, "MongoDB not configured")
48
+ db.command("ping")
49
+ return (True, None)
50
+ except Exception as exc:
51
+ return (False, str(exc))
52
+
53
+
54
+ def ensure_mongo_indexes() -> tuple[bool, str | None]:
55
+ """
56
+ Create indexes used by auth and gallery access patterns.
57
+ Safe to call repeatedly on startup.
58
+ """
59
+ try:
60
+ db = get_mongo_db()
61
+ if db is None:
62
+ return (False, "MongoDB not configured")
63
+
64
+ users = db["users"]
65
+ # Case-insensitive user lookup and uniqueness for login.
66
+ users.create_index("username_lower", unique=True, name="uq_users_username_lower")
67
+ users.create_index("id", name="ix_users_id")
68
+
69
+ gallery = db["gallery_entries"]
70
+ # Frequent access patterns: per-user list newest-first, lookup/delete by id.
71
+ gallery.create_index([("username", 1), ("created_at", -1)], name="ix_gallery_user_created_at")
72
+ gallery.create_index([("username", 1), ("id", 1)], unique=True, name="uq_gallery_user_id")
73
+ gallery.create_index("created_at", name="ix_gallery_created_at")
74
+ return (True, None)
75
+ except PyMongoError as exc:
76
+ return (False, str(exc))
backend/data/gallery/admin.json DELETED
The diff for this file is too large to render. See raw diff
 
backend/data/users.json DELETED
@@ -1,7 +0,0 @@
1
- [
2
- {
3
- "id": 1,
4
- "username": "admin",
5
- "password_hash": "$2b$12$VOqXwimdOqv1jQOLXeIKiecH3OryASPr3c9elGuEb9cTH6s8qsObu"
6
- }
7
- ]
 
 
 
 
 
 
 
 
backend/requirements.txt CHANGED
@@ -11,3 +11,4 @@ bcrypt>=4.0,<5
11
  pyjwt==2.10.1
12
  boto3>=1.35,<2
13
  ddgs>=7.0
 
 
11
  pyjwt==2.10.1
12
  boto3>=1.35,<2
13
  ddgs>=7.0
14
+ pymongo>=4.10,<5
backend/scripts/create_user.py CHANGED
@@ -6,7 +6,6 @@ Or from backend: python scripts/create_user.py --username admin --password yourp
6
  """
7
 
8
  import argparse
9
- import json
10
  import sys
11
  from pathlib import Path
12
 
@@ -14,7 +13,8 @@ from pathlib import Path
14
  backend_dir = Path(__file__).resolve().parent.parent
15
  sys.path.insert(0, str(backend_dir))
16
 
17
- from app.auth import DATA_DIR, USERS_PATH, get_user_by_username, hash_password
 
18
 
19
 
20
  def main():
@@ -37,28 +37,22 @@ def main():
37
  print(f"Error: User '{username}' already exists.", file=sys.stderr)
38
  sys.exit(1)
39
 
40
- DATA_DIR.mkdir(parents=True, exist_ok=True)
41
- users = []
42
- if USERS_PATH.exists():
43
- try:
44
- with open(USERS_PATH, "r") as f:
45
- users = json.load(f)
46
- except (json.JSONDecodeError, OSError):
47
- users = []
48
 
49
  next_id = max((u.get("id") or 0) for u in users) + 1 if users else 1
50
  new_user = {
51
  "id": next_id,
52
  "username": username,
 
53
  "password_hash": hash_password(password),
54
  }
55
- users.append(new_user)
56
-
57
- with open(USERS_PATH, "w") as f:
58
- json.dump(users, f, indent=2)
59
-
60
  print(f"User '{username}' created successfully (id={next_id}).")
61
- print(f"Users file: {USERS_PATH}")
62
 
63
 
64
  if __name__ == "__main__":
 
6
  """
7
 
8
  import argparse
 
9
  import sys
10
  from pathlib import Path
11
 
 
13
  backend_dir = Path(__file__).resolve().parent.parent
14
  sys.path.insert(0, str(backend_dir))
15
 
16
+ from app.auth import get_user_by_username, hash_password
17
+ from app.mongo import get_mongo_db
18
 
19
 
20
  def main():
 
37
  print(f"Error: User '{username}' already exists.", file=sys.stderr)
38
  sys.exit(1)
39
 
40
+ db = get_mongo_db()
41
+ if db is None:
42
+ print("Error: MongoDB is not configured. Set MONGODB_URL and MONGODB_DB_NAME.", file=sys.stderr)
43
+ sys.exit(1)
44
+ users = list(db["users"].find({}, {"_id": 0}))
 
 
 
45
 
46
  next_id = max((u.get("id") or 0) for u in users) + 1 if users else 1
47
  new_user = {
48
  "id": next_id,
49
  "username": username,
50
+ "username_lower": username.lower(),
51
  "password_hash": hash_password(password),
52
  }
53
+ db["users"].insert_one(new_user)
 
 
 
 
54
  print(f"User '{username}' created successfully (id={next_id}).")
55
+ print("Storage: MongoDB")
56
 
57
 
58
  if __name__ == "__main__":