commit 49bb854ca23814a3ef2784c2a722a54972f672fe Author: dmit.b Date: Fri May 8 12:15:56 2026 +0300 Refactor database layer: convert to DatabaseProvider class with initialization diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..21504f8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +Dockerfile +build/ +.dart_tool/ +.git/ +.github/ +.gitignore +.idea/ +.packages diff --git a/.env b/.env new file mode 100644 index 0000000..a78a2a2 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# Environment variables for sky_kfm_backend +# 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 +# TOKEN_LIFETIME in minutes +TOKEN_LIFETIME=600 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..38d8b52 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml new file mode 100644 index 0000000..6dd5fc4 --- /dev/null +++ b/.idea/libraries/Dart_Packages.xml @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml new file mode 100644 index 0000000..741571f --- /dev/null +++ b/.idea/libraries/Dart_SDK.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4b151ab --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..c2dcdba --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b5f5dec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md - Family Safety Tracker + +## Overview +Dart server app (Shelf + PostgreSQL) for sharing geolocation with family members. REST API with JWT auth, bcrypt password hashing, and auto-expiring position data. + +## Quick Start +```bash +dart pub get +dart run bin/server.dart +``` +Server starts on port `8080` (override with `PORT` env var). + +## Code Structure +- **`bin/`** - All application code lives here (not `lib/`, which is empty). + - `bin/server.dart` - Entry point. Starts Shelf server, database connection, and cleanup timer. + - `bin/routes/` - Route handlers (`auth_routes.dart`, `user_routes.dart`, `geo_routes.dart`) + - `bin/repositories/` - Data access layer (`user_repo.dart`, `geoposition_repo.dart`) + - `bin/models/` - Domain models (`user.dart`, `geoposition.dart`, `log.dart`) + - `bin/database/` - Database connection and auto-migration +- **`lib/`** - Empty scaffold directory (stale, can be ignored). + +## Database +- PostgreSQL with connection via environment variables (`POSTGRES_HOST`, `POSTGRES_DB`, etc.) or defaults. +- **Auto-migration**: Tables are created on startup via `Database.initialize()`. No manual migration needed. +- Tables: `users`, `geopositions`, `logs`. + +## Runtime Behavior +- Expired geopositions are cleaned up every 5 minutes by a periodic timer in `server.dart`. +- Share links use UUIDs stored in-memory via `GeopositionRepository`. + +## API Endpoints +- `POST /login` - Authenticate with login/password, returns user data. +- `/user` - CRUD for users. +- `/geo` - POST to create position, UPDATE to update (with lifetime expiry). +- `GET /watch?unique_id=...` - Returns latest position for a share link. +- `/share` - Creates a one-time share link UUID. + +## Testing +```bash +dart test +``` +Tests spawn the server process and hit endpoints. Server listens on port `8080` by default. + +## Build/Quality Commands +```bash +dart analyze # Static analysis (uses analysis_options.yaml) +dart format --set . # Format code +``` + +## Dependencies +- `shelf`, `shelf_router` - HTTP framework +- `postgres` - PostgreSQL driver +- `bcrypt` - Password hashing +- `dart_jsonwebtoken` - JWT tokens +- `dotenv` - Environment config +- `uuid` - UUID generation + +## Notes +- SDK: `^3.10.1` +- No Docker setup present (`.dockerignore` exists but no `Dockerfile`). +- Documentation in `README.md` is in Russian; the API is designed for geolocation sharing between family members. +- `test_bcrypt.dart` is a standalone test file, not part of the test suite. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df9496b --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +A server app built using [Shelf](https://pub.dev/packages/shelf), + +Это новый проект. +Давай добавим описание проекта - +Сервер - приложения для реализации возможности делиться геопозицией с другим человеком. +База данных (в данном случае это будет postges) с отметками о геопозиции человека +Какие таблицы будут в базе данных - +Пользователи - ID, login, pwd +Geoposition - id, x value, y value, datetime, lifetime +Logs - username, action, datetime + +Основа приложения - это REST API. +Вот какие методы нужны. +/login - авторизация. +/user - CRUD пользователей. +/geo - POST - создание позиции, UPDATE - обновление позиции. (при создании указывается время жизни, после которого данные будут удалены из базы) +/watch?{unique id} - возращает геопозицию+время последней отметки + оставшееся время жизни отметки +/share - метод который создает одноразовую ссылку (/watch?{unique id} ) - по которой доступны данные о геопозиции. В памяти приложения создается связь между geo из таблицы и {unique id} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/bin/database/database_provider.dart b/bin/database/database_provider.dart new file mode 100644 index 0000000..d634746 --- /dev/null +++ b/bin/database/database_provider.dart @@ -0,0 +1,356 @@ +import 'dart:io'; + +import 'package:bcrypt/bcrypt.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:postgres/postgres.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/user.dart'; +import '../models/geoposition.dart'; +import '../models/log.dart'; + +class DatabaseProvider { + late Connection _dbConnection; + + final Map _shareLinks = {}; + final _uuid = const Uuid(); + + Future initialize() async { + final dotenv = DotEnv(); + + final host = dotenv['POSTGRES_HOST'] ?? 'localhost'; + 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'] ?? ''; + + try { + final defaultConnection = await Connection.open( + settings: ConnectionSettings(sslMode: SslMode.disable), + Endpoint( + host: host, + port: port, + database: 'postgres', + username: username, + password: password, + ), + ); + + final results = await defaultConnection.execute( + Sql.named('SELECT 1 FROM pg_database WHERE datname = @dbName'), + parameters: {'dbName': databaseName}, + ); + + if (results.isEmpty) { + await defaultConnection.execute( + Sql.named('CREATE DATABASE @dbName'), + parameters: {'dbName': databaseName}, + ); + print('Database $databaseName created.'); + } else { + print('Database $databaseName already exists.'); + } + + await defaultConnection.close(); + + _dbConnection = await Connection.open( + settings: ConnectionSettings(sslMode: SslMode.disable), + Endpoint( + host: host, + port: port, + database: databaseName, + username: username, + password: password, + ), + ); + print('Connected to database $databaseName.'); + + await _dbConnection.execute( + Sql.named(''' + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + login VARCHAR(255) UNIQUE NOT NULL, + pwd_hash VARCHAR(255) NOT NULL + ) + '''), + ); + + await _dbConnection.execute( + Sql.named(''' + CREATE TABLE IF NOT EXISTS geopositions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + x_value DOUBLE PRECISION NOT NULL, + y_value DOUBLE PRECISION NOT NULL, + datetime TIMESTAMP NOT NULL DEFAULT NOW(), + lifetime INTERVAL NOT NULL, + expires_at TIMESTAMP NOT NULL + ) + '''), + ); + + await _dbConnection.execute( + Sql.named(''' + CREATE TABLE IF NOT EXISTS logs ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + action VARCHAR(255) NOT NULL, + datetime TIMESTAMP NOT NULL DEFAULT NOW() + ) + '''), + ); + + print('All tables ensured to exist.'); + } catch (e, stackTrace) { + stderr.writeln('Database initialization error: $e'); + stderr.writeln(stackTrace); + exit(1); + } + } + + Future close() async { + await _dbConnection.close(); + } + + // ==================== User operations ==================== + + Future findUserByLogin(String login) async { + final results = await _dbConnection.execute( + Sql.named('SELECT id, login, pwd_hash FROM users WHERE login = @login'), + parameters: {'login': login}, + ); + + if (results.isEmpty) return null; + final row = results.first; + return User( + id: int.parse(row[0].toString()), + login: row[1] as String, + pwdHash: row[2] as String, + ); + } + + Future createUser(String login, String password) async { + final hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt()); + + final results = await _dbConnection.execute( + Sql.named(''' + INSERT INTO users (login, pwd_hash) VALUES (@login, @pwdHash) + RETURNING id, login, pwd_hash + '''), + parameters: { + 'login': login, + 'pwdHash': hashedPassword, + }, + ); + + final row = results.first; + return User( + id: int.parse(row[0].toString()), + login: row[1] as String, + pwdHash: row[2] as String, + ); + } + + Future deleteUser(int id) async { + await _dbConnection.execute( + Sql.named('DELETE FROM users WHERE id = @id'), + parameters: {'id': id}, + ); + } + + Future updateUser(int id, String newLogin, String? newPassword) async { + if (newPassword != null) { + final hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt()); + await _dbConnection.execute( + Sql.named(''' + UPDATE users SET login = @login, pwd_hash = @pwdHash WHERE id = @id + '''), + parameters: { + 'login': newLogin, + 'pwdHash': hashedPassword, + 'id': id, + }, + ); + } else { + await _dbConnection.execute( + Sql.named('UPDATE users SET login = @login WHERE id = @id'), + parameters: { + 'login': newLogin, + 'id': id, + }, + ); + } + + final user = await findUserByLogin(newLogin); + return user!; + } + + Future> getAllUsers() async { + final results = await _dbConnection.execute( + Sql.named('SELECT id, login, pwd_hash FROM users'), + ); + + return results + .map( + (row) => User( + id: int.parse(row[0].toString()), + login: row[1] as String, + pwdHash: row[2] as String, + ), + ) + .toList(); + } + + // ==================== Geoposition operations ==================== + + Future createPosition( + int userId, + double x, + double y, + Duration lifetime, + ) async { + final expiresAt = DateTime.now().add(lifetime); + + final results = await _dbConnection.execute( + Sql.named(''' + INSERT INTO geopositions (user_id, x_value, y_value, datetime, lifetime, expires_at) + VALUES (@userId, @xValue, @yValue, NOW(), @lifetime, @expiresAt) + RETURNING id, user_id, x_value, y_value, datetime, lifetime, expires_at + '''), + parameters: { + 'userId': userId, + 'xValue': x, + 'yValue': y, + 'lifetime': _toInterval(lifetime), + 'expiresAt': expiresAt.toIso8601String(), + }, + ); + + final row = results.first; + return Geoposition( + id: int.parse(row[0].toString()), + userId: int.parse(row[1].toString()), + xValue: double.parse(row[2].toString()), + yValue: double.parse(row[3].toString()), + datetime: DateTime.parse(row[4].toString()), + lifetime: lifetime, + expiresAt: DateTime.parse(row[6].toString()), + ); + } + + Future updatePosition( + int userId, + double x, + double y, + Duration lifetime, + ) async { + final expiresAt = DateTime.now().add(lifetime); + + final results = await _dbConnection.execute( + Sql.named(''' + INSERT INTO geopositions (user_id, x_value, y_value, datetime, lifetime, expires_at) + VALUES (@userId, @xValue, @yValue, NOW(), @lifetime, @expiresAt) + RETURNING id, user_id, x_value, y_value, datetime, lifetime, expires_at + '''), + parameters: { + 'userId': userId, + 'xValue': x, + 'yValue': y, + 'lifetime': _toInterval(lifetime), + 'expiresAt': expiresAt.toIso8601String(), + }, + ); + + final row = results.first; + return Geoposition( + id: int.parse(row[0].toString()), + userId: int.parse(row[1].toString()), + xValue: double.parse(row[2].toString()), + yValue: double.parse(row[3].toString()), + datetime: DateTime.parse(row[4].toString()), + lifetime: lifetime, + expiresAt: DateTime.parse(row[6].toString()), + ); + } + + Future getLatestPosition(int userId) async { + final results = await _dbConnection.execute( + Sql.named(''' + SELECT id, user_id, x_value, y_value, datetime, lifetime, expires_at + FROM geopositions + WHERE user_id = @userId AND expires_at > NOW() + ORDER BY datetime DESC + LIMIT 1 + '''), + parameters: {'userId': userId}, + ); + + if (results.isEmpty) return null; + + final row = results.first; + return Geoposition( + id: int.parse(row[0].toString()), + userId: int.parse(row[1].toString()), + xValue: double.parse(row[2].toString()), + yValue: double.parse(row[3].toString()), + datetime: DateTime.parse(row[4].toString()), + lifetime: Duration( + seconds: int.tryParse(row[5].toString()) ?? 0), + expiresAt: DateTime.parse(row[6].toString()), + ); + } + + Future cleanupExpired() async { + await _dbConnection.execute( + Sql.named('DELETE FROM geopositions WHERE expires_at < NOW()'), + ); + } + + // ==================== Share operations ==================== + + String createShareId(int userId) { + final uniqueId = _uuid.v4(); + _shareLinks[uniqueId] = userId; + return uniqueId; + } + + int? getUserIdByShareId(String uniqueId) { + return _shareLinks[uniqueId]; + } + + // ==================== Log operations ==================== + + Future createLog(String username, String action) async { + await _dbConnection.execute( + Sql.named(''' + INSERT INTO logs (username, action) VALUES (@username, @action) + '''), + parameters: { + 'username': username, + 'action': action, + }, + ); + } + + Future> getAllLogs() async { + final results = await _dbConnection.execute( + Sql.named('SELECT id, username, action, datetime FROM logs'), + ); + + return results + .map( + (row) => Log( + id: int.parse(row[0].toString()), + username: row[1] as String, + action: row[2] as String, + datetime: DateTime.parse(row[3].toString()), + ), + ) + .toList(); + } + + String _toInterval(Duration duration) { + final seconds = duration.inSeconds; + return '$seconds seconds'; + } +} \ No newline at end of file diff --git a/bin/database/migration.sql b/bin/database/migration.sql new file mode 100644 index 0000000..240ce06 --- /dev/null +++ b/bin/database/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + login VARCHAR(255) UNIQUE NOT NULL, + pwd_hash VARCHAR(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS geopositions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + x_value DOUBLE PRECISION NOT NULL, + y_value DOUBLE PRECISION NOT NULL, + datetime TIMESTAMP NOT NULL DEFAULT NOW(), + lifetime INTERVAL NOT NULL, + expires_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS logs ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + action VARCHAR(255) NOT NULL, + datetime TIMESTAMP NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/bin/models/geoposition.dart b/bin/models/geoposition.dart new file mode 100644 index 0000000..a9e08b3 --- /dev/null +++ b/bin/models/geoposition.dart @@ -0,0 +1,41 @@ +class Geoposition { + final int id; + final int userId; + final double xValue; + final double yValue; + final DateTime datetime; + final Duration lifetime; + final DateTime expiresAt; + + Geoposition({ + required this.id, + required this.userId, + required this.xValue, + required this.yValue, + required this.datetime, + required this.lifetime, + required this.expiresAt, + }); + + Map toJson() { + return { + 'id': id, + 'x': xValue, + 'y': yValue, + 'datetime': datetime.toIso8601String(), + 'remainingSeconds': expiresAt.difference(DateTime.now()).inSeconds, + }; + } + + factory Geoposition.fromMap(Map map) { + return Geoposition( + id: map['id'], + userId: map['user_id'], + xValue: map['x_value'].toDouble(), + yValue: map['y_value'].toDouble(), + datetime: map['datetime'] as DateTime, + lifetime: Duration(seconds: map['lifetime']), + expiresAt: map['expires_at'] as DateTime, + ); + } +} \ No newline at end of file diff --git a/bin/models/log.dart b/bin/models/log.dart new file mode 100644 index 0000000..073c4df --- /dev/null +++ b/bin/models/log.dart @@ -0,0 +1,22 @@ +class Log { + final int id; + final String username; + final String action; + final DateTime datetime; + + Log({ + required this.id, + required this.username, + required this.action, + required this.datetime, + }); + + factory Log.fromMap(Map map) { + return Log( + id: map['id'], + username: map['username'], + action: map['action'], + datetime: map['datetime'] as DateTime, + ); + } +} \ No newline at end of file diff --git a/bin/models/user.dart b/bin/models/user.dart new file mode 100644 index 0000000..4996f57 --- /dev/null +++ b/bin/models/user.dart @@ -0,0 +1,26 @@ +class User { + final int id; + final String login; + final String pwdHash; + + User({ + required this.id, + required this.login, + required this.pwdHash, + }); + + Map toMap() { + return { + 'id': id, + 'login': login, + }; + } + + factory User.fromMap(Map map) { + return User( + id: map['id'], + login: map['login'], + pwdHash: map['pwd_hash'], + ); + } +} \ No newline at end of file diff --git a/bin/routes/auth_routes.dart b/bin/routes/auth_routes.dart new file mode 100644 index 0000000..4ce4420 --- /dev/null +++ b/bin/routes/auth_routes.dart @@ -0,0 +1,52 @@ +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:bcrypt/bcrypt.dart'; +import '../database/database_provider.dart'; +import 'dart:convert'; + +class AuthRoutes { + final DatabaseProvider database; + + AuthRoutes(this.database); + + Router get routes { + final router = Router(); + router.post('/login', _login); + router.get('/watch', _watch); + return router; + } + + Future _login(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + + final login = data['login']; + final password = data['password']; + + final user = await database.findUserByLogin(login); + + if (user == null || !BCrypt.checkpw(password, user.pwdHash)) { + return Response(401, body: 'Invalid credentials'); + } + + return Response(200, body: jsonEncode({'user': user.toMap()})); + } + + Future _watch(Request request) async { + final uniqueId = request.url.queryParameters['unique_id']; + + final userId = database.getUserIdByShareId(uniqueId!); + + if (userId == null) { + return Response(404, body: 'Share link not found'); + } + + final position = await database.getLatestPosition(userId); + + if (position == null) { + return Response(404, body: 'No position available'); + } + + return Response(200, body: position.toJson()); + } +} \ No newline at end of file diff --git a/bin/routes/geo_routes.dart b/bin/routes/geo_routes.dart new file mode 100644 index 0000000..0f3d808 --- /dev/null +++ b/bin/routes/geo_routes.dart @@ -0,0 +1,56 @@ +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database_provider.dart'; +import 'dart:convert'; + +class GeoRoutes { + final DatabaseProvider database; + + GeoRoutes(this.database); + + Router get routes { + final router = Router(); + router.post('/geo', _createPosition); + router.put('/geo', _updatePosition); + router.post('/share', _createShare); + return router; + } + + Future _createPosition(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + + final userId = data['user_id']; + final x = data['x']; + final y = data['y']; + final lifetimeSeconds = data['lifetime']; + final lifetime = Duration(seconds: lifetimeSeconds); + + final position = await database.createPosition(userId, x, y, lifetime); + return Response(201, body: position.toJson()); + } + + Future _updatePosition(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + + final userId = data['user_id']; + final x = data['x']; + final y = data['y']; + final lifetimeSeconds = data['lifetime']; + final lifetime = Duration(seconds: lifetimeSeconds); + + final position = await database.updatePosition(userId, x, y, lifetime); + return Response(200, body: position.toJson()); + } + + Future _createShare(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + + final userId = data['user_id']; + final shareId = database.createShareId(userId); + + return Response(200, body: jsonEncode({'share_id': shareId})); + } +} \ No newline at end of file diff --git a/bin/routes/user_routes.dart b/bin/routes/user_routes.dart new file mode 100644 index 0000000..0ca124a --- /dev/null +++ b/bin/routes/user_routes.dart @@ -0,0 +1,54 @@ +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import '../database/database_provider.dart'; +import 'dart:convert'; + +class UserRoutes { + final DatabaseProvider database; + + UserRoutes(this.database); + + Router get routes { + final router = Router(); + router.get('/user', _getAllUsers); + router.post('/user', _createUser); + router.put('/user/', _updateUser); + router.delete('/user/', _deleteUser); + return router; + } + + Future _getAllUsers(Request request) async { + final users = await database.getAllUsers(); + return Response(200, body: jsonEncode(users.map((u) => u.toMap()).toList())); + } + + Future _createUser(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + + final login = data['login']; + final password = data['password']; + + final user = await database.createUser(login, password); + return Response(201, body: jsonEncode(user.toMap())); + } + + Future _updateUser(Request request) async { + final id = int.parse(request.params['id']!); + + final body = await request.readAsString(); + final data = jsonDecode(body); + + final login = data['login']; + final password = data['password']; + + final user = await database.updateUser(id, login, password); + return Response(200, body: jsonEncode(user.toMap())); + } + + Future _deleteUser(Request request) async { + final id = int.parse(request.params['id']!); + await database.deleteUser(id); + return Response(204); + } +} \ No newline at end of file diff --git a/bin/server.dart b/bin/server.dart new file mode 100644 index 0000000..14ab860 --- /dev/null +++ b/bin/server.dart @@ -0,0 +1,39 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import 'database/database_provider.dart'; +import 'routes/auth_routes.dart'; +import 'routes/user_routes.dart'; +import 'routes/geo_routes.dart'; + +void main(List args) async { + final database = DatabaseProvider(); + await database.initialize(); + + Timer.periodic(const Duration(minutes: 5), (timer) { + database.cleanupExpired(); + }); + + final authRoutes = AuthRoutes(database); + final userRoutes = UserRoutes(database); + final geoRoutes = GeoRoutes(database); + + final router = Router() + ..mount('', authRoutes.routes.call) + ..mount('', userRoutes.routes.call) + ..mount('', geoRoutes.routes.call) + ..get('/', (Request req) => Response.ok('Family Safety Tracker API\n')); + + final handler = Pipeline() + .addMiddleware(logRequests()) + .addHandler(router.call); + + final ip = InternetAddress.anyIPv4; + final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final server = await serve(handler, ip, port); + print('Server listening on port ${server.port}'); +} \ No newline at end of file diff --git a/family_safety_tracker.iml b/family_safety_tracker.iml new file mode 100644 index 0000000..75734c9 --- /dev/null +++ b/family_safety_tracker.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..128248e --- /dev/null +++ b/opencode.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "llama.cpp": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-server (local)", + "options": { + "baseURL": "http://127.0.0.1:9988/v1" + }, + "models": { + "qwen3-coder:a3b": { + "name": "Qwen_Qwen3.5-9B-Q6_K (local)", + "limit": { + "context": 42000, + "output": 42000 + } + } + } + } + }, + "mcp": { + "IntelliJIdea": { + "type": "remote", + "url": "http://127.0.0.1:64342/stream", + "enabled": true + } + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d66b51a --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,517 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bcrypt: + dependency: "direct main" + description: + name: bcrypt + sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef" + url: "https://pub.dev" + source: hosted + version: "2.17.0" + dotenv: + dependency: "direct main" + description: + name: dotenv + sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: "direct dev" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + postgres: + dependency: "direct main" + description: + name: postgres + sha256: "3af8a28b89fef68ee5b26b4fd27254d5a286389e9c7fb6293a5f46e30490f800" + url: "https://pub.dev" + source: hosted + version: "3.5.10" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2f98b16 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,21 @@ +name: family_safety_tracker +description: A server app using the shelf package and Docker. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.10.1 + +dependencies: + shelf: ^1.4.2 + shelf_router: ^1.1.2 + dart_jsonwebtoken: ^2.16.0 + bcrypt: ^1.2.0 + dotenv: ^4.1.0 + postgres: ^3.5.10 + uuid: ^4.5.0 + +dev_dependencies: + http: ^1.2.2 + lints: ^6.0.0 + test: ^1.25.6 \ No newline at end of file diff --git a/test/server_test.dart b/test/server_test.dart new file mode 100644 index 0000000..3081d87 --- /dev/null +++ b/test/server_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +void main() { + final port = '8080'; + final host = 'http://0.0.0.0:$port'; + late Process p; + + setUp(() async { + 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; + }); + + tearDown(() => 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 { + final response = await get(Uri.parse('$host/echo/hello')); + expect(response.statusCode, 200); + expect(response.body, 'hello\n'); + }); + + test('404', () async { + final response = await get(Uri.parse('$host/foobar')); + expect(response.statusCode, 404); + }); +} diff --git a/test_bcrypt.dart b/test_bcrypt.dart new file mode 100644 index 0000000..2d29dc5 --- /dev/null +++ b/test_bcrypt.dart @@ -0,0 +1,5 @@ +import 'package:bcrypt/bcrypt.dart'; + +void main() { + print(BCrypt); +} \ No newline at end of file