Изменения безопасности

This commit is contained in:
Artur 2026-05-03 10:49:07 +05:00
parent a7fe16954f
commit ee7d325856
15 changed files with 240 additions and 33 deletions

25
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

View File

@ -1,3 +1,5 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true
- shared_preferences: true

View File

@ -45,7 +45,7 @@ class SocketService with WidgetsBindingObserver {
} }
if (!allowConnect) return; // Не разрешаем подключение if (!allowConnect) return; // Не разрешаем подключение
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке // В FastAPI эндпоинт ожидает токен в URL-параметре
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token"); final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
//_channel = WebSocketChannel.connect(uri); //_channel = WebSocketChannel.connect(uri);
@ -81,9 +81,12 @@ class SocketService with WidgetsBindingObserver {
} }
bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) { bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
const maxRetries = 5;
if (_channel == null) { if (_channel == null) {
//print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); if (retryCnt < maxRetries) {
sendMessage(data, retryCnt: retryCnt + 1); // Schedule retry with exponential backoff
Future.delayed(Duration(seconds: 1 << retryCnt), () => sendMessage(data, retryCnt: retryCnt + 1));
}
return false; return false;
} }
try { try {

View File

@ -8,6 +8,7 @@ import 'dart:convert';
class ApiService extends ChangeNotifier { class ApiService extends ChangeNotifier {
final _client = http.Client(); final _client = http.Client();
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
bool _isRefreshing = false;
Future<String?> uploadMedia(List<int> bytes) async { Future<String?> uploadMedia(List<int> bytes) async {
try { try {
@ -17,7 +18,6 @@ class ApiService extends ChangeNotifier {
Uri.parse('${AppConstants.baseUrl}/media/upload'), Uri.parse('${AppConstants.baseUrl}/media/upload'),
); );
request.headers.addAll({ request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
}); });
// Добавляем файл в запрос // Добавляем файл в запрос
@ -32,7 +32,7 @@ class ApiService extends ChangeNotifier {
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) // Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.)
// request.headers.addAll({'Authorization': 'Bearer $token'}); // 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); var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -48,6 +48,11 @@ class ApiService extends ChangeNotifier {
} }
Future<bool> refreshToken() async { Future<bool> refreshToken() async {
if (_isRefreshing) {
// Already refreshing, wait for completion or return true assuming it will succeed
return true;
}
_isRefreshing = true;
notifyListeners(); notifyListeners();
try { try {
@ -56,7 +61,7 @@ class ApiService extends ChangeNotifier {
Uri.parse('${AppConstants.baseUrl}/auth/refresh'), Uri.parse('${AppConstants.baseUrl}/auth/refresh'),
body: jsonEncode({'refresh_token': refreshToken}), body: jsonEncode({'refresh_token': refreshToken}),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); ).timeout(Duration(seconds: 30));
final decodedResponse = final decodedResponse =
jsonDecode(utf8.decode(response.bodyBytes)) as Map; jsonDecode(utf8.decode(response.bodyBytes)) as Map;
@ -78,7 +83,9 @@ class ApiService extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
notifyListeners(); notifyListeners();
rethrow; return false;
} finally {
_isRefreshing = false;
} }
} }

View File

@ -54,6 +54,10 @@ class ContactProvider extends ChangeNotifier {
.where((contact) => contact.id != userIdCopy) .where((contact) => contact.id != userIdCopy)
.toList(); .toList();
}); });
// Check if user changed during isolate execution
if (userIdCopy != _currentUserId) {
return; // Discard stale data
}
_allContacts = _contacts; _allContacts = _contacts;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();

View File

@ -37,6 +37,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
StreamSubscription<dynamic>? _socketSubscription; StreamSubscription<dynamic>? _socketSubscription;
bool _isDownloading = false; bool _isDownloading = false;
double _downloadProgress = 0.0; double _downloadProgress = 0.0;
int _downloadedBytes = 0;
int _downloadTotalBytes = 0;
int _apkFileSizeBytes = 0;
CancelToken? _cancelToken = CancelToken(); CancelToken? _cancelToken = CancelToken();
String? _latestApkUrl; String? _latestApkUrl;
bool _showUpdateBanner = false; bool _showUpdateBanner = false;
@ -183,6 +186,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
_showUpdateBanner = true; _showUpdateBanner = true;
_latestApkUrl = data['apk_url']; _latestApkUrl = data['apk_url'];
}); });
if (_latestApkUrl != null) {
final size = await _fetchApkSize(_latestApkUrl!);
if (mounted) {
setState(() {
_apkFileSizeBytes = size;
});
}
}
} }
} }
} catch (e) { } catch (e) {
@ -341,14 +352,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
// Show local notification // Show local notification
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
senderId, senderId,
'', '',
'', '',
NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'Messages', 'Messages',
'Новые сообщения', 'Новые сообщения',
groupKey: groupKey, groupKey: groupKey,
setAsGroupSummary: true, setAsGroupSummary: true,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
@ -492,21 +503,27 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
), ),
), ),
currentAccountPicture: CircleAvatar( currentAccountPicture: CircleAvatar(
backgroundColor: authProvider.avatarUrl == null && authProvider.avatarPath == null backgroundColor:
authProvider.avatarUrl == null &&
authProvider.avatarPath == null
? Theme.of(context).colorScheme.onSurface ? Theme.of(context).colorScheme.onSurface
: null, : null,
backgroundImage: authProvider.avatarUrl != null backgroundImage: authProvider.avatarUrl != null
? NetworkImage(authProvider.avatarUrl!) ? NetworkImage(authProvider.avatarUrl!)
: authProvider.avatarPath != null : authProvider.avatarPath != null
? FileImage(File(authProvider.avatarPath!)) ? FileImage(File(authProvider.avatarPath!))
: null, : null,
child: (authProvider.avatarUrl == null && authProvider.avatarPath == null) child:
(authProvider.avatarUrl == null &&
authProvider.avatarPath == null)
? Text( ? Text(
initials.isEmpty ? 'U' : initials, initials.isEmpty ? 'U' : initials,
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(
context,
).colorScheme.primaryContainer,
), ),
) )
: null, : null,
@ -558,16 +575,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
} }
try { try {
setState(() {
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
// Скачиваем файл «в лоб» // Скачиваем файл «в лоб»
await Dio().download( await Dio().download(
_latestApkUrl!, _latestApkUrl!,
path, path,
cancelToken: _cancelToken, cancelToken: _cancelToken,
onReceiveProgress: (rec, total) { onReceiveProgress: (rec, total) {
if (total != -1) { if (mounted) {
if (mounted) { setState(() {
setState(() => _downloadProgress = rec / total); _downloadedBytes = rec;
} _downloadTotalBytes = total > 0 ? total : 0;
_downloadProgress = total > 0 ? rec / total : 0.0;
});
} }
}, },
); );
@ -585,11 +610,36 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
print("Ошибка: $e"); print("Ошибка: $e");
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isDownloading = false); setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
} }
} }
} }
Future<int> _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() { Widget _buildUpdateBanner() {
return Container( return Container(
margin: const EdgeInsets.fromLTRB( margin: const EdgeInsets.fromLTRB(
@ -626,7 +676,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
child: Text( child: Text(
_isDownloading _isDownloading
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%' ? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
: "Доступно новое обновление!", : _apkFileSizeBytes > 0
? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}'
: 'Доступно новое обновление!',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -643,6 +695,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
_isDownloading = false; _isDownloading = false;
_cancelToken = null; // Обязательно обнуляем токен! _cancelToken = null; // Обязательно обнуляем токен!
_downloadProgress = 0.0; _downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
}); });
} else { } else {
// Если не качаем запускаем // Если не качаем запускаем
@ -679,6 +733,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
color: Colors.white, color: Colors.white,
backgroundColor: Colors.white24, 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),
),
),
], ],
], ],
), ),

View File

@ -5,7 +5,6 @@ import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';

View File

@ -14,10 +14,16 @@ class ContactTile extends StatefulWidget {
class _ContactTileState extends State<ContactTile> { class _ContactTileState extends State<ContactTile> {
SharedPreferences? _prefs; SharedPreferences? _prefs;
Duration? offset;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
DateTime now = DateTime.now();
offset = now.timeZoneOffset;
_initPrefs(); _initPrefs();
} }
@ -126,6 +132,7 @@ class _ContactTileState extends State<ContactTile> {
String _formatTime(DateTime? time) { String _formatTime(DateTime? time) {
if (time == null) return ""; if (time == null) return "";
time = time.add(offset!);
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}"; return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
} }
} }

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.10.0 sdk: ^3.10.0

View File

@ -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 fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import security from app.core import security
@ -8,7 +8,9 @@ from jose import JWTError, jwt
from app.core.security import get_current_user from app.core.security import get_current_user
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import os import os
import re
import uuid import uuid
from io import BytesIO
# бд # бд
@ -30,12 +32,79 @@ if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(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') @mediaRouter.post('/upload')
async def upload_file(file: UploadFile = File(...)): async def upload_file(request: Request, file: UploadFile = File(None)):
# Проверяем, есть ли файл в запросе uploaded_file = file
if not file.filename: 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") 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()) file_id = str(uuid.uuid4())
filename = f"{file_id}.enc" filename = f"{file_id}.enc"
@ -43,7 +112,6 @@ async def upload_file(file: UploadFile = File(...)):
# Сохраняем # Сохраняем
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
content = await file.read()
f.write(content) f.write(content)
print(f"Файл сохранен: {file_path}") print(f"Файл сохранен: {file_path}")

View File

@ -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()

View File

@ -10,7 +10,10 @@ from jose import JWTError, jwt
import os import os
import bcrypt import bcrypt
load_dotenv() 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" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60 REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60

View File

@ -4,8 +4,9 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy import text 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}) engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()

View File

@ -7,9 +7,9 @@ from sqlalchemy.orm import Session
from app.db import models from app.db import models
from firebase_admin import messaging, credentials, exceptions from firebase_admin import messaging, credentials, exceptions
import firebase_admin import firebase_admin
from app.core.config import config
cred = credentials.Certificate( cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
"chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
firebase_admin.initialize_app(cred) firebase_admin.initialize_app(cred)
# бд # бд

View File

@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
import os import os
import asyncio import asyncio
from app.db import models from app.db import models
from app.core.config import config
app = FastAPI() app = FastAPI()
@ -17,7 +18,7 @@ app.include_router(wsRouter)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=config.ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -27,7 +28,7 @@ app.add_middleware(
@app.get("/check-update") @app.get("/check-update")
async def check_update(): async def check_update():
return { return {
"latest_version": "2.0.0", "latest_version": "2.0.1",
"apk_url": "https://api.chepuhagram.ru/get-update", "apk_url": "https://api.chepuhagram.ru/get-update",
"force_update": False "force_update": False
} }