From b7d884df5804d75a213e0c58d1dde0a241a52cd1 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 21 Apr 2026 16:25:27 +0300 Subject: [PATCH] Fix suspension status --- README.md | 19 +-- backend/services/main/dependencies.py | 31 ++++- backend/services/main/key_lifecycle.py | 98 ++++++++++++++- backend/services/main/routes/account.py | 24 ++-- .../main/routes/envelope_messaging.py | 4 +- backend/services/main/routes/messaging.py | 22 ++-- backend/services/main/routes/profile.py | 119 ++++++++---------- package.json | 1 - 8 files changed, 211 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 45955fa..aa3cf50 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,27 @@ FromChat - полностью открытый мессенджер. Его можно попробовать на [сайте](http://fromchat.ru). ## Содержание: + - [Основные моменты](#highlights) - [Использование](#usage) - [Часто задаваемые вопросы](#faq) - [Внос вклада](#contributing) ## Основные моменты + - Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд). - 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя. ## Использование -_В разработке._ + +*В разработке.* ## Часто задаваемые вопросы - -_В разработке._ + + + +*В разработке.* ## Внос вклада -Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev). + +Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev). \ No newline at end of file diff --git a/backend/services/main/dependencies.py b/backend/services/main/dependencies.py index 0893e4c..6895511 100644 --- a/backend/services/main/dependencies.py +++ b/backend/services/main/dependencies.py @@ -18,11 +18,12 @@ def get_db(): finally: db.close() -# Зависимость для получения текущего пользователя -def get_current_user( +# Internal dependency helper to reuse auth/session resolution +def _get_current_user( request: Request, - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db), + credentials: HTTPAuthorizationCredentials, + db: Session, + allow_suspended: bool = False, ) -> User: token = credentials.credentials try: @@ -98,7 +99,7 @@ def get_current_user( db.commit() # Check if user is suspended - if user.suspended: + if user.suspended and not allow_suspended: logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -117,4 +118,22 @@ def get_current_user( request.state.current_user = user request.state.session_id = session_id - return user \ No newline at end of file + return user + + +# Dependency for all standard routes: suspended users are blocked +def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + return _get_current_user(request, credentials, db, allow_suspended=False) + + +# Dependency for read/crypto endpoints that remain accessible for suspended users +def get_current_user_allow_suspended( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + return _get_current_user(request, credentials, db, allow_suspended=True) \ No newline at end of file diff --git a/backend/services/main/key_lifecycle.py b/backend/services/main/key_lifecycle.py index 8d5d773..830a1f9 100644 --- a/backend/services/main/key_lifecycle.py +++ b/backend/services/main/key_lifecycle.py @@ -6,13 +6,56 @@ Uses MESSAGE_RETENTION_DAYS from the environment (see services.shared.message_re import logging from datetime import datetime +from sqlalchemy import or_ from sqlalchemy.orm import Session -from .models import DMEnvelope, MessageEditHistory, DMEditHistory +from .models import DMEnvelope, DMReaction, DMFile, MessageEditHistory, DMEditHistory logger = logging.getLogger("uvicorn.error") +def _is_empty_wrapped_key(value: str | None) -> bool: + return value in (None, "") + + +def _is_decryptable_keyless(dm_envelope: DMEnvelope) -> bool: + return ( + _is_empty_wrapped_key(dm_envelope.sender_wrapped_mek_b64) + and _is_empty_wrapped_key(dm_envelope.recipient_wrapped_mek_b64) + and _is_empty_wrapped_key(dm_envelope.compliance_wrapped_mek_b64) + ) + + +def _delete_dm_envelopes_and_related(db: Session, envelope_ids: list[int]) -> int: + if not envelope_ids: + return 0 + + unique_ids = list(dict.fromkeys(envelope_ids)) + deleted_reactions = db.query(DMReaction).filter( + DMReaction.dm_envelope_id.in_(unique_ids) + ).delete(synchronize_session=False) + deleted_files = db.query(DMFile).filter(DMFile.message_id.in_(unique_ids)).delete(synchronize_session=False) + deleted_dm_edits = db.query(DMEditHistory).filter( + or_( + DMEditHistory.message_id.in_(unique_ids), + DMEditHistory.dm_envelope_id.in_(unique_ids), + ) + ).delete(synchronize_session=False) + deleted_messages = db.query(DMEnvelope).filter( + DMEnvelope.id.in_(unique_ids) + ).delete(synchronize_session=False) + + logger.info( + "Purging %s keyless DM envelopes. reactions=%s files=%s edit_history_rows=%s", + deleted_messages, + deleted_reactions, + deleted_files, + deleted_dm_edits, + ) + + return deleted_messages + + def _retention_timedelta_or_skip(): try: from services.shared.message_retention import get_message_retention @@ -66,6 +109,38 @@ def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) -> return False +def destroy_message_keys_for_user(db: Session, user_id: int, *, commit: bool = True) -> int: + try: + envelopes = db.query(DMEnvelope).filter( + (DMEnvelope.sender_id == user_id) | (DMEnvelope.recipient_id == user_id) + ).all() + + destroyed_count = 0 + for envelope in envelopes: + if envelope.sender_id == user_id and envelope.sender_wrapped_mek_b64 not in (None, ""): + envelope.sender_wrapped_mek_b64 = "" + destroyed_count += 1 + if envelope.recipient_id == user_id and envelope.recipient_wrapped_mek_b64 not in (None, ""): + envelope.recipient_wrapped_mek_b64 = "" + destroyed_count += 1 + + if destroyed_count > 0 and commit: + db.commit() + logger.info( + "Destroyed sender/recipient keys that belonged to user %s in %s DM envelopes (%s keys)", + len(envelopes), + destroyed_count, + user_id, + ) + + return destroyed_count + + except Exception as e: + logger.error("Failed to destroy sender/recipient keys for user %s: %s", user_id, e) + db.rollback() + return 0 + + def cleanup_expired_compliance_keys(db: Session) -> int: delta = _retention_timedelta_or_skip() if delta is None: @@ -113,11 +188,15 @@ def cleanup_expired_message_keys(db: Session) -> int: return 0 keys_destroyed = 0 + keyless_message_ids: list[int] = [] for message in expired_messages: - message.sender_wrapped_mek_b64 = "" - message.recipient_wrapped_mek_b64 = "" - keys_destroyed += 2 + if not _is_empty_wrapped_key(message.sender_wrapped_mek_b64): + message.sender_wrapped_mek_b64 = "" + keys_destroyed += 1 + if not _is_empty_wrapped_key(message.recipient_wrapped_mek_b64): + message.recipient_wrapped_mek_b64 = "" + keys_destroyed += 1 logger.debug( "Destroyed keys for soft-deleted message id=%s (deleted %s)", @@ -125,11 +204,20 @@ def cleanup_expired_message_keys(db: Session) -> int: message.deleted_at.isoformat(), ) + if _is_decryptable_keyless(message): + keyless_message_ids.append(message.id) + + if keyless_message_ids: + deleted_messages = _delete_dm_envelopes_and_related(db, keyless_message_ids) + else: + deleted_messages = 0 + db.commit() logger.info( - "Message key cleanup: destroyed %s keys across %s messages", + "Message key cleanup: destroyed %s keys across %s messages; purged %s keyless messages", keys_destroyed, len(expired_messages), + deleted_messages, ) return keys_destroyed diff --git a/backend/services/main/routes/account.py b/backend/services/main/routes/account.py index 1336a99..706c5ef 100644 --- a/backend/services/main/routes/account.py +++ b/backend/services/main/routes/account.py @@ -9,7 +9,7 @@ from user_agents import parse as parse_ua from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from ..constants import OWNER_USERNAME -from ..dependencies import get_current_user, get_db +from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from ..utils import create_token, get_password_hash, verify_password, get_client_ip from ..validation import is_valid_password, is_valid_username, is_valid_display_name @@ -18,6 +18,7 @@ import os from ..security.audit import log_security from ..security.profanity import contains_profanity from ..security.rate_limit import rate_limit_per_ip +from ..key_lifecycle import destroy_message_keys_for_user router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -314,13 +315,13 @@ def register( } @router.get("/crypto/public-key") -def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def get_public_key(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() return {"publicKey": row.public_key_b64 if row else None} @router.post("/crypto/public-key") -def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def set_public_key(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): pk = payload.get("publicKey") if not pk: raise HTTPException(status_code=400, detail="publicKey required") @@ -337,13 +338,13 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user) @router.get("/crypto/backup") -def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def get_backup(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() return {"blob": row.blob_json if row else None} @router.post("/crypto/backup") -def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def set_backup(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): blob = payload.get("blob") if not blob: raise HTTPException(status_code=400, detail="blob required") @@ -475,7 +476,7 @@ def change_password( @router.get("/users") @rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse -def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): users = db.query(User).order_by(User.username.asc()).all() return { "users": [ @@ -486,14 +487,19 @@ def list_users(request: Request, current_user: User = Depends(get_current_user), @router.get("/crypto/public-key/of/{user_id}") @rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse -def get_public_key_of(request: Request, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def get_public_key_of( + request: Request, + user_id: int, + current_user: User = Depends(get_current_user_allow_suspended), + db: Session = Depends(get_db), +): row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() return {"publicKey": row.public_key_b64 if row else None} @router.get("/users/search") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def search_users(request: Request, q: str, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): if len(q.strip()) < 2: return {"users": []} @@ -554,6 +560,8 @@ async def _delete_user_data(user: User, db: Session): if has_user_id: # Delete all records for this user db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id}) + + destroy_message_keys_for_user(db, user_id, commit=False) db.commit() except Exception as e: diff --git a/backend/services/main/routes/envelope_messaging.py b/backend/services/main/routes/envelope_messaging.py index 0f2d75e..110069b 100644 --- a/backend/services/main/routes/envelope_messaging.py +++ b/backend/services/main/routes/envelope_messaging.py @@ -23,7 +23,7 @@ from pydantic import BaseModel, Field from ..db import get_db from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest -from ..dependencies import get_current_user +from ..dependencies import get_current_user, get_current_user_allow_suspended from ..security.audit import log_security from ..service_calls import ( get_messaging_transport_public_key, @@ -538,7 +538,7 @@ async def get_encrypted_conversation( other_user_id: int, limit: int = 50, offset: int = 0, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db), ): """ diff --git a/backend/services/main/routes/messaging.py b/backend/services/main/routes/messaging.py index eb347c2..8e363be 100644 --- a/backend/services/main/routes/messaging.py +++ b/backend/services/main/routes/messaging.py @@ -14,7 +14,7 @@ from typing import Any import httpx from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status from sqlalchemy.orm import Session -from ..dependencies import get_current_user, get_db +from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db from .account import convert_user from ..constants import OWNER_USERNAME from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse @@ -607,7 +607,7 @@ async def push_test(request: Request, current_user: User = Depends(get_current_u @router.get("/get_messages") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def get_messages(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): messages = db.query(Message).order_by(Message.timestamp.asc()).all() messages_data = [] @@ -626,7 +626,7 @@ class MarkReadRequest(BaseModel): @router.get("/messages/new") @rate_limit_per_ip("60/minute") -async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def get_new_messages(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): """ Return unread public messages (Message.is_read == False). """ @@ -661,7 +661,7 @@ async def mark_messages_read(request: Request, read_request: MarkReadRequest, cu @router.get("/dm/fetch") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): envelopes = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) if since: envelopes = envelopes.filter(DMEnvelope.id > since) @@ -675,7 +675,7 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use @router.get("/dm/history/{other_user_id}") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): if other_user_id <= 0: raise HTTPException(status_code=400, detail="Invalid user ID") @@ -684,7 +684,7 @@ async def dm_history(request: Request, other_user_id: int, current_user: User = # Verify other user exists other_user = db.query(User).filter(User.id == other_user_id).first() - if not other_user or other_user.deleted or other_user.suspended: + if not other_user: raise HTTPException(status_code=404, detail="User not found") envelopes = db.query(DMEnvelope).filter( @@ -700,7 +700,7 @@ async def dm_history(request: Request, other_user_id: int, current_user: User = @router.get("/dm/conversations") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse -async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)): # Get all DM conversations where current user is involved conversations_query = db.query(DMEnvelope).filter( (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) @@ -1361,6 +1361,10 @@ class MessaggingSocketManager: "reason": reason }) + async def send_unsuspension_to_user(self, user_id: int): + """Send unsuspension message to user's WebSocket connections (as batched update)""" + await self.send_update_to_user(user_id, "unsuspended", {}) + async def send_deletion_to_user(self, user_id: int): """Send account deletion message to user's WebSocket connections (as batched update)""" await self.send_update_to_user(user_id, "account_deleted", {}) @@ -1478,7 +1482,7 @@ import httpx async def proxy_normal_file( request: Request, filename: str, - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_allow_suspended) ): """Proxy file requests to file_storage service.""" mod = service_calls._get_file_storage_module() @@ -1531,7 +1535,7 @@ async def test_proxy(): async def proxy_encrypted_file( request: Request, filename: str, - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user_allow_suspended) ): """Proxy file requests to file_storage service.""" mod = service_calls._get_file_storage_module() diff --git a/backend/services/main/routes/profile.py b/backend/services/main/routes/profile.py index 9015583..e92399b 100644 --- a/backend/services/main/routes/profile.py +++ b/backend/services/main/routes/profile.py @@ -9,7 +9,7 @@ import uuid import io from fastapi import Request -from ..dependencies import get_db, get_current_user +from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db from ..models import User, UpdateBioRequest, UserProfileResponse from pydantic import BaseModel from ..validation import is_valid_username, is_valid_display_name @@ -22,6 +22,40 @@ from ..security.rate_limit import rate_limit_per_ip router = APIRouter() +def _build_user_profile_response(user: User, is_owner_request: bool = False) -> UserProfileResponse: + should_hide_profile = (not is_owner_request) and (user.deleted or user.suspended) + if not should_hide_profile: + return UserProfileResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at, + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=user.deleted or False, + ) + + return UserProfileResponse( + id=user.id, + username="deleted", + display_name="Deleted User", + profile_picture=None, + bio=None, + online=False, + last_seen=None, + created_at=None, + verified=False, + suspended=False, + suspension_reason=None, + deleted=True, + ) + + def _ensure_owner_unsuspended(user: User | None, db: Session): if user and user.id == 1 and user.suspended: user.suspended = False @@ -111,7 +145,7 @@ async def get_profile_picture(filename: str): @router.get("/user/profile") async def get_user_profile( - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db) ): """ @@ -132,7 +166,7 @@ async def get_user_profile( verified=current_user.verified, suspended=current_user.suspended or False, suspension_reason=current_user.suspension_reason, - deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted + deleted=current_user.deleted or False, ) except Exception as e: # Log and return a consistent HTTP 500 error with minimal details @@ -278,7 +312,7 @@ async def update_user_bio( @router.get("/user/stats/registered-count") def get_registered_user_count( - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db), ): """Number of registered accounts (non-deleted users).""" @@ -289,6 +323,7 @@ def get_registered_user_count( @router.get("/user/{username}") async def get_user_by_username( username: str, + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db) ): """ @@ -304,41 +339,13 @@ async def get_user_by_username( _ensure_owner_unsuspended(user, db) - # Handle deleted or suspended users - if user.deleted or user.suspended: - return UserProfileResponse( - id=user.id, - username="deleted", - display_name="Deleted User", - profile_picture=None, - bio=None, - online=False, - last_seen=None, # Clear last seen timestamp - created_at=None, # Clear member since timestamp - verified=False, - suspended=False, - suspension_reason=None, - deleted=True - ) - - return UserProfileResponse( - id=user.id, - username=user.username, - display_name=user.display_name, - profile_picture=user.profile_picture, - bio=user.bio, - online=user.online, - last_seen=user.last_seen, - created_at=user.created_at, - verified=user.verified, - suspended=user.suspended or False, - suspension_reason=user.suspension_reason, - deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted - ) + is_owner_request = current_user.id == user.id or current_user.id == 1 + return _build_user_profile_response(user, is_owner_request=is_owner_request) @router.get("/user/id/{user_id}") async def get_user_by_id( user_id: int, + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db) ): """ @@ -354,37 +361,8 @@ async def get_user_by_id( _ensure_owner_unsuspended(user, db) - # Handle deleted or suspended users - if user.deleted or user.suspended: - return UserProfileResponse( - id=user.id, - username="deleted", - display_name="Deleted User", - profile_picture=None, - bio=None, - online=False, - last_seen=None, # Clear last seen timestamp - created_at=None, # Clear member since timestamp - verified=False, - suspended=False, - suspension_reason=None, - deleted=True - ) - - return UserProfileResponse( - id=user.id, - username=user.username, - display_name=user.display_name, - profile_picture=user.profile_picture, - bio=user.bio, - online=user.online, - last_seen=user.last_seen, - created_at=user.created_at, - verified=user.verified, - suspended=user.suspended or False, - suspension_reason=user.suspension_reason, - deleted=user.deleted or False - ) + is_owner_request = current_user.id == user.id or current_user.id == 1 + return _build_user_profile_response(user, is_owner_request=is_owner_request) @router.post("/user/{user_id}/verify") @@ -426,7 +404,7 @@ async def verify_user( @router.get("/user/check-similarity/{user_id}") async def check_user_similarity( user_id: int, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db) ): """ @@ -531,6 +509,13 @@ async def unsuspend_user( target_user.suspended = False target_user.suspension_reason = None db.commit() + + # Send WebSocket unsuspension message + try: + await messagingManager.send_unsuspension_to_user(user_id) + except Exception: + # Log error but don't fail the request + pass log_security( "admin_unsuspend_user", diff --git a/package.json b/package.json index 087435e..0707987 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", "autoprefixer": "^10.4.21", - "baseline-browser-mapping": "^2.9.11", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", "electron": "^39.2.7",