From b36cf07635ca8a52d5facdf6f89a4170696117fd Mon Sep 17 00:00:00 2001 From: kosipov Date: Thu, 2 Apr 2026 19:42:04 +0300 Subject: [PATCH] add autodeploy and add basic auth for file uploader --- .env.example | 4 + .gitea/workflows/deploy.yml | 68 +++++++++++++++++ README.md | 75 ++++++++----------- .../Middleware/EnsureFileUploadBasicAuth.php | 42 +++++++++++ bootstrap/app.php | 4 +- config/file_links.php | 11 ++- routes/api.php | 2 +- routes/web.php | 6 +- tests/Feature/FileLinkControllerTest.php | 58 ++++++++++++-- tests/Feature/FileLinkWebTest.php | 30 +++++++- 10 files changed, 244 insertions(+), 56 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 app/Http/Middleware/EnsureFileUploadBasicAuth.php diff --git a/.env.example b/.env.example index 2c93af3..0f35624 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,7 @@ FILE_LINKS_ENABLED=false FILE_LINKS_DISK=local FILE_LINKS_DIRECTORY=temporary-links FILE_LINKS_MAX_SIZE_KB=10240 +FILE_LINKS_UPLOAD_USER_1=uploader1 +FILE_LINKS_UPLOAD_PASSWORD_1=change-me-1 +FILE_LINKS_UPLOAD_USER_2=uploader2 +FILE_LINKS_UPLOAD_PASSWORD_2=change-me-2 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7470662 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Deploy back2 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup SSH key + run: | + set -euo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p "${DEPLOY_PORT:-22}" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: Deploy Laravel + run: | + set -euo pipefail + + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "DEPLOY_PATH='$DEPLOY_PATH' bash -s" <<'EOF' + set -euo pipefail + + if [ -z "${DEPLOY_PATH:-}" ]; then + echo "DEPLOY_PATH is not set" + exit 1 + fi + + cd "$DEPLOY_PATH" + + if [ ! -d .git ]; then + echo "Expected a git repository at $DEPLOY_PATH" + exit 1 + fi + + git fetch origin main + git checkout main + git reset --hard origin/main + + if [ -f composer.json ]; then + composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev + fi + + if [ -f artisan ]; then + php artisan down --retry=60 || true + trap 'php artisan up || true' EXIT + + php artisan optimize:clear + php artisan migrate --force + php artisan storage:link || true + php artisan config:cache + php artisan view:cache + php artisan queue:restart || true + fi + EOF diff --git a/README.md b/README.md index 0165a77..a137dcb 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,50 @@ -

Laravel Logo

-

-Build Status -Total Downloads -Latest Stable Version -License -

+## Repo for site https://back2.iro.kosipov.ru -## About Laravel +### Autodeploy (Gitea Actions) -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +Workflow file: `.gitea/workflows/deploy.yml` -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +Deploy starts automatically on every push to `main`. -Laravel is accessible, powerful, and provides tools required for large, robust applications. +Required repository secrets in Gitea: -## Learning Laravel +- `DEPLOY_HOST` - host of the production server (example: `back2.iro.kosipov.ru`) +- `DEPLOY_USER` - SSH user for deploy +- `DEPLOY_PATH` - absolute path to Laravel project on server +- `DEPLOY_SSH_KEY` - private SSH key content (for `DEPLOY_USER`) +- `DEPLOY_PORT` - optional, defaults to `22` -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. +What deployment does: -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +- updates project to `origin/main` +- runs `composer install --no-dev --optimize-autoloader` +- enables maintenance mode (`php artisan down`) +- clears stale caches, runs migrations, recreates Laravel caches +- restarts queue workers (`php artisan queue:restart`) +- disables maintenance mode (`php artisan up`) -## Laravel Sponsors +Server prerequisites: -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). +- `php`, `composer`, `git`, `bash` available in PATH +- deploy user has permissions for project files and artisan commands -### Premium Partners +### File Upload Basic Auth -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** +Upload endpoints and upload page are protected by HTTP Basic Auth with 2 users from `.env`: -## Contributing +- `FILE_LINKS_UPLOAD_USER_1` +- `FILE_LINKS_UPLOAD_PASSWORD_1` +- `FILE_LINKS_UPLOAD_USER_2` +- `FILE_LINKS_UPLOAD_PASSWORD_2` -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +Protected routes: -## Code of Conduct +- `GET /files` +- `POST /files` +- `POST /api/files/upload` -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +Download routes stay accessible by signed link: -## Security Vulnerabilities - -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. - -## License - -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +- `GET /files/download/{path}` (signed URL) +- `GET /api/files/download/{path}` (signed URL) diff --git a/app/Http/Middleware/EnsureFileUploadBasicAuth.php b/app/Http/Middleware/EnsureFileUploadBasicAuth.php new file mode 100644 index 0000000..85fb3e9 --- /dev/null +++ b/app/Http/Middleware/EnsureFileUploadBasicAuth.php @@ -0,0 +1,42 @@ +getUser(); + $password = (string) $request->getPassword(); + + foreach ($authorizedUsers as $authorizedUser) { + $validUsername = isset($authorizedUser['username']) && hash_equals((string) $authorizedUser['username'], $username); + $validPassword = isset($authorizedUser['password']) && hash_equals((string) $authorizedUser['password'], $password); + + if ($validUsername && $validPassword) { + return $next($request); + } + } + + return $this->unauthorizedResponse($request); + } + + private function unauthorizedResponse(Request $request): Response + { + if ($request->expectsJson()) { + return new JsonResponse(['message' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED, [ + 'WWW-Authenticate' => 'Basic realm="File Upload"', + ]); + } + + return response('Unauthorized', Response::HTTP_UNAUTHORIZED, [ + 'WWW-Authenticate' => 'Basic realm="File Upload"', + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..13cf64a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'file.upload.basic' => \App\Http\Middleware\EnsureFileUploadBasicAuth::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/file_links.php b/config/file_links.php index b8e77e2..d9d275b 100644 --- a/config/file_links.php +++ b/config/file_links.php @@ -5,5 +5,14 @@ return [ 'disk' => env('FILE_LINKS_DISK', 'local'), 'directory' => env('FILE_LINKS_DIRECTORY', 'temporary-links'), 'max_size_kb' => (int) env('FILE_LINKS_MAX_SIZE_KB', 10240), + 'upload_basic_auth_users' => array_values(array_filter([ + [ + 'username' => env('FILE_LINKS_UPLOAD_USER_1'), + 'password' => env('FILE_LINKS_UPLOAD_PASSWORD_1'), + ], + [ + 'username' => env('FILE_LINKS_UPLOAD_USER_2'), + 'password' => env('FILE_LINKS_UPLOAD_PASSWORD_2'), + ], + ], static fn (array $user): bool => filled($user['username']) && filled($user['password']))), ]; - diff --git a/routes/api.php b/routes/api.php index 4366412..3470688 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,7 +20,7 @@ Route::prefix('habits')->group(function () { }); Route::prefix('files')->group(function () { - Route::post('/upload', [FileLinkController::class, 'upload']); + Route::post('/upload', [FileLinkController::class, 'upload'])->middleware('file.upload.basic'); Route::get('/download/{path}', [FileLinkController::class, 'download']) ->where('path', '.*') ->middleware('signed') diff --git a/routes/web.php b/routes/web.php index b47faac..16a7e9f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,8 +8,10 @@ Route::get('/', function () { return view('welcome'); }); -Route::get('/files', [FileLinkController::class, 'index'])->name('files.index'); -Route::post('/files', [FileLinkController::class, 'uploadWeb'])->name('files.upload'); +Route::middleware('file.upload.basic')->group(function () { + Route::get('/files', [FileLinkController::class, 'index'])->name('files.index'); + Route::post('/files', [FileLinkController::class, 'uploadWeb'])->name('files.upload'); +}); Route::get('/files/download/{path}', [FileLinkController::class, 'download']) ->where('path', '.*') ->middleware('signed') diff --git a/tests/Feature/FileLinkControllerTest.php b/tests/Feature/FileLinkControllerTest.php index ab7f19f..28f47ae 100644 --- a/tests/Feature/FileLinkControllerTest.php +++ b/tests/Feature/FileLinkControllerTest.php @@ -9,14 +9,60 @@ use Tests\TestCase; class FileLinkControllerTest extends TestCase { - public function test_upload_is_unavailable_when_feature_is_disabled(): void + protected function setUp(): void { - Config::set('file_links.enabled', false); + parent::setUp(); + + Config::set('file_links.upload_basic_auth_users', [ + ['username' => 'uploader1', 'password' => 'password1'], + ['username' => 'uploader2', 'password' => 'password2'], + ]); + } + + private function authHeader(string $username = 'uploader1', string $password = 'password1'): array + { + return [ + 'Authorization' => 'Basic ' . base64_encode($username . ':' . $password), + ]; + } + + public function test_upload_requires_basic_auth(): void + { + Config::set('file_links.enabled', true); $response = $this->postJson('/api/files/upload', [ 'file' => UploadedFile::fake()->create('sample.txt', 1), ]); + $response->assertUnauthorized(); + $response->assertHeader('WWW-Authenticate', 'Basic realm="File Upload"'); + } + + public function test_upload_accepts_second_basic_auth_user(): void + { + Storage::fake('local'); + Config::set('file_links.enabled', true); + Config::set('file_links.disk', 'local'); + Config::set('file_links.directory', 'temporary-links'); + + $response = $this->withHeaders($this->authHeader('uploader2', 'password2'))->postJson( + '/api/files/upload', + ['file' => UploadedFile::fake()->create('sample.txt', 1)] + ); + + $response->assertOk(); + $response->assertJsonStructure(['path', 'expiresAt', 'url']); + } + + public function test_upload_is_unavailable_when_feature_is_disabled(): void + { + Config::set('file_links.enabled', false); + + $response = $this->withHeaders($this->authHeader())->postJson( + '/api/files/upload', + ['file' => UploadedFile::fake()->create('sample.txt', 1)] + ); + $response->assertNotFound(); } @@ -27,9 +73,10 @@ class FileLinkControllerTest extends TestCase Config::set('file_links.disk', 'local'); Config::set('file_links.directory', 'temporary-links'); - $response = $this->postJson('/api/files/upload', [ - 'file' => UploadedFile::fake()->create('sample.txt', 1), - ]); + $response = $this->withHeaders($this->authHeader())->postJson( + '/api/files/upload', + ['file' => UploadedFile::fake()->create('sample.txt', 1)] + ); $response->assertOk(); $response->assertJsonStructure(['path', 'expiresAt', 'url']); @@ -50,4 +97,3 @@ class FileLinkControllerTest extends TestCase $downloadResponse->assertHeader('content-disposition'); } } - diff --git a/tests/Feature/FileLinkWebTest.php b/tests/Feature/FileLinkWebTest.php index a41586d..a14f218 100644 --- a/tests/Feature/FileLinkWebTest.php +++ b/tests/Feature/FileLinkWebTest.php @@ -9,11 +9,36 @@ use Tests\TestCase; class FileLinkWebTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + Config::set('file_links.upload_basic_auth_users', [ + ['username' => 'uploader1', 'password' => 'password1'], + ['username' => 'uploader2', 'password' => 'password2'], + ]); + } + + private function authHeader(string $username = 'uploader1', string $password = 'password1'): array + { + return [ + 'Authorization' => 'Basic ' . base64_encode($username . ':' . $password), + ]; + } + + public function test_files_page_requires_basic_auth(): void + { + $response = $this->get('/files'); + + $response->assertUnauthorized(); + $response->assertHeader('WWW-Authenticate', 'Basic realm="File Upload"'); + } + public function test_files_page_shows_disabled_state(): void { Config::set('file_links.enabled', false); - $response = $this->get('/files'); + $response = $this->withHeaders($this->authHeader())->get('/files'); $response->assertOk(); $response->assertSee('Функция отключена конфигом'); @@ -26,7 +51,7 @@ class FileLinkWebTest extends TestCase Config::set('file_links.disk', 'local'); Config::set('file_links.directory', 'temporary-links'); - $response = $this->post('/files', [ + $response = $this->withHeaders($this->authHeader())->post('/files', [ 'file' => UploadedFile::fake()->create('web-sample.txt', 1), ]); @@ -35,4 +60,3 @@ class FileLinkWebTest extends TestCase $response->assertSessionHas('file_expires_at'); } } -