Compare commits

..

2 Commits

Author SHA1 Message Date
937a32ad06 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
2026-04-29 06:49:15 +02:00
df6cce8678 feat: Add SPEC.md for Sozial social media platform 2026-04-29 06:37:09 +02:00
16 changed files with 2275 additions and 14 deletions

577
SPEC.md Normal file
View File

@@ -0,0 +1,577 @@
# Sozial - Social Media & Casual Dating Platform
## 1. Concept & Vision
**Sozial** ist eine moderne Social-Media-Plattform für Casual Dating und Community-Meetups. Die Plattform kombiniert die soziale Interaktion klassischer sozialer Netzwerke mit einer lockeren, einladenden Atmosphäre für ungezwungene Begegnungen. Das Ziel: Menschen zusammenbringen, die sich persönlich treffen wollen ob für ein Café, einen Spaziergang oder einfach ein Gespräch.
Das Erlebnis soll sich anfühlen wie ein guter Abend in einer Bar unkompliziert, offen und natürlich.
## 2. Design Language
### Aesthetic Direction
Inspiriert von modernen Dating-Apps (Tinder, Bumble) kombiniert mit der Funktionalität von Social Networks (Facebook, Instagram). Warme, einladende Farben mit sanften Gradienten und abgerundeten Formen.
### Color Palette
```
Primary: #6366F1 (Indigo - Vertrauen & Modernität)
Secondary: #EC4899 (Pink - Romantik & Energie)
Accent: #F59E0B (Amber - Wärme & Einladung)
Background: #FAFAFA (Hellgrau)
Surface: #FFFFFF (Weiß)
Text Primary: #1F2937 (Dunkelgrau)
Text Secondary: #6B7280 (Mittelgrau)
Success: #10B981 (Grün)
Error: #EF4444 (Rot)
Online: #22C55E (Grün - für Online-Status)
```
### Typography
- **Headlines:** Inter, 700 weight
- **Body:** Inter, 400/500 weight
- **Monospace:** JetBrains Mono (für Code/Tags)
- Font sizes: 12px, 14px, 16px, 20px, 24px, 32px, 48px
### Spatial System
- Base unit: 4px
- Spacing scale: 4, 8, 12, 16, 24, 32, 48, 64, 96
- Border radius: 8px (small), 12px (medium), 16px (large), 9999px (pill)
- Card shadows: `0 2px 8px rgba(0,0,0,0.08)`
### Motion Philosophy
- Micro-interactions: 150ms ease-out
- Page transitions: 300ms ease-in-out
- Loading states: Skeleton screens mit shimmer
- Haptic feedback bei Interaktionen (wo unterstützt)
## 3. Layout & Structure
### Page Architecture
```
┌─────────────────────────────────────────────────────┐
│ Header (Sticky) │
│ [Logo] [Search] [Nav: Feed|Explore|Messages] [⚙️] │
├─────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌────────────────────┐ ┌──────────┐│
│ │ Sidebar │ │ Main Content │ │ Right ││
│ │ │ │ │ │ Sidebar ││
│ │ Profile │ │ (Feed/Profile/ │ │ ││
│ │ Quick │ │ Messages/ │ │ Friend ││
│ │ Actions │ │ Settings) │ │ Requests ││
│ │ │ │ │ │ Trends ││
│ └──────────┘ └────────────────────┘ └──────────┘│
└─────────────────────────────────────────────────────┘
```
### Responsive Strategy
- **Desktop (1200px+):** 3-column layout
- **Tablet (768px-1199px):** 2-column, collapsible sidebar
- **Mobile (<768px):** Single column, bottom navigation
### Subdomain Structure
```
www.sozial.shadow-land.de → Frontend (Public pages, landing)
api.sozial.shadow-land.de → REST API
chat.sozial.shadow-land.de → WebSocket Chat Server
```
## 4. Features & Interactions
### 4.1 User Authentication
**Registration:**
- Email + Password
- OAuth (Google, Apple) - optional
- Email verification required
- Onboarding flow: Profile photo, bio, interests
**Login:**
- Email + Password
- "Remember me" functionality
- Password reset via email
**Session:**
- JWT tokens (access + refresh)
- 24h access token, 30d refresh token
- Automatic refresh
### 4.2 User Profiles
**Profile Data:**
```json
{
"id": "uuid",
"username": "string (unique)",
"display_name": "string",
"bio": "string (max 500 chars)",
"avatar_url": "string",
"photos": ["url1", "url2", "url3", "url4", "url5", "url6"],
"gender": "string",
"interested_in": ["men", "women", "all"],
"age": "number",
"location": {
"city": "string",
"lat": "number",
"lng": "number"
},
"interests": ["tag1", "tag2", "..."],
"is_online": "boolean",
"last_seen": "timestamp",
"created_at": "timestamp",
"settings": {
"show_online_status": "boolean",
"show_distance": "boolean",
"show_age": "boolean",
"receive_messages_from": "everyone|friends|all"
}
}
```
**Profile States:**
- Default view (limited info for non-friends)
- Full profile (for friends)
- Own profile (with edit functionality)
- Preview mode (how others see you)
### 4.3 Social Feed
**Post Types:**
- Text post (max 2000 chars)
- Image post (1-4 images)
- Location check-in
**Post Structure:**
```json
{
"id": "uuid",
"author_id": "uuid",
"type": "text|image|checkin",
"content": "string",
"media_urls": ["url1", "..."],
"location": { "name": "string", "lat": "num", "lng": "num" },
"likes_count": "number",
"comments_count": "number",
"shares_count": "number",
"liked_by_me": "boolean",
"created_at": "timestamp"
}
```
**Interactions:**
- Like (toggle heart icon, +1 animation)
- Comment (expandable section)
- Share (copy link)
- Save (bookmark for later)
- Report (modal with reason selection)
**Feed Algorithm:**
- Chronological by default
- "For You" tab with algorithmic ranking
- Filter by: All | Friends | Nearby | Interests
### 4.4 Friend & Follow System
**Connection Types:**
- **Friends:** Mutual follow, can see full profile + message
- **Following:** One-way follow, limited profile access
- **Followers:** People following you
**Actions:**
- Follow / Unfollow
- Add Friend / Remove Friend
- Block / Report
**Friend Requests:**
- Pending requests badge
- Accept / Decline
- "Suggest Friends" based on mutual connections
### 4.5 Direct Messaging
**Features:**
- 1:1 private messages
- Text + emoji support
- Read receipts (optional setting)
- Typing indicators
- Online/Offline status
- Message search
**Message Structure:**
```json
{
"id": "uuid",
"conversation_id": "uuid",
"sender_id": "uuid",
"content": "string",
"type": "text|image",
"media_url": "string|null",
"read_at": "timestamp|null",
"created_at": "timestamp"
}
```
**Conversation:**
```json
{
"id": "uuid",
"type": "direct|group",
"name": "string (for groups)",
"participants": ["user_id1", "user_id2"],
"last_message": { "...message object" },
"updated_at": "timestamp"
}
```
### 4.6 Discovery & Search
**Search:**
- User search by username/name
- Filter by: location, age, interests, online status
- Recent searches
- Suggested users
**Explore Tab:**
- Featured profiles
- Nearby users (distance-based)
- Users with similar interests
- "Recently active" section
**Discovery Cards:**
- Swipe-style cards (optional toggle)
- Like / Pass / Super Like
- Match notification when mutual like
### 4.7 Real-time Notifications
**Notification Types:**
- New friend request
- Friend request accepted
- New follower
- Post liked
- Post commented
- Message received
- Profile viewed (optional, premium)
**Delivery:**
- Real-time via WebSocket
- Fallback polling every 30s
- Push notifications (PWA + Mobile)
### 4.8 Privacy & Safety
**Privacy Controls:**
- Profile visibility: Public | Friends Only | Private
- Online status visibility
- Read receipts toggle
- Block list management
- Two-factor authentication (optional)
**Safety Features:**
- Report user/post
- Block user (hides from both sides)
- Content moderation (reported content reviewed)
- Age verification (18+)
- Anti-catfishing: Photo verification (future)
## 5. Component Inventory
### 5.1 Navigation
- **TopNav:** Logo, search input, nav links, user menu
- States: default, search-focused, user-menu-open
- **BottomNav (mobile):** Home, Search, Create (+), Messages, Profile
- States: default, active tab highlighted
### 5.2 Cards
- **UserCard:** Avatar, name, bio preview, age, distance, interests, action buttons
- States: default, hover (shadow increase), loading (skeleton)
- **PostCard:** Author header, content, media, action bar, comments preview
- States: default, expanded (showing comments), loading
- **MatchCard:** Two user avatars, "It's a Match!" banner, action buttons
- States: default, new-match (with animation)
### 5.3 Forms
- **Input:** Label, input field, helper text, error message
- States: default, focused, filled, error, disabled
- **TextArea:** Multi-line input with character count
- States: same as Input
- **Select/Dropdown:** Custom styled select
- States: default, open, selected, disabled
- **Button:** Primary, Secondary, Ghost, Danger variants
- States: default, hover, active, loading, disabled
- **Checkbox/Radio:** Custom styled with labels
- States: unchecked, checked, disabled
### 5.4 Feedback
- **Toast:** Success, Error, Warning, Info variants
- Auto-dismiss after 4s, manual dismiss available
- **Modal:** Centered overlay with header, content, actions
- States: entering (fade+scale), visible, exiting
- **Skeleton:** Placeholder shimmer for loading states
- **Spinner:** Circular loading indicator
- **EmptyState:** Illustration + text + action button
### 5.5 Media
- **Avatar:** Circular image with fallback initials
- Sizes: sm (32px), md (48px), lg (64px), xl (128px)
- States: image, loading, error (shows initials)
- **ImageGrid:** 1-4 images in responsive grid
- Lightbox on click
- **VideoPlayer:** Custom controls, autoplay mute on scroll
### 5.6 Chat
- **MessageBubble:** Text content, timestamp, read receipt
- Variants: sent (right, primary color), received (left, gray)
- **ChatListItem:** Avatar, name, last message preview, timestamp, unread badge
- States: default, unread (bold), selected
- **TypingIndicator:** Three bouncing dots animation
## 6. Technical Approach
### 6.1 Tech Stack
**Frontend:**
- React 18 + TypeScript
- Vite (build tool)
- Tailwind CSS (styling)
- React Router v6 (routing)
- TanStack Query (server state)
- Zustand (client state)
- Socket.io-client (real-time)
**Backend:**
- PHP 8.2+ (Laravel-like structure)
- SQLite database (per-user isolation via separate DBs)
- JWT Authentication (firebase/php-jwt)
- RESTful API
- Socket.io for WebSocket (PHP with Swoole/Ratchet)
**Infrastructure:**
- Caddy webserver
- PHP-FPM
- WebSocket server on separate port
- S3-compatible storage for media (MinIO or local)
### 6.2 API Design
**Base URL:** `https://api.sozial.shadow-land.de/v1`
**Authentication:**
```
POST /auth/register
POST /auth/login
POST /auth/refresh
POST /auth/logout
```
**Users:**
```
GET /users/:id
PATCH /users/:id
DELETE /users/:id
GET /users/search?q=query&filters=...
GET /users/:id/posts
GET /users/:id/friends
GET /users/:id/followers
GET /users/:id/following
```
**Posts:**
```
GET /posts
POST /posts
GET /posts/:id
PATCH /posts/:id
DELETE /posts/:id
POST /posts/:id/like
DELETE /posts/:id/like
GET /posts/:id/comments
POST /posts/:id/comments
```
**Relationships:**
```
POST /relationships/follow/:userId
DELETE /relationships/follow/:userId
POST /relationships/friend-request/:userId
POST /relationships/accept-friend/:userId
DELETE /relationships/reject-friend/:userId
GET /relationships/friends
GET /relationships/requests
```
**Messages:**
```
GET /conversations
GET /conversations/:id/messages
POST /conversations/:id/messages
PATCH /messages/:id/read
```
**Media:**
```
POST /media/upload
DELETE /media/:id
```
### 6.3 Data Model
**Users Table:**
```sql
CREATE TABLE 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
interests TEXT, -- JSON array
is_online INTEGER DEFAULT 0,
last_seen INTEGER,
settings TEXT, -- JSON object
created_at INTEGER,
updated_at INTEGER
);
```
**Posts Table:**
```sql
CREATE TABLE posts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL, -- text|image|checkin
content TEXT,
media_urls TEXT, -- JSON array
location TEXT, -- JSON object
created_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
**Relationships Table:**
```sql
CREATE TABLE relationships (
id TEXT PRIMARY KEY,
follower_id TEXT NOT NULL,
following_id TEXT NOT NULL,
type TEXT NOT NULL, -- follow|friend
status TEXT DEFAULT 'active', -- active|blocked
created_at INTEGER,
FOREIGN KEY (follower_id) REFERENCES users(id),
FOREIGN KEY (following_id) REFERENCES users(id)
);
```
**Messages Table:**
```sql
CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT DEFAULT 'text',
media_url TEXT,
read_at INTEGER,
created_at INTEGER,
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
FOREIGN KEY (sender_id) REFERENCES users(id)
);
```
### 6.4 WebSocket Events
**Client → Server:**
- `join_conversation` { conversationId }
- `leave_conversation` { conversationId }
- `send_message` { conversationId, content, type }
- `typing_start` { conversationId }
- `typing_stop` { conversationId }
**Server → Client:**
- `new_message` { message }
- `user_typing` { conversationId, userId }
- `user_online` { userId }
- `user_offline` { userId }
- `notification` { type, data }
### 6.5 Security Considerations
- All passwords hashed with bcrypt (cost factor 12)
- JWT signed with HS256, secret from environment
- CSRF protection via tokens
- Rate limiting on auth endpoints (5/min)
- Input sanitization and validation
- SQL injection prevention via prepared statements
- XSS prevention via output encoding
- File upload validation (type, size, malware scan)
- Secure headers (CSP, HSTS, X-Frame-Options)
## 7. Development Phases
### Phase 1: Foundation
- [ ] Project setup (repository, environments)
- [ ] Authentication system
- [ ] Basic user profiles
- [ ] Database schema
### Phase 2: Social Core
- [ ] Post creation & feed
- [ ] Like & comment system
- [ ] Friend/Follow system
- [ ] User search & discovery
### Phase 3: Messaging
- [ ] Conversation system
- [ ] Direct messaging
- [ ] Real-time updates (WebSocket)
- [ ] Typing indicators
### Phase 4: Polish
- [ ] Notifications
- [ ] Privacy settings
- [ ] Profile customization
- [ ] Media uploads
### Phase 5: Discovery (Future)
- [ ] Match algorithm
- [ ] Location-based discovery
- [ ] Advanced filtering
- [ ] Premium features
## 8. File Structure
```
sozial/
├── www/
│ └── public/
│ └── index.php # Frontend entry
├── api/
│ └── public/
│ └── index.php # API entry
├── chat/
│ └── public/
│ └── index.php # WebSocket entry
├── src/
│ ├── core/ # Framework core
│ ├── modules/ # Feature modules
│ │ ├── auth/
│ │ ├── user/
│ │ ├── post/
│ │ ├── message/
│ │ └── relationship/
│ ├── services/ # Business logic
│ ├── utils/ # Helpers
│ └── config/ # Configuration
├── database/
│ └── schema.sql # Database schema
├── tests/
│ └── unit/
├── SPEC.md
├── README.md
└── .env.example
```

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 <?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) { // Autoloader
case 'GET': spl_autoload_register(function ($class) {
echo json_encode(['status' => 'ok', 'message' => 'API läuft']); // Convert namespace to file path
break; $prefixes = [
case 'POST': 'Core\\' => __DIR__ . '/../src/core/',
$input = json_decode(file_get_contents('php://input'), true); 'Modules\\' => __DIR__ . '/../src/modules/',
echo json_encode(['status' => 'received', 'data' => $input]); 'Services\\' => __DIR__ . '/../src/services/',
break; 'Utils\\' => __DIR__ . '/../src/utils/',
default: 'Middleware\\' => __DIR__ . '/../src/core/',
http_response_code(405); ];
echo json_encode(['error' => 'Method not allowed']);
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();
}
}