Skip to content

Commit 48dff24

Browse files
authored
Merge branch 'main' into steven_update
2 parents 053d23f + cbf7c3b commit 48dff24

88 files changed

Lines changed: 3886 additions & 1403 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ coverage/
3636
backend/.coverage
3737
node_modules
3838
backend/qdrant_storage/
39+
frontend/build/
3940

4041
# Exclude all .env files
4142
.env

README.md

Lines changed: 50 additions & 124 deletions
Large diffs are not rendered by default.

backend/app.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,37 @@
44
from flask_cors import CORS
55
import json, logging, os, re
66
from werkzeug.exceptions import HTTPException
7+
from config import LOG_LEVEL, LOG_EXCLUDE_LEVELS, LOG_FORMAT, LOG_DATE_FORMAT
8+
9+
class ConfigurableLogFilter(logging.Filter):
10+
"""Filter out specific log levels based on configuration."""
11+
def __init__(self, exclude_levels_str):
12+
super().__init__()
13+
# Parse excluded levels from config (e.g., "WARNING,DEBUG" -> [30, 10])
14+
self.excluded_levels = set()
15+
if exclude_levels_str:
16+
for level_name in exclude_levels_str.split(','):
17+
level_name = level_name.strip().upper()
18+
if level_name and hasattr(logging, level_name):
19+
self.excluded_levels.add(getattr(logging, level_name))
20+
21+
def filter(self, record):
22+
return record.levelno not in self.excluded_levels
23+
24+
logging.basicConfig(
25+
level=getattr(logging, LOG_LEVEL, logging.INFO),
26+
format=LOG_FORMAT,
27+
datefmt=LOG_DATE_FORMAT
28+
)
29+
30+
log_filter = ConfigurableLogFilter(LOG_EXCLUDE_LEVELS)
31+
for handler in logging.root.handlers:
32+
handler.addFilter(log_filter)
733

834
from services.db import redis_client
935
from services.canvas_counter import get_canvas_draw_count
1036
from services.graphql_service import commit_transaction_via_graphql
37+
from services.graphql_retry_worker import start_retry_worker, stop_retry_worker
1138
from config import *
1239

1340
app = Flask(__name__)
@@ -78,7 +105,17 @@ def handle_rate_limit_exception(e):
78105
local_regexes = [r"^https?://localhost(:\d+)?$", r"^https?://127\.0\.0\.1(:\d+)?$"]
79106

80107
cors_origins = explicit_allowed + local_regexes
81-
CORS(app, supports_credentials=True, origins=cors_origins)
108+
109+
CORS(app,
110+
resources={r"/*": {
111+
"origins": cors_origins,
112+
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
113+
"allow_headers": ["Content-Type", "Authorization", "X-Requested-With", "Accept"],
114+
"expose_headers": ["Content-Type", "Authorization"],
115+
"supports_credentials": True,
116+
"max_age": 3600
117+
}}
118+
)
82119

83120
def origin_allowed(origin):
84121
"""Return True if the provided origin string is allowed by explicit allowed
@@ -103,17 +140,17 @@ def add_cors_headers(response):
103140
This complements flask-cors and guards against cases where exception paths
104141
or other middleware may return responses without the proper headers.
105142
"""
143+
if response.headers.get("Access-Control-Allow-Origin"):
144+
return response
145+
106146
try:
107147
origin = request.headers.get("Origin")
108148
if origin and origin_allowed(origin):
109149
response.headers["Access-Control-Allow-Origin"] = origin
110150
response.headers["Access-Control-Allow-Credentials"] = "true"
111-
else:
112-
fallback = explicit_allowed[0] if explicit_allowed else "http://localhost:10008"
113-
response.headers.setdefault("Access-Control-Allow-Origin", fallback)
114-
response.headers.setdefault("Access-Control-Allow-Credentials", "true")
115-
response.headers.setdefault("Access-Control-Allow-Headers", "Content-Type,Authorization")
116-
response.headers.setdefault("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
151+
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-Requested-With,Accept"
152+
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
153+
response.headers["Access-Control-Expose-Headers"] = "Content-Type,Authorization"
117154
except Exception:
118155
pass
119156
return response
@@ -142,28 +179,32 @@ def handle_all_exceptions(e):
142179
if origin and origin_allowed(origin):
143180
resp.headers["Access-Control-Allow-Origin"] = origin
144181
resp.headers["Access-Control-Allow-Credentials"] = "true"
182+
resp.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-Requested-With,Accept"
183+
resp.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
184+
resp.headers["Access-Control-Expose-Headers"] = "Content-Type,Authorization"
145185
else:
146186
fallback = explicit_allowed[0] if explicit_allowed else "http://localhost:10008"
147187
resp.headers.setdefault("Access-Control-Allow-Origin", fallback)
148188
resp.headers.setdefault("Access-Control-Allow-Credentials", "true")
149-
resp.headers.setdefault("Access-Control-Allow-Headers", "Content-Type,Authorization")
150-
resp.headers.setdefault("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
189+
resp.headers.setdefault("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With,Accept")
190+
resp.headers.setdefault("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
191+
resp.headers.setdefault("Access-Control-Expose-Headers", "Content-Type,Authorization")
151192
return resp
152193
except Exception:
153194
# If even the error handler fails, return a minimal JSON response
154195
logger.exception("Error while handling exception")
155196
out = make_response(json.dumps({"status": "error", "message": "Fatal error"}), 500)
156197
out.headers.setdefault("Access-Control-Allow-Origin", "http://localhost:10008")
157198
out.headers.setdefault("Access-Control-Allow-Credentials", "true")
158-
out.headers.setdefault("Access-Control-Allow-Headers", "Content-Type,Authorization")
199+
out.headers.setdefault("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With,Accept")
159200
out.headers.setdefault("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
160201
out.headers["Content-Type"] = "application/json"
161202
return out
162203

163204

164205
from flask_socketio import SocketIO
165206
import services.socketio_service as socketio_service
166-
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading")
207+
socketio = SocketIO(app, cors_allowed_origins=cors_origins, async_mode="threading")
167208
socketio_service.socketio = socketio
168209
socketio_service.register_socketio_handlers()
169210

@@ -201,6 +242,14 @@ def handle_all_exceptions(e):
201242
app.register_blueprint(frontend_bp)
202243
app.register_blueprint(analytics_bp)
203244

245+
# Start the GraphQL retry worker in background thread
246+
# Worker sleeps 2 seconds on startup to ensure Redis/MongoDB are ready
247+
start_retry_worker()
248+
249+
# Register cleanup on shutdown
250+
import atexit
251+
atexit.register(stop_retry_worker)
252+
204253
if __name__ == '__main__':
205254
if not redis_client.exists('res-canvas-draw-count'):
206255
init_count = {"id": "res-canvas-draw-count", "value": 0}

backend/config.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
load_dotenv()
44

55
MONGO_ATLAS_URI_SECRET = os.getenv("MONGO_ATLAS_URI")
6-
RES_DB_BASE_URI = os.getenv("RES_DB_BASE_URI")
6+
RES_DB_BASE_URI = os.getenv("RESILIENTDB_BASE_URI")
7+
RES_DB_BASE_URL = RES_DB_BASE_URI
78
RES_DB_API_COMMIT = f"{RES_DB_BASE_URI}/v1/transactions/commit"
89
RES_DB_API_QUERY = f"{RES_DB_BASE_URI}/v1/transactions/"
910
HEADERS = {"Content-Type": "application/json"}
@@ -34,6 +35,7 @@
3435
REFRESH_TOKEN_COOKIE_NAME = os.getenv("REFRESH_TOKEN_COOKIE_NAME", "rescanvas_refresh")
3536
REFRESH_TOKEN_COOKIE_SECURE = os.getenv("REFRESH_TOKEN_COOKIE_SECURE", "False") == "True"
3637
REFRESH_TOKEN_COOKIE_SAMESITE = os.getenv("REFRESH_TOKEN_COOKIE_SAMESITE", "Lax")
38+
REFRESH_TOKEN_COOKIE_PARTITIONED = os.getenv("REFRESH_TOKEN_COOKIE_PARTITIONED", "False") == "True"
3739

3840
ROOM_MASTER_KEY_B64 = os.getenv("ROOM_MASTER_KEY_B64")
3941
if not ROOM_MASTER_KEY_B64:
@@ -77,4 +79,10 @@
7779
RATE_LIMIT_SEARCH_MINUTE = int(os.getenv("RATE_LIMIT_SEARCH_MINUTE", "30"))
7880

7981
# Burst protection
80-
RATE_LIMIT_BURST_SECOND = int(os.getenv("RATE_LIMIT_BURST_SECOND", "10"))
82+
RATE_LIMIT_BURST_SECOND = int(os.getenv("RATE_LIMIT_BURST_SECOND", "10"))
83+
84+
# Logging Configuration
85+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() # DEBUG, INFO, WARNING, ERROR, CRITICAL
86+
LOG_EXCLUDE_LEVELS = os.getenv("LOG_EXCLUDE_LEVELS", "WARNING") # Comma-separated: WARNING,DEBUG
87+
LOG_FORMAT = os.getenv("LOG_FORMAT", "%(asctime)s [%(levelname)s] %(name)s:%(lineno)d – %(message)s")
88+
LOG_DATE_FORMAT = os.getenv("LOG_DATE_FORMAT", "%Y-%m-%d %H:%M:%S")

backend/docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ services:
5959
- JWT_SECRET=${JWT_SECRET:-dev-insecure-change-me}
6060
- OPENAI_API_KEY=${OPENAI_API_KEY}
6161
- ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-True}
62+
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
63+
- REFRESH_TOKEN_COOKIE_SECURE=${REFRESH_TOKEN_COOKIE_SECURE:-True}
64+
- REFRESH_TOKEN_COOKIE_SAMESITE=${REFRESH_TOKEN_COOKIE_SAMESITE:-None}
65+
- REFRESH_TOKEN_COOKIE_PARTITIONED=${REFRESH_TOKEN_COOKIE_PARTITIONED:-True}
6266
volumes:
6367
- ./logs:/app/logs
6468
depends_on:

backend/incubator-resilientdb-resilient-python-cache/example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async def main():
6161
)
6262

6363
resilient_db_config = ResilientDBConfig(
64-
base_url="resilientdb://crow.resilientdb.com",
64+
base_url="resilientdb://dev-crow.resilientdb.com",
6565
http_secure=True,
6666
ws_secure=True
6767
)

backend/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ limits==5.6.0
3131
markdown-it-py==4.0.0
3232
MarkupSafe==3.0.3
3333
mdurl==0.1.2
34-
# motor==3.7.1
34+
motor==2.5.1
3535
ordered-set==4.1.0
3636
packaging==25.0
3737
passlib==1.7.4
@@ -40,7 +40,7 @@ pycparser==2.23
4040
pyee==9.1.1
4141
Pygments==2.19.2
4242
PyJWT==2.3.0
43-
pymongo==4.15.3
43+
pymongo==3.12.3
4444
PyNaCl==1.6.0
4545
pytest==8.3.5
4646
pytest-cov==5.0.0

backend/routes/auth.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from config import (
99
JWT_SECRET, JWT_ISSUER, ACCESS_TOKEN_EXPIRES_SECS, REFRESH_TOKEN_EXPIRES_SECS,
1010
REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_SECURE, REFRESH_TOKEN_COOKIE_SAMESITE,
11+
REFRESH_TOKEN_COOKIE_PARTITIONED,
1112
RATE_LIMIT_LOGIN_HOURLY, RATE_LIMIT_REGISTER_HOURLY, RATE_LIMIT_REFRESH_HOURLY
1213
)
1314
from middleware.auth import require_auth, validate_request_data
@@ -55,6 +56,37 @@ def _find_valid_refresh_token(token_hash):
5556
})
5657
return doc
5758

59+
def _set_refresh_cookie(response, token_value, max_age):
60+
"""Set refresh token cookie with all necessary attributes including Partitioned.
61+
62+
The Partitioned attribute is required for third-party cookies in modern browsers
63+
to prevent cross-site tracking.
64+
"""
65+
cookie_parts = [
66+
f"{REFRESH_TOKEN_COOKIE_NAME}={token_value}",
67+
f"Max-Age={max_age}",
68+
f"Path=/",
69+
]
70+
71+
if REFRESH_TOKEN_COOKIE_SECURE:
72+
cookie_parts.append("Secure")
73+
74+
cookie_parts.append("HttpOnly")
75+
76+
if REFRESH_TOKEN_COOKIE_SAMESITE:
77+
cookie_parts.append(f"SameSite={REFRESH_TOKEN_COOKIE_SAMESITE}")
78+
79+
if REFRESH_TOKEN_COOKIE_PARTITIONED:
80+
cookie_parts.append("Partitioned")
81+
82+
expires = datetime.utcnow() + timedelta(seconds=max_age)
83+
expires_str = expires.strftime("%a, %d %b %Y %H:%M:%S GMT")
84+
cookie_parts.append(f"Expires={expires_str}")
85+
86+
cookie_string = "; ".join(cookie_parts)
87+
response.headers.add("Set-Cookie", cookie_string)
88+
return response
89+
5890
@auth_bp.route("/auth/register", methods=["POST"])
5991
@limiter.limit(f"{RATE_LIMIT_REGISTER_HOURLY}/hour")
6092
@validate_request_data({
@@ -96,7 +128,7 @@ def register():
96128
expires_at = datetime.utcnow() + timedelta(seconds=REFRESH_TOKEN_EXPIRES_SECS)
97129
_store_refresh_token(user["_id"], h, expires_at)
98130
resp = make_response(jsonify({"status":"ok","token": access, "user": {"username": user["username"], "walletPubKey": user.get("walletPubKey")}}), 201)
99-
resp.set_cookie(REFRESH_TOKEN_COOKIE_NAME, raw_refresh, httponly=True, secure=REFRESH_TOKEN_COOKIE_SECURE, samesite=REFRESH_TOKEN_COOKIE_SAMESITE, max_age=REFRESH_TOKEN_EXPIRES_SECS)
131+
_set_refresh_cookie(resp, raw_refresh, REFRESH_TOKEN_EXPIRES_SECS)
100132
return resp
101133

102134
@auth_bp.route("/auth/login", methods=["POST"])
@@ -130,7 +162,7 @@ def login():
130162
expires_at = datetime.utcnow() + timedelta(seconds=REFRESH_TOKEN_EXPIRES_SECS)
131163
_store_refresh_token(user["_id"], h, expires_at)
132164
resp = make_response(jsonify({"status":"ok","token": access, "user": {"username": user["username"], "walletPubKey": user.get("walletPubKey")}}))
133-
resp.set_cookie(REFRESH_TOKEN_COOKIE_NAME, raw_refresh, httponly=True, secure=REFRESH_TOKEN_COOKIE_SECURE, samesite=REFRESH_TOKEN_COOKIE_SAMESITE, max_age=REFRESH_TOKEN_EXPIRES_SECS)
165+
_set_refresh_cookie(resp, raw_refresh, REFRESH_TOKEN_EXPIRES_SECS)
134166
return resp
135167

136168
@auth_bp.route("/auth/refresh", methods=["POST"])
@@ -150,7 +182,7 @@ def refresh():
150182
expires_at = datetime.utcnow() + timedelta(seconds=REFRESH_TOKEN_EXPIRES_SECS)
151183
_store_refresh_token(user["_id"], new_h, expires_at)
152184
resp = make_response(jsonify({"status":"ok","token": access}))
153-
resp.set_cookie(REFRESH_TOKEN_COOKIE_NAME, new_raw, httponly=True, secure=REFRESH_TOKEN_COOKIE_SECURE, samesite=REFRESH_TOKEN_COOKIE_SAMESITE, max_age=REFRESH_TOKEN_EXPIRES_SECS)
185+
_set_refresh_cookie(resp, new_raw, REFRESH_TOKEN_EXPIRES_SECS)
154186
return resp
155187

156188
@auth_bp.route("/auth/logout", methods=["POST"])

backend/routes/export.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,9 @@ def import_canvas(room_id):
408408
if field in stroke:
409409
stroke_data[field] = stroke[field]
410410

411-
# Generate unique stroke ID
412-
draw_count = get_canvas_draw_count()
411+
# ATOMIC OPERATION: Increment counter and get the NEW value
412+
# This must happen FIRST to ensure unique stroke IDs even under concurrent load
413+
draw_count = increment_canvas_draw_count()
413414
stroke_id = f"res-canvas-draw-{draw_count}"
414415
stroke_data["id"] = stroke_id
415416

@@ -486,7 +487,7 @@ def import_canvas(room_id):
486487
logger.warning(f"import_canvas: GraphQL commit failed for {stroke_id}: {e}")
487488
# Continue even if GraphQL fails - data is in Redis/MongoDB
488489

489-
increment_canvas_draw_count()
490+
# Note: increment_canvas_draw_count() already called above - do not call again
490491
imported_count += 1
491492

492493
except Exception as e:

backend/routes/get_canvas_data.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -777,14 +777,21 @@ def get_canvas_data():
777777

778778
try:
779779
# We'll scan for both undo- and redo- prefix markers.
780-
# Query uses a simple regex on the stored asset.data.id field inside
781-
# transactions. This may be somewhat heavy but is necessary for recovery.
780+
# Use range query instead of regex to leverage index
781+
# Range query with prefix matching is much faster than regex (6-18x speedup)
782+
# Index: transactions.value.asset.data.id + _id (created by undo_redo_marker_idx)
782783
for prefix in ("undo-", "redo-"):
783784
# Find any transaction blocks that contain an asset.data.id starting with the prefix.
784-
# We sort latest-first by _id so we see the most recent writes earliest.
785+
# Use range query: $gte prefix and $lt prefix+highest_unicode to match prefix*
786+
# This allows MongoDB to use the index efficiently
785787
try:
786788
cursor = strokes_coll.find(
787-
{"transactions.value.asset.data.id": {"$regex": f"^{prefix}"}},
789+
{
790+
"transactions.value.asset.data.id": {
791+
"$gte": prefix,
792+
"$lt": prefix + "\uffff" # Unicode max ensures prefix matching
793+
}
794+
},
788795
sort=[("_id", -1)]
789796
)
790797
except Exception:
@@ -846,23 +853,47 @@ def get_canvas_data():
846853
except Exception:
847854
end_idx = 0
848855

856+
# OPTIMIZATION: Use Redis pipeline to fetch all keys in parallel (10-20x faster)
857+
# Build list of all keys to fetch
858+
keys_to_fetch = [f"res-canvas-draw-{i}" for i in range(start_idx, end_idx)]
859+
860+
# Fetch all keys in one pipeline operation
861+
redis_results = {}
862+
if keys_to_fetch:
863+
try:
864+
pipe = redis_client.pipeline()
865+
for key_id in keys_to_fetch:
866+
pipe.get(key_id)
867+
raw_results = pipe.execute()
868+
869+
# Map results back to keys
870+
for idx, key_id in enumerate(keys_to_fetch):
871+
if idx < len(raw_results):
872+
redis_results[key_id] = raw_results[idx]
873+
except Exception as e:
874+
logger.warning(f"Redis pipeline failed, falling back to sequential: {e}")
875+
# Fallback to sequential if pipeline fails
876+
for key_id in keys_to_fetch:
877+
try:
878+
redis_results[key_id] = redis_client.get(key_id)
879+
except Exception:
880+
redis_results[key_id] = None
881+
882+
# Process each stroke (same logic as before, but using pre-fetched results)
849883
for i in range(start_idx, end_idx):
850884
key_id = f"res-canvas-draw-{i}"
851885
drawing = None
852886

853-
# 1) Try Redis cached entry first
854-
try:
855-
raw = redis_client.get(key_id)
856-
if raw:
887+
# 1) Try Redis cached entry first (from pipeline results)
888+
raw = redis_results.get(key_id)
889+
if raw:
890+
try:
891+
drawing = json.loads(raw)
892+
except Exception:
857893
try:
858-
drawing = json.loads(raw)
894+
drawing = json.loads(raw.decode()) if isinstance(raw, (bytes, bytearray)) else None
859895
except Exception:
860-
try:
861-
drawing = json.loads(raw.decode()) if isinstance(raw, (bytes, bytearray)) else None
862-
except Exception:
863-
drawing = None
864-
except Exception:
865-
drawing = None
896+
drawing = None
866897

867898
# 2) If not in Redis, try Mongo fallback for this specific key
868899
if not drawing:

0 commit comments

Comments
 (0)