Compare commits
12 Commits
b10f16daa0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 937a32ad06 | |||
| df6cce8678 | |||
| 70d812dab8 | |||
| 9a45775686 | |||
| 7e0e20c3b0 | |||
| 249e452c1e | |||
| 3f69968d5e | |||
| 89a39db7e2 | |||
| 50af75703d | |||
| d1770f5bd2 | |||
| 8f10932f8e | |||
| 3b4604ce5a |
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Uploads / User Data
|
||||||
|
uploads/
|
||||||
|
storage/
|
||||||
|
writable/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build Output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
20
.opencode/rules/backend.md
Normal file
20
.opencode/rules/backend.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Backend Rules
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- PHP 8+ Syntax
|
||||||
|
- Prepared Statements für DB Queries
|
||||||
|
- Input Validation immer auf Server-Seite
|
||||||
|
- Keine secrets in Code — .env nutzen
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
- JSON als Standard-Format
|
||||||
|
- HTTP Status Codes korrekt nutzen
|
||||||
|
- GET/POST/etc. semantisch richtig
|
||||||
|
|
||||||
|
## SQLite
|
||||||
|
|
||||||
|
- Transactions für mehrere Writes
|
||||||
|
- FOREIGN KEY Constraints aktiviert
|
||||||
|
- Indexes auf häufige Query-Spalten
|
||||||
17
.opencode/rules/frontend.md
Normal file
17
.opencode/rules/frontend.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Frontend Rules
|
||||||
|
|
||||||
|
## HTML
|
||||||
|
|
||||||
|
- Semantisches HTML5
|
||||||
|
- Accessibility beachten (a11y)
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
- CSS Variables für Theming
|
||||||
|
- Mobile-First Ansatz
|
||||||
|
- Keine inline Styles (außer dynamisch)
|
||||||
|
|
||||||
|
## JS
|
||||||
|
|
||||||
|
- Vanilla JS bevorzugt, kein Framework-Overhead
|
||||||
|
- ES6+ Syntax
|
||||||
20
.opencode/rules/general.md
Normal file
20
.opencode/rules/general.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# General Rules
|
||||||
|
|
||||||
|
## Projekt Start
|
||||||
|
|
||||||
|
Bei einem neuen Projekt:
|
||||||
|
1. README.md lesen / erstellen
|
||||||
|
2. AGENTS.md lesen
|
||||||
|
3. docs/planung/ öffnen und Projektstruktur verstehen
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- Kleinbuchstaben mit Bindestrichen: `mein-projekt`
|
||||||
|
- Subdomains als Verzeichnisse unter `www/`
|
||||||
|
- Document-Root immer `public/`
|
||||||
|
|
||||||
|
## Git Flow
|
||||||
|
|
||||||
|
1. Branch für Feature: `feature/<name>`
|
||||||
|
2. Commit oft mit aussagekräftigen Messages
|
||||||
|
3. Merge via PR oder direkt (bei kleinen Fixes)
|
||||||
22
.opencode/rules/security.md
Normal file
22
.opencode/rules/security.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Security Rules
|
||||||
|
|
||||||
|
## Generell
|
||||||
|
|
||||||
|
- Nie Secrets in Git
|
||||||
|
- Input immer validieren
|
||||||
|
- Output immer escapen
|
||||||
|
|
||||||
|
## XSS
|
||||||
|
|
||||||
|
- HTML specialchars() für User-Input in HTML
|
||||||
|
- Content-Security-Policy Header setzen
|
||||||
|
|
||||||
|
## CSRF
|
||||||
|
|
||||||
|
- CSRF Token bei Forms
|
||||||
|
- SameSite=Cookie
|
||||||
|
|
||||||
|
## SQL Injection
|
||||||
|
|
||||||
|
- Prepared Statements immer nutzen
|
||||||
|
- Nie User-Input direkt in SQL
|
||||||
13
.opencode/settings.json
Normal file
13
.opencode/settings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"permissions": {
|
||||||
|
"bash": true,
|
||||||
|
"php": true,
|
||||||
|
"python": false,
|
||||||
|
"node": false,
|
||||||
|
"git": true,
|
||||||
|
"npm": false,
|
||||||
|
"composer": false
|
||||||
|
},
|
||||||
|
"model": "default"
|
||||||
|
}
|
||||||
30
.opencode/skills/help/SKILL.md
Normal file
30
.opencode/skills/help/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Help Skill
|
||||||
|
|
||||||
|
## Projekt Status
|
||||||
|
|
||||||
|
Zeigt die aktuelle Projektstruktur und offene TODOs.
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
Manuell: Zeigt die Verzeichnisstruktur und weist auf fehlende Dateien hin.
|
||||||
|
|
||||||
|
## Struktur Check
|
||||||
|
|
||||||
|
```
|
||||||
|
skeleton/
|
||||||
|
├── .git/
|
||||||
|
├── .gitignore ✓
|
||||||
|
├── AGENTS.md ✓
|
||||||
|
├── .opencode/
|
||||||
|
│ ├── settings.json ✓
|
||||||
|
│ ├── rules/ ✓
|
||||||
|
│ └── skills/ ✓
|
||||||
|
├── docs/
|
||||||
|
│ ├── doku/ ✓
|
||||||
|
│ └── planung/ ✓
|
||||||
|
├── www/
|
||||||
|
│ ├── api/public/ ✓
|
||||||
|
│ ├── game/public/ ✓
|
||||||
|
│ └── www/public/ ✓
|
||||||
|
└── README.md ✓
|
||||||
|
```
|
||||||
577
SPEC.md
Normal file
577
SPEC.md
Normal 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
|
||||||
|
```
|
||||||
1
docs/doku/.gitkeep
Normal file
1
docs/doku/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder
|
||||||
1
docs/planung/.gitkeep
Normal file
1
docs/planung/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder
|
||||||
249
www/api/database/schema.sql
Normal file
249
www/api/database/schema.sql
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
-- Sozial Platform Database Schema
|
||||||
|
-- SQLite
|
||||||
|
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
photos TEXT, -- JSON array
|
||||||
|
gender TEXT,
|
||||||
|
interested_in TEXT, -- JSON array
|
||||||
|
age INTEGER,
|
||||||
|
location TEXT, -- JSON object {city, lat, lng}
|
||||||
|
interests TEXT, -- JSON array
|
||||||
|
is_online INTEGER DEFAULT 0,
|
||||||
|
last_seen INTEGER,
|
||||||
|
settings TEXT, -- JSON object
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profiles table (extended profile data)
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL UNIQUE,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
birthday TEXT,
|
||||||
|
city TEXT,
|
||||||
|
country TEXT,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
height_cm INTEGER,
|
||||||
|
body_type TEXT,
|
||||||
|
education TEXT,
|
||||||
|
occupation TEXT,
|
||||||
|
relationship_status TEXT,
|
||||||
|
looking_for TEXT,
|
||||||
|
about_me TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Posts table
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'text', -- text|image|checkin
|
||||||
|
content TEXT,
|
||||||
|
media_urls TEXT, -- JSON array
|
||||||
|
location TEXT, -- JSON object {name, lat, lng}
|
||||||
|
likes_count INTEGER DEFAULT 0,
|
||||||
|
comments_count INTEGER DEFAULT 0,
|
||||||
|
shares_count INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post likes table
|
||||||
|
CREATE TABLE IF NOT EXISTS post_likes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
post_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post comments table
|
||||||
|
CREATE TABLE IF NOT EXISTS post_comments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
post_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES post_comments(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Relationships table (follows and friends)
|
||||||
|
CREATE TABLE IF NOT EXISTS relationships (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
follower_id TEXT NOT NULL,
|
||||||
|
following_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'follow', -- follow|friend
|
||||||
|
status TEXT DEFAULT 'active', -- active|blocked|pending
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(follower_id, following_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Swipes table (for discovery feature)
|
||||||
|
CREATE TABLE IF NOT EXISTS swipes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
target_user_id TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL, -- like|pass|super_like
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, target_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Matches table
|
||||||
|
CREATE TABLE IF NOT EXISTS matches (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user1_id TEXT NOT NULL,
|
||||||
|
user2_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user1_id, user2_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chats (conversations) table
|
||||||
|
CREATE TABLE IF NOT EXISTS chats (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'direct', -- direct|group
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chat participants table
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_participants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
last_read_at INTEGER,
|
||||||
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(chat_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chat messages table
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
type TEXT DEFAULT 'text', -- text|image|system
|
||||||
|
media_url TEXT,
|
||||||
|
read_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Events table
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
creator_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
event_type TEXT DEFAULT 'meetup', -- meetup|party|date|other
|
||||||
|
location_name TEXT,
|
||||||
|
location_address TEXT,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
start_date INTEGER,
|
||||||
|
end_date INTEGER,
|
||||||
|
max_participants INTEGER,
|
||||||
|
is_public INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Event participants table
|
||||||
|
CREATE TABLE IF NOT EXISTS event_participants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'going', -- going|maybe|not_going
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(event_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Notifications table
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- friend_request|friend_accepted|post_liked|post_commented|message|match|system
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
data TEXT, -- JSON object with additional data
|
||||||
|
is_read INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Media uploads table
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_name TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Refresh tokens table for JWT
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
revoked_at INTEGER,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relationships_follower ON relationships(follower_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relationships_following ON relationships(following_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_chat_id ON chat_messages(chat_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_swipes_user_id ON swipes(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_matches_user1 ON matches(user1_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_matches_user2 ON matches(user2_id);
|
||||||
@@ -1,20 +1,102 @@
|
|||||||
<?php
|
<?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);
|
||||||
|
|||||||
13
www/api/src/config/app.php
Normal file
13
www/api/src/config/app.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Application Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => 'Sozial',
|
||||||
|
'env' => $_ENV['APP_ENV'] ?? 'development',
|
||||||
|
'debug' => $_ENV['APP_DEBUG'] ?? true,
|
||||||
|
'url' => 'https://api.sozial.shadow-land.de',
|
||||||
|
'version' => 'v1',
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
];
|
||||||
14
www/api/src/config/database.php
Normal file
14
www/api/src/config/database.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'database' => __DIR__ . '/../../database/sozial.db',
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
11
www/api/src/config/jwt.php
Normal file
11
www/api/src/config/jwt.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* JWT Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'secret' => $_ENV['JWT_SECRET'] ?? 'sozial-dev-secret-change-in-production',
|
||||||
|
'algorithm' => 'HS256',
|
||||||
|
'access_token_ttl' => 86400, // 24 hours in seconds
|
||||||
|
'refresh_token_ttl' => 2592000, // 30 days in seconds
|
||||||
|
];
|
||||||
78
www/api/src/core/Database.php
Normal file
78
www/api/src/core/Database.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Connection Singleton
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
private static string $dsn;
|
||||||
|
private static array $options;
|
||||||
|
|
||||||
|
public static function init(array $config): void
|
||||||
|
{
|
||||||
|
$driver = $config['driver'] ?? 'sqlite';
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
$dbPath = $config['database'];
|
||||||
|
// Ensure directory exists
|
||||||
|
$dir = dirname($dbPath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
self::$dsn = "sqlite:{$dbPath}";
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$options = $config['options'] ?? [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getInstance(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
try {
|
||||||
|
self::$instance = new PDO(self::$dsn, null, null, self::$options);
|
||||||
|
|
||||||
|
// Enable foreign keys for SQLite
|
||||||
|
if (strpos(self::$dsn, 'sqlite') === 0) {
|
||||||
|
self::$instance->exec('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
throw new \Exception("Database connection failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function close(): void
|
||||||
|
{
|
||||||
|
self::$instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database with schema
|
||||||
|
*/
|
||||||
|
public static function initialize(string $schemaPath): void
|
||||||
|
{
|
||||||
|
$pdo = self::getInstance();
|
||||||
|
$schema = file_get_contents($schemaPath);
|
||||||
|
|
||||||
|
// SQLite doesn't support multiple statements in exec, so we need to split
|
||||||
|
$statements = array_filter(
|
||||||
|
array_map('trim', explode(';', $schema)),
|
||||||
|
fn($s) => !empty($s) && strpos($s, '--') !== 0
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
$pdo->exec($statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
www/api/src/core/Middleware.php
Normal file
53
www/api/src/core/Middleware.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Authentication Middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Middleware;
|
||||||
|
|
||||||
|
use Core\Request;
|
||||||
|
use Core\Response;
|
||||||
|
use Services\JwtService;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
private static ?JwtService $jwtService = null;
|
||||||
|
|
||||||
|
public static function setJwtService(JwtService $service): void
|
||||||
|
{
|
||||||
|
self::$jwtService = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handle(Request $request): bool
|
||||||
|
{
|
||||||
|
if (self::$jwtService === null) {
|
||||||
|
// Load config if not set
|
||||||
|
$config = require __DIR__ . '/../config/jwt.php';
|
||||||
|
self::$jwtService = new JwtService($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $request->getAuthToken();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
Response::unauthorized('No token provided');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = self::$jwtService->validateToken($token);
|
||||||
|
|
||||||
|
if (!$payload) {
|
||||||
|
Response::unauthorized('Invalid or expired token');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['type'] ?? '') !== 'access') {
|
||||||
|
Response::unauthorized('Invalid token type');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user ID to request for later use
|
||||||
|
$request->userId = $payload['sub'];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
www/api/src/core/Request.php
Normal file
86
www/api/src/core/Request.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HTTP Request Helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
private array $body;
|
||||||
|
private array $headers;
|
||||||
|
private string $method;
|
||||||
|
private string $path;
|
||||||
|
private array $query;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$this->path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$this->query = $_GET;
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$this->body = json_decode($rawBody, true) ?? [];
|
||||||
|
|
||||||
|
// Capture headers
|
||||||
|
$this->headers = $this->parseHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseHeaders(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($_SERVER as $key => $value) {
|
||||||
|
if (strpos($key, 'HTTP_') === 0) {
|
||||||
|
$header = str_replace('_', '-', substr($key, 5));
|
||||||
|
$headers[strtolower($header)] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): string
|
||||||
|
{
|
||||||
|
return $this->method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return $this->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuery(string $key = null, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
return $this->query[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(string $key = null, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->body;
|
||||||
|
}
|
||||||
|
return $this->body[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeader(string $name): ?string
|
||||||
|
{
|
||||||
|
return $this->headers[strtolower($name)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthToken(): ?string
|
||||||
|
{
|
||||||
|
$auth = $this->getHeader('authorization');
|
||||||
|
if ($auth && preg_match('/Bearer\s+(.+)/i', $auth, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientIp(): string
|
||||||
|
{
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
60
www/api/src/core/Response.php
Normal file
60
www/api/src/core/Response.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HTTP Response Helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
class Response
|
||||||
|
{
|
||||||
|
public static function json(mixed $data, int $status = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function success(mixed $data = null, string $message = 'OK', int $status = 200): void
|
||||||
|
{
|
||||||
|
self::json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, int $status = 400, mixed $errors = null): void
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$response['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::json($response, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message = 'Resource not found'): void
|
||||||
|
{
|
||||||
|
self::error($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unauthorized(string $message = 'Unauthorized'): void
|
||||||
|
{
|
||||||
|
self::error($message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forbidden(string $message = 'Forbidden'): void
|
||||||
|
{
|
||||||
|
self::error($message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validationError(mixed $errors): void
|
||||||
|
{
|
||||||
|
self::error('Validation failed', 422, $errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
www/api/src/core/Router.php
Normal file
104
www/api/src/core/Router.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple Router
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
private array $middlewares = [];
|
||||||
|
|
||||||
|
public function get(string $path, callable $handler, array $middleware = []): self
|
||||||
|
{
|
||||||
|
$this->addRoute('GET', $path, $handler, $middleware);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, callable $handler, array $middleware = []): self
|
||||||
|
{
|
||||||
|
$this->addRoute('POST', $path, $handler, $middleware);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, callable $handler, array $middleware = []): self
|
||||||
|
{
|
||||||
|
$this->addRoute('PUT', $path, $handler, $middleware);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patch(string $path, callable $handler, array $middleware = []): self
|
||||||
|
{
|
||||||
|
$this->addRoute('PATCH', $path, $handler, $middleware);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path, callable $handler, array $middleware = []): self
|
||||||
|
{
|
||||||
|
$this->addRoute('DELETE', $path, $handler, $middleware);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRoute(string $method, string $path, callable $handler, array $middleware): void
|
||||||
|
{
|
||||||
|
// Convert {param} to regex
|
||||||
|
$pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $path);
|
||||||
|
$pattern = '#^' . $pattern . '$#';
|
||||||
|
|
||||||
|
$this->routes[] = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'pattern' => $pattern,
|
||||||
|
'handler' => $handler,
|
||||||
|
'middleware' => $middleware
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(Request $request): void
|
||||||
|
{
|
||||||
|
$method = $request->getMethod();
|
||||||
|
$path = $request->getPath();
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($route['pattern'], $path, $matches)) {
|
||||||
|
// Extract route params
|
||||||
|
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||||
|
|
||||||
|
// Run middleware
|
||||||
|
foreach ($route['middleware'] as $middleware) {
|
||||||
|
if (!$this->runMiddleware($middleware, $params)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call handler with params
|
||||||
|
$handler = $route['handler'];
|
||||||
|
$handler($request, $params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::notFound('Endpoint not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runMiddleware(string|array $middleware, array $params): bool
|
||||||
|
{
|
||||||
|
if (is_string($middleware)) {
|
||||||
|
// Load middleware class
|
||||||
|
$middlewareClass = "Middleware\\{$middleware}";
|
||||||
|
if (class_exists($middlewareClass)) {
|
||||||
|
$instance = new $middlewareClass();
|
||||||
|
return $instance->handle();
|
||||||
|
}
|
||||||
|
} elseif (is_array($middleware)) {
|
||||||
|
// Closure middleware
|
||||||
|
return $middleware($params);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
252
www/api/src/modules/auth/AuthController.php
Normal file
252
www/api/src/modules/auth/AuthController.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Authentication Controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Modules\Auth;
|
||||||
|
|
||||||
|
use Core\Database;
|
||||||
|
use Core\Request;
|
||||||
|
use Core\Response;
|
||||||
|
use Utils\Helpers;
|
||||||
|
use Services\JwtService;
|
||||||
|
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
private JwtService $jwtService;
|
||||||
|
|
||||||
|
public function __construct(Database $db, JwtService $jwtService)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
$this->jwtService = $jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/register
|
||||||
|
*/
|
||||||
|
public function register(Request $request): void
|
||||||
|
{
|
||||||
|
$email = $request->getBody('email');
|
||||||
|
$password = $request->getBody('password');
|
||||||
|
$username = $request->getBody('username');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (!$email || !Helpers::isValidEmail($email)) {
|
||||||
|
$errors['email'] = 'Valid email is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$password || strlen($password) < 6) {
|
||||||
|
$errors['password'] = 'Password must be at least 6 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$username || !Helpers::isValidUsername($username)) {
|
||||||
|
$errors['username'] = 'Username must be 3-30 alphanumeric characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
Response::validationError($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Check if email exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
Response::validationError(['email' => 'Email already registered']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
Response::validationError(['username' => 'Username already taken']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
$userId = Helpers::generateUUID();
|
||||||
|
$now = Helpers::timestamp();
|
||||||
|
$passwordHash = Helpers::hashPassword($password);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO users (id, email, password_hash, username, display_name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$email,
|
||||||
|
$passwordHash,
|
||||||
|
$username,
|
||||||
|
$username, // display_name defaults to username
|
||||||
|
$now,
|
||||||
|
$now
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
$accessToken = $this->jwtService->generateAccessToken($userId);
|
||||||
|
$refreshToken = $this->jwtService->generateRefreshToken($userId);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
$this->storeRefreshToken($userId, $refreshToken);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'user' => [
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => $email,
|
||||||
|
'username' => $username
|
||||||
|
],
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'token_type' => 'Bearer'
|
||||||
|
], 'Registration successful', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/login
|
||||||
|
*/
|
||||||
|
public function login(Request $request): void
|
||||||
|
{
|
||||||
|
$email = $request->getBody('email');
|
||||||
|
$password = $request->getBody('password');
|
||||||
|
|
||||||
|
if (!$email || !$password) {
|
||||||
|
Response::validationError([
|
||||||
|
'email' => 'Email is required',
|
||||||
|
'password' => 'Password is required'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || !Helpers::verifyPassword($password, $user['password_hash'])) {
|
||||||
|
Response::unauthorized('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last seen
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET last_seen = ?, is_online = 1 WHERE id = ?');
|
||||||
|
$stmt->execute([Helpers::timestamp(), $user['id']]);
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
$accessToken = $this->jwtService->generateAccessToken($user['id']);
|
||||||
|
$refreshToken = $this->jwtService->generateRefreshToken($user['id']);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
$this->storeRefreshToken($user['id'], $refreshToken);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'display_name' => $user['display_name'],
|
||||||
|
'avatar_url' => $user['avatar_url']
|
||||||
|
],
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'token_type' => 'Bearer'
|
||||||
|
], 'Login successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/refresh
|
||||||
|
*/
|
||||||
|
public function refresh(Request $request): void
|
||||||
|
{
|
||||||
|
$refreshToken = $request->getBody('refresh_token');
|
||||||
|
|
||||||
|
if (!$refreshToken) {
|
||||||
|
Response::validationError(['refresh_token' => 'Refresh token is required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->jwtService->validateToken($refreshToken);
|
||||||
|
|
||||||
|
if (!$payload || ($payload['type'] ?? '') !== 'refresh') {
|
||||||
|
Response::unauthorized('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $payload['sub'];
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Verify refresh token exists and is valid
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM refresh_tokens
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$tokenRecord = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$tokenRecord || Helpers::isExpired($tokenRecord['expires_at'])) {
|
||||||
|
Response::unauthorized('Refresh token expired or revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
$accessToken = $this->jwtService->generateAccessToken($userId);
|
||||||
|
$newRefreshToken = $this->jwtService->generateRefreshToken($userId);
|
||||||
|
|
||||||
|
// Revoke old refresh token
|
||||||
|
$stmt = $pdo->prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?');
|
||||||
|
$stmt->execute([Helpers::timestamp(), $tokenRecord['id']]);
|
||||||
|
|
||||||
|
// Store new refresh token
|
||||||
|
$this->storeRefreshToken($userId, $newRefreshToken);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'refresh_token' => $newRefreshToken,
|
||||||
|
'token_type' => 'Bearer'
|
||||||
|
], 'Token refreshed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): void
|
||||||
|
{
|
||||||
|
$refreshToken = $request->getBody('refresh_token');
|
||||||
|
|
||||||
|
if ($refreshToken && isset($request->userId)) {
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE refresh_tokens
|
||||||
|
SET revoked_at = ?
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL
|
||||||
|
');
|
||||||
|
$stmt->execute([Helpers::timestamp(), $request->userId]);
|
||||||
|
|
||||||
|
// Set user offline
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET is_online = 0 WHERE id = ?');
|
||||||
|
$stmt->execute([$request->userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(null, 'Logged out successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store refresh token in database
|
||||||
|
*/
|
||||||
|
private function storeRefreshToken(string $userId, string $token): void
|
||||||
|
{
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
Helpers::generateUUID(),
|
||||||
|
$userId,
|
||||||
|
hash('sha256', $token),
|
||||||
|
Helpers::getExpiration(2592000), // 30 days
|
||||||
|
Helpers::timestamp()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
353
www/api/src/modules/user/UserController.php
Normal file
353
www/api/src/modules/user/UserController.php
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* User Controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Modules\User;
|
||||||
|
|
||||||
|
use Core\Database;
|
||||||
|
use Core\Request;
|
||||||
|
use Core\Response;
|
||||||
|
use Utils\Helpers;
|
||||||
|
|
||||||
|
class UserController
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
|
||||||
|
public function __construct(Database $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id
|
||||||
|
*/
|
||||||
|
public function show(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? $request->userId ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::unauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id, username, email, display_name, bio, avatar_url, photos,
|
||||||
|
gender, interested_in, age, location, interests, is_online,
|
||||||
|
last_seen, settings, created_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
Response::notFound('User not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON fields
|
||||||
|
$user['photos'] = Helpers::parseJson($user['photos'], []);
|
||||||
|
$user['interested_in'] = Helpers::parseJson($user['interested_in'], []);
|
||||||
|
$user['location'] = Helpers::parseJson($user['location'], null);
|
||||||
|
$user['interests'] = Helpers::parseJson($user['interests'], []);
|
||||||
|
$user['settings'] = Helpers::parseJson($user['settings'], []);
|
||||||
|
$user['is_online'] = (bool) $user['is_online'];
|
||||||
|
|
||||||
|
// Check if own profile to show full data
|
||||||
|
$isOwnProfile = isset($request->userId) && $request->userId === $userId;
|
||||||
|
|
||||||
|
if (!$isOwnProfile) {
|
||||||
|
// Hide sensitive data for other users
|
||||||
|
unset($user['email']);
|
||||||
|
unset($user['settings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(['user' => $user]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /users/:id
|
||||||
|
*/
|
||||||
|
public function update(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? $request->userId ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::unauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can only update own profile
|
||||||
|
if (!isset($request->userId) || $request->userId !== $userId) {
|
||||||
|
Response::forbidden('You can only update your own profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getBody();
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
$allowedFields = [
|
||||||
|
'display_name', 'bio', 'avatar_url', 'photos', 'gender',
|
||||||
|
'interested_in', 'age', 'location', 'interests', 'settings'
|
||||||
|
];
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($allowedFields as $field) {
|
||||||
|
if (isset($body[$field])) {
|
||||||
|
$value = $body[$field];
|
||||||
|
|
||||||
|
// Encode arrays to JSON
|
||||||
|
if (in_array($field, ['photos', 'interested_in', 'interests'])) {
|
||||||
|
$value = Helpers::jsonEncode($value);
|
||||||
|
} elseif ($field === 'location') {
|
||||||
|
$value = Helpers::jsonEncode($value);
|
||||||
|
} elseif ($field === 'settings') {
|
||||||
|
$value = Helpers::jsonEncode($value);
|
||||||
|
} elseif ($field === 'bio') {
|
||||||
|
$value = substr($value, 0, 500); // Max 500 chars
|
||||||
|
} elseif ($field === 'age') {
|
||||||
|
$value = (int) $value;
|
||||||
|
if ($value < 18 || $value > 120) {
|
||||||
|
Response::validationError(['age' => 'Age must be between 18 and 120']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = "{$field} = ?";
|
||||||
|
$values[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
Response::validationError(['general' => 'No valid fields to update']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = 'updated_at = ?';
|
||||||
|
$values[] = Helpers::timestamp();
|
||||||
|
$values[] = $userId;
|
||||||
|
|
||||||
|
$sql = 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($values);
|
||||||
|
|
||||||
|
// Fetch updated user
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
// Parse JSON fields
|
||||||
|
$user['photos'] = Helpers::parseJson($user['photos'], []);
|
||||||
|
$user['interested_in'] = Helpers::parseJson($user['interested_in'], []);
|
||||||
|
$user['location'] = Helpers::parseJson($user['location'], null);
|
||||||
|
$user['interests'] = Helpers::parseJson($user['interests'], []);
|
||||||
|
$user['settings'] = Helpers::parseJson($user['settings'], []);
|
||||||
|
|
||||||
|
Response::success(['user' => $user], 'Profile updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /users/:id
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? $request->userId ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::unauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can only delete own account
|
||||||
|
if (!isset($request->userId) || $request->userId !== $userId) {
|
||||||
|
Response::forbidden('You can only delete your own account');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Delete user (cascade will handle related records)
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
Response::success(null, 'Account deleted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/search
|
||||||
|
*/
|
||||||
|
public function search(Request $request): void
|
||||||
|
{
|
||||||
|
$query = $request->getQuery('q', '');
|
||||||
|
$limit = min((int) $request->getQuery('limit', 20), 100);
|
||||||
|
$offset = (int) $request->getQuery('offset', 0);
|
||||||
|
|
||||||
|
if (strlen($query) < 2) {
|
||||||
|
Response::validationError(['q' => 'Search query must be at least 2 characters']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
$searchTerm = "%{$query}%";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id, username, display_name, bio, avatar_url, age, location, interests, is_online
|
||||||
|
FROM users
|
||||||
|
WHERE (username LIKE ? OR display_name LIKE ?)
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$searchTerm, $searchTerm, $limit, $offset]);
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Parse JSON fields for each user
|
||||||
|
foreach ($users as &$user) {
|
||||||
|
$user['location'] = Helpers::parseJson($user['location'], null);
|
||||||
|
$user['interests'] = Helpers::parseJson($user['interests'], []);
|
||||||
|
$user['is_online'] = (bool) $user['is_online'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'users' => $users,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id/posts
|
||||||
|
*/
|
||||||
|
public function posts(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::notFound('User ID required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min((int) $request->getQuery('limit', 20), 100);
|
||||||
|
$offset = (int) $request->getQuery('offset', 0);
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $limit, $offset]);
|
||||||
|
$posts = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Parse JSON fields
|
||||||
|
foreach ($posts as &$post) {
|
||||||
|
$post['media_urls'] = Helpers::parseJson($post['media_urls'], []);
|
||||||
|
$post['location'] = Helpers::parseJson($post['location'], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'posts' => $posts,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id/friends
|
||||||
|
*/
|
||||||
|
public function friends(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::notFound('User ID required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
// Get mutual friends (where both users follow each other)
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN relationships r1 ON u.id = r1.following_id
|
||||||
|
INNER JOIN relationships r2 ON u.id = r2.follower_id
|
||||||
|
WHERE r1.follower_id = ? AND r2.following_id = ?
|
||||||
|
AND r1.type = "friend" AND r2.type = "friend"
|
||||||
|
AND r1.status = "active" AND r2.status = "active"
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $userId]);
|
||||||
|
$friends = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($friends as &$friend) {
|
||||||
|
$friend['is_online'] = (bool) $friend['is_online'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(['friends' => $friends]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id/followers
|
||||||
|
*/
|
||||||
|
public function followers(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::notFound('User ID required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN relationships r ON u.id = r.follower_id
|
||||||
|
WHERE r.following_id = ? AND r.status = "active"
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$followers = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($followers as &$follower) {
|
||||||
|
$follower['is_online'] = (bool) $follower['is_online'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(['followers' => $followers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id/following
|
||||||
|
*/
|
||||||
|
public function following(Request $request, array $params): void
|
||||||
|
{
|
||||||
|
$userId = $params['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
Response::notFound('User ID required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->db->getInstance();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.is_online
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN relationships r ON u.id = r.following_id
|
||||||
|
WHERE r.follower_id = ? AND r.status = "active"
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$following = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($following as &$user) {
|
||||||
|
$user['is_online'] = (bool) $user['is_online'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(['following' => $following]);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
www/api/src/services/JWT.php
Normal file
131
www/api/src/services/JWT.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Minimal JWT Implementation
|
||||||
|
* Based on Firebase PHP-JWT
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Firebase\JWT;
|
||||||
|
|
||||||
|
class JWT
|
||||||
|
{
|
||||||
|
const ASN1_INTEGER = 0x02;
|
||||||
|
const ASN1_SEQUENCE = 0x30;
|
||||||
|
|
||||||
|
public static function encode(array $payload, string $key, string $algorithm = 'HS256'): string
|
||||||
|
{
|
||||||
|
$header = ['typ' => 'JWT', 'alg' => $algorithm];
|
||||||
|
|
||||||
|
$segments = [];
|
||||||
|
$segments[] = self::base64UrlEncode(self::jsonEncode($header));
|
||||||
|
$segments[] = self::base64UrlEncode(self::jsonEncode($payload));
|
||||||
|
|
||||||
|
$signingInput = implode('.', $segments);
|
||||||
|
$signature = self::sign($signingInput, $key, $algorithm);
|
||||||
|
$segments[] = self::base64UrlEncode($signature);
|
||||||
|
|
||||||
|
return implode('.', $segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function decode(string $jwt, Key $key): object
|
||||||
|
{
|
||||||
|
$tks = explode('.', $jwt);
|
||||||
|
|
||||||
|
if (count($tks) !== 3) {
|
||||||
|
throw new \UnexpectedValueException('Invalid token structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
list($headb64, $bodyb64, $cryptob64) = $tks;
|
||||||
|
|
||||||
|
$header = self::jsonDecode(self::base64UrlDecode($headb64));
|
||||||
|
if (!$header) {
|
||||||
|
throw new \UnexpectedValueException('Invalid header encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = self::jsonDecode(self::base64UrlDecode($bodyb64));
|
||||||
|
if (!$payload) {
|
||||||
|
throw new \UnexpectedValueException('Invalid payload encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sig = self::base64UrlDecode($cryptob64);
|
||||||
|
|
||||||
|
if (isset($key->getKeyType()['kty']) && $key->getKeyType()['kty'] === 'RSA') {
|
||||||
|
throw new \RuntimeException('RSA not implemented, use HS256');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sigInput = $headb64 . '.' . $bodyb64;
|
||||||
|
$algorithm = $header->alg ?? 'HS256';
|
||||||
|
|
||||||
|
if (!self::verify($sigInput, $sig, $key->getKeyMaterial(), $algorithm)) {
|
||||||
|
throw new \UnexpectedValueException('Signature verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (isset($payload->exp) && $payload->exp < time()) {
|
||||||
|
throw new \UnexpectedValueException('Token has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sign(string $input, string $key, string $algorithm): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $input, $key, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function verify(string $input, string $signature, string $key, string $algorithm): bool
|
||||||
|
{
|
||||||
|
$expected = hash_hmac('sha256', $input, $key, true);
|
||||||
|
return hash_equals($expected, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function jsonEncode($data): string
|
||||||
|
{
|
||||||
|
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function jsonDecode(string $data): ?object
|
||||||
|
{
|
||||||
|
return json_decode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base64UrlEncode(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base64UrlDecode(string $data): string
|
||||||
|
{
|
||||||
|
$remainder = strlen($data) % 4;
|
||||||
|
if ($remainder) {
|
||||||
|
$data .= str_repeat('=', 4 - $remainder);
|
||||||
|
}
|
||||||
|
return base64_decode(strtr($data, '-_', '+/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Key
|
||||||
|
{
|
||||||
|
private string $keyMaterial;
|
||||||
|
private string $algorithm;
|
||||||
|
|
||||||
|
public function __construct(string $keyMaterial, string $algorithm)
|
||||||
|
{
|
||||||
|
$this->keyMaterial = $keyMaterial;
|
||||||
|
$this->algorithm = $algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKeyMaterial(): string
|
||||||
|
{
|
||||||
|
return $this->keyMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlgorithm(): string
|
||||||
|
{
|
||||||
|
return $this->algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKeyType(): array
|
||||||
|
{
|
||||||
|
return ['kty' => 'oct'];
|
||||||
|
}
|
||||||
|
}
|
||||||
97
www/api/src/services/JwtService.php
Normal file
97
www/api/src/services/JwtService.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* JWT Service for Authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/JWT.php';
|
||||||
|
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Firebase\JWT\Key;
|
||||||
|
use Utils\Helpers;
|
||||||
|
|
||||||
|
class JwtService
|
||||||
|
{
|
||||||
|
private string $secret;
|
||||||
|
private string $algorithm;
|
||||||
|
private int $accessTokenTtl;
|
||||||
|
private int $refreshTokenTtl;
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
$this->secret = $config['secret'];
|
||||||
|
$this->algorithm = $config['algorithm'];
|
||||||
|
$this->accessTokenTtl = $config['access_token_ttl'];
|
||||||
|
$this->refreshTokenTtl = $config['refresh_token_ttl'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token
|
||||||
|
*/
|
||||||
|
public function generateAccessToken(string $userId, array $additionalClaims = []): string
|
||||||
|
{
|
||||||
|
$now = Helpers::timestamp();
|
||||||
|
$payload = array_merge([
|
||||||
|
'iss' => 'sozial-api',
|
||||||
|
'sub' => $userId,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => Helpers::getExpiration($this->accessTokenTtl),
|
||||||
|
'type' => 'access'
|
||||||
|
], $additionalClaims);
|
||||||
|
|
||||||
|
return JWT::encode($payload, $this->secret, $this->algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token
|
||||||
|
*/
|
||||||
|
public function generateRefreshToken(string $userId): string
|
||||||
|
{
|
||||||
|
$now = Helpers::timestamp();
|
||||||
|
$payload = [
|
||||||
|
'iss' => 'sozial-api',
|
||||||
|
'sub' => $userId,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => Helpers::getExpiration($this->refreshTokenTtl),
|
||||||
|
'type' => 'refresh',
|
||||||
|
'jti' => Helpers::generateUUID()
|
||||||
|
];
|
||||||
|
|
||||||
|
return JWT::encode($payload, $this->secret, $this->algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and decode token
|
||||||
|
*/
|
||||||
|
public function validateToken(string $token): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decoded = JWT::decode($token, new Key($this->secret, $this->algorithm));
|
||||||
|
return (array) $decoded;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user ID from token
|
||||||
|
*/
|
||||||
|
public function getUserIdFromToken(string $token): ?string
|
||||||
|
{
|
||||||
|
$payload = $this->validateToken($token);
|
||||||
|
return $payload['sub'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is expired
|
||||||
|
*/
|
||||||
|
public function isTokenExpired(string $token): bool
|
||||||
|
{
|
||||||
|
$payload = $this->validateToken($token);
|
||||||
|
if (!$payload) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Helpers::isExpired($payload['exp']);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
www/api/src/utils/helpers.php
Normal file
101
www/api/src/utils/helpers.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Utility Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Utils;
|
||||||
|
|
||||||
|
class Helpers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate UUID v4
|
||||||
|
*/
|
||||||
|
public static function generateUUID(): string
|
||||||
|
{
|
||||||
|
$data = random_bytes(16);
|
||||||
|
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||||
|
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current timestamp
|
||||||
|
*/
|
||||||
|
public static function timestamp(): int
|
||||||
|
{
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash password using bcrypt
|
||||||
|
*/
|
||||||
|
public static function hashPassword(string $password): string
|
||||||
|
{
|
||||||
|
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify password
|
||||||
|
*/
|
||||||
|
public static function verifyPassword(string $password, string $hash): bool
|
||||||
|
{
|
||||||
|
return password_verify($password, $hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
public static function isValidEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate username format (alphanumeric, underscore, 3-30 chars)
|
||||||
|
*/
|
||||||
|
public static function isValidUsername(string $username): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string input
|
||||||
|
*/
|
||||||
|
public static function sanitize(string $input): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON string safely
|
||||||
|
*/
|
||||||
|
public static function parseJson(string $json, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$result = json_decode($json, true);
|
||||||
|
return json_last_error() === JSON_ERROR_NONE ? $result : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode to JSON safely
|
||||||
|
*/
|
||||||
|
public static function jsonEncode(mixed $data): string
|
||||||
|
{
|
||||||
|
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expiration timestamp
|
||||||
|
*/
|
||||||
|
public static function getExpiration(int $ttl): int
|
||||||
|
{
|
||||||
|
return self::timestamp() + $ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if timestamp is expired
|
||||||
|
*/
|
||||||
|
public static function isExpired(int $timestamp): bool
|
||||||
|
{
|
||||||
|
return $timestamp < self::timestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
www/game/public/index.php
Normal file
17
www/game/public/index.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* game.domain.de — Spielkomponente
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Game</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Game</h1>
|
||||||
|
<p>Spielkomponente.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user