diff --git a/www/api/database/schema.sql b/www/api/database/schema.sql new file mode 100644 index 0000000..a0abb8d --- /dev/null +++ b/www/api/database/schema.sql @@ -0,0 +1,249 @@ +-- Sozial Platform Database Schema +-- SQLite + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + bio TEXT, + avatar_url TEXT, + photos TEXT, -- JSON array + gender TEXT, + interested_in TEXT, -- JSON array + age INTEGER, + location TEXT, -- JSON object {city, lat, lng} + interests TEXT, -- JSON array + is_online INTEGER DEFAULT 0, + last_seen INTEGER, + settings TEXT, -- JSON object + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Profiles table (extended profile data) +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT, + birthday TEXT, + city TEXT, + country TEXT, + latitude REAL, + longitude REAL, + height_cm INTEGER, + body_type TEXT, + education TEXT, + occupation TEXT, + relationship_status TEXT, + looking_for TEXT, + about_me TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Posts table +CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', -- text|image|checkin + content TEXT, + media_urls TEXT, -- JSON array + location TEXT, -- JSON object {name, lat, lng} + likes_count INTEGER DEFAULT 0, + comments_count INTEGER DEFAULT 0, + shares_count INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Post likes table +CREATE TABLE IF NOT EXISTS post_likes ( + id TEXT PRIMARY KEY, + post_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(post_id, user_id) +); + +-- Post comments table +CREATE TABLE IF NOT EXISTS post_comments ( + id TEXT PRIMARY KEY, + post_id TEXT NOT NULL, + user_id TEXT NOT NULL, + parent_id TEXT, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES post_comments(id) ON DELETE CASCADE +); + +-- Relationships table (follows and friends) +CREATE TABLE IF NOT EXISTS relationships ( + id TEXT PRIMARY KEY, + follower_id TEXT NOT NULL, + following_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'follow', -- follow|friend + status TEXT DEFAULT 'active', -- active|blocked|pending + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(follower_id, following_id) +); + +-- Swipes table (for discovery feature) +CREATE TABLE IF NOT EXISTS swipes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + target_user_id TEXT NOT NULL, + direction TEXT NOT NULL, -- like|pass|super_like + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, target_user_id) +); + +-- Matches table +CREATE TABLE IF NOT EXISTS matches ( + id TEXT PRIMARY KEY, + user1_id TEXT NOT NULL, + user2_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user1_id, user2_id) +); + +-- Chats (conversations) table +CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'direct', -- direct|group + name TEXT, + avatar_url TEXT, + created_by TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Chat participants table +CREATE TABLE IF NOT EXISTS chat_participants ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + user_id TEXT NOT NULL, + joined_at INTEGER NOT NULL, + last_read_at INTEGER, + is_admin INTEGER DEFAULT 0, + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(chat_id, user_id) +); + +-- Chat messages table +CREATE TABLE IF NOT EXISTS chat_messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT DEFAULT 'text', -- text|image|system + media_url TEXT, + read_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE, + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Events table +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + creator_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + event_type TEXT DEFAULT 'meetup', -- meetup|party|date|other + location_name TEXT, + location_address TEXT, + latitude REAL, + longitude REAL, + start_date INTEGER, + end_date INTEGER, + max_participants INTEGER, + is_public INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Event participants table +CREATE TABLE IF NOT EXISTS event_participants ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + user_id TEXT NOT NULL, + status TEXT DEFAULT 'going', -- going|maybe|not_going + created_at INTEGER NOT NULL, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(event_id, user_id) +); + +-- Notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, -- friend_request|friend_accepted|post_liked|post_commented|message|match|system + title TEXT NOT NULL, + body TEXT, + data TEXT, -- JSON object with additional data + is_read INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Media uploads table +CREATE TABLE IF NOT EXISTS media ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + filename TEXT NOT NULL, + original_name TEXT, + mime_type TEXT, + size INTEGER, + url TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Refresh tokens table for JWT +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Indexes for better performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); +CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); +CREATE INDEX IF NOT EXISTS idx_relationships_follower ON relationships(follower_id); +CREATE INDEX IF NOT EXISTS idx_relationships_following ON relationships(following_id); +CREATE INDEX IF NOT EXISTS idx_chat_messages_chat_id ON chat_messages(chat_id); +CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at); +CREATE INDEX IF NOT EXISTS idx_swipes_user_id ON swipes(user_id); +CREATE INDEX IF NOT EXISTS idx_matches_user1 ON matches(user1_id); +CREATE INDEX IF NOT EXISTS idx_matches_user2 ON matches(user2_id); diff --git a/www/api/public/index.php b/www/api/public/index.php index 3509002..d8d0f1f 100644 --- a/www/api/public/index.php +++ b/www/api/public/index.php @@ -1,20 +1,102 @@ 'ok', 'message' => 'API läuft']); - break; - case 'POST': - $input = json_decode(file_get_contents('php://input'), true); - echo json_encode(['status' => 'received', 'data' => $input]); - break; - default: - http_response_code(405); - echo json_encode(['error' => 'Method not allowed']); +// Autoloader +spl_autoload_register(function ($class) { + // Convert namespace to file path + $prefixes = [ + 'Core\\' => __DIR__ . '/../src/core/', + 'Modules\\' => __DIR__ . '/../src/modules/', + 'Services\\' => __DIR__ . '/../src/services/', + 'Utils\\' => __DIR__ . '/../src/utils/', + 'Middleware\\' => __DIR__ . '/../src/core/', + ]; + + foreach ($prefixes as $prefix => $baseDir) { + $len = strlen($prefix); + if (strncmp($prefix, $class, $len) === 0) { + $relativeClass = substr($class, $len); + $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; + if (file_exists($file)) { + require $file; + return; + } + } + } +}); + +// Load configurations +$dbConfig = require __DIR__ . '/../src/config/database.php'; +$jwtConfig = require __DIR__ . '/../src/config/jwt.php'; +$appConfig = require __DIR__ . '/../src/config/app.php'; + +// Initialize database +use Core\Database; +Database::init($dbConfig); + +// Initialize database schema if tables don't exist +$pdo = Database::getInstance(); +$stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'"); +if (!$stmt->fetch()) { + $schemaPath = __DIR__ . '/../database/schema.sql'; + if (file_exists($schemaPath)) { + Database::initialize($schemaPath); + } } + +// Initialize JWT service +use Services\JwtService; +$jwtService = new JwtService($jwtConfig); + +// Initialize auth middleware +use Middleware\Auth; +Auth::setJwtService($jwtService); + +// Create request +use Core\Request; +$request = new Request(); + +// Create router +use Core\Router; +$router = new Router(); + +// Load controllers +use Modules\Auth\AuthController; +use Modules\User\UserController; + +$authController = new AuthController(Database::getInstance(), $jwtService); +$userController = new UserController(Database::getInstance()); + +// Auth routes +$router->post('/auth/register', fn($req) => $authController->register($req)); +$router->post('/auth/login', fn($req) => $authController->login($req)); +$router->post('/auth/refresh', fn($req) => $authController->refresh($req)); +$router->post('/auth/logout', fn($req) => $authController->logout($req), ['Auth']); + +// User routes +$router->get('/users/search', fn($req) => $userController->search($req)); +$router->get('/users/{id}', fn($req, $params) => $userController->show($req, $params)); +$router->patch('/users/{id}', fn($req, $params) => $userController->update($req, $params), ['Auth']); +$router->delete('/users/{id}', fn($req, $params) => $userController->destroy($req, $params), ['Auth']); +$router->get('/users/{id}/posts', fn($req, $params) => $userController->posts($req, $params)); +$router->get('/users/{id}/friends', fn($req, $params) => $userController->friends($req, $params)); +$router->get('/users/{id}/followers', fn($req, $params) => $userController->followers($req, $params)); +$router->get('/users/{id}/following', fn($req, $params) => $userController->following($req, $params)); + +// Health check +$router->get('/health', function($req) { + \Core\Response::success(['status' => 'ok', 'service' => 'sozial-api']); +}); + +// Dispatch request +$router->dispatch($request); diff --git a/www/api/src/config/app.php b/www/api/src/config/app.php new file mode 100644 index 0000000..963683b --- /dev/null +++ b/www/api/src/config/app.php @@ -0,0 +1,13 @@ + 'Sozial', + 'env' => $_ENV['APP_ENV'] ?? 'development', + 'debug' => $_ENV['APP_DEBUG'] ?? true, + 'url' => 'https://api.sozial.shadow-land.de', + 'version' => 'v1', + 'timezone' => 'UTC', +]; diff --git a/www/api/src/config/database.php b/www/api/src/config/database.php new file mode 100644 index 0000000..6d69d1a --- /dev/null +++ b/www/api/src/config/database.php @@ -0,0 +1,14 @@ + 'sqlite', + 'database' => __DIR__ . '/../../database/sozial.db', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; diff --git a/www/api/src/config/jwt.php b/www/api/src/config/jwt.php new file mode 100644 index 0000000..99e5c0e --- /dev/null +++ b/www/api/src/config/jwt.php @@ -0,0 +1,11 @@ + $_ENV['JWT_SECRET'] ?? 'sozial-dev-secret-change-in-production', + 'algorithm' => 'HS256', + 'access_token_ttl' => 86400, // 24 hours in seconds + 'refresh_token_ttl' => 2592000, // 30 days in seconds +]; diff --git a/www/api/src/core/Database.php b/www/api/src/core/Database.php new file mode 100644 index 0000000..0196c7d --- /dev/null +++ b/www/api/src/core/Database.php @@ -0,0 +1,78 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + } + + public static function getInstance(): PDO + { + if (self::$instance === null) { + try { + self::$instance = new PDO(self::$dsn, null, null, self::$options); + + // Enable foreign keys for SQLite + if (strpos(self::$dsn, 'sqlite') === 0) { + self::$instance->exec('PRAGMA foreign_keys = ON'); + } + } catch (PDOException $e) { + throw new \Exception("Database connection failed: " . $e->getMessage()); + } + } + + return self::$instance; + } + + public static function close(): void + { + self::$instance = null; + } + + /** + * Initialize database with schema + */ + public static function initialize(string $schemaPath): void + { + $pdo = self::getInstance(); + $schema = file_get_contents($schemaPath); + + // SQLite doesn't support multiple statements in exec, so we need to split + $statements = array_filter( + array_map('trim', explode(';', $schema)), + fn($s) => !empty($s) && strpos($s, '--') !== 0 + ); + + foreach ($statements as $statement) { + $pdo->exec($statement); + } + } +} diff --git a/www/api/src/core/Middleware.php b/www/api/src/core/Middleware.php new file mode 100644 index 0000000..97602f6 --- /dev/null +++ b/www/api/src/core/Middleware.php @@ -0,0 +1,53 @@ +getAuthToken(); + + if (!$token) { + Response::unauthorized('No token provided'); + return false; + } + + $payload = self::$jwtService->validateToken($token); + + if (!$payload) { + Response::unauthorized('Invalid or expired token'); + return false; + } + + if (($payload['type'] ?? '') !== 'access') { + Response::unauthorized('Invalid token type'); + return false; + } + + // Attach user ID to request for later use + $request->userId = $payload['sub']; + + return true; + } +} diff --git a/www/api/src/core/Request.php b/www/api/src/core/Request.php new file mode 100644 index 0000000..16708d7 --- /dev/null +++ b/www/api/src/core/Request.php @@ -0,0 +1,86 @@ +method = $_SERVER['REQUEST_METHOD']; + $this->path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $this->query = $_GET; + + // Parse body + $rawBody = file_get_contents('php://input'); + $this->body = json_decode($rawBody, true) ?? []; + + // Capture headers + $this->headers = $this->parseHeaders(); + } + + private function parseHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (strpos($key, 'HTTP_') === 0) { + $header = str_replace('_', '-', substr($key, 5)); + $headers[strtolower($header)] = $value; + } + } + return $headers; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->query; + } + return $this->query[$key] ?? $default; + } + + public function getBody(string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->body; + } + return $this->body[$key] ?? $default; + } + + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } + + public function getAuthToken(): ?string + { + $auth = $this->getHeader('authorization'); + if ($auth && preg_match('/Bearer\s+(.+)/i', $auth, $matches)) { + return $matches[1]; + } + return null; + } + + public function getClientIp(): string + { + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } +} diff --git a/www/api/src/core/Response.php b/www/api/src/core/Response.php new file mode 100644 index 0000000..ad03328 --- /dev/null +++ b/www/api/src/core/Response.php @@ -0,0 +1,60 @@ + true, + 'message' => $message, + 'data' => $data + ], $status); + } + + public static function error(string $message, int $status = 400, mixed $errors = null): void + { + $response = [ + 'success' => false, + 'error' => $message + ]; + + if ($errors !== null) { + $response['errors'] = $errors; + } + + self::json($response, $status); + } + + public static function notFound(string $message = 'Resource not found'): void + { + self::error($message, 404); + } + + public static function unauthorized(string $message = 'Unauthorized'): void + { + self::error($message, 401); + } + + public static function forbidden(string $message = 'Forbidden'): void + { + self::error($message, 403); + } + + public static function validationError(mixed $errors): void + { + self::error('Validation failed', 422, $errors); + } +} diff --git a/www/api/src/core/Router.php b/www/api/src/core/Router.php new file mode 100644 index 0000000..cf564c0 --- /dev/null +++ b/www/api/src/core/Router.php @@ -0,0 +1,104 @@ +addRoute('GET', $path, $handler, $middleware); + return $this; + } + + public function post(string $path, callable $handler, array $middleware = []): self + { + $this->addRoute('POST', $path, $handler, $middleware); + return $this; + } + + public function put(string $path, callable $handler, array $middleware = []): self + { + $this->addRoute('PUT', $path, $handler, $middleware); + return $this; + } + + public function patch(string $path, callable $handler, array $middleware = []): self + { + $this->addRoute('PATCH', $path, $handler, $middleware); + return $this; + } + + public function delete(string $path, callable $handler, array $middleware = []): self + { + $this->addRoute('DELETE', $path, $handler, $middleware); + return $this; + } + + private function addRoute(string $method, string $path, callable $handler, array $middleware): void + { + // Convert {param} to regex + $pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $path); + $pattern = '#^' . $pattern . '$#'; + + $this->routes[] = [ + 'method' => $method, + 'path' => $path, + 'pattern' => $pattern, + 'handler' => $handler, + 'middleware' => $middleware + ]; + } + + public function dispatch(Request $request): void + { + $method = $request->getMethod(); + $path = $request->getPath(); + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + if (preg_match($route['pattern'], $path, $matches)) { + // Extract route params + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + + // Run middleware + foreach ($route['middleware'] as $middleware) { + if (!$this->runMiddleware($middleware, $params)) { + return; + } + } + + // Call handler with params + $handler = $route['handler']; + $handler($request, $params); + return; + } + } + + Response::notFound('Endpoint not found'); + } + + private function runMiddleware(string|array $middleware, array $params): bool + { + if (is_string($middleware)) { + // Load middleware class + $middlewareClass = "Middleware\\{$middleware}"; + if (class_exists($middlewareClass)) { + $instance = new $middlewareClass(); + return $instance->handle(); + } + } elseif (is_array($middleware)) { + // Closure middleware + return $middleware($params); + } + return true; + } +} diff --git a/www/api/src/modules/auth/AuthController.php b/www/api/src/modules/auth/AuthController.php new file mode 100644 index 0000000..6c3a065 --- /dev/null +++ b/www/api/src/modules/auth/AuthController.php @@ -0,0 +1,252 @@ +db = $db; + $this->jwtService = $jwtService; + } + + /** + * POST /auth/register + */ + public function register(Request $request): void + { + $email = $request->getBody('email'); + $password = $request->getBody('password'); + $username = $request->getBody('username'); + + // Validation + $errors = []; + + if (!$email || !Helpers::isValidEmail($email)) { + $errors['email'] = 'Valid email is required'; + } + + if (!$password || strlen($password) < 6) { + $errors['password'] = 'Password must be at least 6 characters'; + } + + if (!$username || !Helpers::isValidUsername($username)) { + $errors['username'] = 'Username must be 3-30 alphanumeric characters'; + } + + if (!empty($errors)) { + Response::validationError($errors); + } + + $pdo = $this->db->getInstance(); + + // Check if email exists + $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?'); + $stmt->execute([$email]); + if ($stmt->fetch()) { + Response::validationError(['email' => 'Email already registered']); + } + + // Check if username exists + $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ($stmt->fetch()) { + Response::validationError(['username' => 'Username already taken']); + } + + // Create user + $userId = Helpers::generateUUID(); + $now = Helpers::timestamp(); + $passwordHash = Helpers::hashPassword($password); + + $stmt = $pdo->prepare(' + INSERT INTO users (id, email, password_hash, username, display_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $userId, + $email, + $passwordHash, + $username, + $username, // display_name defaults to username + $now, + $now + ]); + + // Generate tokens + $accessToken = $this->jwtService->generateAccessToken($userId); + $refreshToken = $this->jwtService->generateRefreshToken($userId); + + // Store refresh token + $this->storeRefreshToken($userId, $refreshToken); + + Response::success([ + 'user' => [ + 'id' => $userId, + 'email' => $email, + 'username' => $username + ], + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_type' => 'Bearer' + ], 'Registration successful', 201); + } + + /** + * POST /auth/login + */ + public function login(Request $request): void + { + $email = $request->getBody('email'); + $password = $request->getBody('password'); + + if (!$email || !$password) { + Response::validationError([ + 'email' => 'Email is required', + 'password' => 'Password is required' + ]); + } + + $pdo = $this->db->getInstance(); + + // Find user + $stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?'); + $stmt->execute([$email]); + $user = $stmt->fetch(); + + if (!$user || !Helpers::verifyPassword($password, $user['password_hash'])) { + Response::unauthorized('Invalid email or password'); + } + + // Update last seen + $stmt = $pdo->prepare('UPDATE users SET last_seen = ?, is_online = 1 WHERE id = ?'); + $stmt->execute([Helpers::timestamp(), $user['id']]); + + // Generate tokens + $accessToken = $this->jwtService->generateAccessToken($user['id']); + $refreshToken = $this->jwtService->generateRefreshToken($user['id']); + + // Store refresh token + $this->storeRefreshToken($user['id'], $refreshToken); + + Response::success([ + 'user' => [ + 'id' => $user['id'], + 'email' => $user['email'], + 'username' => $user['username'], + 'display_name' => $user['display_name'], + 'avatar_url' => $user['avatar_url'] + ], + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_type' => 'Bearer' + ], 'Login successful'); + } + + /** + * POST /auth/refresh + */ + public function refresh(Request $request): void + { + $refreshToken = $request->getBody('refresh_token'); + + if (!$refreshToken) { + Response::validationError(['refresh_token' => 'Refresh token is required']); + } + + $payload = $this->jwtService->validateToken($refreshToken); + + if (!$payload || ($payload['type'] ?? '') !== 'refresh') { + Response::unauthorized('Invalid refresh token'); + } + + $userId = $payload['sub']; + $pdo = $this->db->getInstance(); + + // Verify refresh token exists and is valid + $stmt = $pdo->prepare(' + SELECT * FROM refresh_tokens + WHERE user_id = ? AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1 + '); + $stmt->execute([$userId]); + $tokenRecord = $stmt->fetch(); + + if (!$tokenRecord || Helpers::isExpired($tokenRecord['expires_at'])) { + Response::unauthorized('Refresh token expired or revoked'); + } + + // Generate new tokens + $accessToken = $this->jwtService->generateAccessToken($userId); + $newRefreshToken = $this->jwtService->generateRefreshToken($userId); + + // Revoke old refresh token + $stmt = $pdo->prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?'); + $stmt->execute([Helpers::timestamp(), $tokenRecord['id']]); + + // Store new refresh token + $this->storeRefreshToken($userId, $newRefreshToken); + + Response::success([ + 'access_token' => $accessToken, + 'refresh_token' => $newRefreshToken, + 'token_type' => 'Bearer' + ], 'Token refreshed'); + } + + /** + * POST /auth/logout + */ + public function logout(Request $request): void + { + $refreshToken = $request->getBody('refresh_token'); + + if ($refreshToken && isset($request->userId)) { + $pdo = $this->db->getInstance(); + $stmt = $pdo->prepare(' + UPDATE refresh_tokens + SET revoked_at = ? + WHERE user_id = ? AND revoked_at IS NULL + '); + $stmt->execute([Helpers::timestamp(), $request->userId]); + + // Set user offline + $stmt = $pdo->prepare('UPDATE users SET is_online = 0 WHERE id = ?'); + $stmt->execute([$request->userId]); + } + + Response::success(null, 'Logged out successfully'); + } + + /** + * Store refresh token in database + */ + private function storeRefreshToken(string $userId, string $token): void + { + $pdo = $this->db->getInstance(); + $stmt = $pdo->prepare(' + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + '); + + $stmt->execute([ + Helpers::generateUUID(), + $userId, + hash('sha256', $token), + Helpers::getExpiration(2592000), // 30 days + Helpers::timestamp() + ]); + } +} diff --git a/www/api/src/modules/user/UserController.php b/www/api/src/modules/user/UserController.php new file mode 100644 index 0000000..cb2e099 --- /dev/null +++ b/www/api/src/modules/user/UserController.php @@ -0,0 +1,353 @@ +db = $db; + } + + /** + * GET /users/:id + */ + public function show(Request $request, array $params): void + { + $userId = $params['id'] ?? $request->userId ?? null; + + if (!$userId) { + Response::unauthorized(); + return; + } + + $pdo = $this->db->getInstance(); + + $stmt = $pdo->prepare(' + SELECT id, username, email, display_name, bio, avatar_url, photos, + gender, interested_in, age, location, interests, is_online, + last_seen, settings, created_at + FROM users WHERE id = ? + '); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user) { + Response::notFound('User not found'); + return; + } + + // Parse JSON fields + $user['photos'] = Helpers::parseJson($user['photos'], []); + $user['interested_in'] = Helpers::parseJson($user['interested_in'], []); + $user['location'] = Helpers::parseJson($user['location'], null); + $user['interests'] = Helpers::parseJson($user['interests'], []); + $user['settings'] = Helpers::parseJson($user['settings'], []); + $user['is_online'] = (bool) $user['is_online']; + + // Check if own profile to show full data + $isOwnProfile = isset($request->userId) && $request->userId === $userId; + + if (!$isOwnProfile) { + // Hide sensitive data for other users + unset($user['email']); + unset($user['settings']); + } + + Response::success(['user' => $user]); + } + + /** + * PATCH /users/:id + */ + public function update(Request $request, array $params): void + { + $userId = $params['id'] ?? $request->userId ?? null; + + if (!$userId) { + Response::unauthorized(); + return; + } + + // Can only update own profile + if (!isset($request->userId) || $request->userId !== $userId) { + Response::forbidden('You can only update your own profile'); + return; + } + + $body = $request->getBody(); + $pdo = $this->db->getInstance(); + + // Build update query dynamically + $allowedFields = [ + 'display_name', 'bio', 'avatar_url', 'photos', 'gender', + 'interested_in', 'age', 'location', 'interests', 'settings' + ]; + + $updates = []; + $values = []; + + foreach ($allowedFields as $field) { + if (isset($body[$field])) { + $value = $body[$field]; + + // Encode arrays to JSON + if (in_array($field, ['photos', 'interested_in', 'interests'])) { + $value = Helpers::jsonEncode($value); + } elseif ($field === 'location') { + $value = Helpers::jsonEncode($value); + } elseif ($field === 'settings') { + $value = Helpers::jsonEncode($value); + } elseif ($field === 'bio') { + $value = substr($value, 0, 500); // Max 500 chars + } elseif ($field === 'age') { + $value = (int) $value; + if ($value < 18 || $value > 120) { + Response::validationError(['age' => 'Age must be between 18 and 120']); + return; + } + } + + $updates[] = "{$field} = ?"; + $values[] = $value; + } + } + + if (empty($updates)) { + Response::validationError(['general' => 'No valid fields to update']); + return; + } + + $updates[] = 'updated_at = ?'; + $values[] = Helpers::timestamp(); + $values[] = $userId; + + $sql = 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($values); + + // Fetch updated user + $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + // Parse JSON fields + $user['photos'] = Helpers::parseJson($user['photos'], []); + $user['interested_in'] = Helpers::parseJson($user['interested_in'], []); + $user['location'] = Helpers::parseJson($user['location'], null); + $user['interests'] = Helpers::parseJson($user['interests'], []); + $user['settings'] = Helpers::parseJson($user['settings'], []); + + Response::success(['user' => $user], 'Profile updated successfully'); + } + + /** + * DELETE /users/:id + */ + public function destroy(Request $request, array $params): void + { + $userId = $params['id'] ?? $request->userId ?? null; + + if (!$userId) { + Response::unauthorized(); + return; + } + + // Can only delete own account + if (!isset($request->userId) || $request->userId !== $userId) { + Response::forbidden('You can only delete your own account'); + return; + } + + $pdo = $this->db->getInstance(); + + // Delete user (cascade will handle related records) + $stmt = $pdo->prepare('DELETE FROM users WHERE id = ?'); + $stmt->execute([$userId]); + + Response::success(null, 'Account deleted successfully'); + } + + /** + * GET /users/search + */ + public function search(Request $request): void + { + $query = $request->getQuery('q', ''); + $limit = min((int) $request->getQuery('limit', 20), 100); + $offset = (int) $request->getQuery('offset', 0); + + if (strlen($query) < 2) { + Response::validationError(['q' => 'Search query must be at least 2 characters']); + return; + } + + $pdo = $this->db->getInstance(); + $searchTerm = "%{$query}%"; + + $stmt = $pdo->prepare(' + SELECT id, username, display_name, bio, avatar_url, age, location, interests, is_online + FROM users + WHERE (username LIKE ? OR display_name LIKE ?) + LIMIT ? OFFSET ? + '); + $stmt->execute([$searchTerm, $searchTerm, $limit, $offset]); + $users = $stmt->fetchAll(); + + // Parse JSON fields for each user + foreach ($users as &$user) { + $user['location'] = Helpers::parseJson($user['location'], null); + $user['interests'] = Helpers::parseJson($user['interests'], []); + $user['is_online'] = (bool) $user['is_online']; + } + + Response::success([ + 'users' => $users, + 'limit' => $limit, + 'offset' => $offset + ]); + } + + /** + * GET /users/:id/posts + */ + public function posts(Request $request, array $params): void + { + $userId = $params['id'] ?? null; + + if (!$userId) { + Response::notFound('User ID required'); + return; + } + + $limit = min((int) $request->getQuery('limit', 20), 100); + $offset = (int) $request->getQuery('offset', 0); + + $pdo = $this->db->getInstance(); + + $stmt = $pdo->prepare(' + SELECT * FROM posts + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + '); + $stmt->execute([$userId, $limit, $offset]); + $posts = $stmt->fetchAll(); + + // Parse JSON fields + foreach ($posts as &$post) { + $post['media_urls'] = Helpers::parseJson($post['media_urls'], []); + $post['location'] = Helpers::parseJson($post['location'], null); + } + + Response::success([ + 'posts' => $posts, + 'limit' => $limit, + 'offset' => $offset + ]); + } + + /** + * GET /users/:id/friends + */ + public function friends(Request $request, array $params): void + { + $userId = $params['id'] ?? null; + + if (!$userId) { + Response::notFound('User ID required'); + return; + } + + $pdo = $this->db->getInstance(); + + // Get mutual friends (where both users follow each other) + $stmt = $pdo->prepare(' + SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online + FROM users u + INNER JOIN relationships r1 ON u.id = r1.following_id + INNER JOIN relationships r2 ON u.id = r2.follower_id + WHERE r1.follower_id = ? AND r2.following_id = ? + AND r1.type = "friend" AND r2.type = "friend" + AND r1.status = "active" AND r2.status = "active" + '); + $stmt->execute([$userId, $userId]); + $friends = $stmt->fetchAll(); + + foreach ($friends as &$friend) { + $friend['is_online'] = (bool) $friend['is_online']; + } + + Response::success(['friends' => $friends]); + } + + /** + * GET /users/:id/followers + */ + public function followers(Request $request, array $params): void + { + $userId = $params['id'] ?? null; + + if (!$userId) { + Response::notFound('User ID required'); + return; + } + + $pdo = $this->db->getInstance(); + + $stmt = $pdo->prepare(' + SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online + FROM users u + INNER JOIN relationships r ON u.id = r.follower_id + WHERE r.following_id = ? AND r.status = "active" + '); + $stmt->execute([$userId]); + $followers = $stmt->fetchAll(); + + foreach ($followers as &$follower) { + $follower['is_online'] = (bool) $follower['is_online']; + } + + Response::success(['followers' => $followers]); + } + + /** + * GET /users/:id/following + */ + public function following(Request $request, array $params): void + { + $userId = $params['id'] ?? null; + + if (!$userId) { + Response::notFound('User ID required'); + return; + } + + $pdo = $this->db->getInstance(); + + $stmt = $pdo->prepare(' + SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online + FROM users u + INNER JOIN relationships r ON u.id = r.following_id + WHERE r.follower_id = ? AND r.status = "active" + '); + $stmt->execute([$userId]); + $following = $stmt->fetchAll(); + + foreach ($following as &$user) { + $user['is_online'] = (bool) $user['is_online']; + } + + Response::success(['following' => $following]); + } +} diff --git a/www/api/src/services/JWT.php b/www/api/src/services/JWT.php new file mode 100644 index 0000000..6ed3795 --- /dev/null +++ b/www/api/src/services/JWT.php @@ -0,0 +1,131 @@ + 'JWT', 'alg' => $algorithm]; + + $segments = []; + $segments[] = self::base64UrlEncode(self::jsonEncode($header)); + $segments[] = self::base64UrlEncode(self::jsonEncode($payload)); + + $signingInput = implode('.', $segments); + $signature = self::sign($signingInput, $key, $algorithm); + $segments[] = self::base64UrlEncode($signature); + + return implode('.', $segments); + } + + public static function decode(string $jwt, Key $key): object + { + $tks = explode('.', $jwt); + + if (count($tks) !== 3) { + throw new \UnexpectedValueException('Invalid token structure'); + } + + list($headb64, $bodyb64, $cryptob64) = $tks; + + $header = self::jsonDecode(self::base64UrlDecode($headb64)); + if (!$header) { + throw new \UnexpectedValueException('Invalid header encoding'); + } + + $payload = self::jsonDecode(self::base64UrlDecode($bodyb64)); + if (!$payload) { + throw new \UnexpectedValueException('Invalid payload encoding'); + } + + $sig = self::base64UrlDecode($cryptob64); + + if (isset($key->getKeyType()['kty']) && $key->getKeyType()['kty'] === 'RSA') { + throw new \RuntimeException('RSA not implemented, use HS256'); + } + + $sigInput = $headb64 . '.' . $bodyb64; + $algorithm = $header->alg ?? 'HS256'; + + if (!self::verify($sigInput, $sig, $key->getKeyMaterial(), $algorithm)) { + throw new \UnexpectedValueException('Signature verification failed'); + } + + // Check expiration + if (isset($payload->exp) && $payload->exp < time()) { + throw new \UnexpectedValueException('Token has expired'); + } + + return $payload; + } + + private static function sign(string $input, string $key, string $algorithm): string + { + return hash_hmac('sha256', $input, $key, true); + } + + private static function verify(string $input, string $signature, string $key, string $algorithm): bool + { + $expected = hash_hmac('sha256', $input, $key, true); + return hash_equals($expected, $signature); + } + + private static function jsonEncode($data): string + { + return json_encode($data, JSON_UNESCAPED_UNICODE); + } + + private static function jsonDecode(string $data): ?object + { + return json_decode($data); + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $data): string + { + $remainder = strlen($data) % 4; + if ($remainder) { + $data .= str_repeat('=', 4 - $remainder); + } + return base64_decode(strtr($data, '-_', '+/')); + } +} + +class Key +{ + private string $keyMaterial; + private string $algorithm; + + public function __construct(string $keyMaterial, string $algorithm) + { + $this->keyMaterial = $keyMaterial; + $this->algorithm = $algorithm; + } + + public function getKeyMaterial(): string + { + return $this->keyMaterial; + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function getKeyType(): array + { + return ['kty' => 'oct']; + } +} diff --git a/www/api/src/services/JwtService.php b/www/api/src/services/JwtService.php new file mode 100644 index 0000000..e865971 --- /dev/null +++ b/www/api/src/services/JwtService.php @@ -0,0 +1,97 @@ +secret = $config['secret']; + $this->algorithm = $config['algorithm']; + $this->accessTokenTtl = $config['access_token_ttl']; + $this->refreshTokenTtl = $config['refresh_token_ttl']; + } + + /** + * Generate access token + */ + public function generateAccessToken(string $userId, array $additionalClaims = []): string + { + $now = Helpers::timestamp(); + $payload = array_merge([ + 'iss' => 'sozial-api', + 'sub' => $userId, + 'iat' => $now, + 'exp' => Helpers::getExpiration($this->accessTokenTtl), + 'type' => 'access' + ], $additionalClaims); + + return JWT::encode($payload, $this->secret, $this->algorithm); + } + + /** + * Generate refresh token + */ + public function generateRefreshToken(string $userId): string + { + $now = Helpers::timestamp(); + $payload = [ + 'iss' => 'sozial-api', + 'sub' => $userId, + 'iat' => $now, + 'exp' => Helpers::getExpiration($this->refreshTokenTtl), + 'type' => 'refresh', + 'jti' => Helpers::generateUUID() + ]; + + return JWT::encode($payload, $this->secret, $this->algorithm); + } + + /** + * Validate and decode token + */ + public function validateToken(string $token): ?array + { + try { + $decoded = JWT::decode($token, new Key($this->secret, $this->algorithm)); + return (array) $decoded; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get user ID from token + */ + public function getUserIdFromToken(string $token): ?string + { + $payload = $this->validateToken($token); + return $payload['sub'] ?? null; + } + + /** + * Check if token is expired + */ + public function isTokenExpired(string $token): bool + { + $payload = $this->validateToken($token); + if (!$payload) { + return true; + } + return Helpers::isExpired($payload['exp']); + } +} diff --git a/www/api/src/utils/helpers.php b/www/api/src/utils/helpers.php new file mode 100644 index 0000000..485c155 --- /dev/null +++ b/www/api/src/utils/helpers.php @@ -0,0 +1,101 @@ + 12]); + } + + /** + * Verify password + */ + public static function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Validate email format + */ + public static function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * Validate username format (alphanumeric, underscore, 3-30 chars) + */ + public static function isValidUsername(string $username): bool + { + return preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username) === 1; + } + + /** + * Sanitize string input + */ + public static function sanitize(string $input): string + { + return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); + } + + /** + * Parse JSON string safely + */ + public static function parseJson(string $json, mixed $default = null): mixed + { + $result = json_decode($json, true); + return json_last_error() === JSON_ERROR_NONE ? $result : $default; + } + + /** + * Encode to JSON safely + */ + public static function jsonEncode(mixed $data): string + { + return json_encode($data, JSON_UNESCAPED_UNICODE); + } + + /** + * Get expiration timestamp + */ + public static function getExpiration(int $ttl): int + { + return self::timestamp() + $ttl; + } + + /** + * Check if timestamp is expired + */ + public static function isExpired(int $timestamp): bool + { + return $timestamp < self::timestamp(); + } +}