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:
2026-04-29 06:49:15 +02:00
parent df6cce8678
commit 937a32ad06
15 changed files with 1698 additions and 14 deletions

249
www/api/database/schema.sql Normal file
View 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);

View File

@@ -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);

View 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',
];

View 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,
],
];

View 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
];

View 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);
}
}
}

View 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;
}
}

View 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';
}
}

View 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
View 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;
}
}

View 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()
]);
}
}

View 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]);
}
}

View 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'];
}
}

View 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']);
}
}

View 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();
}
}