diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..8ff0418 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,88 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + 'role' => 'required|in:student,teacher,guardian', + ]); + + $user = clone new User(); + $user->name = $request->name; + $user->email = $request->email; + $user->password = Hash::make($request->password); + $user->role = $request->role; + $user->save(); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'user' => $user, + ], 201); + } + + public function login(Request $request) + { + $request->validate([ + 'email' => 'required|string|email', + 'password' => 'required|string', + ]); + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['البيانات المدخلة غير صحيحة.'], + ]); + } + + // Revoke all previous tokens for security if needed + $user->tokens()->delete(); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'user' => $user, + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'message' => 'تم تسجيل الخروج بنجاح.' + ]); + } + + public function deleteAccount(Request $request) + { + $user = $request->user(); + + // Revoke all tokens + $user->tokens()->delete(); + + // Delete user (soft delete if configured, or hard delete) + $user->delete(); + + return response()->json([ + 'message' => 'تم حذف الحساب بنجاح.' + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/CourseController.php b/backend/app/Http/Controllers/Api/CourseController.php new file mode 100644 index 0000000..200816f --- /dev/null +++ b/backend/app/Http/Controllers/Api/CourseController.php @@ -0,0 +1,21 @@ +get(); + return response()->json(['courses' => $courses]); + } + + public function show(Course $course) + { + $course->load('lessons.quizzes'); + return response()->json(['course' => $course]); + } +} diff --git a/backend/app/Http/Controllers/Api/TeacherContentController.php b/backend/app/Http/Controllers/Api/TeacherContentController.php new file mode 100644 index 0000000..ae23be0 --- /dev/null +++ b/backend/app/Http/Controllers/Api/TeacherContentController.php @@ -0,0 +1,33 @@ +user()->role !== 'teacher') { + throw ValidationException::withMessages(['role' => 'غير مصرح لك بإضافة محتوى.']); + } + + $request->validate([ + 'course_id' => 'required|exists:courses,id', + 'title' => 'required|string|max:255', + 'content' => 'required|string', + 'order' => 'integer', + ]); + + $lesson = new Lesson(); + $lesson->course_id = $request->course_id; + $lesson->title = $request->title; + $lesson->content = $request->content; + $lesson->order = $request->order ?? 1; + $lesson->save(); + + return response()->json(['lesson' => $lesson], 201); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 8ddab4a..8ef424f 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -11,13 +11,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password', 'role', 'api_token', 'grade', 'track'])] #[Hidden(['password', 'remember_token', 'api_token'])] class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * Get the attributes that should be cast. diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 47e6fea..6f183c3 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -21,6 +21,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + \Illuminate\Support\Facades\RateLimiter::for('api', function (\Illuminate\Http\Request $request) { + return \Illuminate\Cache\RateLimiting\Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + + \Illuminate\Support\Facades\RateLimiter::for('auth', function (\Illuminate\Http\Request $request) { + return \Illuminate\Cache\RateLimiting\Limit::perMinute(5)->by($request->ip()); + }); } } diff --git a/backend/composer.json b/backend/composer.json index 13dd10f..5fec2c2 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.4", "laravel/framework": "^13.17", + "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index 893f80c..c9a2482 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "15ca84f430f0353684380a50b2f0c67d", + "content-hash": "ab081cf41e953752e41017abd7d1c7be", "packages": [ { "name": "brick/math", @@ -1340,6 +1340,69 @@ }, "time": "2026-08-20T12:55:36+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.16", @@ -8696,7 +8759,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/backend/database/migrations/2026_08_26_133838_create_personal_access_tokens_table.php b/backend/database/migrations/2026_08_26_133838_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2026_08_26_133838_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index b4f61ae..c793dd8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -18,3 +18,27 @@ Route::prefix('v1')->middleware('api.token')->group(function (): void { Route::get('/guardian/students/{student}/summary', [GuardianSummaryController::class, 'show']) ->name('guardian.students.summary'); }); + +use App\Http\Controllers\Api\AuthController; + +Route::prefix('auth')->middleware('throttle:auth')->group(function () { + Route::post('/register', [AuthController::class, 'register']); + Route::post('/login', [AuthController::class, 'login']); +}); + +Route::prefix('auth')->middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout']); + Route::delete('/account', [AuthController::class, 'deleteAccount']); +}); + +use App\Http\Controllers\Api\CourseController; +use App\Http\Controllers\Api\TeacherContentController; + +Route::middleware('auth:sanctum')->group(function () { + // Student/General Browsing + Route::get('/courses', [CourseController::class, 'index']); + Route::get('/courses/{course}', [CourseController::class, 'show']); + + // Teacher Uploads + Route::post('/teacher/lessons', [TeacherContentController::class, 'storeLesson']); +}); diff --git a/docker-compose.yml b/docker-compose.yml index db967f0..837dce5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy server: image: nginx:1.27-alpine @@ -24,8 +26,21 @@ services: depends_on: - app + redis: + image: redis:alpine + container_name: saqel_redis + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + db: - image: mysql:8.0 + image: mysql:8.4 container_name: saqel_mysql restart: unless-stopped environment: @@ -36,7 +51,7 @@ services: volumes: - mysql_data:/var/lib/mysql healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} || exit 1"] + test: [ "CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} || exit 1" ] interval: 10s timeout: 5s retries: 10 @@ -48,11 +63,14 @@ services: dockerfile: docker/php/Dockerfile container_name: saqel_queue restart: unless-stopped - command: ["php", "artisan", "queue:work", "--tries=3", "--timeout=120"] + command: [ "php", "artisan", "queue:work", "--tries=3", "--timeout=120" ] depends_on: db: condition: service_healthy + redis: + condition: service_healthy volumes: mysql_data: storage_data: + redis_data: diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 048f591..e74cb4c 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -2,7 +2,7 @@ FROM php:8.4-fpm-alpine ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -RUN install-php-extensions pdo_mysql bcmath intl zip gd opcache pcntl +RUN install-php-extensions pdo_mysql bcmath intl zip gd opcache pcntl redis COPY --from=composer:2 /usr/bin/composer /usr/bin/composer