From ee7d32585689d3914264562a10a5001b291fbc97 Mon Sep 17 00:00:00 2001 From: Artur Date: Sun, 3 May 2026 10:49:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 25 ++++++ devtools_options.yaml | 2 + lib/data/datasources/ws_client.dart | 9 +- lib/domain/services/api_service.dart | 15 +++- lib/logic/contact_provider.dart | 4 + lib/presentation/screens/contacts_screen.dart | 88 ++++++++++++++++--- lib/presentation/screens/settings_screen.dart | 1 - lib/presentation/widgets/contact_tile.dart | 7 ++ pubspec.yaml | 2 +- srv/app/api/endpoints/media.py | 78 ++++++++++++++-- srv/app/core/config.py | 25 ++++++ srv/app/core/security.py | 5 +- srv/app/db/models.py | 3 +- srv/app/websocket/connection_manager.py | 4 +- srv/main.py | 5 +- 15 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0fab021 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "chepuhagram", + "request": "launch", + "type": "dart" + }, + { + "name": "chepuhagram (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "chepuhagram (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml index fa0b357..b042345 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,3 +1,5 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index f41e84f..462be8e 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -45,7 +45,7 @@ class SocketService with WidgetsBindingObserver { } if (!allowConnect) return; // Не разрешаем подключение - // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке + // В FastAPI эндпоинт ожидает токен в URL-параметре final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token"); //_channel = WebSocketChannel.connect(uri); @@ -81,9 +81,12 @@ class SocketService with WidgetsBindingObserver { } bool sendMessage(Map data, {int retryCnt = 0}) { + const maxRetries = 5; if (_channel == null) { - //print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); - sendMessage(data, retryCnt: retryCnt + 1); + if (retryCnt < maxRetries) { + // Schedule retry with exponential backoff + Future.delayed(Duration(seconds: 1 << retryCnt), () => sendMessage(data, retryCnt: retryCnt + 1)); + } return false; } try { diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index bee5236..4629f80 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -8,6 +8,7 @@ import 'dart:convert'; class ApiService extends ChangeNotifier { final _client = http.Client(); final _storage = const FlutterSecureStorage(); + bool _isRefreshing = false; Future uploadMedia(List bytes) async { try { @@ -17,7 +18,6 @@ class ApiService extends ChangeNotifier { Uri.parse('${AppConstants.baseUrl}/media/upload'), ); request.headers.addAll({ - 'Content-Type': 'application/json', 'Authorization': 'Bearer $token', }); // Добавляем файл в запрос @@ -32,7 +32,7 @@ class ApiService extends ChangeNotifier { // Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) // request.headers.addAll({'Authorization': 'Bearer $token'}); - var streamedResponse = await request.send(); + var streamedResponse = await request.send().timeout(Duration(seconds: 30)); var response = await http.Response.fromStream(streamedResponse); if (response.statusCode == 200) { @@ -48,6 +48,11 @@ class ApiService extends ChangeNotifier { } Future refreshToken() async { + if (_isRefreshing) { + // Already refreshing, wait for completion or return true assuming it will succeed + return true; + } + _isRefreshing = true; notifyListeners(); try { @@ -56,7 +61,7 @@ class ApiService extends ChangeNotifier { Uri.parse('${AppConstants.baseUrl}/auth/refresh'), body: jsonEncode({'refresh_token': refreshToken}), headers: {'Content-Type': 'application/json'}, - ); + ).timeout(Duration(seconds: 30)); final decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; @@ -78,7 +83,9 @@ class ApiService extends ChangeNotifier { } } catch (e) { notifyListeners(); - rethrow; + return false; + } finally { + _isRefreshing = false; } } diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index 859dc60..067474e 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -54,6 +54,10 @@ class ContactProvider extends ChangeNotifier { .where((contact) => contact.id != userIdCopy) .toList(); }); + // Check if user changed during isolate execution + if (userIdCopy != _currentUserId) { + return; // Discard stale data + } _allContacts = _contacts; _isLoading = false; notifyListeners(); diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index f451de3..8a6679b 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -37,6 +37,9 @@ class _ContactsScreenState extends State with RouteAware { StreamSubscription? _socketSubscription; bool _isDownloading = false; double _downloadProgress = 0.0; + int _downloadedBytes = 0; + int _downloadTotalBytes = 0; + int _apkFileSizeBytes = 0; CancelToken? _cancelToken = CancelToken(); String? _latestApkUrl; bool _showUpdateBanner = false; @@ -183,6 +186,14 @@ class _ContactsScreenState extends State with RouteAware { _showUpdateBanner = true; _latestApkUrl = data['apk_url']; }); + if (_latestApkUrl != null) { + final size = await _fetchApkSize(_latestApkUrl!); + if (mounted) { + setState(() { + _apkFileSizeBytes = size; + }); + } + } } } } catch (e) { @@ -341,14 +352,14 @@ class _ContactsScreenState extends State with RouteAware { // Show local notification await flutterLocalNotificationsPlugin.show( - senderId, + senderId, '', '', NotificationDetails( android: AndroidNotificationDetails( 'Messages', 'Новые сообщения', - groupKey: groupKey, + groupKey: groupKey, setAsGroupSummary: true, importance: Importance.high, priority: Priority.high, @@ -492,21 +503,27 @@ class _ContactsScreenState extends State with RouteAware { ), ), currentAccountPicture: CircleAvatar( - backgroundColor: authProvider.avatarUrl == null && authProvider.avatarPath == null + backgroundColor: + authProvider.avatarUrl == null && + authProvider.avatarPath == null ? Theme.of(context).colorScheme.onSurface : null, backgroundImage: authProvider.avatarUrl != null ? NetworkImage(authProvider.avatarUrl!) : authProvider.avatarPath != null - ? FileImage(File(authProvider.avatarPath!)) - : null, - child: (authProvider.avatarUrl == null && authProvider.avatarPath == null) + ? FileImage(File(authProvider.avatarPath!)) + : null, + child: + (authProvider.avatarUrl == null && + authProvider.avatarPath == null) ? Text( initials.isEmpty ? 'U' : initials, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primaryContainer, + color: Theme.of( + context, + ).colorScheme.primaryContainer, ), ) : null, @@ -558,16 +575,24 @@ class _ContactsScreenState extends State with RouteAware { } try { + setState(() { + _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; + }); + // Скачиваем файл «в лоб» await Dio().download( _latestApkUrl!, path, cancelToken: _cancelToken, onReceiveProgress: (rec, total) { - if (total != -1) { - if (mounted) { - setState(() => _downloadProgress = rec / total); - } + if (mounted) { + setState(() { + _downloadedBytes = rec; + _downloadTotalBytes = total > 0 ? total : 0; + _downloadProgress = total > 0 ? rec / total : 0.0; + }); } }, ); @@ -585,11 +610,36 @@ class _ContactsScreenState extends State with RouteAware { print("Ошибка: $e"); } finally { if (mounted) { - setState(() => _isDownloading = false); + setState(() { + _isDownloading = false; + _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; + }); } } } + Future _fetchApkSize(String url) async { + try { + final response = await http.head(Uri.parse(url)); + final lengthHeader = response.headers['content-length']; + if (lengthHeader == null) return 0; + return int.tryParse(lengthHeader) ?? 0; + } catch (_) { + return 0; + } + } + + String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const kb = 1024; + const mb = kb * 1024; + if (bytes < kb) return '$bytes B'; + if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB'; + return '${(bytes / mb).toStringAsFixed(1)} MB'; + } + Widget _buildUpdateBanner() { return Container( margin: const EdgeInsets.fromLTRB( @@ -626,7 +676,9 @@ class _ContactsScreenState extends State with RouteAware { child: Text( _isDownloading ? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%' - : "Доступно новое обновление!", + : _apkFileSizeBytes > 0 + ? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}' + : 'Доступно новое обновление!', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -643,6 +695,8 @@ class _ContactsScreenState extends State with RouteAware { _isDownloading = false; _cancelToken = null; // Обязательно обнуляем токен! _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; }); } else { // Если не качаем — запускаем @@ -679,6 +733,14 @@ class _ContactsScreenState extends State with RouteAware { color: Colors.white, backgroundColor: Colors.white24, ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + '${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + ), ], ], ), diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 967df90..45ce634 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -5,7 +5,6 @@ import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '/logic/auth_provider.dart'; -import '/core/theme_manager.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index 512467e..993ab81 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -14,10 +14,16 @@ class ContactTile extends StatefulWidget { class _ContactTileState extends State { SharedPreferences? _prefs; + + + Duration? offset; + @override void initState() { super.initState(); + DateTime now = DateTime.now(); + offset = now.timeZoneOffset; _initPrefs(); } @@ -126,6 +132,7 @@ class _ContactTileState extends State { String _formatTime(DateTime? time) { if (time == null) return ""; + time = time.add(offset!); return "${time.hour}:${time.minute.toString().padLeft(2, '0')}"; } } diff --git a/pubspec.yaml b/pubspec.yaml index 1b9a8d3..418284a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.1+2 +version: 2.0.1+1 environment: sdk: ^3.10.0 diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py index cb0c14c..595e478 100644 --- a/srv/app/api/endpoints/media.py +++ b/srv/app/api/endpoints/media.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile +from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.core import security @@ -8,7 +8,9 @@ from jose import JWTError, jwt from app.core.security import get_current_user from fastapi.responses import FileResponse import os +import re import uuid +from io import BytesIO # бд @@ -30,12 +32,79 @@ if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) +def _parse_multipart_body(body: bytes): + try: + if not body.startswith(b'--'): + return None + + boundary, rest = body.split(b'\r\n', 1) + parts = body.split(boundary) + for part in parts: + if not part or part in (b'--', b'--\r\n'): + continue + + part = part.strip(b'\r\n') + if not part: + continue + + headers, _, content = part.partition(b'\r\n\r\n') + if not headers or content is None: + continue + + disposition_match = re.search( + br'Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?', + headers, + re.IGNORECASE, + ) + if not disposition_match: + continue + + field_name = disposition_match.group(1).decode('utf-8', errors='ignore') + filename = disposition_match.group(2) + if field_name != 'file': + continue + + filename = filename.decode('utf-8', errors='ignore') if filename else 'upload.bin' + content_type_match = re.search(br'Content-Type:\s*([\w\-\/]+)', headers, re.IGNORECASE) + content_type = ( + content_type_match.group(1).decode('utf-8', errors='ignore') + if content_type_match + else 'application/octet-stream' + ) + return filename, content.rstrip(b'\r\n'), content_type + except Exception: + return None + return None + + @mediaRouter.post('/upload') -async def upload_file(file: UploadFile = File(...)): - # Проверяем, есть ли файл в запросе - if not file.filename: +async def upload_file(request: Request, file: UploadFile = File(None)): + uploaded_file = file + if uploaded_file is None: + raw_body = await request.body() + parsed = _parse_multipart_body(raw_body) + if parsed is not None: + filename, content, content_type = parsed + uploaded_file = UploadFile( + filename=filename, + file=BytesIO(content), + content_type=content_type, + ) + + if uploaded_file is None or not uploaded_file.filename: raise HTTPException(status_code=400, detail="No selected file") + # Валидация размера файла (макс 10MB) + MAX_FILE_SIZE = 10 * 1024 * 1024 + content = await uploaded_file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="File too large (max 10MB)") + + # Валидация типа файла (для зашифрованных файлов пропускаем, так как content_type не image) + # ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} + # if uploaded_file.content_type not in ALLOWED_TYPES: + # raise HTTPException(status_code=400, detail="Invalid file type") + # Генерируем уникальное имя, чтобы файлы не перезаписывались file_id = str(uuid.uuid4()) filename = f"{file_id}.enc" @@ -43,7 +112,6 @@ async def upload_file(file: UploadFile = File(...)): # Сохраняем with open(file_path, "wb") as f: - content = await file.read() f.write(content) print(f"Файл сохранен: {file_path}") diff --git a/srv/app/core/config.py b/srv/app/core/config.py index e69de29..62971ae 100644 --- a/srv/app/core/config.py +++ b/srv/app/core/config.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + # Database + DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./chepuhagram.db") + + # Security + JWT_KEY: str = os.getenv("JWT_KEY", "") + if not JWT_KEY: + raise RuntimeError("JWT_KEY environment variable not set") + + # Firebase + FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json") + + # Server + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "8000")) + + # CORS + ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",") + +config = Config() \ No newline at end of file diff --git a/srv/app/core/security.py b/srv/app/core/security.py index 4e841e7..a4dec68 100644 --- a/srv/app/core/security.py +++ b/srv/app/core/security.py @@ -10,7 +10,10 @@ from jose import JWTError, jwt import os import bcrypt load_dotenv() -SECRET_KEY = os.getenv("JWT_KEY").strip() +SECRET_KEY = os.getenv("JWT_KEY") +if not SECRET_KEY: + raise RuntimeError("JWT_KEY environment variable not set") +SECRET_KEY = SECRET_KEY.strip() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60 diff --git a/srv/app/db/models.py b/srv/app/db/models.py index 1b10887..3e1d201 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -4,8 +4,9 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime from sqlalchemy.sql import func from sqlalchemy import text +from app.core.config import config -SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" +SQLALCHEMY_DATABASE_URL = config.DATABASE_URL engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index c72f3c5..d112a0e 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -7,9 +7,9 @@ from sqlalchemy.orm import Session from app.db import models from firebase_admin import messaging, credentials, exceptions import firebase_admin +from app.core.config import config -cred = credentials.Certificate( - "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json") +cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH) firebase_admin.initialize_app(cred) # бд diff --git a/srv/main.py b/srv/main.py index 0d577c4..03b2736 100644 --- a/srv/main.py +++ b/srv/main.py @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware import os import asyncio from app.db import models +from app.core.config import config app = FastAPI() @@ -17,7 +18,7 @@ app.include_router(wsRouter) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=config.ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -27,7 +28,7 @@ app.add_middleware( @app.get("/check-update") async def check_update(): return { - "latest_version": "2.0.0", + "latest_version": "2.0.1", "apk_url": "https://api.chepuhagram.ru/get-update", "force_update": False }