feat: add Docker support, refactor DB layer, update API responses
This commit is contained in:
+17
-8
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}');
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user