From a468a4135c5f5d2e551e467c8adf3f97a87934d3 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Mon, 7 Sep 2026 10:28:08 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BatchCreateVenueAdminAccountsCommand.php | 136 ------------------ .../ResetVenueAdminPasswordsCommand.php | 106 -------------- app/Support/VenueAdminCredentials.php | 34 ----- deploy/nginx-default-and-uploads.conf | 130 +++++++++++++++++ deploy/nginx-deny-upload-scripts.conf | 1 + .../ResetVenueAdminPasswordsCommandTest.php | 104 -------------- tests/Unit/VenueAdminCredentialsTest.php | 38 ----- 7 files changed, 131 insertions(+), 418 deletions(-) delete mode 100644 app/Console/Commands/BatchCreateVenueAdminAccountsCommand.php delete mode 100644 app/Console/Commands/ResetVenueAdminPasswordsCommand.php delete mode 100644 app/Support/VenueAdminCredentials.php create mode 100644 deploy/nginx-default-and-uploads.conf delete mode 100644 tests/Feature/ResetVenueAdminPasswordsCommandTest.php delete mode 100644 tests/Unit/VenueAdminCredentialsTest.php diff --git a/app/Console/Commands/BatchCreateVenueAdminAccountsCommand.php b/app/Console/Commands/BatchCreateVenueAdminAccountsCommand.php deleted file mode 100644 index 26167f3..0000000 --- a/app/Console/Commands/BatchCreateVenueAdminAccountsCommand.php +++ /dev/null @@ -1,136 +0,0 @@ -orderBy('id')->get(['id', 'name']); - if ($venues->isEmpty()) { - $this->warn('数据库中暂无场馆。'); - - return self::FAILURE; - } - - $this->info("将处理 {$venues->count()} 个场馆。"); - - $rows = []; - $reservedNames = User::query()->pluck('username')->all(); - $reservedSet = array_fill_keys($reservedNames, true); - $batchUsed = []; - - foreach ($venues as $venue) { - $name = (string) $venue->name; - $base = VenueAdminCredentials::acronymFromVenueName($name); - $plainPassword = VenueAdminCredentials::randomPassword(); - - $username = $base; - $suffix = 2; - while (isset($reservedSet[$username]) || isset($batchUsed[$username])) { - $username = $base.$suffix; - $suffix++; - } - $batchUsed[$username] = true; - - $rows[] = [ - 'venue_id' => $venue->id, - 'name' => $name, - 'username' => $username, - 'password_plain' => $plainPassword, - 'role' => '场馆管理员', - 'venue_name' => $name, - ]; - } - - $defaultDir = storage_path('app/exports'); - if (! is_dir($defaultDir)) { - mkdir($defaultDir, 0755, true); - } - - $outPath = $this->option('output'); - if (! $outPath) { - $outPath = $defaultDir.'/venue_admins_'.now()->format('Ymd_His').'.xlsx'; - } elseif (! str_starts_with((string) $outPath, '/')) { - $outPath = base_path($outPath); - } - - $dryRun = (bool) $this->option('dry-run'); - - if ($dryRun) { - $this->warn('当前为 --dry-run:只生成 Excel,不会写入 users / user_venue。去掉 --dry-run 才会入库。'); - } else { - $this->info('即将写入数据库(users、user_venue),随后生成 Excel…'); - DB::transaction(function () use ($rows) { - foreach ($rows as $row) { - $user = User::updateOrCreate( - ['username' => $row['username']], - [ - 'name' => $row['name'], - 'email' => null, - 'password' => $row['password_plain'], - 'role' => 'venue_admin', - 'is_active' => true, - ] - ); - - $user->venues()->sync([$row['venue_id']]); - } - }); - $this->info('数据库写入完成:已创建/更新 '.count($rows).' 个场馆管理员并完成场馆绑定。'); - } - - $this->writeXlsx($outPath, $rows); - $this->info("Excel 已生成:{$outPath}"); - - if ($dryRun) { - $this->warn('本次未写入数据库。若需要入库请执行:php artisan venues:create-admin-accounts(不要带 --dry-run)'); - - return self::SUCCESS; - } - - return self::SUCCESS; - } - - /** - * @param array $rows - */ - private function writeXlsx(string $path, array $rows): void - { - $sheetRows = [ - ['场馆ID', '姓名', '用户名', '密码(明文)', '角色', '绑定场馆'], - ]; - foreach ($rows as $r) { - $sheetRows[] = [ - $r['venue_id'], - $r['name'], - $r['username'], - $r['password_plain'], - $r['role'], - $r['venue_name'], - ]; - } - - $spreadsheet = new Spreadsheet; - $sheet = $spreadsheet->getActiveSheet(); - $sheet->setTitle('场馆管理员账号'); - $sheet->fromArray($sheetRows, null, 'A1'); - - $writer = new Xlsx($spreadsheet); - $writer->save($path); - } -} diff --git a/app/Console/Commands/ResetVenueAdminPasswordsCommand.php b/app/Console/Commands/ResetVenueAdminPasswordsCommand.php deleted file mode 100644 index 1206c15..0000000 --- a/app/Console/Commands/ResetVenueAdminPasswordsCommand.php +++ /dev/null @@ -1,106 +0,0 @@ -where('role', 'venue_admin') - ->orderBy('id') - ->get(['id', 'username', 'name']); - - if ($users->isEmpty()) { - $this->warn('没有 role=venue_admin 的账号。'); - - return self::SUCCESS; - } - - $this->info('将处理 '.$users->count().' 个场馆管理员账号。'); - - $dryRun = (bool) $this->option('dry-run'); - if ($dryRun) { - foreach ($users as $user) { - $this->line($user->id."\t".$user->username); - } - $this->warn('当前为 --dry-run:未改密码、未失效 token、未导出。去掉 --dry-run 才会写入。'); - - return self::SUCCESS; - } - - $rows = []; - DB::transaction(function () use ($users, &$rows) { - foreach ($users as $user) { - $plainPassword = VenueAdminCredentials::randomPassword(); - $user->password = $plainPassword; - $user->save(); - $user->tokens()->delete(); - - $rows[] = [ - 'id' => $user->id, - 'name' => (string) $user->name, - 'username' => (string) $user->username, - 'password_plain' => $plainPassword, - ]; - } - }); - - $defaultDir = storage_path('app/exports'); - if (! is_dir($defaultDir)) { - mkdir($defaultDir, 0755, true); - } - - $outPath = $this->option('output'); - if (! $outPath) { - $outPath = $defaultDir.'/venue_admin_password_reset_'.now()->format('Ymd_His').'.xlsx'; - } elseif (! str_starts_with((string) $outPath, '/')) { - $outPath = base_path($outPath); - } - - $this->writeXlsx((string) $outPath, $rows); - $this->info('已重置 '.$users->count().' 个场馆管理员密码,并作废其现有 token。'); - $this->info('Excel 已生成(请安全交给运营,不要提交到 git):'.$outPath); - - return self::SUCCESS; - } - - /** - * @param array $rows - */ - private function writeXlsx(string $path, array $rows): void - { - $sheetRows = [ - ['用户ID', '姓名', '用户名', '新密码(明文)'], - ]; - foreach ($rows as $r) { - $sheetRows[] = [ - $r['id'], - $r['name'], - $r['username'], - $r['password_plain'], - ]; - } - - $spreadsheet = new Spreadsheet; - $sheet = $spreadsheet->getActiveSheet(); - $sheet->setTitle('场馆管理员新密码'); - $sheet->fromArray($sheetRows, null, 'A1'); - - $writer = new Xlsx($spreadsheet); - $writer->save($path); - } -} diff --git a/app/Support/VenueAdminCredentials.php b/app/Support/VenueAdminCredentials.php deleted file mode 100644 index 7b4dcac..0000000 --- a/app/Support/VenueAdminCredentials.php +++ /dev/null @@ -1,34 +0,0 @@ -join(''); - $joined = strtolower($joined); - $joined = preg_replace('/[^a-z0-9]/', '', $joined) ?? ''; - - return $joined !== '' ? $joined : 'v'; - } - - /** - * 随机强密码:每次不同,长度至少 12,含大小写字母、数字与符号。 - */ - public static function randomPassword(int $length = 16): string - { - $length = max(12, $length); - - return Str::password($length, true, true, true, false); - } -} diff --git a/deploy/nginx-default-and-uploads.conf b/deploy/nginx-default-and-uploads.conf new file mode 100644 index 0000000..2f5d86c --- /dev/null +++ b/deploy/nginx-default-and-uploads.conf @@ -0,0 +1,130 @@ +# 苏州科普站 Nginx 草稿:默认站点 + 上传目录禁止解析 PHP +# 仅供运维对照落地,不要直接覆盖线上未确认的路径/证书/php-fpm 套接字。 +# 占位请替换: +# ROOT 本站 public 目录,例如 /var/www/szkp-map-service/public +# FPM_SOCK 本站独立 php-fpm,例如 unix:/run/php/php8.1-fpm-szkp.sock +# SERVER_NAME 本站域名(可多个) +# 不要改应用 URL:图片仍走 /storage/uploads/... + +# --------------------------------------------------------------------------- +# 一、默认站点:扫 IP、未知 Host 不要落到本站 +# 必须出现在本站 server 之前,或明确 listen ... default_server +# --------------------------------------------------------------------------- + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # 444:直接断开,不回你站的页面/框架/路径 + return 444; +} + +# Nginx >= 1.19.4 推荐:HTTPS 扫 IP 时拒绝握手,不必准备假证书 +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + ssl_reject_handshake on; +} + +# 若当前 Nginx 不支持 ssl_reject_handshake,改用下面这段(二选一,不要两段都开) +# server { +# listen 443 ssl default_server; +# listen [::]:443 ssl default_server; +# server_name _; +# ssl_certificate /path/to/any.crt; +# ssl_certificate_key /path/to/any.key; +# return 444; +# } + +# --------------------------------------------------------------------------- +# 二、本站(科普)正式站点 +# 确认本站 listen 行上没有 default_server +# --------------------------------------------------------------------------- + +server { + listen 80; + listen [::]:80; + server_name YOUR_SZKP_DOMAIN.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name YOUR_SZKP_DOMAIN.example.com; + + root /var/www/szkp-map-service/public; + index index.php; + charset utf-8; + + ssl_certificate /path/to/szkp.fullchain.pem; + ssl_certificate_key /path/to/szkp.privkey.pem; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + + # 上传目录:只当静态文件,绝不进 PHP + # ^~ 优先于正则,避免 *.php 落到下面的 location ~ \.php$ + location ^~ /storage/uploads { + alias /var/www/szkp-map-service/storage/app/public/uploads; + # 若已做 public/storage 软链,可改成不写 alias,仅靠 root + try_files: + # try_files $uri =404; + + add_header X-Content-Type-Options nosniff always; + types { } + default_type application/octet-stream; + + location ~* \.(php|phtml|phar|php[0-9]|pht|phps|shtml)$ { + deny all; + return 404; + } + + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_pass unix:/run/php/php8.1-fpm-szkp.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} + +# --------------------------------------------------------------------------- +# 三、同机其他站点(如 www.szyljt.com) +# 若该站也能访问到同一份 storage,必须同样禁止解析,否则扫那个域名仍能打到上传文件。 +# 把下面 location 原样放进那个站点的 server {},路径改成实际 uploads 目录。 +# --------------------------------------------------------------------------- + +# location ^~ /storage/uploads { +# alias /var/www/szkp-map-service/storage/app/public/uploads; +# add_header X-Content-Type-Options nosniff always; +# location ~* \.(php|phtml|phar|php[0-9]|pht|phps|shtml)$ { +# deny all; +# return 404; +# } +# try_files $uri =404; +# } + +# --------------------------------------------------------------------------- +# 落地检查(运维执行,不必改代码) +# 1. nginx -t && 重载 +# 2. 浏览器或 curl 访问 http://服务器IP/ 应无本站页面(连接被关或非 200 业务页) +# 3. 用本站域名访问一张已有 jpg,地址仍是 /storage/uploads/xxx.jpg,能正常显示 +# 4. 访问 /storage/uploads 下任何 .php 应为 404,且不应再被 PHP 执行 +# 5. 本站与同机其他站使用不同 Linux 用户、不同 php-fpm 池,并限制 open_basedir +# --------------------------------------------------------------------------- diff --git a/deploy/nginx-deny-upload-scripts.conf b/deploy/nginx-deny-upload-scripts.conf index 3146e80..54bf07d 100644 --- a/deploy/nginx-deny-upload-scripts.conf +++ b/deploy/nginx-deny-upload-scripts.conf @@ -1,3 +1,4 @@ +# 完整草稿(默认站点 + 上传目录)见同目录 nginx-default-and-uploads.conf # 运维将本片段放入对应 server {},不要改应用 URL。 # ^~ 优先于正则,避免 /storage/uploads 下的脚本落入 PHP location。 # 路径以实际部署的 public 目录为准;若已做 public/storage 软链,用下面这段即可。 diff --git a/tests/Feature/ResetVenueAdminPasswordsCommandTest.php b/tests/Feature/ResetVenueAdminPasswordsCommandTest.php deleted file mode 100644 index 6424a07..0000000 --- a/tests/Feature/ResetVenueAdminPasswordsCommandTest.php +++ /dev/null @@ -1,104 +0,0 @@ - 'sqlite', - 'database.connections.sqlite.database' => ':memory:', - ]); - DB::purge(); - DB::reconnect(); - - Schema::create('users', function (Blueprint $table) { - $table->id(); - $table->string('username')->nullable()->unique(); - $table->string('name'); - $table->string('email')->nullable(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->string('role')->default('venue_admin'); - $table->boolean('is_active')->default(true); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('personal_access_tokens', function (Blueprint $table) { - $table->id(); - $table->morphs('tokenable'); - $table->string('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - } - - public function test_dry_run_does_not_change_password_or_tokens(): void - { - $user = $this->venueAdmin('old_admin'); - $user->createToken('admin-token'); - $oldHash = $user->password; - - $this->artisan('venues:reset-admin-passwords', ['--dry-run' => true]) - ->assertSuccessful(); - - $fresh = $user->fresh(); - $this->assertSame($oldHash, $fresh->password); - $this->assertSame(1, $fresh->tokens()->count()); - } - - public function test_reset_writes_new_hash_revokes_tokens_and_exports_xlsx(): void - { - $user = $this->venueAdmin('venue_one'); - $user->createToken('admin-token'); - $oldHash = $user->password; - - $out = storage_path('app/exports/test_venue_admin_password_reset.xlsx'); - if (is_file($out)) { - unlink($out); - } - - $this->artisan('venues:reset-admin-passwords', ['--output' => $out]) - ->assertSuccessful(); - - $fresh = $user->fresh(); - $this->assertNotSame($oldHash, $fresh->password); - $this->assertSame(0, $fresh->tokens()->count()); - $this->assertFileExists($out); - - $sheet = IOFactory::load($out)->getActiveSheet()->toArray(); - $this->assertSame('venue_one', $sheet[1][2]); - $plain = (string) $sheet[1][3]; - $this->assertGreaterThanOrEqual(12, strlen($plain)); - $this->assertTrue(Hash::check($plain, $fresh->password)); - - unlink($out); - } - - private function venueAdmin(string $username): User - { - return User::query()->create([ - 'username' => $username, - 'name' => '场馆管理员', - 'email' => $username.'@example.test', - 'password' => 'OldPlace!holder1', - 'role' => 'venue_admin', - 'is_active' => true, - ]); - } -} diff --git a/tests/Unit/VenueAdminCredentialsTest.php b/tests/Unit/VenueAdminCredentialsTest.php deleted file mode 100644 index 94e4ed4..0000000 --- a/tests/Unit/VenueAdminCredentialsTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertNotSame($first, $second); - $this->assertSame( - VenueAdminCredentials::acronymFromVenueName($name), - VenueAdminCredentials::acronymFromVenueName($name) - ); - } - - public function test_random_password_meets_strength_rules(): void - { - $password = VenueAdminCredentials::randomPassword(); - - $this->assertGreaterThanOrEqual(12, strlen($password)); - $this->assertMatchesRegularExpression('/[A-Z]/', $password); - $this->assertMatchesRegularExpression('/[a-z]/', $password); - $this->assertMatchesRegularExpression('/[0-9]/', $password); - $this->assertMatchesRegularExpression('/[^A-Za-z0-9]/', $password); - } - - public function test_legacy_acronym_password_helper_is_removed(): void - { - $this->assertFalse(method_exists(VenueAdminCredentials::class, 'passwordFromAcronym')); - } -}