This commit is contained in:
Dmitry 2025-11-27 22:45:45 +03:00
parent 5eed9dc2f1
commit a5560944a5
16 changed files with 27 additions and 722 deletions

View file

@ -1,5 +1,6 @@
---
alwaysApply: true
---
mnemo_cards_web_v2 is a web app. It should only run on web! Not mobile or macos. And tests should also only run on web!
mnemo_cards_web_v2 is a web app. It should only run on web! Not mobile or macos. And tests should also only run on web! It runs on a remote server.
Telegram bot also runs on a web server
Актуальные доступы лежат в .access

View file

@ -3,6 +3,7 @@ alwaysApply: true
---
Use yx_state and yx_scope,
Follow clean architecture techique,
Modularize and split into files,
You can use ../mnemo_cards app as a design and product features reference, but NEVER copy its architecture.
When you finish with another step:
Write unit tests for all the functionalities

Binary file not shown.

View file

@ -23,7 +23,7 @@ class BotConfig {
final String backendUrl;
final int shareDailyLimit;
static const String defaultBackendUrl = 'http://localhost:8443';
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
static const int defaultShareDailyLimit = 1;
factory BotConfig.fromArgs(

View file

@ -38,6 +38,6 @@ _flutter.buildConfig = {"engineRevision":"d3d45dcf251823c1769909cd43698d126db38d
_flutter.loader.load({
serviceWorkerSettings: {
serviceWorkerVersion: "1977728131"
serviceWorkerVersion: "2889484362"
}
});

View file

@ -82,7 +82,7 @@ class HttpRepositoryV2 {
}
// Check dynamic paths (e.g., /auth/telegram/code-status/<code>)
if (normalizedPath.startsWith('/auth/telegram/code-status/')) {
if (normalizedPath.startsWith('/auth/telegram/code-status/') || normalizedPath.startsWith('/auth/oauth/')) {
return true;
}

View file

@ -1,15 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
import 'package:mnemo_cards_web_v2/domain/config/api_config_v2.dart';
import 'package:mnemo_cards_web_v2/domain/models/telegram_auth_code_status.dart';
import 'package:mnemo_cards_web_v2/presentation/pages/auth/sign_in_with_google_button.dart';
import 'package:mnemo_cards_web_v2/presentation/pages/auth/sign_in_with_telegram.dart';
/// Authentication page
///
@ -24,12 +22,6 @@ class AuthPage extends StatefulWidget {
class _AuthPageState extends State<AuthPage> {
bool _isLoading = false;
String? _errorMessage;
final _telegramCodeController = TextEditingController();
TelegramAuthCodeStatus? _webCodeStatus;
Timer? _codeStatusTimer;
Timer? _countdownTimer;
DateTime? _codeExpiryTime;
bool _autoLoginAttempted = false;
Future<void> _loginWithGoogle(BuildContext context) async {
setState(() {
@ -93,454 +85,15 @@ class _AuthPageState extends State<AuthPage> {
}
}
Future<void> _loginWithTelegram(BuildContext context) async {
final code = _telegramCodeController.text.trim();
if (code.isEmpty) {
setState(() {
_errorMessage = 'Введите код авторизации';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
Future<void> _onTelegramLoginSuccess(BuildContext context) async {
// Get router before async operations to avoid context issues
final router = GoRouter.of(context);
try {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
throw Exception('AppScope not available');
}
// Login via Telegram with code
final user = await appScope.authService.loginWithTelegram(code);
if (!mounted) return;
if (user != null) {
log('Telegram login successful: ${user.email}', name: 'AuthPage');
_stopCodePolling();
_countdownTimer?.cancel();
_countdownTimer = null;
setState(() {
_webCodeStatus = _webCodeStatus?.copyWith(
state: TelegramAuthCodeState.used,
remainingSeconds: 0,
isUsed: true,
isClaimed: true,
expiresAt: DateTime.now(),
);
});
// Clear code field
_telegramCodeController.clear();
// Create UserScope if it doesn't exist
if (appScope.userScopeHolder.scope == null) {
await appScope.userScopeHolder.create();
}
// Update user state
appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify that UserScope has changed
appScope.notifyUserScopeChanged();
// Notify router about auth change
appScope.userScopeHolder.notifyAuthChanged();
// Navigate to home
router.go('/home');
} else {
setState(() {
_errorMessage = 'Неверный код авторизации';
});
}
} catch (e, s) {
log('Error in Telegram login', error: e, stackTrace: s, name: 'AuthPage');
if (mounted) {
setState(() {
_errorMessage = 'Ошибка авторизации: ${e.toString()}';
});
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _generateTelegramCode(BuildContext context) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
throw Exception('AppScope not available');
}
final status = await appScope.authService.createTelegramWebCode();
if (!mounted) return;
_autoLoginAttempted = false;
_startCountdownTimer(status);
setState(() {
_webCodeStatus = status;
_telegramCodeController.text = status.code;
});
// Copy code to clipboard for convenience
await Clipboard.setData(ClipboardData(text: status.code));
if (mounted) {
_startCodePolling(context, status.code);
// Automatically open Telegram bot with the code
await _openTelegramBotWithCode(status.code);
}
} catch (e, s) {
log(
'Error generating Telegram code',
error: e,
stackTrace: s,
name: 'AuthPage',
);
if (mounted) {
setState(() {
_errorMessage = 'Не удалось получить код. Попробуйте ещё раз.';
});
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _startCodePolling(BuildContext context, String code) {
_stopCodePolling();
// Trigger immediate status check
unawaited(_refreshCodeStatus(context, code));
_codeStatusTimer = Timer.periodic(
const Duration(seconds: 3),
(_) => _refreshCodeStatus(context, code),
);
}
void _stopCodePolling() {
_codeStatusTimer?.cancel();
_codeStatusTimer = null;
}
Future<void> _refreshCodeStatus(BuildContext context, String code) async {
try {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
return;
}
final status = await appScope.authService.getTelegramCodeStatus(code);
if (!mounted) return;
_startCountdownTimer(status);
setState(() {
_webCodeStatus = status;
});
if (status.isExpired) {
_stopCodePolling();
if (mounted) {
setState(() {
_errorMessage = 'Срок действия кода истёк. Сгенерируйте новый.';
});
}
} else if (status.isConsumed) {
_stopCodePolling();
} else if (status.isReadyForLogin && !_autoLoginAttempted && mounted) {
_autoLoginAttempted = true;
await _loginWithTelegram(context);
}
} catch (e, s) {
log(
'Failed to refresh Telegram code status',
error: e,
stackTrace: s,
name: 'AuthPage',
);
}
}
void _startCountdownTimer(TelegramAuthCodeStatus status) {
final now = DateTime.now();
final expiresAt =
status.expiresAt ?? now.add(Duration(seconds: status.remainingSeconds));
_codeExpiryTime = expiresAt;
_countdownTimer?.cancel();
if (status.isExpired || status.isConsumed) {
_countdownTimer = null;
return;
}
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
_countdownTimer?.cancel();
return;
}
if (_codeExpiryTime != null && DateTime.now().isAfter(_codeExpiryTime!)) {
_countdownTimer?.cancel();
setState(() {});
} else {
setState(() {});
}
});
}
String _formatRemaining() {
final expiresAt = _codeExpiryTime;
if (expiresAt == null) {
return '00:00';
}
final remaining = expiresAt.difference(DateTime.now());
if (remaining.isNegative) {
return '00:00';
}
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
Widget _buildTelegramStatusCard(BuildContext context) {
final status = _webCodeStatus!;
final theme = Theme.of(context);
IconData icon;
String statusLabel;
Color iconColor;
switch (status.state) {
case TelegramAuthCodeState.claimed:
icon = Icons.verified;
statusLabel = 'Код подтверждён';
iconColor = theme.colorScheme.secondary;
break;
case TelegramAuthCodeState.used:
icon = Icons.done_all;
statusLabel = 'Код использован';
iconColor = theme.colorScheme.secondary;
break;
case TelegramAuthCodeState.expired:
icon = Icons.timer_off;
statusLabel = 'Код истёк';
iconColor = Colors.red;
break;
case TelegramAuthCodeState.pending:
icon = Icons.hourglass_top;
statusLabel = 'Ожидаем отправку кода в боте';
iconColor = theme.colorScheme.onSurface;
break;
}
final isActive = !status.isExpired && !status.isConsumed;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: iconColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
statusLabel,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 12),
SelectableText(
'Код: ${status.code}',
style: theme.textTheme.bodyLarge?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
isActive
? 'Осталось времени: ${_formatRemaining()}'
: 'Код больше не активен',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (status.isReadyForLogin)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Код отправлен. Нажмите «Войти» для завершения.',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else if (status.isExpired)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Получите новый код и повторите попытку.',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else if (status.isConsumed)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Код уже использован для входа.',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Бот должен открыться автоматически. Отправьте код в боте, чтобы продолжить.',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
/// Open Telegram bot with a specific code in the start parameter
Future<void> _openTelegramBotWithCode(String code) async {
if (mounted && _errorMessage != null) {
setState(() {
_errorMessage = null;
});
}
// Format: https://t.me/bot_username?start=code
// Bot extracts 6-digit code from payload using regex (\d{6})$
final deepLink = Uri.parse(
ApiConfigV2.telegramBotDeepLink(code),
);
try {
final launched = await launchUrl(
deepLink,
mode: LaunchMode.externalApplication,
);
if (!launched) {
throw Exception('Could not launch Telegram');
}
} catch (e, s) {
log(
'Error opening Telegram bot',
error: e,
stackTrace: s,
name: 'AuthPage',
);
if (mounted) {
setState(() {
_errorMessage =
'Не удалось открыть Telegram. Попробуйте вручную: ${deepLink.toString()}';
});
}
}
}
/// Open Telegram bot without code (base link)
Future<void> _openTelegramBot() async {
if (mounted && _errorMessage != null) {
setState(() {
_errorMessage = null;
});
}
final deepLink = Uri.parse(ApiConfigV2.telegramBotDeepLinkBase);
try {
final launched = await launchUrl(
deepLink,
mode: LaunchMode.externalApplication,
);
if (!launched) {
throw Exception('Could not launch Telegram');
}
} catch (e, s) {
log(
'Error opening Telegram bot',
error: e,
stackTrace: s,
name: 'AuthPage',
);
if (mounted) {
setState(() {
_errorMessage =
'Не удалось открыть Telegram. Попробуйте вручную: ${deepLink.toString()}';
});
}
}
}
@override
void dispose() {
_codeStatusTimer?.cancel();
_countdownTimer?.cancel();
_telegramCodeController.dispose();
super.dispose();
}
@ -643,35 +196,9 @@ class _AuthPageState extends State<AuthPage> {
),
],
// Login buttons
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isLoading
? null
: () => _loginWithGoogle(context),
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.g_mobiledata, size: 28),
label: Text(
_isLoading ? 'Signing in...' : 'Sign in with Google',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SignInWithGoogleButton(
onPressed: () => _loginWithGoogle(context),
isLoading: _isLoading,
),
const SizedBox(height: 24),
// Divider
@ -703,154 +230,10 @@ class _AuthPageState extends State<AuthPage> {
),
const SizedBox(height: 24),
// Telegram auth code input
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Введите код из Telegram бота',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
TextField(
controller: _telegramCodeController,
enabled: !_isLoading,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: '123456',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.hintColor,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.dividerColor,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.dividerColor,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.cardColor,
prefixIcon: Icon(
Icons.code,
color: theme.colorScheme.onSurface,
),
counterText: '',
),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
maxLength: 6,
onSubmitted: (_) => _loginWithTelegram(context),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoading
? null
: () => _generateTelegramCode(context),
icon: const Icon(Icons.bolt, size: 20),
label: Text(
_isLoading ? 'Создание...' : 'Получить код',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _openTelegramBot,
icon: const Icon(Icons.telegram, size: 20),
label: Text(
'Открыть бота',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: theme.dividerColor,
width: 1,
),
),
),
),
],
),
if (_webCodeStatus != null) ...[
const SizedBox(height: 16),
_buildTelegramStatusCard(context),
],
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isLoading
? null
: () => _loginWithTelegram(context),
icon: const Icon(Icons.telegram, size: 24),
label: Text(
'Войти с кодом Telegram',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
TextButton(
onPressed: _isLoading ? null : () => context.go('/home'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
),
child: Text(
'Continue as guest',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
),
SignInWithTelegram(
onLoginSuccess: () => _onTelegramLoginSuccess(context),
onError: (error) => setState(() => _errorMessage = error),
isLoading: _isLoading,
),
const SizedBox(height: 16),
Text(

View file

@ -533,54 +533,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.9.3"
flutter_secure_storage:
dependency: transitive
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_svg:
dependency: transitive
description:

View file

@ -55,17 +55,7 @@ server {
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# CORS headers for API
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization, user_token, request_token, app_version" always;
add_header Access-Control-Expose-Headers "Authorization" always;
add_header Access-Control-Max-Age "86400" always;
# Handle preflight OPTIONS requests
if ($request_method = OPTIONS) {
return 204;
}
# CORS headers are handled by the backend server, nginx only proxies
# Proxy to HTTP backend (SSL termination in nginx)
location / {

View file

@ -169,22 +169,11 @@ EOF
# API Backend specific settings
client_max_body_size 100M;
# CORS headers for API
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization, user_token, request_token, app_version" always;
add_header Access-Control-Expose-Headers "Authorization" always;
add_header Access-Control-Max-Age "86400" always;
# CORS headers are handled by the backend server, nginx only proxies
# Handle preflight OPTIONS requests
if (\$request_method = OPTIONS) {
return 204;
}
# Proxy to backend server (HTTPS for API, HTTP for others)
# Proxy to backend server (HTTP for API - backend runs in dual mode)
location / {
proxy_pass https://127.0.0.1:$upstream_port;
proxy_ssl_verify off; # Skip SSL verification for local connection
proxy_pass http://127.0.0.1:$upstream_port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;

View file

@ -1,5 +1,5 @@
# Nginx configuration for api.mnemo-cards.online
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:14:27 MSK 2025
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:41:03 MSK 2025
# Service: api
server {
@ -28,22 +28,11 @@ server {
# API Backend specific settings
client_max_body_size 100M;
# CORS headers for API
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization, user_token, request_token, app_version" always;
add_header Access-Control-Expose-Headers "Authorization" always;
add_header Access-Control-Max-Age "86400" always;
# CORS headers are handled by the backend server, nginx only proxies
# Handle preflight OPTIONS requests
if ($request_method = OPTIONS) {
return 204;
}
# Proxy to backend server (HTTPS for API, HTTP for others)
# Proxy to backend server (HTTP for API - backend runs in dual mode)
location / {
proxy_pass https://127.0.0.1:8081;
proxy_ssl_verify off; # Skip SSL verification for local connection
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -1,5 +1,5 @@
# Nginx configuration for code.mnemo-cards.online
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:14:27 MSK 2025
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:41:03 MSK 2025
# Service: forgejo
server {

View file

@ -1,5 +1,5 @@
# Nginx configuration for mnemo-cards.online
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:14:27 MSK 2025
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:41:03 MSK 2025
# Service: webapp
server {

View file

@ -1,5 +1,5 @@
# Nginx configuration for vscode.mnemo-cards.online
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:14:27 MSK 2025
# Generated by generate-nginx-configs.sh on Thu Nov 27 22:41:03 MSK 2025
# Service: vscode
server {