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,10 +19,10 @@ sudo cp server_build.exe server.exe
rm server_build.exe rm server_build.exe
# Проверяем наличие Let's Encrypt сертификатов для двойного режима # Проверяем наличие 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)" echo "✅ Let's Encrypt сертификаты найдены. Запускаем в двойном режиме (HTTP + HTTPS)"
# Обновляем systemd сервис для двойного режима # Обновляем 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 else
echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..." echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..."
@ -30,16 +30,16 @@ else
sudo systemctl stop mnemo_cards_server 2>/dev/null || true sudo systemctl stop mnemo_cards_server 2>/dev/null || true
# Пытаемся получить Let's Encrypt сертификат # Пытаемся получить Let's Encrypt сертификат
echo "🔐 Получаем Let's Encrypt сертификат для домена 1592725-cf88967.twc1.net..." echo "🔐 Получаем Let's Encrypt сертификат для домена api.memo-cards.online..."
if sudo certbot certonly --standalone -d 1592725-cf88967.twc1.net --non-interactive --agree-tos --email admin@1592725-cf88967.twc1.net; then 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 "✅ Let's Encrypt сертификат успешно создан!"
echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)" echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)"
# Обновляем systemd сервис для двойного режима # Обновляем 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 else
echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме" echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме"
echo "💡 Убедитесь, что:" echo "💡 Убедитесь, что:"
echo " - Домен 1592725-cf88967.twc1.net указывает на этот сервер" echo " - Домен api.memo-cards.online указывает на этот сервер"
echo " - Порт 80 доступен извне" echo " - Порт 80 доступен извне"
echo " - Firewall разрешает входящие соединения на порт 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:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
Middleware accessMiddleware(AccessService accessService, PackManager packManager) { Middleware accessMiddleware(
AccessService accessService, PackManager packManager) {
return (Handler inner) { return (Handler inner) {
return (Request request) async { return (Request request) async {
final loader = ResourceLoader(packManager); final loader = ResourceLoader(packManager);
@ -30,5 +31,3 @@ AccessService buildAccessService(PackManager packManager) {
final adminPolicy = AdminAccessPolicy(); final adminPolicy = AdminAccessPolicy();
return AccessService(packPolicy, adminPolicy); return AccessService(packPolicy, adminPolicy);
} }

View file

@ -19,7 +19,8 @@ class AccessService {
required int packId, required int packId,
UserModel? user, UserModel? user,
}) async { }) 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; return decision.allowed;
} }
@ -28,16 +29,19 @@ class AccessService {
required int packId, required int packId,
UserModel? user, UserModel? user,
}) async { }) 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) { 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 { Future<void> requireAdmin(AdminAction action, {UserModel? user}) async {
final decision = adminPolicy.check(action: action, user: user); final decision = adminPolicy.check(action: action, user: user);
if (!decision.allowed) { 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 @override
String toString() => 'AccessDenied($status): $message'; String toString() => 'AccessDenied($status): $message';
} }

View file

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

View file

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

View file

@ -20,13 +20,14 @@ class PackAccessPolicy {
} }
// Public preview packs can be viewed without auth // Public preview packs can be viewed without auth
if (publicPackIds.contains(packId) && { if (publicPackIds.contains(packId) &&
PackAccessAction.view, {
PackAccessAction.cards, PackAccessAction.view,
PackAccessAction.tests, PackAccessAction.cards,
PackAccessAction.list, PackAccessAction.tests,
PackAccessAction.buy, PackAccessAction.list,
}.contains(action)) { PackAccessAction.buy,
}.contains(action)) {
final enabled = await loader.isPackEnabled(packId); final enabled = await loader.isPackEnabled(packId);
if (!enabled) { if (!enabled) {
return const AccessDecision.denied(404, 'Pack not found'); 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; return model?.enabled == true;
} }
} }

View file

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

View file

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

View file

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

View file

@ -17,7 +17,8 @@ const _publicGetPrefixes = {
}; };
final List<RegExp> _strictAuthRegexps = [ 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 /// Middleware for API v2 Bearer token authentication
@ -62,7 +63,6 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
normalizedPath.startsWith('$prefix/'), normalizedPath.startsWith('$prefix/'),
); );
// Extract Bearer token from Authorization header // Extract Bearer token from Authorization header
final authHeader = request.headers['authorization']; final authHeader = request.headers['authorization'];
final hasBearerToken = final hasBearerToken =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -241,9 +241,10 @@ class UserManager {
final todayNormalized = DateTime(today.year, today.month, today.day); final todayNormalized = DateTime(today.year, today.month, today.day);
// Add today's study date if not already present // Add today's study date if not already present
if (!studyDates.any((date) => date.year == todayNormalized.year && if (!studyDates.any((date) =>
date.month == todayNormalized.month && date.year == todayNormalized.year &&
date.day == todayNormalized.day)) { date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
studyDates.add(todayNormalized); studyDates.add(todayNormalized);
} }
@ -257,10 +258,12 @@ class UserManager {
// Calculate total study time (simplified - add time from this test) // Calculate total study time (simplified - add time from this test)
final testDurationMinutes = 5; // Assume 5 minutes per test as default 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 // 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 // Update pack progress if test was for a specific pack
if (testStat.testId != null) { if (testStat.testId != null) {
@ -316,13 +319,15 @@ class UserManager {
UserDataModel userData, UserDataModel userData,
) async { ) async {
try { try {
final newlyUnlockedAchievements = await _achievementManager.checkAndUnlockAchievements( final newlyUnlockedAchievements =
await _achievementManager.checkAndUnlockAchievements(
user.id!, user.id!,
userData, userData,
); );
if (newlyUnlockedAchievements.isNotEmpty) { 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; return newlyUnlockedAchievements;
@ -367,7 +372,8 @@ class UserManager {
accuracy: accuracy, 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) { } catch (e, s) {
print('Error updating session tracking: $e\n$s'); print('Error updating session tracking: $e\n$s');
// Don't fail the main operation if session tracking fails // 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 (для деплоя) ### Вариант 3: Production CORS (для деплоя)
Для production замените `'*'` на конкретный домен: Для production версии:
```dart 1. **Backend** уже настроен с правильными CORS настройками ✅
'Access-Control-Allow-Origin': 'https://your-domain.com', 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" export SERVER_USER="root"
# Domain configuration # Domain configuration
export DOMAIN="5492281-cf88967.twc1.net" export DOMAIN="memo-cards.online"
# ============================================================================= # =============================================================================
# APPLICATION CONFIGURATION # APPLICATION CONFIGURATION
@ -37,9 +37,9 @@ export NGINX_ENABLED="/etc/nginx/sites-enabled/$APP_NAME"
# ============================================================================= # =============================================================================
# API endpoints # 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="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 # SSL CONFIGURATION
@ -69,9 +69,10 @@ export X_CONTENT_TYPE_OPTIONS="nosniff"
export REFERRER_POLICY="no-referrer-when-downgrade" export REFERRER_POLICY="no-referrer-when-downgrade"
export STRICT_TRANSPORT_SECURITY="max-age=31536000; includeSubDomains" 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 COOP_POLICY="same-origin"
export COEP_POLICY="require-corp" export COEP_POLICY="require-corp"
# Note: COEP/COOP headers are disabled in nginx.conf to allow CORS requests to API
# Cache settings # Cache settings
export CACHE_EXPIRES="1y" export CACHE_EXPIRES="1y"
@ -98,7 +99,7 @@ export WEB_PERMISSIONS="755"
# EMAIL CONFIGURATION (for Let's Encrypt) # EMAIL CONFIGURATION (for Let's Encrypt)
# ============================================================================= # =============================================================================
export LETSENCRYPT_EMAIL="admin@$DOMAIN" export LETSENCRYPT_EMAIL="admin@memo-cards.online"
# ============================================================================= # =============================================================================
# FIREWALL CONFIGURATION # FIREWALL CONFIGURATION
@ -112,7 +113,8 @@ export FIREWALL_ALLOW_SSH="ssh"
# ============================================================================= # =============================================================================
export CRON_RENEWAL_TIMES="0 12 * * * 0 0 * * *" 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 # COLORS FOR OUTPUT

View file

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

View file

@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name 147.45.152.129; server_name memo-cards.online;
# Redirect HTTP to HTTPS # Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
@ -8,11 +8,15 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name 147.45.152.129; server_name memo-cards.online;
# SSL configuration # SSL configuration - Let's Encrypt (preferred)
ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt; ssl_certificate /etc/letsencrypt/live/memo-cards.online/fullchain.pem;
ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key; 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_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_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; ssl_prefer_server_ciphers off;
@ -50,8 +54,9 @@ server {
location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp"; # Removed COEP/COOP headers that interfere with CORS requests to API
add_header Cross-Origin-Opener-Policy "same-origin"; # add_header Cross-Origin-Embedder-Policy "require-corp";
# add_header Cross-Origin-Opener-Policy "same-origin";
} }
# Security - deny access to hidden files # 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)\$ { location ~* \\.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
expires $CACHE_EXPIRES; expires $CACHE_EXPIRES;
add_header Cache-Control "$CACHE_CONTROL"; add_header Cache-Control "$CACHE_CONTROL";
add_header Cross-Origin-Embedder-Policy "$COEP_POLICY"; # Disabled COEP/COOP headers to allow CORS requests to API
add_header Cross-Origin-Opener-Policy "$COOP_POLICY"; # add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
# add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
} }
# Security - deny access to hidden files # Security - deny access to hidden files