Phase 1: Foundation - Database schema, core PHP structure, JWT auth, and User CRUD
Implemented: - Database schema (SQLite) with tables: users, profiles, posts, swipes, matches, chats, chat_messages, events, notifications, relationships, post_likes, post_comments, refresh_tokens, media - Core PHP structure following skeleton pattern (www/api/) - JWT Authentication (register, login, refresh, logout) - User profile CRUD (show, update, delete, search) - User sub-resources (posts, friends, followers, following) - Minimal Firebase JWT implementation for PHP without Composer
This commit is contained in:
249
www/api/database/schema.sql
Normal file
249
www/api/database/schema.sql
Normal file
@@ -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);
|
||||
@@ -1,20 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* api.domain.de — REST API
|
||||
* Sozial API - Entry Point
|
||||
*
|
||||
* This is the main entry point for the Sozial REST API.
|
||||
* All requests are routed through this file.
|
||||
*/
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
// Error reporting for development
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
echo json_encode(['status' => '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);
|
||||
|
||||
13
www/api/src/config/app.php
Normal file
13
www/api/src/config/app.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* Application Configuration
|
||||
*/
|
||||
|
||||
return [
|
||||
'name' => 'Sozial',
|
||||
'env' => $_ENV['APP_ENV'] ?? 'development',
|
||||
'debug' => $_ENV['APP_DEBUG'] ?? true,
|
||||
'url' => 'https://api.sozial.shadow-land.de',
|
||||
'version' => 'v1',
|
||||
'timezone' => 'UTC',
|
||||
];
|
||||
14
www/api/src/config/database.php
Normal file
14
www/api/src/config/database.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Configuration
|
||||
*/
|
||||
|
||||
return [
|
||||
'driver' => '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,
|
||||
],
|
||||
];
|
||||
11
www/api/src/config/jwt.php
Normal file
11
www/api/src/config/jwt.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* JWT Configuration
|
||||
*/
|
||||
|
||||
return [
|
||||
'secret' => $_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
|
||||
];
|
||||
78
www/api/src/core/Database.php
Normal file
78
www/api/src/core/Database.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Connection Singleton
|
||||
*/
|
||||
|
||||
namespace Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
private static string $dsn;
|
||||
private static array $options;
|
||||
|
||||
public static function init(array $config): void
|
||||
{
|
||||
$driver = $config['driver'] ?? 'sqlite';
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$dbPath = $config['database'];
|
||||
// Ensure directory exists
|
||||
$dir = dirname($dbPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
self::$dsn = "sqlite:{$dbPath}";
|
||||
}
|
||||
|
||||
self::$options = $config['options'] ?? [
|
||||
PDO::ATTR_ERRMODE => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
www/api/src/core/Middleware.php
Normal file
53
www/api/src/core/Middleware.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentication Middleware
|
||||
*/
|
||||
|
||||
namespace Middleware;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Services\JwtService;
|
||||
|
||||
class Auth
|
||||
{
|
||||
private static ?JwtService $jwtService = null;
|
||||
|
||||
public static function setJwtService(JwtService $service): void
|
||||
{
|
||||
self::$jwtService = $service;
|
||||
}
|
||||
|
||||
public static function handle(Request $request): bool
|
||||
{
|
||||
if (self::$jwtService === null) {
|
||||
// Load config if not set
|
||||
$config = require __DIR__ . '/../config/jwt.php';
|
||||
self::$jwtService = new JwtService($config);
|
||||
}
|
||||
|
||||
$token = $request->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;
|
||||
}
|
||||
}
|
||||
86
www/api/src/core/Request.php
Normal file
86
www/api/src/core/Request.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* HTTP Request Helper
|
||||
*/
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Request
|
||||
{
|
||||
private array $body;
|
||||
private array $headers;
|
||||
private string $method;
|
||||
private string $path;
|
||||
private array $query;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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';
|
||||
}
|
||||
}
|
||||
60
www/api/src/core/Response.php
Normal file
60
www/api/src/core/Response.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* HTTP Response Helper
|
||||
*/
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function json(mixed $data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'OK', int $status = 200): void
|
||||
{
|
||||
self::json([
|
||||
'success' => 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);
|
||||
}
|
||||
}
|
||||
104
www/api/src/core/Router.php
Normal file
104
www/api/src/core/Router.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple Router
|
||||
*/
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private array $middlewares = [];
|
||||
|
||||
public function get(string $path, callable $handler, array $middleware = []): self
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
252
www/api/src/modules/auth/AuthController.php
Normal file
252
www/api/src/modules/auth/AuthController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentication Controller
|
||||
*/
|
||||
|
||||
namespace Modules\Auth;
|
||||
|
||||
use Core\Database;
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Utils\Helpers;
|
||||
use Services\JwtService;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
private Database $db;
|
||||
private JwtService $jwtService;
|
||||
|
||||
public function __construct(Database $db, JwtService $jwtService)
|
||||
{
|
||||
$this->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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
353
www/api/src/modules/user/UserController.php
Normal file
353
www/api/src/modules/user/UserController.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
/**
|
||||
* User Controller
|
||||
*/
|
||||
|
||||
namespace Modules\User;
|
||||
|
||||
use Core\Database;
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Utils\Helpers;
|
||||
|
||||
class UserController
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
131
www/api/src/services/JWT.php
Normal file
131
www/api/src/services/JWT.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal JWT Implementation
|
||||
* Based on Firebase PHP-JWT
|
||||
*/
|
||||
|
||||
namespace Firebase\JWT;
|
||||
|
||||
class JWT
|
||||
{
|
||||
const ASN1_INTEGER = 0x02;
|
||||
const ASN1_SEQUENCE = 0x30;
|
||||
|
||||
public static function encode(array $payload, string $key, string $algorithm = 'HS256'): string
|
||||
{
|
||||
$header = ['typ' => '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'];
|
||||
}
|
||||
}
|
||||
97
www/api/src/services/JwtService.php
Normal file
97
www/api/src/services/JwtService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* JWT Service for Authentication
|
||||
*/
|
||||
|
||||
namespace Services;
|
||||
|
||||
require_once __DIR__ . '/JWT.php';
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Utils\Helpers;
|
||||
|
||||
class JwtService
|
||||
{
|
||||
private string $secret;
|
||||
private string $algorithm;
|
||||
private int $accessTokenTtl;
|
||||
private int $refreshTokenTtl;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
101
www/api/src/utils/helpers.php
Normal file
101
www/api/src/utils/helpers.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Utility Functions
|
||||
*/
|
||||
|
||||
namespace Utils;
|
||||
|
||||
class Helpers
|
||||
{
|
||||
/**
|
||||
* Generate UUID v4
|
||||
*/
|
||||
public static function generateUUID(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp
|
||||
*/
|
||||
public static function timestamp(): int
|
||||
{
|
||||
return time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
public static function hashPassword(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user