This commit is contained in:
Dmitry 2025-11-16 15:15:21 +03:00
parent a0c508883d
commit 5fd160e851
36 changed files with 1125 additions and 169 deletions

39
.forgejo/README.md Normal file
View file

@ -0,0 +1,39 @@
# Forgejo CI/CD для Mnemo Cards
## Быстрый старт
1. **Настройте секреты в Forgejo:**
```
DEPLOY_SSH_PRIVATE_KEY # SSH ключ для деплоя
```
2. **Протестируйте локально:**
```bash
./test_ci_locally.sh
```
3. **Push в main/master:**
```bash
git push origin main
```
## Workflows
- `ci-main.yml` - Главный pipeline
- `ci-backend.yml` - Backend (Dart)
- `ci-web.yml` - Web app (Flutter)
- `ci-mobile.yml` - Mobile app (Flutter)
- `cd-deploy.yml` - Автоматический деплой
- `code-quality.yml` - Контроль качества
- `release.yml` - Автоматические релизы
## Создание релиза
```bash
git tag v1.2.3
git push origin v1.2.3
```
## Подробная документация
Смотрите [CI_CD_SETUP.md](../CI_CD_SETUP.md) для полной информации.

View file

@ -0,0 +1,100 @@
name: Deploy Applications
on:
push:
branches: [ main, master ]
workflow_run:
workflows: ["Backend CI", "Web App CI"]
types:
- completed
jobs:
deploy-backend:
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download backend artifact
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
name: backend-binary
path: mnemo_cards_backend/
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
- name: Add server to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H 147.45.152.129 >> ~/.ssh/known_hosts
- name: Deploy backend
run: |
chmod +x mnemo_cards_backend/build_app.sh
./mnemo_cards_backend/build_app.sh
deploy-web:
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download web artifact
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
name: web-build
path: mnemo_cards_web_v2/build/web/
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
- name: Add server to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H 147.45.152.129 >> ~/.ssh/known_hosts
- name: Deploy web app
run: |
cd mnemo_cards_web_v2
chmod +x deploy/deploy.sh
./deploy/deploy.sh
deploy-mobile:
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download Android APK
uses: actions/download-artifact@v4
with:
name: android-apk
path: ./artifacts/
- name: Download iOS build
uses: actions/download-artifact@v4
with:
name: ios-build
path: ./artifacts/ios/
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
./artifacts/app-release.apk
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,127 @@
name: Backend CI
on:
push:
branches: [ main, master ]
paths:
- 'mnemo_cards_backend/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_common_backend/**'
pull_request:
branches: [ main, master ]
paths:
- 'mnemo_cards_backend/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_common_backend/**'
jobs:
test:
runs-on: ubuntu-latest
container: dart:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Dart SDK
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Cache pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards_backend/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_common_backend/.dart_tool
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
- name: Install common dependencies
run: |
cd mnemo_cards_common
dart pub get
- name: Install common backend dependencies
run: |
cd mnemo_cards_common_backend
dart pub get
- name: Install backend dependencies
run: |
cd mnemo_cards_backend
dart pub get
- name: Run code generation
run: |
cd mnemo_cards_backend
dart run build_runner build --delete-conflicting-outputs
- name: Run linting
run: |
cd mnemo_cards_backend
dart analyze lib/
dart format --set-exit-if-changed lib/
- name: Run tests
run: |
cd mnemo_cards_backend
dart test --coverage=coverage
dart pub global activate coverage
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./mnemo_cards_backend/coverage/lcov.info
flags: backend
name: backend-coverage
build:
needs: test
runs-on: ubuntu-latest
container: dart:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Dart SDK
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Cache pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards_backend/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_common_backend/.dart_tool
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
- name: Install dependencies
run: |
cd mnemo_cards_common
dart pub get
cd ../mnemo_cards_common_backend
dart pub get
cd ../mnemo_cards_backend
dart pub get
- name: Build backend executable
run: |
cd mnemo_cards_backend
dart compile exe lib/main.dart -o server.exe
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: backend-binary
path: mnemo_cards_backend/server.exe

View file

@ -0,0 +1,28 @@
name: Main CI Pipeline
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
code-quality:
uses: ./.forgejo/workflows/code-quality.yml
backend-ci:
needs: code-quality
uses: ./.forgejo/workflows/ci-backend.yml
web-ci:
needs: code-quality
uses: ./.forgejo/workflows/ci-web.yml
mobile-ci:
needs: code-quality
uses: ./.forgejo/workflows/ci-mobile.yml
deploy:
needs: [backend-ci, web-ci, mobile-ci]
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
uses: ./.forgejo/workflows/cd-deploy.yml

View file

@ -0,0 +1,216 @@
name: Mobile App CI
on:
push:
branches: [ main, master ]
paths:
- 'mnemo_cards/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_frontend_common/**'
- 'chat/mnemo_cards_chat/**'
- 'games/packages/**'
- 'funny_letters/**'
pull_request:
branches: [ main, master ]
paths:
- 'mnemo_cards/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_frontend_common/**'
- 'chat/mnemo_cards_chat/**'
- 'games/packages/**'
- 'funny_letters/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_frontend_common/.dart_tool
chat/mnemo_cards_chat/.dart_tool
funny_letters/.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Install common dependencies
run: |
cd mnemo_cards_common
flutter pub get
- name: Install frontend common dependencies
run: |
cd mnemo_cards_frontend_common
flutter pub get
- name: Install chat dependencies
run: |
cd chat/mnemo_cards_chat
flutter pub get
- name: Install funny letters dependencies
run: |
cd funny_letters
flutter pub get
- name: Install mobile app dependencies
run: |
cd mnemo_cards
flutter pub get
- name: Run code generation
run: |
cd mnemo_cards
flutter pub run build_runner build --delete-conflicting-outputs
- name: Run linting
run: |
cd mnemo_cards
flutter analyze lib/
flutter format --set-exit-if-changed lib/
- name: Run tests
run: |
cd mnemo_cards
flutter test --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./mnemo_cards/coverage/lcov.info
flags: mobile
name: mobile-coverage
build-android:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_frontend_common/.dart_tool
chat/mnemo_cards_chat/.dart_tool
funny_letters/.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Install dependencies
run: |
cd mnemo_cards_common
flutter pub get
cd ../mnemo_cards_frontend_common
flutter pub get
cd ../chat/mnemo_cards_chat
flutter pub get
cd ../funny_letters
flutter pub get
cd ../mnemo_cards
flutter pub get
- name: Build Android APK
run: |
cd mnemo_cards
flutter build apk --release
- name: Upload Android APK
uses: actions/upload-artifact@v4
with:
name: android-apk
path: mnemo_cards/build/app/outputs/flutter-apk/app-release.apk
build-ios:
needs: test
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_frontend_common/.dart_tool
chat/mnemo_cards_chat/.dart_tool
funny_letters/.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Install dependencies
run: |
cd mnemo_cards_common
flutter pub get
cd ../mnemo_cards_frontend_common
flutter pub get
cd ../chat/mnemo_cards_chat
flutter pub get
cd ../funny_letters
flutter pub get
cd ../mnemo_cards
flutter pub get
- name: Build iOS
run: |
cd mnemo_cards
flutter build ios --release --no-codesign
- name: Upload iOS build
uses: actions/upload-artifact@v4
with:
name: ios-build
path: mnemo_cards/build/ios/iphoneos/

View file

@ -0,0 +1,138 @@
name: Web App CI
on:
push:
branches: [ main, master ]
paths:
- 'mnemo_cards_web_v2/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_frontend_common/**'
- 'chat/mnemo_cards_chat/**'
- 'games/packages/**'
pull_request:
branches: [ main, master ]
paths:
- 'mnemo_cards_web_v2/**'
- 'mnemo_cards_common/**'
- 'mnemo_cards_frontend_common/**'
- 'chat/mnemo_cards_chat/**'
- 'games/packages/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards_web_v2/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_frontend_common/.dart_tool
chat/mnemo_cards_chat/.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Install common dependencies
run: |
cd mnemo_cards_common
flutter pub get
- name: Install frontend common dependencies
run: |
cd mnemo_cards_frontend_common
flutter pub get
- name: Install chat dependencies
run: |
cd chat/mnemo_cards_chat
flutter pub get
- name: Install web app dependencies
run: |
cd mnemo_cards_web_v2
flutter pub get
- name: Run code generation
run: |
cd mnemo_cards_web_v2
flutter pub run build_runner build --delete-conflicting-outputs
- name: Run linting
run: |
cd mnemo_cards_web_v2
flutter analyze lib/
flutter format --set-exit-if-changed lib/
- name: Run tests
run: |
cd mnemo_cards_web_v2
flutter test --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./mnemo_cards_web_v2/coverage/lcov.info
flags: web
name: web-coverage
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
mnemo_cards_web_v2/.dart_tool
mnemo_cards_common/.dart_tool
mnemo_cards_frontend_common/.dart_tool
chat/mnemo_cards_chat/.dart_tool
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Install dependencies
run: |
cd mnemo_cards_common
flutter pub get
cd ../mnemo_cards_frontend_common
flutter pub get
cd ../chat/mnemo_cards_chat
flutter pub get
cd ../../mnemo_cards_web_v2
flutter pub get
- name: Build web app
run: |
cd mnemo_cards_web_v2
flutter build web --release --dart-define=API_BASE_URL=https://1592725-cf88967.twc1.net:8081
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: web-build
path: mnemo_cards_web_v2/build/web/

View file

@ -0,0 +1,129 @@
name: Code Quality
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
quality-gate:
runs-on: ubuntu-latest
container: dart:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
**/.dart_tool
key: ${{ runner.os }}-deps-${{ hashFiles('**/pubspec.lock') }}
- name: Install all dependencies
run: |
find . -name "pubspec.yaml" -exec dirname {} \; | xargs -I {} sh -c 'cd {} && flutter pub get || dart pub get'
- name: Run code generation
run: |
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards")
for project in "${projects[@]}"; do
if [ -d "$project" ]; then
cd $project
flutter pub run build_runner build --delete-conflicting-outputs || true
cd ..
fi
done
- name: Lint all projects
run: |
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards" "mnemo_cards_common" "mnemo_cards_common_backend" "mnemo_cards_frontend_common")
for project in "${projects[@]}"; do
if [ -d "$project" ]; then
echo "Linting $project..."
cd $project
flutter analyze lib/ || dart analyze lib/ || echo "Analysis failed for $project"
flutter format --set-exit-if-changed lib/ || echo "Format check failed for $project"
cd ..
fi
done
- name: Run tests with coverage
run: |
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards")
for project in "${projects[@]}"; do
if [ -d "$project" ]; then
echo "Testing $project..."
cd $project
flutter test --coverage || dart test --coverage || echo "Tests failed for $project"
cd ..
fi
done
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
files: ./mnemo_cards_backend/coverage/lcov.info,./mnemo_cards_web_v2/coverage/lcov.info,./mnemo_cards/coverage/lcov.info
flags: unittests
name: codecov-umbrella
- name: Quality gate check
run: |
# Check if coverage is above threshold
if [ -f "coverage/coverage.json" ]; then
COVERAGE=$(jq '.coverage' coverage/coverage.json 2>/dev/null || echo "0")
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage is below 80%: $COVERAGE%"
exit 1
fi
fi
# Check for critical linting issues
if grep -r "error:" .github/workflows/code-quality.log 2>/dev/null; then
echo "Found critical linting errors"
exit 1
fi
echo "✅ Quality gate passed"
security-scan:
runs-on: ubuntu-latest
container: dart:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run security audit
run: |
# Check for known vulnerabilities in dependencies
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards" "mnemo_cards_common" "mnemo_cards_common_backend" "mnemo_cards_frontend_common")
for project in "${projects[@]}"; do
if [ -d "$project" ]; then
cd $project
echo "Auditing $project..."
flutter pub audit || dart pub audit || echo "Audit failed for $project"
cd ..
fi
done
- name: Check for secrets
run: |
# Simple check for potential secrets
if grep -r -i "password\|secret\|key\|token" --include="*.dart" --include="*.yaml" --include="*.json" . | grep -v "example\|test\|mock\|fake" | grep -v "pubspec"; then
echo "⚠️ Potential secrets found in code"
echo "Please review and ensure no real secrets are committed"
else
echo "✅ No obvious secrets found"
fi

View file

@ -0,0 +1,101 @@
name: Create Release
on:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Generate changelog
id: changelog
run: |
# Get previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "Generating changelog from $PREVIOUS_TAG to ${GITHUB_REF#refs/tags/}"
# Generate changelog
CHANGELOG=$(git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD | head -20)
if [ -z "$CHANGELOG" ]; then
CHANGELOG="No changes since last release"
fi
# Escape newlines for GitHub output
CHANGELOG="${CHANGELOG//'%'/'%25'}"
CHANGELOG="${CHANGELOG//$'\n'/'%0A'}"
CHANGELOG="${CHANGELOG//$'\r'/'%0D'}"
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
name: "Release ${{ steps.get_version.outputs.version }}"
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
## Downloads
- Backend binary: Download from CI artifacts
- Web app: Automatically deployed
- Mobile app: Download APK from CI artifacts
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-version:
runs-on: ubuntu-latest
needs: release
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Update version in pubspec files
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "Updating version to $VERSION"
# Update main app version
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards/pubspec.yaml
# Update backend version
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards_backend/pubspec.yaml
# Update web version
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards_web_v2/pubspec.yaml
- name: Commit version update
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
git commit -m "chore: bump version to ${{ github.ref_name }}" || echo "No changes to commit"
- name: Push version update
run: |
git push origin main || git push origin master

View file

@ -19,27 +19,27 @@ sudo cp server_build.exe server.exe
rm server_build.exe
# Проверяем наличие Let's Encrypt сертификатов для двойного режима
if [ -d "/etc/letsencrypt/live/1592725-cf88967.twc1.net" ]; then
if [ -d "/etc/letsencrypt/live/api.memo-cards.online" ]; then
echo "✅ Let's Encrypt сертификаты найдены. Запускаем в двойном режиме (HTTP + HTTPS)"
# Обновляем systemd сервис для двойного режима
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/1592725-cf88967.twc1.net -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/api.memo-cards.online -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
else
echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..."
# Останавливаем сервер временно для получения сертификата
sudo systemctl stop mnemo_cards_server 2>/dev/null || true
# Пытаемся получить Let's Encrypt сертификат
echo "🔐 Получаем Let's Encrypt сертификат для домена 1592725-cf88967.twc1.net..."
if sudo certbot certonly --standalone -d 1592725-cf88967.twc1.net --non-interactive --agree-tos --email admin@1592725-cf88967.twc1.net; then
echo "🔐 Получаем Let's Encrypt сертификат для домена api.memo-cards.online..."
if sudo certbot certonly --standalone -d api.memo-cards.online --non-interactive --agree-tos --email admin@api.memo-cards.online; then
echo "✅ Let's Encrypt сертификат успешно создан!"
echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)"
# Обновляем systemd сервис для двойного режима
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/1592725-cf88967.twc1.net -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/api.memo-cards.online -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
else
echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме"
echo "💡 Убедитесь, что:"
echo " - Домен 1592725-cf88967.twc1.net указывает на этот сервер"
echo " - Домен api.memo-cards.online указывает на этот сервер"
echo " - Порт 80 доступен извне"
echo " - Firewall разрешает входящие соединения на порт 80"
# Возвращаем обычный режим

View file

@ -5,7 +5,8 @@ import 'package:mnemo_cards_backend/api/authorize/resource_loader.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:shelf/shelf.dart';
Middleware accessMiddleware(AccessService accessService, PackManager packManager) {
Middleware accessMiddleware(
AccessService accessService, PackManager packManager) {
return (Handler inner) {
return (Request request) async {
final loader = ResourceLoader(packManager);
@ -30,5 +31,3 @@ AccessService buildAccessService(PackManager packManager) {
final adminPolicy = AdminAccessPolicy();
return AccessService(packPolicy, adminPolicy);
}

View file

@ -19,7 +19,8 @@ class AccessService {
required int packId,
UserModel? user,
}) async {
final decision = await packPolicy.check(action: action, packId: packId, user: user);
final decision =
await packPolicy.check(action: action, packId: packId, user: user);
return decision.allowed;
}
@ -28,16 +29,19 @@ class AccessService {
required int packId,
UserModel? user,
}) async {
final decision = await packPolicy.check(action: action, packId: packId, user: user);
final decision =
await packPolicy.check(action: action, packId: packId, user: user);
if (!decision.allowed) {
throw AccessDenied(decision.httpStatus, decision.reason ?? 'Access denied');
throw AccessDenied(
decision.httpStatus, decision.reason ?? 'Access denied');
}
}
Future<void> requireAdmin(AdminAction action, {UserModel? user}) async {
final decision = adminPolicy.check(action: action, user: user);
if (!decision.allowed) {
throw AccessDenied(decision.httpStatus, decision.reason ?? 'Access denied');
throw AccessDenied(
decision.httpStatus, decision.reason ?? 'Access denied');
}
}
}
@ -49,5 +53,3 @@ class AccessDenied implements Exception {
@override
String toString() => 'AccessDenied($status): $message';
}

View file

@ -27,13 +27,10 @@ class AccessDecision {
httpStatus = 200,
reason = null;
const AccessDecision.denied(this.httpStatus, this.reason)
: allowed = false;
const AccessDecision.denied(this.httpStatus, this.reason) : allowed = false;
}
class AccessContext {
final Object? user;
const AccessContext({this.user});
}

View file

@ -13,4 +13,3 @@ class AdminAccessPolicy {
return const AccessDecision.denied(403, 'Admin access required');
}
}

View file

@ -20,13 +20,14 @@ class PackAccessPolicy {
}
// Public preview packs can be viewed without auth
if (publicPackIds.contains(packId) && {
PackAccessAction.view,
PackAccessAction.cards,
PackAccessAction.tests,
PackAccessAction.list,
PackAccessAction.buy,
}.contains(action)) {
if (publicPackIds.contains(packId) &&
{
PackAccessAction.view,
PackAccessAction.cards,
PackAccessAction.tests,
PackAccessAction.list,
PackAccessAction.buy,
}.contains(action)) {
final enabled = await loader.isPackEnabled(packId);
if (!enabled) {
return const AccessDecision.denied(404, 'Pack not found');
@ -63,5 +64,3 @@ class PackAccessPolicy {
}
}
}

View file

@ -27,5 +27,3 @@ class ResourceLoader {
return model?.enabled == true;
}
}

View file

@ -45,5 +45,3 @@ Future<Response> guardPack(
return accessErrorResponse(e);
}
}

View file

@ -45,7 +45,8 @@ class AdminUsersApiV2 {
Future<Response> _ensureAdmin(Request request) async {
try {
await request.access!.requireAdmin(AdminAction.access, user: request.user);
await request.access!
.requireAdmin(AdminAction.access, user: request.user);
return Response.ok(null);
} on AccessDenied catch (e) {
return Response(e.status, body: e.message);
@ -79,7 +80,9 @@ class AdminUsersApiV2 {
});
final dtos = await Future.wait(
users.whereType<UserModel>().map((model) async => (await model.toDto()).toJson()),
users
.whereType<UserModel>()
.map((model) async => (await model.toDto()).toJson()),
);
return _json({'users': dtos});
@ -95,11 +98,7 @@ class AdminUsersApiV2 {
}
final ids = await backend_main.isar.txn(
() => backend_main.isar.userModels
.where()
.anyId()
.idProperty()
.findAll(),
() => backend_main.isar.userModels.where().anyId().idProperty().findAll(),
);
return _json({'ids': ids.map((id) => id.toString()).join(',')});
@ -118,7 +117,8 @@ class AdminUsersApiV2 {
}
final payments = await _paymentManager.getUserPayments(userId);
final dtos = payments.map((payment) => payment.toDto(withToken: true)).toList();
final dtos =
payments.map((payment) => payment.toDto(withToken: true)).toList();
return _json({'payments': dtos.map((p) => p.toJson()).toList()});
}
@ -187,4 +187,3 @@ class AdminUsersApiV2 {
Router get router => _$AdminUsersApiV2Router(this);
}

View file

@ -166,4 +166,3 @@ class AdsApiV2 {
Router get router => _$AdsApiV2Router(this);
}

View file

@ -17,7 +17,8 @@ const _publicGetPrefixes = {
};
final List<RegExp> _strictAuthRegexps = [
RegExp(r'^/packs/\d+/tests'), // Pack tests require authentication (except for public packs)
RegExp(
r'^/packs/\d+/tests'), // Pack tests require authentication (except for public packs)
];
/// Middleware for API v2 Bearer token authentication
@ -62,7 +63,6 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
normalizedPath.startsWith('$prefix/'),
);
// Extract Bearer token from Authorization header
final authHeader = request.headers['authorization'];
final hasBearerToken =

View file

@ -35,7 +35,8 @@ class DiscountsApiV2 {
Future<Response> _ensureAdmin(Request request) async {
try {
await request.access!.requireAdmin(AdminAction.access, user: request.user);
await request.access!
.requireAdmin(AdminAction.access, user: request.user);
return Response.ok(null);
} on AccessDenied catch (e) {
return Response(e.status, body: e.message);
@ -131,4 +132,3 @@ class DiscountsApiV2 {
Router get router => _$DiscountsApiV2Router(this);
}

View file

@ -233,8 +233,7 @@ class PacksApiV2 {
final packJson = pack.toJson();
if (user != null) {
await user.packs.load();
final packModel =
await backend_main.isar.cardPackModels.get(packIdInt);
final packModel = await backend_main.isar.cardPackModels.get(packIdInt);
final isPurchased = packModel != null &&
(user.packs.contains(packId) ||
(await _hasSubscriptionAccess(user)));

View file

@ -43,7 +43,8 @@ class PromocodesApiV2 {
Future<Response> _ensureAdmin(Request request) async {
try {
await request.access!.requireAdmin(AdminAction.access, user: request.user);
await request.access!
.requireAdmin(AdminAction.access, user: request.user);
return Response.ok(null);
} on AccessDenied catch (e) {
return Response(e.status, body: e.message);
@ -183,4 +184,3 @@ class PromocodesApiV2 {
Router get router => _$PromocodesApiV2Router(this);
}

View file

@ -84,7 +84,6 @@ class TasksApiV2 {
// Build query
var builder = isar.userTaskModels.where().filter();
if (type != null) {
builder = builder.typeEqualTo(type);
@ -110,10 +109,8 @@ class TasksApiV2 {
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
}
final tasks = await builder.idIsNotNull()
.offset(offset)
.limit(limit)
.findAll();
final tasks =
await builder.idIsNotNull().offset(offset).limit(limit).findAll();
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
} catch (e) {
@ -164,7 +161,8 @@ class TasksApiV2 {
});
// Update user progress
await _updateUserProgress(userId.toString(), taskId.toString(), 'in_progress');
await _updateUserProgress(
userId.toString(), taskId.toString(), 'in_progress');
return _json({
'task': updatedTask.toJson(),
@ -223,7 +221,8 @@ class TasksApiV2 {
});
// Update user progress with rewards
await _updateUserProgress(userId.toString(), taskId.toString(), 'completed',
await _updateUserProgress(
userId.toString(), taskId.toString(), 'completed',
rewards: updatedTask.rewards);
return _json({
@ -324,7 +323,8 @@ class TasksApiV2 {
final achievements = List<String>.from(progress.achievements);
if (status == 'completed' && rewards != null) {
final completedTasks = Map<String, DateTime>.from(progress.completedTasks);
final completedTasks =
Map<String, DateTime>.from(progress.completedTasks);
completedTasks[taskId] = DateTime.now();
for (final reward in rewards) {
@ -345,11 +345,14 @@ class TasksApiV2 {
}
final updatedProgress = progress.copyWith(
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
completedTasksJson: UserTaskProgressModel.completedTasksToJson(completedTasks),
taskStatusesJson:
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
completedTasksJson:
UserTaskProgressModel.completedTasksToJson(completedTasks),
totalXp: totalXp,
totalCoins: totalCoins,
achievementsJson: UserTaskProgressModel.achievementsToJson(achievements),
achievementsJson:
UserTaskProgressModel.achievementsToJson(achievements),
lastUpdated: DateTime.now(),
);
@ -358,7 +361,8 @@ class TasksApiV2 {
});
} else {
final updatedProgress = progress.copyWith(
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
taskStatusesJson:
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
lastUpdated: DateTime.now(),
);

View file

@ -287,8 +287,10 @@ class UsersApiV2 {
try {
// Parse query parameters
final packId = request.url.queryParameters['packId'];
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
final offset = int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
final limit =
int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
final offset =
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
final sortBy = request.url.queryParameters['sortBy'] ?? 'difficulty';
final needsReview = request.url.queryParameters['needsReview'] == 'true';
@ -340,8 +342,10 @@ class UsersApiV2 {
break;
case 'recent':
words.sort((a, b) {
final aTime = a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
final bTime = b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
final aTime =
a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
final bTime =
b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime); // Most recent first
});
break;
@ -460,7 +464,10 @@ class UsersApiV2 {
return _json({'result': true, 'sessionId': sessionDto.sessionId});
} catch (e) {
return _json(
{'error': 'bad_request', 'message': 'Invalid session data: ${e.toString()}'},
{
'error': 'bad_request',
'message': 'Invalid session data: ${e.toString()}'
},
statusCode: 400,
);
}
@ -481,7 +488,8 @@ class UsersApiV2 {
return _json([]);
}
final achievements = _statisticsCalculator.calculateAchievementProgress(userData);
final achievements =
_statisticsCalculator.calculateAchievementProgress(userData);
return _json(achievements.map((a) => a.toJson()).toList());
} catch (e) {
return _json(
@ -493,4 +501,3 @@ class UsersApiV2 {
Router get router => _$UsersApiV2Router(this);
}

View file

@ -9,8 +9,7 @@ import 'auth_code_storage.dart';
class TelegramAuthCodeService {
final AuthCodeStorage _storage;
TelegramAuthCodeService()
: _storage = AuthCodeStorage();
TelegramAuthCodeService() : _storage = AuthCodeStorage();
/// Generate a new authentication code for a Telegram user (legacy bot flow).
String generateCodeForTelegramUser({

View file

@ -27,7 +27,8 @@ class TasksSeederTask with Task {
// App Internal Tasks
UserTaskModel(
title: 'Пройди 3 теста сегодня',
description: 'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
description:
'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
type: 'app_internal',
difficulty: 'easy',
rewards: [
@ -42,7 +43,8 @@ class TasksSeederTask with Task {
UserTaskModel(
title: 'Достигни 80% правильных ответов в тесте',
description: 'Пройди тест с результатом не менее 80% правильных ответов.',
description:
'Пройди тест с результатом не менее 80% правильных ответов.',
type: 'app_internal',
difficulty: 'medium',
rewards: [
@ -58,7 +60,8 @@ class TasksSeederTask with Task {
// External Tasks
UserTaskModel(
title: 'Подпишись на Telegram канал',
description: 'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
description:
'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
type: 'external',
difficulty: 'easy',
rewards: [
@ -73,24 +76,30 @@ class TasksSeederTask with Task {
UserTaskModel(
title: 'Закажи еду на испанском',
description: 'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
description:
'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
type: 'external',
difficulty: 'hard',
rewards: [
TaskRewardModel(type: 'xp', amount: 200),
TaskRewardModel(type: 'coins', amount: 50),
TaskRewardModel(type: 'achievement', amount: 0, achievementId: 'real_world_practice'),
TaskRewardModel(
type: 'achievement',
amount: 0,
achievementId: 'real_world_practice'),
],
status: 'available',
createdAt: now,
expiresAt: weekFromNow.add(const Duration(days: 14)), // 3 weeks
instructions: 'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
instructions:
'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
tags: ['speaking', 'restaurant', 'video', 'real_world'],
),
UserTaskModel(
title: 'Посмотри испанский фильм с субтитрами',
description: 'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
description:
'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
type: 'external',
difficulty: 'medium',
rewards: [
@ -106,7 +115,8 @@ class TasksSeederTask with Task {
// Social Tasks
UserTaskModel(
title: 'Поделись прогрессом в соцсетях',
description: 'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
description:
'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
type: 'social',
difficulty: 'easy',
rewards: [
@ -121,7 +131,8 @@ class TasksSeederTask with Task {
UserTaskModel(
title: 'Найди партнёра для практики',
description: 'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
description:
'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
type: 'social',
difficulty: 'medium',
rewards: [
@ -137,13 +148,17 @@ class TasksSeederTask with Task {
// Additional App Tasks
UserTaskModel(
title: 'Изучай слова ежедневно 7 дней подряд',
description: 'Учи новые слова в приложении каждый день в течение недели.',
description:
'Учи новые слова в приложении каждый день в течение недели.',
type: 'app_internal',
difficulty: 'hard',
rewards: [
TaskRewardModel(type: 'xp', amount: 150),
TaskRewardModel(type: 'coins', amount: 35),
TaskRewardModel(type: 'achievement', amount: 0, achievementId: 'consistency_master'),
TaskRewardModel(
type: 'achievement',
amount: 0,
achievementId: 'consistency_master'),
],
status: 'available',
createdAt: now,
@ -153,7 +168,8 @@ class TasksSeederTask with Task {
UserTaskModel(
title: 'Пройди тест по грамматике',
description: 'Заверши специализированный тест по грамматике испанского языка.',
description:
'Заверши специализированный тест по грамматике испанского языка.',
type: 'app_internal',
difficulty: 'medium',
rewards: [
@ -168,7 +184,8 @@ class TasksSeederTask with Task {
UserTaskModel(
title: 'Практикуй произношение',
description: 'Заверши урок по произношению или запиши себя, читая текст на испанском.',
description:
'Заверши урок по произношению или запиши себя, читая текст на испанском.',
type: 'app_internal',
difficulty: 'easy',
rewards: [
@ -189,7 +206,6 @@ class TasksSeederTask with Task {
});
print('Successfully seeded ${tasks.length} user tasks');
} catch (e) {
print('Error seeding tasks: $e');
rethrow;

View file

@ -17,7 +17,8 @@ class AchievementManager {
AchievementManager(this._isar);
/// Get all available achievements with their definitions
List<AchievementDefinition> get allAchievementDefinitions => _achievementDefinitions;
List<AchievementDefinition> get allAchievementDefinitions =>
_achievementDefinitions;
/// Check and update achievements for a user
///
@ -35,7 +36,8 @@ class AchievementManager {
for (final definition in _achievementDefinitions) {
// Skip if already unlocked
if (currentAchievements.any((a) => a.id == definition.id && a.isUnlocked)) {
if (currentAchievements
.any((a) => a.id == definition.id && a.isUnlocked)) {
continue;
}
@ -119,7 +121,9 @@ class AchievementManager {
final user = await _isar.userModels.get(userId);
if (user?.userData.value == null) return [];
return user!.userData.value!.achievements.map((model) => model.toDto()).toList();
return user!.userData.value!.achievements
.map((model) => model.toDto())
.toList();
}
/// Save achievement to user data
@ -137,7 +141,8 @@ class AchievementManager {
model,
];
final updatedUserData = userData.copyWith(achievements: updatedAchievements);
final updatedUserData =
userData.copyWith(achievements: updatedAchievements);
await _isar.writeTxn(() async {
await _isar.userDataModels.put(updatedUserData);
@ -170,7 +175,8 @@ class AchievementManager {
title: 'First Pack',
description: 'Complete your first pack',
type: AchievementType.firstPackCompleted,
evaluate: (data) => Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
evaluate: (data) =>
Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
supportsProgress: false,
),
@ -182,7 +188,8 @@ class AchievementManager {
type: AchievementType.streak3Days,
evaluate: (data) => Future.value(data.currentStreak >= 3),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -192,7 +199,8 @@ class AchievementManager {
type: AchievementType.streak7Days,
evaluate: (data) => Future.value(data.currentStreak >= 7),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -202,7 +210,8 @@ class AchievementManager {
type: AchievementType.streak30Days,
evaluate: (data) => Future.value(data.currentStreak >= 30),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -212,7 +221,8 @@ class AchievementManager {
type: AchievementType.streak100Days,
evaluate: (data) => Future.value(data.currentStreak >= 100),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
),
// Words Mastery
@ -223,7 +233,8 @@ class AchievementManager {
type: AchievementType.words10Learned,
evaluate: (data) => Future.value(data.words.length >= 10),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -233,7 +244,8 @@ class AchievementManager {
type: AchievementType.words50Learned,
evaluate: (data) => Future.value(data.words.length >= 50),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -243,7 +255,8 @@ class AchievementManager {
type: AchievementType.words100Learned,
evaluate: (data) => Future.value(data.words.length >= 100),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -253,7 +266,8 @@ class AchievementManager {
type: AchievementType.words500Learned,
evaluate: (data) => Future.value(data.words.length >= 500),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -263,7 +277,8 @@ class AchievementManager {
type: AchievementType.words1000Learned,
evaluate: (data) => Future.value(data.words.length >= 1000),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
),
// Performance Achievements
@ -274,10 +289,12 @@ class AchievementManager {
type: AchievementType.perfectTestScore,
evaluate: (data) => Future.value(
data.testsStatistics.any((test) => test.attempts.any((attempt) {
final totalWords = attempt.words.length;
final correctWords = attempt.words.where((word) => word.correct > word.incorrect).length;
return totalWords > 0 && correctWords == totalWords;
})),
final totalWords = attempt.words.length;
final correctWords = attempt.words
.where((word) => word.correct > word.incorrect)
.length;
return totalWords > 0 && correctWords == totalWords;
})),
),
supportsProgress: false,
),
@ -289,9 +306,9 @@ class AchievementManager {
type: AchievementType.speedLearner,
evaluate: (data) => Future.value(
data.packProgress.any((pack) =>
pack.progress >= 1.0 &&
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
),
pack.progress >= 1.0 &&
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
),
),
supportsProgress: false,
),
@ -304,7 +321,8 @@ class AchievementManager {
type: AchievementType.dedicatedLearner,
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
calculateProgress: (data) => Future.value(
(data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
),
// Time-based Achievements
@ -338,7 +356,8 @@ class AchievementManager {
type: AchievementType.consistentLearner,
evaluate: (data) => Future.value(data.longestStreak >= 30),
supportsProgress: true,
calculateProgress: (data) => Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
calculateProgress: (data) =>
Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
@ -351,7 +370,8 @@ class AchievementManager {
),
supportsProgress: true,
calculateProgress: (data) => Future.value(
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0).clamp(0.0, 1.0),
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0)
.clamp(0.0, 1.0),
),
),
];

View file

@ -28,7 +28,8 @@ class SessionTracker {
///
/// Returns the session ID of the active session.
/// If no active session exists, creates a new one.
Future<String> getOrCreateSession(int userId, {
Future<String> getOrCreateSession(
int userId, {
String? packId,
String? testId,
}) async {

View file

@ -54,7 +54,8 @@ class StatisticsCalculator {
var streak = 0;
var currentDate = DateTime.now();
currentDate = DateTime(currentDate.year, currentDate.month, currentDate.day);
currentDate =
DateTime(currentDate.year, currentDate.month, currentDate.day);
// Check if today or yesterday has activity
final hasRecentActivity = normalizedDates.any((date) {
@ -113,10 +114,11 @@ class StatisticsCalculator {
// Then add other difficult words if we haven't reached the limit
if (result.length < limit) {
final remaining = words.where((word) =>
!word.needsReview &&
!result.any((added) => added.word == word.word)
).take(limit - result.length);
final remaining = words
.where((word) =>
!word.needsReview &&
!result.any((added) => added.word == word.word))
.take(limit - result.length);
result.addAll(remaining);
}
@ -204,7 +206,8 @@ class StatisticsCalculator {
// Calculate aggregates
final totalDays = endDate.difference(startDate).inDays + 1;
final activeDays = periodDailyTime.length;
final totalMinutes = periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
final totalMinutes =
periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0;
// Calculate streak in period
@ -222,7 +225,8 @@ class StatisticsCalculator {
'dailyActivity': periodDailyTime.map(
(date, minutes) => MapEntry(date.toIso8601String(), minutes),
),
'studyDates': periodStudyDates.map((date) => date.toIso8601String()).toList(),
'studyDates':
periodStudyDates.map((date) => date.toIso8601String()).toList(),
};
}
@ -302,7 +306,8 @@ class StatisticsCalculator {
int calculateUserLevel(UserDataModel data) {
final wordsLearned = data.words.length;
final studyHours = data.totalStudyTimeMinutes / 60.0;
final packsCompleted = data.packProgress.where((p) => p.progress >= 1.0).length;
final packsCompleted =
data.packProgress.where((p) => p.progress >= 1.0).length;
// Simple level calculation
final score = wordsLearned + (studyHours * 2) + (packsCompleted * 10);
@ -337,16 +342,19 @@ class StatisticsCalculator {
}
// Calculate accuracy
final totalCorrect = data.words.fold<double>(0, (sum, word) => sum + word.correct);
final totalIncorrect = data.words.fold<double>(0, (sum, word) => sum + word.incorrect);
final totalCorrect =
data.words.fold<double>(0, (sum, word) => sum + word.correct);
final totalIncorrect =
data.words.fold<double>(0, (sum, word) => sum + word.incorrect);
final accuracy = totalCorrect / (totalCorrect + totalIncorrect);
// Calculate average difficulty (simplified)
final averageDifficulty = data.words.fold<double>(0, (sum, word) {
final total = word.correct + word.incorrect + word.skipped;
if (total == 0) return sum;
return sum + (word.incorrect / total);
}) / totalWords;
final total = word.correct + word.incorrect + word.skipped;
if (total == 0) return sum;
return sum + (word.incorrect / total);
}) /
totalWords;
// Calculate consistency (based on streak)
final consistency = min(1.0, data.currentStreak / 30.0);

View file

@ -21,8 +21,7 @@ extension UserDataModelExtension on UserDataModel {
currentStreak: currentStreak,
longestStreak: longestStreak,
packProgress: {
for (final progress in packProgress)
progress.packId: progress.toDto()
for (final progress in packProgress) progress.packId: progress.toDto()
},
studyDates: List<DateTime>.from(studyDates),
categoryMinutes: Map<String, int>.from(categoryMinutes),

View file

@ -241,9 +241,10 @@ class UserManager {
final todayNormalized = DateTime(today.year, today.month, today.day);
// Add today's study date if not already present
if (!studyDates.any((date) => date.year == todayNormalized.year &&
date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
if (!studyDates.any((date) =>
date.year == todayNormalized.year &&
date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
studyDates.add(todayNormalized);
}
@ -257,10 +258,12 @@ class UserManager {
// Calculate total study time (simplified - add time from this test)
final testDurationMinutes = 5; // Assume 5 minutes per test as default
final totalStudyTimeMinutes = currentData.totalStudyTimeMinutes + testDurationMinutes;
final totalStudyTimeMinutes =
currentData.totalStudyTimeMinutes + testDurationMinutes;
// Calculate pack progress if we have pack info
final packProgress = List<PackProgressDto>.from(currentData.packProgress);
final packProgress =
List<PackProgressDto>.from(currentData.packProgress);
// Update pack progress if test was for a specific pack
if (testStat.testId != null) {
@ -316,13 +319,15 @@ class UserManager {
UserDataModel userData,
) async {
try {
final newlyUnlockedAchievements = await _achievementManager.checkAndUnlockAchievements(
final newlyUnlockedAchievements =
await _achievementManager.checkAndUnlockAchievements(
user.id!,
userData,
);
if (newlyUnlockedAchievements.isNotEmpty) {
print('New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
print(
'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
}
return newlyUnlockedAchievements;
@ -367,7 +372,8 @@ class UserManager {
accuracy: accuracy,
);
print('Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
print(
'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
} catch (e, s) {
print('Error updating session tracking: $e\n$s');
// Don't fail the main operation if session tracking fails

View file

@ -96,11 +96,16 @@ flutter run -d chrome --web-port=8080
### Вариант 3: Production CORS (для деплоя)
Для production замените `'*'` на конкретный домен:
Для production версии:
```dart
'Access-Control-Allow-Origin': 'https://your-domain.com',
```
1. **Backend** уже настроен с правильными CORS настройками ✅
2. **Frontend** использует `api.memo-cards.online`
3. **Nginx** настроен без конфликтующих COEP/COOP headers ✅
Текущая конфигурация позволяет:
- ✅ Запросы с `memo-cards.online` на `api.memo-cards.online`
- ✅ Все необходимые HTTP методы и headers
- ✅ Preflight OPTIONS запросы
## Диагностика

View file

@ -15,7 +15,7 @@ export SERVER_IP="147.45.152.129"
export SERVER_USER="root"
# Domain configuration
export DOMAIN="5492281-cf88967.twc1.net"
export DOMAIN="memo-cards.online"
# =============================================================================
# APPLICATION CONFIGURATION
@ -37,9 +37,9 @@ export NGINX_ENABLED="/etc/nginx/sites-enabled/$APP_NAME"
# =============================================================================
# API endpoints
export API_BASE_URL="https://1592725-cf88967.twc1.net:8081"
export API_BASE_URL="https://api.memo-cards.online:8081"
#export API_BASE_URL_DEV="http://localhost:8000"
export API_BASE_URL_DEV="https://1592725-cf88967.twc1.net:8080"
export API_BASE_URL_DEV="https://api.memo-cards.online:8080"
# =============================================================================
# SSL CONFIGURATION
@ -69,9 +69,10 @@ export X_CONTENT_TYPE_OPTIONS="nosniff"
export REFERRER_POLICY="no-referrer-when-downgrade"
export STRICT_TRANSPORT_SECURITY="max-age=31536000; includeSubDomains"
# CORS headers for Flutter web assets
# CORS headers for Flutter web assets (disabled to avoid conflicts with API CORS)
export COOP_POLICY="same-origin"
export COEP_POLICY="require-corp"
# Note: COEP/COOP headers are disabled in nginx.conf to allow CORS requests to API
# Cache settings
export CACHE_EXPIRES="1y"
@ -98,7 +99,7 @@ export WEB_PERMISSIONS="755"
# EMAIL CONFIGURATION (for Let's Encrypt)
# =============================================================================
export LETSENCRYPT_EMAIL="admin@$DOMAIN"
export LETSENCRYPT_EMAIL="admin@memo-cards.online"
# =============================================================================
# FIREWALL CONFIGURATION
@ -112,7 +113,8 @@ export FIREWALL_ALLOW_SSH="ssh"
# =============================================================================
export CRON_RENEWAL_TIMES="0 12 * * * 0 0 * * *"
export CRON_RENEWAL_COMMAND="certbot renew --quiet --post-hook \"systemctl reload nginx\""
export CRON_RENEWAL_COMMAND="certbot renew --quiet --post-hook \"systemctl reload nginx\" --cert-name memo-cards.online"
export CRON_API_RENEWAL_COMMAND="certbot renew --quiet --cert-name api.memo-cards.online"
# =============================================================================
# COLORS FOR OUTPUT

View file

@ -42,14 +42,30 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
# Set proper permissions
chown -R $WEB_USER:$WEB_GROUP $WEB_ROOT
chmod -R $WEB_PERMISSIONS $WEB_ROOT
# Install SSL certificate first (self-signed for now)
if [ ! -f "$SSL_SELF_CERT" ]; then
echo "Generating self-signed SSL certificate..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout $SSL_SELF_KEY \
-out $SSL_SELF_CERT \
-subj "/C=RU/ST=Moscow/L=Moscow/O=MnemoCards/OU=IT/CN=$SERVER_IP"
# Install SSL certificate - try Let's Encrypt first
if [ ! -d "/etc/letsencrypt/live/memo-cards.online" ]; then
echo "🔐 Attempting to get Let's Encrypt SSL certificate for memo-cards.online..."
# Stop nginx temporarily for certificate issuance
systemctl stop nginx 2>/dev/null || true
if certbot certonly --standalone -d memo-cards.online --non-interactive --agree-tos --email $LETSENCRYPT_EMAIL; then
echo "✅ Let's Encrypt certificate obtained successfully!"
else
echo "❌ Failed to get Let's Encrypt certificate. Generating self-signed certificate..."
if [ ! -f "$SSL_SELF_CERT" ]; then
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout $SSL_SELF_KEY \
-out $SSL_SELF_CERT \
-subj "/C=RU/ST=Moscow/L=Moscow/O=MnemoCards/OU=IT/CN=memo-cards.online"
fi
fi
# Start nginx again
systemctl start nginx 2>/dev/null || true
else
echo "✅ Let's Encrypt certificate already exists for memo-cards.online"
fi
# Configure nginx
@ -83,9 +99,9 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
ufw --force enable
echo "Deployment completed successfully!"
echo "Application is available at: https://$SERVER_IP"
echo "Application is available at: https://memo-cards.online"
EOF
print_success "Deployment completed successfully! 🎉"
print_success "Your app is now available at: https://$SERVER_IP"
print_warning "Note: The SSL certificate is self-signed. For production, consider using Let's Encrypt or a commercial certificate."
print_success "Your app is now available at: https://memo-cards.online"
print_info "SSL certificate: Let's Encrypt (preferred) or self-signed (fallback)"

View file

@ -1,18 +1,22 @@
server {
listen 80;
server_name 147.45.152.129;
server_name memo-cards.online;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name 147.45.152.129;
# SSL configuration
ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
server_name memo-cards.online;
# SSL configuration - Let's Encrypt (preferred)
ssl_certificate /etc/letsencrypt/live/memo-cards.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/memo-cards.online/privkey.pem;
# Fallback to self-signed certificates if Let's Encrypt fails
# ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
# ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
@ -50,8 +54,9 @@ server {
location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp";
add_header Cross-Origin-Opener-Policy "same-origin";
# Removed COEP/COOP headers that interfere with CORS requests to API
# add_header Cross-Origin-Embedder-Policy "require-corp";
# add_header Cross-Origin-Opener-Policy "same-origin";
}
# Security - deny access to hidden files

View file

@ -116,8 +116,9 @@ server {
location ~* \\.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
expires $CACHE_EXPIRES;
add_header Cache-Control "$CACHE_CONTROL";
add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
# Disabled COEP/COOP headers to allow CORS requests to API
# add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
# add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
}
# Security - deny access to hidden files