feat: add Docker support, refactor DB layer, update API responses

This commit is contained in:
dmit.b
2026-05-08 16:38:57 +03:00
parent 9ecbc3aa79
commit cd85f5f2db
12 changed files with 568 additions and 74 deletions
+17 -8
View File
@@ -1,9 +1,18 @@
.dockerignore .env
Dockerfile .env.*
build/ *.md
.dart_tool/ !.gitignore
.git/ .DS_Store
.github/
.gitignore
.idea/ .idea/
.packages .vscode/
*.iml
*.swp
*.swo
*~
node_modules/
dist/
build/
.Dockerfile
Dockerfile*
docker-compose*.yml
*.log
+41
View File
@@ -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"]
+317 -16
View File
@@ -1,18 +1,319 @@
A server app built using [Shelf](https://pub.dev/packages/shelf), # Family Safety Tracker API
Это новый проект. REST API для обмена геолокацией между членами семьи.
Давай добавим описание проекта -
Сервер - приложения для реализации возможности делиться геопозицией с другим человеком.
База данных (в данном случае это будет postges) с отметками о геопозиции человека
Какие таблицы будут в базе данных -
Пользователи - ID, login, pwd
Geoposition - id, x value, y value, datetime, lifetime
Logs - username, action, datetime
Основа приложения - это REST API. ## Аутентификация
Вот какие методы нужны.
/login - авторизация. Сервис использует JWT токены для авторизации.
/user - CRUD пользователей.
/geo - POST - создание позиции, UPDATE - обновление позиции. (при создании указывается время жизни, после которого данные будут удалены из базы) ### JWT Токен
/watch?{unique id} - возращает геопозицию+время последней отметки + оставшееся время жизни отметки
/share - метод который создает одноразовую ссылку (/watch?{unique id} ) - по которой доступны данные о геопозиции. В памяти приложения создается связь между geo из таблицы и {unique id} При успешном `POST /login` возвращается JWT токен:
```json
{
"user": {...},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Структура токена:**
- `issuer: "family_safety_tracker"`
- `payload: {"user_id": N, "login": "user"}`
### Авторизация
Защищённые эндпоинты (`/watch`, `/geo`, `/share`) требуют JWT в заголовке:
```http
Authorization: Bearer <jwt_token>
```
---
## Таблица эндпоинтов
| Метод | 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 <jwt_token>
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 <jwt_token>
Content-Type: application/json
PUT /geo?id=1
{
"x": 55.7558,
"y": 37.6173
}
```
#### Успешный ответ (200)
Ничего не возвращается.
---
### `POST /share` — Создать share-ссылку
#### Запрос
```http
Authorization: Bearer <jwt_token>
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
+7 -3
View File
@@ -1,9 +1,13 @@
# Environment variables for sky_kfm_backend # Environment variables for family_safety_tracker
# JWT secret used for signing tokens # JWT secret used for signing tokens
JWT_SECRET=your-super-secret-key-change-me JWT_SECRET=your-super-secret-key-change-me
# Secret pepper added to passwords before hashing (should be random and kept secret) # Secret pepper added to passwords before hashing (should be random and kept secret)
PASSWORD_PEPPER=your-random-pepper-string-change-me PASSWORD_PEPPER=your-random-pepper-string-change-me
# Database connection URL # Database connection settings
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/family_safety POSTGRES_HOST="db"
POSTGRES_PORT="5432"
POSTGRES_DB="family_safety"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
# TOKEN_LIFETIME in minutes # TOKEN_LIFETIME in minutes
TOKEN_LIFETIME=600 TOKEN_LIFETIME=600
+5 -6
View File
@@ -18,11 +18,11 @@ class DatabaseProvider {
Future<void> initialize() async { Future<void> initialize() async {
final dotenv = DotEnv(); final dotenv = DotEnv();
final host = dotenv['POSTGRES_HOST'] ?? 'localhost'; final host = dotenv['POSTGRES_HOST'] ?? 'db';
final port = int.parse(dotenv['POSTGRES_PORT'] ?? '5432'); final port = int.parse(dotenv['POSTGRES_PORT'] ?? '5432');
final databaseName = dotenv['POSTGRES_DB'] ?? 'family_safety'; final databaseName = dotenv['POSTGRES_DB'] ?? 'family_safety';
final username = dotenv['POSTGRES_USER'] ?? 'postgres'; final username = dotenv['POSTGRES_USER'] ?? 'postgres';
final password = dotenv['POSTGRES_PASSWORD'] ?? ''; final password = dotenv['POSTGRES_PASSWORD'] ?? 'postgres';
try { try {
final defaultConnection = await Connection.open( final defaultConnection = await Connection.open(
@@ -43,8 +43,7 @@ class DatabaseProvider {
if (results.isEmpty) { if (results.isEmpty) {
await defaultConnection.execute( await defaultConnection.execute(
Sql.named('CREATE DATABASE @dbName'), Sql('CREATE DATABASE $databaseName'),
parameters: {'dbName': databaseName},
); );
print('Database $databaseName created.'); print('Database $databaseName created.');
} else { } else {
@@ -78,7 +77,7 @@ class DatabaseProvider {
await _dbConnection.execute( await _dbConnection.execute(
Sql.named(''' Sql.named('''
CREATE TABLE IF NOT EXISTS geopositions ( CREATE TABLE IF NOT EXISTS geopositions (
id SERIAL PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
x_value DOUBLE PRECISION NOT NULL, x_value DOUBLE PRECISION NOT NULL,
y_value DOUBLE PRECISION NOT NULL, y_value DOUBLE PRECISION NOT NULL,
last_update TIMESTAMP NOT NULL DEFAULT NOW(), last_update TIMESTAMP NOT NULL DEFAULT NOW(),
@@ -265,7 +264,7 @@ class DatabaseProvider {
Future<Geoposition?> getLatestPosition() async { Future<Geoposition?> getLatestPosition() async {
final results = await _dbConnection.execute( final results = await _dbConnection.execute(
Sql.named(''' Sql.named('''
SELECT id, x_value, y_value, last_update, expires_at SELECT x_value, y_value, last_update, expires_at
FROM geopositions FROM geopositions
WHERE expires_at > NOW() WHERE expires_at > NOW()
ORDER BY last_update DESC ORDER BY last_update DESC
+9 -5
View File
@@ -3,7 +3,7 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dotenv/dotenv.dart'; import 'package:dotenv/dotenv.dart';
class AuthMiddleware { class AuthMiddleware {
final Handler handler; final Future<Response> Function(Request, String) handler;
AuthMiddleware(this.handler); AuthMiddleware(this.handler);
@@ -19,11 +19,15 @@ class AuthMiddleware {
try { try {
final dotenv = DotEnv(); final dotenv = DotEnv();
final secret = dotenv['JWT_SECRET'] ?? ''; 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); return handler(request, login);
} catch (e) { } on JWTExpiredException {
return Response(401, body: 'Invalid or expired token'); return Response(401, body: 'Token expired');
} on JWTException {
return Response(401, body: 'Invalid token');
} }
} }
} }
+9 -7
View File
@@ -44,10 +44,7 @@ class AuthRoutes {
final token = jwt.sign(SecretKey(secret)); final token = jwt.sign(SecretKey(secret));
await database.createLog(login, 'Successful login'); await database.createLog(login, 'Successful login');
return Response(200, body: jsonEncode({ return Response(200, body: jsonEncode(token));
'user': user.toMap(),
'token': token
}));
} }
Future<Response> _register(Request request) async { Future<Response> _register(Request request) async {
@@ -57,9 +54,9 @@ class AuthRoutes {
final login = data['login']; final login = data['login'];
final password = data['password']; final password = data['password'];
final user = await database.createUser(login, password); await database.createUser(login, password);
await database.createLog(login, 'User registration'); await database.createLog(login, 'User registration');
return Response(201, body: jsonEncode(user.toMap())); return Response(201);
} }
Future<Response> _watch(Request request, String login) async { Future<Response> _watch(Request request, String login) async {
@@ -78,6 +75,11 @@ class AuthRoutes {
} }
await database.createLog(login, 'Accessed share link'); 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,
}));
} }
} }
+6 -4
View File
@@ -16,7 +16,7 @@ class GeoRoutes {
return router; return router;
} }
Future<Response> _updatePosition(Request request) async { Future<Response> _updatePosition(Request request, String login) async {
final id = int.parse(request.url.queryParameters['id']!); final id = int.parse(request.url.queryParameters['id']!);
final body = await request.readAsString(); final body = await request.readAsString();
final data = jsonDecode(body); final data = jsonDecode(body);
@@ -24,11 +24,12 @@ class GeoRoutes {
final x = data['x']; final x = data['x'];
final y = data['y']; final y = data['y'];
final position = await database.updatePosition(id, x, y); await database.updatePosition(id, x, y);
return Response(200, body: position.toJson()); await database.createLog(login, 'Updated position id=$id');
return Response(200);
} }
Future<Response> _createShare(Request request) async { Future<Response> _createShare(Request request, String login) async {
final body = await request.readAsString(); final body = await request.readAsString();
final data = jsonDecode(body); final data = jsonDecode(body);
@@ -38,6 +39,7 @@ class GeoRoutes {
final position = await database.createPosition(x, y); final position = await database.createPosition(x, y);
final shareId = database.createShareId(); final shareId = database.createShareId();
await database.createLog(login, 'Created share link geo_id=${position.id}');
return Response(201, body: jsonEncode({ return Response(201, body: jsonEncode({
'geo_id': position.id, 'geo_id': position.id,
'share_id': shareId 'share_id': shareId
+3 -3
View File
@@ -21,8 +21,8 @@ void main(List<String> args) async {
final geoRoutes = GeoRoutes(database); final geoRoutes = GeoRoutes(database);
final router = Router() final router = Router()
..mount('', authRoutes.routes.call) ..mount('/', authRoutes.routes.call)
..mount('', geoRoutes.routes.call) ..mount('/', geoRoutes.routes.call)
..get('/', (Request req) => Response.ok('Family Safety Tracker API\n')); ..get('/', (Request req) => Response.ok('Family Safety Tracker API\n'));
final handler = Pipeline() final handler = Pipeline()
@@ -30,7 +30,7 @@ void main(List<String> args) async {
.addHandler(router.call); .addHandler(router.call);
final ip = InternetAddress.anyIPv4; 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); final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}'); print('Server listening on port ${server.port}');
} }
+35
View File
@@ -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:
+2 -2
View File
@@ -11,8 +11,8 @@
"qwen3-coder:a3b": { "qwen3-coder:a3b": {
"name": "Qwen_Qwen3.5-9B-Q6_K (local)", "name": "Qwen_Qwen3.5-9B-Q6_K (local)",
"limit": { "limit": {
"context": 42000, "context": 248000,
"output": 42000 "output": 65655
} }
} }
} }
+117 -20
View File
@@ -1,39 +1,136 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart'; import 'package:http/http.dart' as http;
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
final port = '8080'; final port = '9090';
final host = 'http://0.0.0.0:$port'; final host = 'http://localhost:$port';
late Process p; late Process p;
String? authToken;
setUp(() async { Future<String?> 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<String, dynamic>;
authToken = data['token'];
}
return authToken;
}
setUpAll(() async {
stdout.writeln("Starting server...");
p = await Process.start( p = await Process.start(
'dart', 'dart',
['run', 'bin/server.dart'], ['run', 'bin/server.dart'],
environment: {'PORT': port}, 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()); tearDownAll(() {
p.kill();
test('Root', () async {
final response = await get(Uri.parse('$host/'));
expect(response.statusCode, 200);
expect(response.body, 'Hello, World!\n');
}); });
test('Echo', () async { group('Root endpoint', () {
final response = await get(Uri.parse('$host/echo/hello')); test('GET / - Root endpoint', () async {
expect(response.statusCode, 200); final response = await http.get(Uri.parse('$host/'));
expect(response.body, 'hello\n'); 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 { group('User registration', () {
final response = await get(Uri.parse('$host/foobar')); test('POST /reg - Register new user', () async {
expect(response.statusCode, 404); 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<String>());
});
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);
});
});
}