Изменения безопасности
This commit is contained in:
parent
a7fe16954f
commit
ee7d325856
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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<String, dynamic> 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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'dart:convert';
|
|||
class ApiService extends ChangeNotifier {
|
||||
final _client = http.Client();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
bool _isRefreshing = false;
|
||||
|
||||
Future<String?> uploadMedia(List<int> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
StreamSubscription<dynamic>? _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<ContactsScreen> with RouteAware {
|
|||
_showUpdateBanner = true;
|
||||
_latestApkUrl = data['apk_url'];
|
||||
});
|
||||
if (_latestApkUrl != null) {
|
||||
final size = await _fetchApkSize(_latestApkUrl!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_apkFileSizeBytes = size;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -492,21 +503,27 @@ class _ContactsScreenState extends State<ContactsScreen> 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<ContactsScreen> 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<ContactsScreen> with RouteAware {
|
|||
print("Ошибка: $e");
|
||||
} finally {
|
||||
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() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
|
|
@ -626,7 +676,9 @@ class _ContactsScreenState extends State<ContactsScreen> 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<ContactsScreen> with RouteAware {
|
|||
_isDownloading = false;
|
||||
_cancelToken = null; // Обязательно обнуляем токен!
|
||||
_downloadProgress = 0.0;
|
||||
_downloadedBytes = 0;
|
||||
_downloadTotalBytes = 0;
|
||||
});
|
||||
} else {
|
||||
// Если не качаем — запускаем
|
||||
|
|
@ -679,6 +733,14 @@ class _ContactsScreenState extends State<ContactsScreen> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -15,9 +15,15 @@ class ContactTile extends StatefulWidget {
|
|||
class _ContactTileState extends State<ContactTile> {
|
||||
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<ContactTile> {
|
|||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return "";
|
||||
time = time.add(offset!);
|
||||
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
# бд
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue