From cd85f5f2db05fc832aaca0703f1504173afcc251 Mon Sep 17 00:00:00 2001 From: "dmit.b" Date: Fri, 8 May 2026 16:38:57 +0300 Subject: [PATCH] feat: add Docker support, refactor DB layer, update API responses --- .dockerignore | 25 ++- Dockerfile | 41 ++++ README.md | 333 ++++++++++++++++++++++++++-- .env => bin/.env | 10 +- bin/database/database_provider.dart | 11 +- bin/middleware/auth_middleware.dart | 14 +- bin/routes/auth_routes.dart | 16 +- bin/routes/geo_routes.dart | 10 +- bin/server.dart | 6 +- docker-compose.yml | 35 +++ opencode.json | 4 +- test/server_test.dart | 137 ++++++++++-- 12 files changed, 568 insertions(+), 74 deletions(-) create mode 100644 Dockerfile rename .env => bin/.env (56%) create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore index 21504f8..d05bca3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,18 @@ -.dockerignore -Dockerfile -build/ -.dart_tool/ -.git/ -.github/ -.gitignore +.env +.env.* +*.md +!.gitignore +.DS_Store .idea/ -.packages +.vscode/ +*.iml +*.swp +*.swo +*~ +node_modules/ +dist/ +build/ +.Dockerfile +Dockerfile* +docker-compose*.yml +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d95c245 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM dart:3.10.1 AS builder + +WORKDIR /app + +# Copy only pubspec files first for better caching +COPY pubspec.yaml pubspec.lock ./ + +# Install dependencies +RUN dart pub get + +# Copy all source files +COPY . . + +# Build the Dart application +RUN dart pub get && dart compile exe bin/server.dart -o bin/server.exe + +# Production stage +FROM debian:bookworm-slim AS production + +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the compiled executable from builder +COPY --from=builder /app/bin/server.exe ./bin/server.exe + +# Set environment variables +ENV PORT=9090 +ENV POSTGRES_HOST=localhost +ENV POSTGRES_PORT=5432 +ENV POSTGRES_DB=family_safety +ENV POSTGRES_USER=postgres +ENV POSTGRES_PASSWORD=postgres +ENV JWT_SECRET=your-super-secret-key-change-me +ENV TOKEN_LIFETIME=600 + +EXPOSE 9090 + +CMD ["./bin/server.exe"] diff --git a/README.md b/README.md index df9496b..3fdcb6c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,319 @@ -A server app built using [Shelf](https://pub.dev/packages/shelf), +# Family Safety Tracker API -Это новый проект. -Давай добавим описание проекта - -Сервер - приложения для реализации возможности делиться геопозицией с другим человеком. -База данных (в данном случае это будет postges) с отметками о геопозиции человека -Какие таблицы будут в базе данных - -Пользователи - ID, login, pwd -Geoposition - id, x value, y value, datetime, lifetime -Logs - username, action, datetime +REST API для обмена геолокацией между членами семьи. -Основа приложения - это REST API. -Вот какие методы нужны. -/login - авторизация. -/user - CRUD пользователей. -/geo - POST - создание позиции, UPDATE - обновление позиции. (при создании указывается время жизни, после которого данные будут удалены из базы) -/watch?{unique id} - возращает геопозицию+время последней отметки + оставшееся время жизни отметки -/share - метод который создает одноразовую ссылку (/watch?{unique id} ) - по которой доступны данные о геопозиции. В памяти приложения создается связь между geo из таблицы и {unique id} \ No newline at end of file +## Аутентификация + +Сервис использует JWT токены для авторизации. + +### JWT Токен + +При успешном `POST /login` возвращается JWT токен: + +```json +{ + "user": {...}, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Структура токена:** +- `issuer: "family_safety_tracker"` +- `payload: {"user_id": N, "login": "user"}` + +### Авторизация + +Защищённые эндпоинты (`/watch`, `/geo`, `/share`) требуют JWT в заголовке: + +```http +Authorization: Bearer +``` + +--- + +## Таблица эндпоинтов + +| Метод | URL | Описание | Защита | +|-------|-----|----------|--------| +| `POST` | `/login` | Вход в систему | — | +| `POST` | `/reg` | Регистрация нового пользователя | — | +| `GET` | `/watch?unique_id=...` | Получить последнюю позицию по share-ссылке | ✅ | +| `PUT` | `/geo?id=N` | Обновить геопозицию | ✅ | +| `POST` | `/share` | Создать share-ссылку | ✅ | + +--- + +## Эндпоинты + +### `POST /login` — Вход + +#### Запрос + +```bash +curl -X POST http://localhost:9090/reg \ + -H "Content-Type: application/json" \ + -d '{"login": "ivan", "password": "secret123"}' +``` + +#### PowerShell + +```powershell +$body = @{ login = "ivan"; password = "secret123" } | ConvertTo-Json +Invoke-RestMethod -Uri http://localhost:9090/reg -Method Post -Body $body -ContentType "application/json" +``` + +#### Успешный ответ (200) + +```json +"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +#### Ошибки + +| Код | Сообщение | +|-----|-----------| +| `401` | `Invalid credentials` — неверный логин или пароль | + +--- + +### `POST /reg` — Регистрация + +#### Запрос + +```http +Content-Type: application/json + +{"login": "maria", "password": "newpass"} +``` + +#### Успешный ответ (201) + +Ничего не возвращается. + +--- + +### `GET /watch?unique_id=...` — Получить позицию + +#### Запрос + +```http +Authorization: Bearer + +GET /watch?unique_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +#### Успешный ответ (200) + +```json +{ + "id": 42, + "x": 55.7558, + "y": 37.6173, + "created_at": "2024-01-01T12:00:00Z", + "expires_at": "2024-01-01T12:05:00Z" +} +``` + +#### Ошибки + +| Код | Сообщение | +|-----|-----------| +| `404` | `Share link not found` — неверный `unique_id` | +| `404` | `No position available` — share-ссылка создана, но позиция не была добавлена | + +--- + +### `PUT /geo?id=N` — Обновить позицию + +#### Запрос + +```http +Authorization: Bearer +Content-Type: application/json + +PUT /geo?id=1 + +{ + "x": 55.7558, + "y": 37.6173 +} +``` + +#### Успешный ответ (200) + +Ничего не возвращается. + +--- + +### `POST /share` — Создать share-ссылку + +#### Запрос + +```http +Authorization: Bearer +Content-Type: application/json + +{ + "x": 55.7558, + "y": 37.6173 +} +``` + +#### Успешный ответ (201) + +```json +{ + "geo_id": 5, + "share_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +--- + +## Примеры запросов + +### Регистрация + +```bash +curl -X POST http://localhost:8080/reg \ + -H "Content-Type: application/json" \ + -d '{"login": "ivan", "password": "secret123"}' +``` + +### Вход + +```bash +curl -X POST http://localhost:9090/login \ + -H "Content-Type: application/json" \ + -d '{"login": "ivan", "password": "secret123"}' +``` + +#### PowerShell + +```powershell +$body = @{ login = "ivan"; password = "secret123" } | ConvertTo-Json +Invoke-RestMethod -Uri http://localhost:9090/login -Method Post -Body $body -ContentType "application/json" +``` + +### Создание share-ссылки + +```bash +TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + +curl -X POST http://localhost:9090/share \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"x": 55.7558, "y": 37.6173}' +``` + +#### PowerShell + +```powershell +$token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +$body = @{ x = 55.7558; y = 37.6173 } | ConvertTo-Json +Invoke-RestMethod -Uri http://localhost:9090/share -Method Post -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } -Body $body +``` + +### Получение позиции + +```bash +TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +SHARE_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890" + +curl "http://localhost:9090/watch?unique_id=$SHARE_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +#### PowerShell + +```powershell +$token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +$shareId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +Invoke-RestMethod -Uri "http://localhost:9090/watch?unique_id=$shareId" -Method Get -Headers @{ Authorization = "Bearer $token" } +``` + +### Обновление позиции + +```bash +TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + +curl -X PUT "http://localhost:9090/geo?id=1" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"x": 55.7558, "y": 37.6173}' +``` + +#### PowerShell + +```powershell +$token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +$body = @{ x = 55.7558; y = 37.6173 } | ConvertTo-Json +Invoke-RestMethod -Uri "http://localhost:9090/geo?id=1" -Method Put -Headers @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } -Body $body +``` + +--- + +## Технические детали + +### Сервер +- Порт: `9090` (изменяется через `PORT`) + +### Технологии +- **Dart** (SDK `^3.10.1`) +- **Shelf** + **Shelf Router** — HTTP фреймворк +- **PostgreSQL** — база данных +- **bcrypt** — хэширование паролей +- **dart_jsonwebtoken** — JWT-токены +- **dotenv** — переменные окружения + +### Переменные окружения + +| Переменная | Описание | +|------------|----------| +| `PORT` | Порт сервера (по умолчанию 8080) | +| `POSTGRES_HOST` | Хост PostgreSQL | +| `POSTGRES_DB` | База данных | +| `POSTGRES_USER` | Пользователь | +| `POSTGRES_PASSWORD` | Пароль | +| `JWT_SECRET` | Секрет для JWT-токенов | + +### Автоматическая миграция + +При запуске сервера таблицы создаются автоматически: +- `users` — пользователи +- `geopositions` — геопозиции +- `logs` — логи + +--- + +## Структура проекта + +``` +bin/ +├── server.dart # Точка входа +├── routes/ +│ ├── auth_routes.dart # Маршруты авторизации +│ └── geo_routes.dart # Маршруты геолокации +├── repositories/ +│ ├── user_repo.dart +│ └── geoposition_repo.dart +├── models/ +│ ├── user.dart +│ ├── geoposition.dart +│ └── log.dart +└── database/ + └── database_provider.dart +``` + +--- + +## Запуск + +```bash +dart pub get +dart run bin/server.dart +``` + +## Лицензия + +MIT diff --git a/.env b/bin/.env similarity index 56% rename from .env rename to bin/.env index a78a2a2..79a32e9 100644 --- a/.env +++ b/bin/.env @@ -1,9 +1,13 @@ -# Environment variables for sky_kfm_backend +# Environment variables for family_safety_tracker # JWT secret used for signing tokens JWT_SECRET=your-super-secret-key-change-me # Secret pepper added to passwords before hashing (should be random and kept secret) PASSWORD_PEPPER=your-random-pepper-string-change-me -# Database connection URL -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/family_safety +# Database connection settings +POSTGRES_HOST="db" +POSTGRES_PORT="5432" +POSTGRES_DB="family_safety" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" # TOKEN_LIFETIME in minutes TOKEN_LIFETIME=600 \ No newline at end of file diff --git a/bin/database/database_provider.dart b/bin/database/database_provider.dart index 40f45cb..cc4b3e0 100644 --- a/bin/database/database_provider.dart +++ b/bin/database/database_provider.dart @@ -18,11 +18,11 @@ class DatabaseProvider { Future initialize() async { final dotenv = DotEnv(); - final host = dotenv['POSTGRES_HOST'] ?? 'localhost'; + final host = dotenv['POSTGRES_HOST'] ?? 'db'; final port = int.parse(dotenv['POSTGRES_PORT'] ?? '5432'); final databaseName = dotenv['POSTGRES_DB'] ?? 'family_safety'; final username = dotenv['POSTGRES_USER'] ?? 'postgres'; - final password = dotenv['POSTGRES_PASSWORD'] ?? ''; + final password = dotenv['POSTGRES_PASSWORD'] ?? 'postgres'; try { final defaultConnection = await Connection.open( @@ -43,8 +43,7 @@ class DatabaseProvider { if (results.isEmpty) { await defaultConnection.execute( - Sql.named('CREATE DATABASE @dbName'), - parameters: {'dbName': databaseName}, + Sql('CREATE DATABASE $databaseName'), ); print('Database $databaseName created.'); } else { @@ -78,7 +77,7 @@ class DatabaseProvider { await _dbConnection.execute( Sql.named(''' CREATE TABLE IF NOT EXISTS geopositions ( - id SERIAL PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), x_value DOUBLE PRECISION NOT NULL, y_value DOUBLE PRECISION NOT NULL, last_update TIMESTAMP NOT NULL DEFAULT NOW(), @@ -265,7 +264,7 @@ class DatabaseProvider { Future getLatestPosition() async { final results = await _dbConnection.execute( Sql.named(''' - SELECT id, x_value, y_value, last_update, expires_at + SELECT x_value, y_value, last_update, expires_at FROM geopositions WHERE expires_at > NOW() ORDER BY last_update DESC diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart index 92d4576..e4d79a2 100644 --- a/bin/middleware/auth_middleware.dart +++ b/bin/middleware/auth_middleware.dart @@ -3,7 +3,7 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dotenv/dotenv.dart'; class AuthMiddleware { - final Handler handler; + final Future Function(Request, String) handler; AuthMiddleware(this.handler); @@ -19,11 +19,15 @@ class AuthMiddleware { try { final dotenv = DotEnv(); final secret = dotenv['JWT_SECRET'] ?? ''; - final decoded = JWT.verify(token, SecretKey(secret)); + final jwt = JWT.verify(token, SecretKey(secret)); + final payload = jwt.payload; + final login = payload['login'] as String; - return handler(request); - } catch (e) { - return Response(401, body: 'Invalid or expired token'); + return handler(request, login); + } on JWTExpiredException { + return Response(401, body: 'Token expired'); + } on JWTException { + return Response(401, body: 'Invalid token'); } } } \ No newline at end of file diff --git a/bin/routes/auth_routes.dart b/bin/routes/auth_routes.dart index dbb7796..49f6091 100644 --- a/bin/routes/auth_routes.dart +++ b/bin/routes/auth_routes.dart @@ -44,10 +44,7 @@ class AuthRoutes { final token = jwt.sign(SecretKey(secret)); await database.createLog(login, 'Successful login'); - return Response(200, body: jsonEncode({ - 'user': user.toMap(), - 'token': token - })); + return Response(200, body: jsonEncode(token)); } Future _register(Request request) async { @@ -57,9 +54,9 @@ class AuthRoutes { final login = data['login']; final password = data['password']; - final user = await database.createUser(login, password); + await database.createUser(login, password); await database.createLog(login, 'User registration'); - return Response(201, body: jsonEncode(user.toMap())); + return Response(201); } Future _watch(Request request, String login) async { @@ -78,6 +75,11 @@ class AuthRoutes { } await database.createLog(login, 'Accessed share link'); - return Response(200, body: jsonEncode(position.toJson())); + return Response(200, body: jsonEncode({ + 'x': position.xValue, + 'y': position.yValue, + 'last_update': position.lastUpdate, + 'expires_at': position.expiresAt, + })); } } \ No newline at end of file diff --git a/bin/routes/geo_routes.dart b/bin/routes/geo_routes.dart index de47fbe..3b6df04 100644 --- a/bin/routes/geo_routes.dart +++ b/bin/routes/geo_routes.dart @@ -16,7 +16,7 @@ class GeoRoutes { return router; } - Future _updatePosition(Request request) async { + Future _updatePosition(Request request, String login) async { final id = int.parse(request.url.queryParameters['id']!); final body = await request.readAsString(); final data = jsonDecode(body); @@ -24,11 +24,12 @@ class GeoRoutes { final x = data['x']; final y = data['y']; - final position = await database.updatePosition(id, x, y); - return Response(200, body: position.toJson()); + await database.updatePosition(id, x, y); + await database.createLog(login, 'Updated position id=$id'); + return Response(200); } - Future _createShare(Request request) async { + Future _createShare(Request request, String login) async { final body = await request.readAsString(); final data = jsonDecode(body); @@ -38,6 +39,7 @@ class GeoRoutes { final position = await database.createPosition(x, y); final shareId = database.createShareId(); + await database.createLog(login, 'Created share link geo_id=${position.id}'); return Response(201, body: jsonEncode({ 'geo_id': position.id, 'share_id': shareId diff --git a/bin/server.dart b/bin/server.dart index cb22ad1..76004fd 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -21,8 +21,8 @@ void main(List args) async { final geoRoutes = GeoRoutes(database); final router = Router() - ..mount('', authRoutes.routes.call) - ..mount('', geoRoutes.routes.call) + ..mount('/', authRoutes.routes.call) + ..mount('/', geoRoutes.routes.call) ..get('/', (Request req) => Response.ok('Family Safety Tracker API\n')); final handler = Pipeline() @@ -30,7 +30,7 @@ void main(List args) async { .addHandler(router.call); final ip = InternetAddress.anyIPv4; - final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final port = int.parse(Platform.environment['PORT'] ?? '9090'); final server = await serve(handler, ip, port); print('Server listening on port ${server.port}'); } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b47cf35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + container_name: family_safety_db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: family_safety + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + + app: + build: . + container_name: family_safety_app + restart: unless-stopped + ports: + - "9090:9090" + depends_on: + db: + condition: service_healthy + + +volumes: + postgres_data: diff --git a/opencode.json b/opencode.json index 128248e..f36bf6d 100644 --- a/opencode.json +++ b/opencode.json @@ -11,8 +11,8 @@ "qwen3-coder:a3b": { "name": "Qwen_Qwen3.5-9B-Q6_K (local)", "limit": { - "context": 42000, - "output": 42000 + "context": 248000, + "output": 65655 } } } diff --git a/test/server_test.dart b/test/server_test.dart index 3081d87..377da8d 100644 --- a/test/server_test.dart +++ b/test/server_test.dart @@ -1,39 +1,136 @@ +import 'dart:convert'; import 'dart:io'; -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; import 'package:test/test.dart'; void main() { - final port = '8080'; - final host = 'http://0.0.0.0:$port'; + final port = '9090'; + final host = 'http://localhost:$port'; late Process p; + String? authToken; - setUp(() async { + Future getAuthToken() async { + if (authToken != null) return authToken; + + final regResponse = await http.post( + Uri.parse('$host/reg'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'login': 'testuser', 'password': 'testpass'}), + ); + + final loginResponse = await http.post( + Uri.parse('$host/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'login': 'testuser', 'password': 'testpass'}), + ); + + if (loginResponse.statusCode == 200) { + final data = jsonDecode(loginResponse.body) as Map; + authToken = data['token']; + } + return authToken; + } + + setUpAll(() async { + stdout.writeln("Starting server..."); p = await Process.start( 'dart', ['run', 'bin/server.dart'], environment: {'PORT': port}, ); - // Wait for server to start and print to stdout. - await p.stdout.first; + + // Wait for server to be ready + for (int i = 0; i < 30; i++) { + try { + final response = await http.get(Uri.parse('$host/')); + if (response.statusCode == 200) break; + } catch (e) { + // Server not ready yet + } + await Future.delayed(Duration(seconds: 1)); + } + stdout.writeln("Server ready"); }); - tearDown(() => p.kill()); - - test('Root', () async { - final response = await get(Uri.parse('$host/')); - expect(response.statusCode, 200); - expect(response.body, 'Hello, World!\n'); + tearDownAll(() { + p.kill(); }); - test('Echo', () async { - final response = await get(Uri.parse('$host/echo/hello')); - expect(response.statusCode, 200); - expect(response.body, 'hello\n'); + group('Root endpoint', () { + test('GET / - Root endpoint', () async { + final response = await http.get(Uri.parse('$host/')); + expect(response.statusCode, 200); + expect(response.body, contains('Family Safety Tracker API')); + }); + + test('GET /nonexistent - 404', () async { + final response = await http.get(Uri.parse('$host/nonexistent')); + expect(response.statusCode, 404); + }); }); - test('404', () async { - final response = await get(Uri.parse('$host/foobar')); - expect(response.statusCode, 404); + group('User registration', () { + test('POST /reg - Register new user', () async { + final response = await http.post( + Uri.parse('$host/reg'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'login': 'newuser', 'password': 'newpass'}), + ); + expect(response.statusCode, 201); + final data = jsonDecode(response.body); + expect(data['login'], 'newuser'); + }); }); -} + + group('Authentication', () { + test('POST /login - Valid credentials', () async { + await getAuthToken(); + expect(authToken, isNotEmpty); + }); + + test('POST /login - Invalid credentials', () async { + final response = await http.post( + Uri.parse('$host/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'login': 'testuser', 'password': 'wrongpass'}), + ); + expect(response.statusCode, 401); + }); + }); + + group('Geo operations', () { + test('POST /share - Create share link', () async { + await getAuthToken(); + final response = await http.post( + Uri.parse('$host/share'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }, + body: jsonEncode({'x': 40.7128, 'y': -74.006}), + ); + expect(response.statusCode, 201); + final data = jsonDecode(response.body); + expect(data['share_id'], isA()); + }); + + test('POST /share - No auth token', () async { + final response = await http.post( + Uri.parse('$host/share'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'x': 40.7128, 'y': -74.006}), + ); + expect(response.statusCode, 401); + }); + }); + + group('Watch endpoint', () { + test('GET /watch - Invalid share link', () async { + final response = await http.get( + Uri.parse('$host/watch?unique_id=invalid-uuid'), + ); + expect(response.statusCode, 404); + }); + }); +} \ No newline at end of file